在企业级业务系统中,定时任务的管理方式直接决定了系统的灵活性和可维护性。很多团队一开始用 Quartz.NET 或 Coravel 硬编码 Cron 表达式,后来发现运营侧需要通过后台页面随时启停、修改执行频率——这时”动态注册”就成了刚需。

本文基于 SwitchData 项目的真实实现,从零拆解一套完整的 Hangfire 数据库驱动定时任务体系:独立 Worker 进程 + SQL Server 持久化 + 动态 Cron 注册/注销 + 多实例部署。读完你能理解为什么 Hangfire 比老牌调度框架更适合云原生场景,以及如何在生产项目中正确落地。


一、为什么选 Hangfire 而不是 Quartz.NET?

先来厘清一个经典问题:既然已有成熟的 Quartz.NET,为什么还要引入 Hangfire?

核心差异在于 任务定义的位置:

维度 Quartz.NET Hangfire
任务注册时机 编译期硬编码 JobClass + CronTrigger 运行时任意注入
持久化 JobStore 自定义表结构 内置 Hangfire.SqlServer,自动建表
多实例协调 需要 RemoteMisfireHandler + 集群锁 内置分布式锁,开箱即用
管理面板 需要额外搭建 自带 Dashboard(/hangfire)
与 DI 容器 需配置 ISchedulerFactory,较繁琐 原生支持 IServiceProvider,AddHangfireServer() 一行搞定
业务耦合 Job 类必须实现 IJob 接口 任意静态/实例方法,零侵入

SwitchData 项目选择 Hangfire 的关键原因是最后一项——业务任务(网元 ORDP 直连、MML 脚本下发)已经封装在 NeTaskExecutor 中,不需要为调度框架改写签名。


二、架构总览

在动手之前,先把整条链路画清楚:

flowchart TD
    A[数据库 NeTask 表] -->|GetListAsync 加载| B[JobWorkerService BackgroundService]
    B -->|EnableSchedule 启用的任务| C[NeTaskHangfireScheduler]
    B -->|DisableSchedule 停用的任务| C
    C -->|AddOrUpdate Cron| D[(Hangfire SQL Server 存储)]
    C -->|RemoveIfExists| D
    D -->|后台扫描| E[Hangfire Server]
    E -->|反射调用| F[NeTaskExecutor.ExecuteRecurringAsync]
    G[API 控制器] -->|手动触发| H[TriggerSchedule / Enqueue]
    H --> C
    style A fill:#e8f4fd,stroke:#2196F3
    style D fill:#fff3e0,stroke:#ff9800
    style E fill:#f3e5f5,stroke:#9c27b0

三个关键角色:

  1. JobWorkerService —— 后台服务,从数据库拉取任务列表,动态同步到 Hangfire
  2. NeTaskHangfireScheduler —— 调度门面,封装 IRecurringJobManager 和 IBackgroundJobClient
  3. Hangfire Server —— 独立进程,负责扫描 Cron 表达式、分布式锁、实际执行

三、注册 Hangfire:独立 Worker 进程配置

SwitchData 把 Hangfire Server 放在了一个独立的 Worker 项目(SwitchData.Job)里,而不是 API 进程中。这样做的好处是:任务耗时再长也不会阻塞 Web 请求,部署也更灵活(可以单独扩缩容 Worker 实例)。

// SwitchData.Job/Program.cs
using Hangfire;
using Hangfire.SqlServer;

var builder = WebApplication.CreateBuilder(args);

// 先初始化数据库连接(Hangfire 需要持久化存储)
DbConnectionManager.Init(builder.Configuration);
await DbConnectionManager.WaitForReadyAsync(DatabaseNames.Default);

// ---- Hangfire 核心配置 ----
builder.Services.AddHangfire(configuration => configuration
    .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
    .UseSimpleAssemblyNameTypeSerializer()
    .UseRecommendedSerializerSettings()
    .UseSqlServerStorage(
        DbConnectionManager.Get(DatabaseNames.Default),
        new SqlServerStorageOptions
        {
            CommandBatchMaxTimeout = TimeSpan.FromMinutes(3),
            SlidingInvisibilityTimeout = TimeSpan.FromMinutes(10),
            QueuePollInterval = TimeSpan.Zero,
            UseRecommendedIsolationLevel = true,
            DisableGlobalLocks = true  // 多实例部署时必须开启
        }));

// 注册 Hangfire Server(负责实际执行任务)
builder.Services.AddHangfireServer();

// 注册自定义服务
builder.Services.AddHostedService<JobWorkerService>();
builder.Services.AddSingleton<NeTaskHangfireScheduler>();

var app = builder.Build();

// Dashboard 面板(生产环境记得加鉴权!)
app.UseHangfireDashboard();

app.Run();

几个容易被忽略的配置项详解:

  • SlidingInvisibilityTimeout = 10 分钟 —— 如果一个 Worker 实例崩溃,其他 Worker 最多等待 10 分钟就会接管它未完成的任务。默认 5 分钟,但网元直连任务单条可能跑 3~5 分钟,调大避免”幽灵任务”。
  • QueuePollInterval = TimeSpan.Zero —— 禁止队列轮询退避,任务入队后立即执行。代价是数据库 QPS 略高,但对于毫秒级响应的业务系统是值得的。
  • DisableGlobalLocks = true —— 默认情况下 Hangfire 会在数据库中创建 GlobalLock 确保”同一时刻只有一个 Server 实例在调度”。但如果你在 Kubernetes 里部署多个 Worker 副本,关掉这个锁让多个 Server 同时扫描,会更快发现到期任务。注意:关掉后每个 Cron 任务的实际执行仍然由 DistributedLock 保护,不会重复执行。

四、NeTaskHangfireScheduler:动态调度门面

这是整个方案最核心的类。它不直接操作 Hangfire 的原始 API,而是提供了对业务层友好的几个方法:

// SwitchData.Business/NeData/NeTaskHangfireScheduler.cs
public class NeTaskHangfireScheduler
{
    private readonly IBackgroundJobClient _backgroundJobClient;
    private readonly IRecurringJobManager _recurringJobManager;

    // Hangfire 自动注入
    public NeTaskHangfireScheduler(
        IBackgroundJobClient backgroundJobClient,
        IRecurringJobManager recurringJobManager)
    {
        _backgroundJobClient = backgroundJobClient;
        _recurringJobManager = recurringJobManager;
    }

    public void EnableSchedule(int taskId, string jobId, string cronExpression)
    {
        _recurringJobManager.AddOrUpdate(
            jobId,
            () => NeTaskExecutor.ExecuteRecurringAsync(
                taskId,
                NeTaskTriggerSource.Schedule,
                null),
            cronExpression,
            new RecurringJobOptions
            {
                TimeZone = TimeZoneInfo.Local
            });
    }

    public void DisableSchedule(string jobId)
    {
        _recurringJobManager.RemoveIfExists(jobId);
    }

    public void TriggerSchedule(int taskId)
    {
        _backgroundJobClient.Enqueue(() =>
            NeTaskExecutor.ExecuteRecurringAsync(
                taskId,
                NeTaskTriggerSource.Manual,
                null));
    }

    public void Enqueue(int executionId)
    {
        _backgroundJobClient.Enqueue(() =>
            NeTaskExecutor.ExecuteAsync(executionId, null));
    }

    public void Schedule(int executionId, DateTime executeAt)
    {
        _backgroundJobClient.Schedule(() =>
            NeTaskExecutor.ExecuteAsync(executionId, null),
            executeAt);
    }
}

设计要点:

  1. AddOrUpdate 是幂等的 —— 同一个 jobId 反复调用不会重复注册,只会更新 Cron 表达式。这意味着我们可以放心地在后台服务启动时无脑同步所有任务,而不需要先查一遍 Hangfire 里有没有。
  2. jobId 用数据库主键关联 —— NeTask 表里每个任务有一个 JobId 字段(业务唯一标识),用它做 Hangfire 的任务标识,后台页面改 Cron 后只需重新调用 EnableSchedule 即可热更新。
  3. 触发来源枚举 —— 方法签名里带了 NeTaskTriggerSource,区分”定时触发”和”手动触发”,执行日志里可以追溯来源。

五、JobWorkerService:数据库驱动的自动同步

光有调度门面还不够,谁来把数据库里的任务同步到 Hangfire?JobWorkerService 就是那个”中间人”:

// SwitchData.Job/JobWorkerService.cs
public class JobWorkerService : BackgroundService
{
    private readonly NeTaskHangfireScheduler _scheduler;

    public JobWorkerService(NeTaskHangfireScheduler scheduler)
    {
        _scheduler = scheduler;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // 启动时先同步一次
        _ = Task.Run(() => SyncJobsAsync(stoppingToken), stoppingToken);

        // 心跳循环
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            // 心跳扩展点:可以加健康检查上报
        }
    }

    private async Task SyncJobsAsync(CancellationToken cancellationToken)
    {
        var tasks = NeTaskService.GetListAsync(cancellationToken: cancellationToken);
        var enabledCount = 0;
        var totalCount = 0;

        await foreach (var task in tasks)
        {
            totalCount++;
            if (task.Enabled)
            {
                enabledCount++;
                _scheduler.EnableSchedule(task.Id, task.JobId, task.CronExpression);
            }
            else
            {
                _scheduler.DisableSchedule(task.JobId);
            }
        }

        Logger.Info($"定时任务同步完成:启用 {enabledCount} 个,停用 {totalCount - enabledCount} 个");
    }
}

注意这里用的是 await foreach —— 假设 GetListAsync 返回的是 IAsyncEnumerable,逐个拉取避免一次性加载几百条任务到内存。


六、一个真实的 Cron 调度案例

业务侧在后台页面配置好 Cron 表达式后,流程是这样的:

sequenceDiagram
    participant UI as 运营后台
    participant API as SwitchData.Api
    participant DB as NeTask 表
    participant Scheduler as NeTaskHangfireScheduler
    participant Hangfire as Hangfire Storage

    UI->>API: POST /api/ne-task/enable (taskId=42, cron="0 */30 * * *")
    API->>DB: UPDATE SET Enabled=1, CronExpression='0 */30 * * *'
    API->>Scheduler: EnableSchedule(42, "job-42", "0 */30 * * *")
    Scheduler->>Hangfire: AddOrUpdate("job-42", ...)
    Hangfire-->>Scheduler: OK(幂等,已存在则更新 Cron)
    Scheduler-->>API: 返回 200
    Note over Hangfire: 每 30 分钟自动触发 NeTaskExecutor

如果运营把 Cron 改成 “0 2 * * *“(每天凌晨 2 点),只需要再调一次 EnableSchedule,Hangfire 会自动替换旧的调度规则,不需要重启服务。


七、多实例部署:踩过的坑

一个容易踩的坑:如果同时启动两个 Worker 实例,会出现什么?

不会重复执行。Hangfire 的 AddOrUpdate 在数据库层用 MERGE 语句保证了同一个 jobId 只有一条记录。实际执行时 SlidingInvisibilityTimeout + DistributedLock 也会确保同一时间只有一个 Server 拿到执行权。

但有一个例外——第一次启动时的 SyncJobsAsync:两个实例同时读数据库、同时调 AddOrUpdate,会不会竞争?

答案是不会,因为 AddOrUpdate 本身就带了并发保护。我们实测在一个 8 核服务器上同时拉起 3 个 Worker 进程,Hangfire Dashboard 里同一时间只有一个实例在跑任务。

真正的优化点在于 PeriodicTimer 心跳——目前两个实例都在跑 30 秒一次的心跳循环,但只有一个真正执行任务。如果你想在 Kubernetes 里优雅缩容,可以把心跳改成 Leader Election 模式。


八、常见 Cron 表达式速查

在后台页面给运营提供配置时,附上这张表能减少很多沟通成本:

场景 Cron 表达式 说明
每天凌晨 2 点 0 2 * * * 最常见的离线数据同步
每 30 分钟 */30 * * * * 网元状态轮询
工作日 9 点 0 9 * * 1-5 只在周一到周五执行
每月 1 号凌晨 4 点 0 4 1 * * 月度报表生成
每周一凌晨 1 点 0 1 * * 1 周度数据归档

Cron 表达式格式:分 时 日 月 周,标准 5 段式(Hangfire 默认用 5 段,不是 Quartz 的 6 段)。


九、Dashboard 安全加固(生产必做)

app.UseHangfireDashboard() 默认不鉴权,任何访问 /hangfire 的人都能触发任务。生产环境必须加一层保护:

// 挂在 ASP.NET Core 授权之后
app.MapWhen(ctx => ctx.Request.Path.StartsWithSegments("/hangfire"), hangApp =>
{
    hangApp.Use(async (context, next) =>
    {
        if (!context.User.IsInRole("HangfireAdmin"))
        {
            context.Response.StatusCode = 403;
            return;
        }
        await next();
    });
    hangApp.UseHangfireDashboard();
});

十、总结:Hangfire 选型 checklist

回到最初的问题——什么时候用 Hangfire?

✅ 适合的场景: - 定时任务数量多(> 10 个)、需要后台动态管理 - 任务执行时间长(分钟级)、需要独立 Worker 隔离 - 多实例部署、需要分布式锁兜底 - 想要自带 Dashboard 监控

❌ 不太适合的场景: - 只有几个固定的定时任务,写死 Cron 就够了 - 任务需要严格的秒级精度(Hangfire 扫描间隔是 1 分钟粒度) - 不想引入 Hangfire.SqlServer 的额外数据库表

SwitchData 项目走了一个经典路线:把复杂的任务执行逻辑封装在 NeTaskExecutor 里,Hangfire 只负责”什么时候调、调几次”。调度和业务解耦,以后想换成 Quartz.NET 或 Coravel 也很容易——只需要替换 NeTaskHangfireScheduler 的实现。

这才是好的架构设计:框架是服务,不是枷锁。