1. 课程导入与工程全景

在绝大多数真实业务系统中,用户请求只是冰山一角。真正支撑系统稳定运转的,往往是那些“看不见”的后台工作:订单超时自动关闭、库存定时对账、消息队列消费、报表每日凌晨生成、缓存预热、日志归档、第三方接口轮询补偿……这些工作不直接面向用户,却决定了系统能否在无人值守的情况下持续正确运行。本课程要解决的,正是如何用 .NET 把这些后台工作组织成可靠、可观测、可维护的工程结构。

先厘清两个核心概念。后台服务(Background Service)指随宿主进程长期运行、在后台线程或异步任务中持续工作的组件,它关注的是“一直活着并持续做事”。任务调度(Task Scheduling)指按照时间、周期或触发条件安排任务何时执行,它关注的是“在正确的时间做正确的事”。二者经常配合出现:一个后台服务内部可以承载一个调度器,调度器再触发具体任务。理解这条层次关系,是后续所有工程决策的基础。

后台服务回答“谁来长期承载工作”,任务调度回答“工作何时被触发”,具体任务回答“到底做什么”。三者分离,系统才可测试、可替换、可观测。

下面这张图展示了本节最值得先建立的整体认知:从宿主进程出发,后台服务作为长期载体,向下连接调度触发层,再落到具体业务任务,并统一汇入日志、指标与健康检查等可观测通道。

宿主进程 Host Generic Host / WebApplication 后台服务 BackgroundService IHostedService · ExecuteAsync · 生命周期托管 调度触发层 Scheduler 周期 Timer · Cron 表达式 · 队列触发 业务任务 Task 对账 · 清理 · 推送 · 报表 · 补偿 可观测通道 结构化日志 指标 Metrics 健康检查 外部依赖 数据库 / 缓存 消息队列 第三方 API 统一由 DI 容器注册,随宿主启动与停止 异常需自行捕获,避免拖垮整个宿主

典型应用场景可以按“触发方式”和“业务性质”两个维度来理解。按触发方式,常见有:固定周期型(每 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、查询条件是否正确、以及数据库连接是否可用。

时间基准务必统一。后台任务跨时区、跨服务器运行时,混用本地时间与 UTC 是超时判断出错的最常见原因。建议全链路使用 UTC 存储与比较,仅在展示层转换时区。