Published on

后台任务的两条正交轴:时间触发与消息触发为什么必须分清

Authors
  • avatar
    Name
    Jack Qin
    Twitter

"写一个定时任务"几乎是每个后端都会的事。难的从来不是写出来,而是回答一个更基础的问题:这段后台工作,凭什么触发、在哪个进程里跑、能不能直接去碰别的模块? 这三个问题彼此正交,但它们在脑子里一旦糊成一团,代码就会朝着"哪里能引用就引用哪里"的方向慢慢腐烂。

本文不讲"怎么配 Quartz"或"怎么写一个 MassTransit 消费者",而是想把后台任务这件事拆成几笔清晰的账:触发语义是一笔账、进程归属是另一笔、模块通信又是一笔。把这三笔账分开看,"Quartz 还是 MassTransit"这种选型题就不再是口味问题,而是有确定答案的机制题。下面用某环境监测平台的模块化单体(.NET 10、Web-Queue-Worker、RabbitMQ、MassTransit、Quartz、PostgreSQL)作为贯穿全篇的工作样例。

第一笔账:触发语义——时钟驱动 vs 消息驱动

后台任务最核心的一道选择题,本质是问"是什么让这段工作开始跑?"答案只有两类,而这两类对应两套完全不同的基础设施。

一类是时钟/日历驱动:到点了、每隔多久一次、按排程重复。它的触发源是一个时间表,与系统里发生了什么事无关——校准提醒、扫描待发邮件排程、定时重算热力图,都属于这一类。这正是 Quartz 作业(CalibrationReminderJobProcessEmailSchedulesJobRecalculateHeatmapJob)存在的理由:它的抽象就是"触发器 = 时钟"。

另一类是消息驱动:有一条命令或一个事件到了,需要异步、解耦地处理它。触发源不是时间,而是"系统里发生了某件事"——发一封邮件、抓取资产位置、抓取监测数据,都是这一类(SendEmailConsumerScrapeAssetLocationsConsumerScrapeSensorDataConsumer)。这是 MassTransit 消费者的领域:它的抽象是"触发器 = 一条消息"。

关键不在于记住哪些类用 Quartz、哪些用 MassTransit,而在于看清这两套抽象建模的是两种不同的因果。把它们当成"两种都能定时跑东西的工具"去随意挑,迟早会写出用消费者硬模拟定时器、或用定时器轮询去替代事件订阅的别扭代码。判据应当回到触发语义本身:触发源是时钟,就是 Quartz;触发源是消息,就是消费者。

更值得注意的是这两者经常串联:一个 Quartz 作业到点了,并不亲自干活,而是派发一条命令,由消费者去实际执行。ProcessEmailSchedulesJob 到点扫描排程、派发发信命令,SendEmailConsumer 真正把邮件发出去。这条串联揭示了一个干净的分工:作业回答"何时",消费者回答"如何"。 把"何时"和"如何"塞进同一个组件,是后台任务代码开始难维护的第一个征兆——一个既扛着 cron 表达式、又扛着重试/解耦语义的胖类,两种关注点会互相拖累。

第二笔账:进程归属——发布与消费分在两个主机

第二笔账问的是"这段代码该在哪个进程里跑?"在 Web-Queue-Worker 模式下,这条边界画得很硬:

  • API 主机只负责发布(publish-only),不消费任何消息。
  • Worker 主机负责消费消息、运行 Quartz 作业。

这条边界不是洁癖,它有实打实的后果。后台处理往往拖着一堆重量级依赖——以这个平台为例,热力图渲染服务 IHeatmapRenderer 就是 Worker 专属的:AddEmailWorkerServices() 依赖它已被注册,而这条依赖链只在 Worker 里成立

于是出现了一个隐蔽的坑:不小心把 Worker 专属服务注册进了 API 主机。 直接后果是 API 启动时白白拖进一堆它根本用不上的原生渲染库;更深的后果是,它掩盖了一个架构问题——某段本该只在 Worker 进程里跑的代码,悄悄渗进了面向请求的 API 进程。第一种后果是性能税,第二种是边界塌陷的前兆。所以规则很简单:它属于 Worker,就只在 Worker 里出现。 进程归属这笔账,要在依赖注入的接线处就结清。

第三笔账:模块通信——只通过消息契约对话

第三笔账问的是"一个模块想让另一个模块干活,凭什么方式?"这是模块化单体能否长期成立的命门。

平台的答案是:跨模块、跨主机的通信,一律走共享抽象,绝不直接引用对方。抽象层是 ICommandDispatcherIEventPublisher 这组接口,加上基础设施层的 MassTransitCommandDispatcher 实现。由此派生出三条约束:

  • 模块不直接依赖另一个主机。 API 侧的模块不能反向引用 Worker 里的东西,反之亦然。
  • 不绕过消息抽象做临时跨模块引用。 该用命令/事件解耦的地方,别图省事直接 new 一个对方的服务。
  • 传输细节留在基础设施/主机层。 模块代码里不该出现 RabbitMQ、MassTransit 的具体类型。

这套约束的本质,是强制模块之间只通过消息契约对话。而它真正要防的东西,是一种渐进的腐蚀——模块化单体几乎从不死于一次大重构,它是被无数次"就这一次,直接引用一下"慢慢蚀空的。每一次为了"快一点"绕过抽象的直接引用,都在边界上凿掉一小块;凿到某个临界点,"模块化"就只剩名字,剩下的是一个谁都不敢动的大泥球。

注意第三笔账和前两笔的关系:模块通信的纪律,正是靠第一笔账(用命令/事件而非直接调用)和第二笔账(发布/消费分进程)撑起来的。三笔账不是并列的清单,而是同一套边界在不同维度上的投影。

让模块自描述:注册模式如何把边界还给模块

边界要可维护,就不能散落在主机的某个大文件里——那样每加一个模块都得回去改主机,主机迟早膨胀成一个无所不知的上帝对象。这个平台的做法是让每个模块自管自己,统一暴露三个注册口子:

  • Add{Module}(services, config)——基础注册;
  • AddConsumers(x)——注册自己的 MassTransit 消费者;
  • AddJobs(q, config)——注册自己的 Quartz 作业。

Worker 主机要做的只是聚合:Quartz 注册聚合各模块的作业,MassTransit 注册聚合各模块的消费者,ConfigureEndpoints(context) 统一接线端点。主机不需要知道任何模块内部有什么。

这套模式的价值在于模块是自描述的:加一个模块,它自己把作业和消费者带进来,边界由模块自己声明、而非由主机集中维护。这恰好呼应了前面三笔账——触发语义、进程归属、模块通信的纪律,只有当每个模块都能独立地、就近地声明自己的那一份,才不会在主机层退化成一锅粥。

测试该钉在哪一层

既然后台任务的难点是触发语义和派发副作用,测试就该直接钉在这两处,而不是绕一大圈做端到端:

  • 直接在作业/消费者层测调度或重复逻辑——不要为了验证一个 cron 表达式而跑一整条端到端链路。
  • 验证派发的副作用与状态更新——作业到点了,是否真的派发了那条命令?消费者处理完,状态是否落库?(参考 ProcessEmailSchedulesJobTestsSendHeatmapReportConsumerTests。)
  • 覆盖时区敏感行为,前提是该模块本就依赖时区。排程类作业(周报、每日报告)几乎一定涉及时区,而这部分逻辑最容易在夏令时切换、UTC/本地混用时出错——它脆弱,所以必须直接测。

这里的取舍很明确:测"作业有没有在对的时间派发对的命令",比测"整条链路最终有没有发出邮件"信噪比高得多。后者会被一堆正交的故障源污染,前者直接命中后台任务真正的故障轴。

可迁移的那一层

抛开 Quartz 和 MassTransit 的具体 API,这个案例真正可迁移的认知有两条:

第一,"后台任务"不是一个原子概念,而是几笔正交的账。 触发语义(时钟 vs 消息)、进程归属(发布 vs 消费)、通信方式(契约 vs 直接引用)——把它们分开问,选型就有确定答案;把它们糊在一起,就会写出胖组件和越界引用。面对任何后台工作,先拆账,再下手。

第二,模块边界是被默认值和捷径侵蚀的,不是被重构推倒的。 一个 Worker 专属服务误注册进 API、一次"就这一次"的直接引用、一种新发明的调度写法(明明 Quartz 已是仓库标准)——单看每一处都微不足道,累积起来就是大泥球。守住边界的唯一办法,是把这些约束写成可执行的契约(自描述的模块注册、钉在派发层的测试),让"绕过去"在机制上变难,而不是只写在文档里靠自觉。