在 SwitchData 网络配置管理系统中,有一批周期性任务需要执行:网元日志采集与解析、UPF 地址配置导入、PCF 统计文件解析等。这些任务具备三个共性:类型可扩展、调度方式多样(定时 / 手动 / 延迟)、必须防重复触发。
本文基于项目的真实代码,讲清楚如何用 Hangfire 做后台任务调度,用 工厂 + 策略 模式让每种任务独立实现,用 幂等性 Key + 状态机 防重复执行,最后用 BackgroundService 在 Worker 启动时一次性把数据库中的任务同步到 Hangfire。
整体架构
`mermaid graph TD A[Web API / Job Worker 启动] –> B[NeTaskHangfireScheduler] B –> C[NeTaskExecutorFactory 工厂] C –> D[INeTaskExecutor 策略接口] D –> E[CollectTaskExecutor 采集] D –> F[ParseTaskExecutor 解析] D –> G[ParseUpfAddressTaskExecutor] D –> H[ParseZxPcfStatFileTaskExecutor]
style A fill:#f9f,stroke:#333
style B fill:#bbf,stroke:#333
style C fill:#bfb,stroke:#333
style D fill:#fbb,stroke:#333
`
- 调度层:NeTaskHangfireScheduler 封装 Hangfire 的 AddOrUpdate / Enqueue / Schedule
- 执行层:INeTaskExecutor 接口 +
NeTaskExecutorBase
泛型抽象基类 + 若干具体 Executor - 工厂层:NeTaskExecutorFactory 根据 NeTaskType 枚举返回对应策略实现
- 持久化层:NeTaskExecution 记录每次执行的上下文、状态、幂等性 Key
第一步:Hangfire 基础配置
Hangfire 建议独立成一个 Worker 进程(项目中的 SwitchData.Job),不要跟 Web API 混在一起,否则在 IIS 回收时会丢失后台处理能力。
SwitchData.Job/Program.cs 里的配置:
`csharp builder.Services.AddHangfire(config => config .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 }));
builder.Services.AddHangfireServer();
builder.Services.AddSingleton
几个关键参数解释:
| 参数 | 作用 |
|---|---|
| SlidingInvisibilityTimeout = 10min | 任务被 Worker 取出后,10 分钟内还没完成则视为失败,其他 Worker 可接管 |
| QueuePollInterval = Zero | 队列轮询零延迟,实时性优先 |
| DisableGlobalLocks = true | 单机部署下关掉全局锁,减少数据库竞争 |
第二步:调度器封装
直接在业务代码里写 RecurringJob.AddOrUpdate(…) 太散。NeTaskHangfireScheduler 把 Hangfire 原始 API 包装成项目语义:
`csharp public class NeTaskHangfireScheduler { private readonly IBackgroundJobClient _backgroundJobClient; private readonly IRecurringJobManager _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);
} `
设计要点:Enqueue / Schedule 都传 executionId 而不是 taskId——因为手动触发、延迟执行的场景需要一条独立的执行记录,而不是直接复用定时任务配置。
第三步:工厂 + 策略——让每种任务独立实现
策略接口
`csharp public interface INeTaskExecutor { NeTaskType TaskType { get; } Type PayloadType { get; }
Task<JsonDataResult> ExecuteAsync(
NeTaskExecution execution, object payload,
PerformContext context, CancellationToken cancellationToken = default);
string CreateIdempotencyKey(int? taskId, object payload);
bool TryGetPayload(string payloadJson, out object payload);
} `
泛型抽象基类(模板方法)
`csharp public abstract class NeTaskExecutorBase
public abstract Task<JsonDataResult> ExecuteAsync(
NeTaskExecution execution, TPayload payload,
PerformContext context, CancellationToken cancellationToken = default);
public abstract string CreateIdempotencyKey(int? taskId, TPayload payload);
async Task<JsonDataResult> INeTaskExecutor.ExecuteAsync(
NeTaskExecution execution, object payload,
PerformContext context, CancellationToken cancellationToken)
=> await ExecuteAsync(execution, (TPayload)payload, context, cancellationToken);
bool INeTaskExecutor.TryGetPayload(string payloadJson, out object payload)
{
try
{
payload = JsonHelper.Deserialize<TPayload>(payloadJson);
return payload != null;
}
catch { payload = default; return false; }
}
} `
具体实现示例
`csharp public class ParseTaskExecutor :
NeTaskExecutorBase
public override string CreateIdempotencyKey(int? taskId, ParseTaskPayload payload)
{
if (taskId.HasValue) return "TaskId=" + taskId;
if (payload.NeGroupId.HasValue)
return "TaskType=" + (int)TaskType + ":NeGroupId=" + payload.NeGroupId;
if (payload.DeviceId.HasValue)
return "TaskType=" + (int)TaskType + ":DeviceId=" + payload.DeviceId;
throw new InvalidOperationException("NeGroupId 和 DeviceId 不能同时为空");
}
public override async Task<JsonDataResult> ExecuteAsync(
NeTaskExecution execution, ParseTaskPayload payload,
PerformContext context, CancellationToken cancellationToken)
{
if (payload.NeGroupId.HasValue)
return await NeLogService.ParseAndPersistGroupAsync(
payload.NeGroupId.Value, execution.Id, true, cancellationToken);
return await NeLogService.ParseAndPersistDeviceAsync(
payload.DeviceId.Value, execution.Id, true, cancellationToken);
}
} `
工厂——注册表模式
`csharp public static class NeTaskExecutorFactory { private static readonly Dictionary<NeTaskType, INeTaskExecutor> _executors;
static NeTaskExecutorFactory()
{
_executors = new()
{
[NeTaskType.Collect] = new CollectTaskExecutor(),
[NeTaskType.Parse] = new ParseTaskExecutor(),
[NeTaskType.ParseUpfAddress] = new ParseUpfAddressTaskExecutor(),
[NeTaskType.ParseZxPcfStatFile] = new ParseZxPcfStatFileTaskExecutor()
};
}
public static INeTaskExecutor GetExecutor(NeTaskType taskType)
=> _executors.TryGetValue(taskType, out var ex)
? ex
: throw new NotSupportedException("不支持任务类型:" + taskType);
} `
扩展新任务 = 新建一个继承 NeTaskExecutorBase
第四步:幂等性控制 + 状态机
Hangfire 会重试失败的任务,用户可能手动点了立即执行,定时调度也在跑——同一个任务在某个时间点可能被多个触发器同时命中。
状态流转
mermaid stateDiagram-v2 [*] --> Pending: CreateAsync (手动/延迟) [*] --> Running: CreateAsync (定时触发) Pending --> Running: SetRunningStatusAsync (CAS 更新) Running --> Success: 业务执行成功 Running --> Failed: 异常 / 返回 success=false
关键:CAS 更新防止并发
`csharp public static async Task
int rows = await database.ExecuteNonQueryAsync(sql, ...);
return rows > 0;
} `
这条 UPDATE … WHERE Status = Pending 就是经典的 Compare-And-Swap。多个 Worker 同时读到 Pending,但只有一个能成功更新,其他拿到 0 行就跳过。
定时任务的并发保护
`csharp var execution = await NeTaskExecutionService.TryCreateAsync( taskId, neTask.TaskType, triggerSource, neTask.PayloadJson, cancellationToken);
if (execution == null) { DbLogHelper.AddLog(“任务调度”, “任务执行”, false, “任务[TaskId=” + taskId + ”]正在执行,跳过本次触发”); return; } `
TryCreateAsync 调用 CreateAsync 时吞掉了数据库唯一约束异常(幂等 Key 冲突),返回 null 表示跳过本次触发。
第五步:Worker 启动时全量同步
后台服务 JobWorkerService 在 Worker 进程启动时从数据库里读出所有 Enabled = true 的任务,一次性注册到 Hangfire:
`csharp public class JobWorkerService : BackgroundService { private readonly NeTaskHangfireScheduler _scheduler;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Logger.Info("定时任务Worker启动");
_ = Task.Run(() => SyncJobsAsync(stoppingToken), stoppingToken);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken)) { }
}
private async Task SyncJobsAsync(CancellationToken cancellationToken)
{
await foreach (var task in NeTaskService.GetListAsync(cancellationToken))
{
if (task.Enabled)
_scheduler.EnableSchedule(task.Id, task.JobId, task.CronExpression);
else
_scheduler.DisableSchedule(task.JobId);
}
}
} `
为什么要用 BackgroundService:SwitchData.Job 是一个最小化的 WebApplication,启动后 app.Run() 就阻塞了。注册一个 HostedService,它的 ExecuteAsync 会在 Host 启动后立刻执行,刚好用来做初始化同步。
第六步:手动触发 / 延迟执行
除了定时,项目还支持从界面点立即执行和指定 X 分钟后执行:
`csharp [HttpPost(“execute-now”)] public async Task
[HttpPost(“execute-at”)] public async Task
NeTaskExecutionService.CreateAsync 内部会根据 executeAt 是否有值决定调 scheduler.Enqueue 还是 scheduler.Schedule。
实战总结
- Worker 进程独立:Hangfire 不跟 Web API 混跑,避免 IIS 回收丢失任务
- 工厂 + 策略:新增任务类型 = 新增 Executor 子类 + 一行注册,开闭原则贯彻到底
- 幂等性 Key + CAS 更新:定时触发、手动触发、延迟触发三条路径共享一套去重机制
- 执行记录持久化:每条执行都落库,带状态机流转,既能审计也能让 CAS 生效
- BackgroundService 全量同步:数据库才是配置的唯一真源,Worker 每次启动自动对齐
这套架构已经在生产环境稳定运行数月,采集 / 解析任务每天自动跑好几趟,手动触发也不会跟定时冲突。如果你也在做类似的后台任务调度,不妨把这几个关键做法抄过去。