在企业级业务系统中,定时任务的管理方式直接决定了系统的灵活性和可维护性。很多团队一开始用 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
三个关键角色:
- JobWorkerService —— 后台服务,从数据库拉取任务列表,动态同步到 Hangfire
- NeTaskHangfireScheduler —— 调度门面,封装 IRecurringJobManager 和 IBackgroundJobClient
- 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);
}
}
设计要点:
- AddOrUpdate 是幂等的 —— 同一个 jobId 反复调用不会重复注册,只会更新 Cron 表达式。这意味着我们可以放心地在后台服务启动时无脑同步所有任务,而不需要先查一遍 Hangfire 里有没有。
- jobId 用数据库主键关联 —— NeTask 表里每个任务有一个 JobId 字段(业务唯一标识),用它做 Hangfire 的任务标识,后台页面改 Cron 后只需重新调用 EnableSchedule 即可热更新。
- 触发来源枚举 —— 方法签名里带了 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 的实现。
这才是好的架构设计:框架是服务,不是枷锁。