- Published on
Outbox 不是可选项:从"先写库再发消息"的崩溃窗口看消息可靠性的第一性原理
- Authors

- Name
- Jack Qin
"先写数据库,再发消息通知别人"——这是后台系统里最常见的两步操作,常见到几乎没人会停下来看它一眼。但这两步之间有一个不可见的裂缝:进程可能恰好在第一步成功、第二步还没执行时崩掉。数据落地了,通知却永远没发出去。储罐到了临界水位、库里记了、告警邮件却不会发——这种 bug 在生产环境是灾难级的,而且它不是"如果代码写错了"才会发生,是这个两步结构本身注定会在某个时刻发生。
本文以某环境监测平台为样本,讲 Web-Queue-Worker 这套老牌模式在一个 .NET 10 后端里的落地。但我不想只描述"用了 RabbitMQ + MassTransit",而想把几个关键决策拆到第一性原理:Web 层为什么必须"只发布、不等待"、Outbox 为什么不是可省的优化而是正确性的前提、定时任务为什么用 Quartz 而不是裸 Timer、以及失败的消息最后该去哪。每一条的核心都是同一个问题——为什么是这个选择,而不是看起来更简单的那个。
先厘清这套模式到底在解什么问题
这套后端要处理两类性质完全不同的工作:
- 同步的 HTTP 请求:用户点开仪表盘、查粉尘读数、配置告警阈值——这些必须快,响应时间不能被后台任务拖累;
- 慢的后台工作:触发外部数据抓取、发定时邮件、调 AI 生成图表描述、重算热力图——这些慢且可以异步,没人会盯着浏览器等它跑完。
把这两类活塞进同一个请求线程,结果就是一句话能概括的灾难:"一个用户点了导出,全站 HTTP 变慢。" Web-Queue-Worker 的全部意义,就是把这两类工作物理隔离——快的归快的,慢的归慢的,互不拖累。
约束同样务实:团队 1–2 人,不能引入需要专人运维的重型设施;单台 PostgreSQL,已有;消息中间件要能跑在一个轻量 Docker 容器里。三个角色各司其职:
| 角色 | 职责 | 实现 |
|---|---|---|
| Web | HTTP 请求、鉴权、把命令/事件发布到队列 | Api(ASP.NET Core) |
| Queue | 解耦 Web 与 Worker,缓冲异步任务 | RabbitMQ(经 MassTransit) |
| Worker | 消费消息、跑定时任务、调外部 API | Worker(.NET 后台服务) |
flowchart LR
Client[Client] --> Web[Web API]
Web -->|发布命令| Queue[Queue / RabbitMQ]
Queue -->|消费| Worker[Worker]
Web -.->|200 / 202 立即返回,不阻塞| Client
Worker -.->|邮件 / 告警 / 抓取,长耗时异步| Done[完成]
"只发布、不等待":把一整类性能问题从根上消除
这是整套模式的灵魂,也是它最反直觉的一点。Web 层是 publish-only 的——它把命令丢进队列就立刻返回,从不等待后台任务完成。无论后台活儿跑 1 秒还是 60 秒,HTTP 响应时间都不受影响。
举例:前端请求"生成 AI 图表描述",Web 层发布一个 GenerateAiDescriptions 命令、返回一个 jobId 就结束了;真正去调 AI 服务的是 Worker,可能要跑几十秒。用户拿到的是 202 Accepted,而不是干等。
值得想清楚的是这条规则解决问题的层次。它不是"让慢请求快一点"的优化,而是从结构上让"后台任务的耗时"和"HTTP 响应时间"这两个变量彻底解耦。一旦解耦,"后台任务变慢拖垮前台"这一整类问题就不再可能发生——不是被缓解,是被消除。这是结构性修复和优化性修复的区别:优化是把一根有问题的轴调好一点,结构性修复是把这根轴从问题里拿掉。
Worker 有两种被唤醒的方式:
| 触发 | 机制 | 例子 |
|---|---|---|
| 消息驱动 | MassTransit 从 RabbitMQ 消费 | 发邮件、AI 描述、触发抓取 |
| 调度驱动 | Quartz.NET cron 任务 | 储罐告警(每 5 分钟)、校准提醒(每天 8 点) |
消息驱动是"被外部事件唤醒",调度驱动是"自己按表干活"。两者都跑在 Worker 进程里。
为什么是 MassTransit,而不是裸客户端
选 MassTransit 而不是直接调 RabbitMQ 客户端,本质是一个"自己写 vs 拿成熟件"的判断,但判断的依据不是"省事",而是可靠性机制的正确实现成本极高:
| 选项 | 结论 |
|---|---|
| MassTransit | 内置重试策略、熔断、死信队列;EF Core 事务性 Outbox;传输无关,可换中间件 |
| 裸 RabbitMQ 客户端 | 所有可靠性机制都得自己写 |
重试退避、死信、事务性 Outbox 这些东西,每一个单独写都不难,但全部正确地写、并且在并发和崩溃场景下都正确,是一笔很大且容易出错的工程。这正是该用成熟组件的地方。
中间件本身也比较过:
| 选项 | 结论 |
|---|---|
| RabbitMQ | 成熟、MassTransit 原生支持、轻量 Docker 容器、自带管理界面 |
| Redis Streams | 只有当 Redis 已在技术栈里才划算 |
| 基于 PostgreSQL 的方案 | 把队列设施耦合进数据库,损害数据库可移植性 |
| 云托管消息服务 | 对自托管部署引入厂商锁定和成本 |
核心理念:用 MassTransit 把"业务代码"和"具体传输"隔开。 模块代码只调 ICommandDispatcher.SendAsync 和 IEventPublisher.PublishAsync,永远不直接 import MassTransit。想从 RabbitMQ 换成别的中间件,只改 Infrastructure.Messaging 一个工程,业务代码一行不动——换中间件的爆炸半径被锁死在一个工程内。
Outbox:这不是优化,是正确性本身
这是整篇里最重要的一条,也是开篇那个裂缝的正面回答。没有 Outbox,"先写库再发消息"这两步之间一旦崩溃,系统就进入不一致状态:
// 没有 Outbox —— 崩溃窗口:
await dbContext.SaveChangesAsync(); // 成功
// ← 进程在这里崩溃
await publisher.Publish(event); // 永远没发出去 —— Worker 永远收不到通知
数据写进去了,但通知 Worker 的消息丢了。储罐到了临界水位、库里记了,告警邮件却永远不会发。这里要看清的是:这个崩溃窗口不是"小概率的意外",而是这个两步结构的固有属性。 只要"写库"和"发消息"是两个独立的、非原子的操作,就一定存在一个时刻让进程死在中间。你没法靠"写得更小心"消除它,因为它不在代码的对错层面,而在结构层面。
事务性 Outbox 的做法是把"发消息"折叠进"写库"的同一个原子操作里:把消息写进同一个数据库事务里的 outbox 表。一个 relay 异步地把它投递到 RabbitMQ,投递失败就重试。业务数据和消息要么一起提交、要么一起回滚,不存在中间态——那个崩溃窗口被消除了,因为两步变成了一步。这还附带一个好处:即使 RabbitMQ 当时不可用,消息也不会丢,它躺在 outbox 表里等着被投递。
所以"Outbox 不能省"不是一句经验之谈,而是一个推论:只要你需要"数据落地"和"事件通知"保持一致,而它们又写在两个不同的存储系统里,你就必须有某种机制把这两个写折叠成一个原子操作——Outbox 就是这个机制。 省掉它,省的不是代码,是正确性。这是它和重试、死信这些"可靠性增强"在性质上的根本区别:那些是让系统更健壮,Outbox 是让系统正确。
定时任务:为什么裸 Timer 不够
| 选项 | 结论 |
|---|---|
| Quartz.NET | 任务状态持久化到 PostgreSQL,Worker 重启后定时任务不重复触发 |
| Hangfire | 把任务持久化绑死在数据库,还多一个 dashboard 的负担 |
IHostedService + Timer | 无持久化、无 cron 表达式 |
关键在"持久化 + 重启幂等"。裸 Timer 把任务调度状态放在内存里——Worker 一重启,内存清空,它要么忘掉还没到点的任务,要么把已经触发过的任务重新触发一遍。Quartz 把任务状态存在 PostgreSQL,重启时知道哪些已经跑过、哪些还没到点,不会重复触发。
这又是一个"状态放哪"决定一切的例子:进程内状态在进程重启时一定丢失,所以任何需要跨重启保持的状态都必须落到进程外的持久存储。 定时任务的"上次跑到哪"恰恰是这种状态。裸 Timer 不是实现得差,是它的设计前提(进程不重启)在真实部署里根本不成立。
实现要点:让机制账落到代码
三类消息,对应三种语义
| 类型 | 接口 | 流向 | 例子 |
|---|---|---|---|
| 命令 Command | ICommandDispatcher.SendAsync<T> | Api → Worker(点对点) | SendScheduledEmail、ProcessTankAlert |
| 集成事件 Integration Event | IEventPublisher.PublishAsync<T> | 发布者 → 所有订阅者(扇出) | TankLevelCritical、DeviceCalibrationExpired |
| 定时任务 Scheduled Job | Quartz IJob | Worker 内部(cron 触发) | 储罐水位检查、校准提醒 |
命令和事件的区别不是技术细节,是耦合方向:命令是"让某个指定的人去做一件事"(点对点,发送方知道谁该做),事件是"发生了一件事,谁关心谁接"(扇出,发布方不知道也不关心谁在听)。选错会把松耦合写成紧耦合。
队列命名与重试死信
用 KebabCaseEndpointNameFormatter 把类型名转成队列名:SendScheduledEmail → send-scheduled-email。
失败的消息有一套退避策略,最终进死信队列而不是凭空消失:
flowchart TD
Deliver[消息投递] --> OK{Worker 处理}
OK -->|成功| Done[移除 ✓]
OK -->|失败| Retry[立即重试 × 3]
Retry --> Backoff[退避重试 5s → 15s → 30s]
Backoff --> Error[仍失败 → 进 _error 队列<br/>RabbitMQ 管理界面 :15672 可见]
_error 队列是运维的"事故现场"——消费者为什么反复失败,去那里查。配套告警阈值是"_error 队列深度 > 10 条就叫人"。需要去重的消费者用 EventId 做幂等。这里的设计取向是:失败不该静默消失,而该堆到一个可见、可查、有告警的地方。 消息凭空蒸发是最难排查的故障,死信队列把"消失"换成"可见"。
消费者注册(显式,不扫描)
services.AddMassTransit(x =>
{
x.SetKebabCaseEndpointNameFormatter();
MonitoringModule.AddConsumers(x);
TankManagementModule.AddConsumers(x);
EmailModule.AddConsumers(x);
// ... 所有模块
x.UsingRabbitMq((ctx, cfg) => { cfg.Host(uri); cfg.ConfigureEndpoints(ctx); });
});
逐个模块显式注册消费者,不做程序集扫描——理由和宿主端点注册一致:可读性和可排查性,在小团队里压过"少打几行"。
部分消费者与定时任务清单
消费者(消息驱动)举例:
| 模块 | 消费者 | 处理 |
|---|---|---|
| Monitoring | ScrapeSensorDataConsumer | 触发外部抓取 |
| TankManagement | ProcessTankAlertConsumer | 收到 TankLevelCritical 后发告警邮件 |
| Geospatial | ScrapingCompletedConsumer | 抓取完成后触发热力图重算 |
SendEmailConsumer | 发定时/事务邮件 | |
| Reporting | GenerateAiDescriptionsConsumer | 调 AI 生成描述 |
定时任务(Quartz cron)举例:
| 模块 | 任务 | cron | 用途 |
|---|---|---|---|
| TankManagement | CheckTankLevelsJob | 每 5 分钟 | 检查水位,越界则发布 TankLevelCritical |
ProcessEmailSchedulesJob | 每分钟 | 找到期的 schedule,派发 SendEmail | |
| Assets | CalibrationReminderJob | 每天 8 点 | 找逾期校准的资产,派发提醒 |
| Monitoring | ScrapeDeviceDataJob | 每 15 分钟 | 触发外部抓取 |
| Reporting | WeeklyReportJob | 周一 7 点 | 生成周报 |
所有 cron 任务都配 TimeZoneInfo.FindSystemTimeZoneById("Australia/Perth")——定时任务的"8 点"是站点本地时间,不是 UTC。这点不显式声明,cron 会跟着服务器时区跑,迁移机器或换时区时静默错位,而且因为它不报错,往往很久以后才被发现。
外部集成的统一约束
所有外部依赖(外部抓取 API、AI 服务、SMTP、对象存储、OIDC 提供方)走统一规则:
- 全部通过
IHttpClientFactory命名客户端注册,带 typed wrapper; - 凭据只从环境变量加载,绝不硬编码;
- 每个 provider 在所属模块的
Infrastructure/层有接口,Application 层从不直接调HttpClient; - 失败通过
Result<T>上报——Worker 不会因为非关键失败而崩溃。比如 AI 描述调失败,就返回空描述,不影响主流程。
最后一条体现了一个判断:外部依赖的失败要分级。 AI 描述生成失败不该把整个 Worker 拖垮——它是"锦上添花",失败就降级(空描述),而不是让一个非关键路径的异常冒泡成进程崩溃。
可迁移的那一层
抛开 RabbitMQ 和 MassTransit 的具体 API,这套模式真正可迁移的认知有两条。
第一条关于结构性修复 vs 优化性修复:Web 层 publish-only 不是"让慢请求快一点",而是把"后台耗时"这个变量从"HTTP 响应时间"里彻底拿掉。当你面对一类反复出现的性能问题时,值得先问一句——我是在调一根有问题的轴,还是能把这根轴从问题里移除?
第二条关于跨存储一致性:只要一个操作需要同时写两个独立的存储系统(数据库 + 消息队列、数据库 + 缓存、数据库 + 外部 API),它们之间就一定有一个崩溃窗口,而消除窗口的唯一办法是把两个写折叠成一个原子操作——Outbox 是数据库+队列这一对的标准答案。看到"先写 A 再写 B",就该警觉:这两步之间崩了会怎样?
最后诚实标注边界——这套模式不是处处适用:
- 没有真正的长耗时后台任务:如果所有操作都能在一次 HTTP 里快速完成,引入队列和 Worker 是纯负担;
- 任务量极小且能容忍丢失:偶尔丢一条无关紧要的后台任务,那 Outbox、死信这套可靠性机制可能是过度设计。
Web-Queue-Worker 的甜区是:系统里同时存在"必须快的同步请求"和"可以慢的异步工作",且后者的可靠交付有业务意义——告警不能丢、邮件不能漏。这个监测平台正好是典型场景。一旦后台任务的可靠交付没有业务意义,Outbox 这套就从"正确性前提"退化成"过度设计"——边界就在这里。