1. 课程导入与工程全景
在绝大多数真实业务系统中,用户请求只是冰山一角。真正支撑系统稳定运转的,往往是那些“看不见”的后台工作:订单超时自动关闭、库存定时对账、消息队列消费、报表每日凌晨生成、缓存预热、日志归档、第三方接口轮询补偿……这些工作不直接面向用户,却决定了系统能否在无人值守的情况下持续正确运行。本课程要解决的,正是如何用 .NET 把这些后台工作组织成可靠、可观测、可维护的工程结构。
先厘清两个核心概念。后台服务(Background Service)指随宿主进程长期运行、在后台线程或异步任务中持续工作的组件,它关注的是“一直活着并持续做事”。任务调度(Task Scheduling)指按照时间、周期或触发条件安排任务何时执行,它关注的是“在正确的时间做正确的事”。二者经常配合出现:一个后台服务内部可以承载一个调度器,调度器再触发具体任务。理解这条层次关系,是后续所有工程决策的基础。
下面这张图展示了本节最值得先建立的整体认知:从宿主进程出发,后台服务作为长期载体,向下连接调度触发层,再落到具体业务任务,并统一汇入日志、指标与健康检查等可观测通道。
典型应用场景可以按“触发方式”和“业务性质”两个维度来理解。按触发方式,常见有:固定周期型(每 30 秒轮询一次待处理订单)、日历时间型(每天 02:00 生成前一日报表)、事件驱动型(收到消息队列消息后立即处理)、延迟型(下单 15 分钟后未支付则关闭)。按业务性质,则可分为数据同步与对账、批量计算与报表、清理与归档、通知与推送、补偿与重试等。识别场景属于哪一类,直接决定你该选 Timer、Cron 还是队列消费者。
技术栈选型上,.NET 提供了从轻到重的完整光谱。最基础的是 IHostedService 与 BackgroundService,适合自建简单循环;System.Threading.Timer 或 PeriodicTimer 负责周期触发;需要 Cron 语义时,可引入 NCrontab 或 Quartz.NET;需要持久化、集群、失败重试与可视化时,Hangfire 或 Quartz.NET 更合适;跨服务解耦则交给 RabbitMQ、Kafka 等消息中间件。选型原则是:先用最轻的方案满足需求,当出现“需要持久化调度状态、需要多实例互斥、需要失败自动重试”这三类信号时,再升级到专业调度框架。
| 方案 | 适用场景 | 关键能力 | 注意点 |
|---|---|---|---|
| BackgroundService + PeriodicTimer | 单实例、简单周期任务 | 轻量、无额外依赖 | 无持久化,重启丢状态 |
| NCrontab | 需要 Cron 表达式 | 解析 Cron,配合自建循环 | 仍需自行处理并发与重试 |
| Quartz.NET | 复杂调度、集群 | 持久化、 misfire 策略、集群互斥 | 配置与表结构较重 |
| Hangfire | 需要可视化与自动重试 | 仪表盘、重试、延迟任务 | 依赖存储,注意队列隔离 |
以一个具体例子说明“实际怎么做”。假设要实现“每 30 秒扫描一次超时未支付订单并关闭”。在 .NET 中,先定义一个继承 BackgroundService 的类,在 ExecuteAsync 中创建 PeriodicTimer,循环等待触发;每次触发时创建一个 DI 作用域,从作用域中解析出订单仓储,查询 Status = Pending 且 CreatedAt <= UtcNow.AddMinutes(-15) 的订单,逐条更新状态并写入日志。关键细节有三:一是必须用 IServiceScopeFactory 创建作用域,因为后台服务是单例,不能直接注入 Scoped 仓储;二是循环体内要 try/catch,单次异常不能让整个服务退出;三是查询要加索引并限制批量大小,例如每次最多处理 200 条,避免一次拉取过多拖慢数据库。
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IOrderRepository>();
var expired = await repo.GetPendingBeforeAsync(
DateTime.UtcNow.AddMinutes(-15), limit: 200, stoppingToken);
foreach (var order in expired)
{
order.CloseAsTimeout();
_logger.LogInformation("订单 {OrderId} 已超时关闭", order.Id);
}
await repo.SaveChangesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "超时订单扫描失败,将在下个周期重试");
}
}
}
运行后如何判断是否达成目标?观察三类信号:日志中是否按周期出现“已超时关闭”记录且数量合理;数据库中对应订单状态是否从 Pending 变为 Closed;若接入指标,可观察每轮处理耗时与处理条数是否稳定。如果日志持续报错或处理条数长期为 0,就要检查时间基准是否统一使用 UTC、查询条件是否正确、以及数据库连接是否可用。