zhaojie.tech/me
消息与分布式事务 2026-10-10T00:00:00.000Z

f01-outbox-pattern-implementation


title: Outbox 模式:把”发消息”变成”和业务一起写库” date: 2026-10-14 tags: [分布式, RabbitMQ, MySQL, 架构]

Outbox 模式:把”发消息”变成”和业务一起写库”

单据审核通过了,打印没出、库存台账没动。翻日志,下游模块一脸无辜:没收到消息。再翻一遍代码——审核逻辑最后一步发 RabbitMQ,前面那段事务里有个校验抛了异常回滚。消息发出去了,业务没成功。这是”幽灵消息”;反过来还有更糟的一种:事务提交成功,进程在提交后、发消息前崩溃,消息永远丢了,这是”丢单”。

两种病根是同一个:业务成功与消息发出,是两个独立动作,中间没有原子性。

先想清楚为什么不能”直接发”

我在单据审核这个场景里推演过三条路:

  1. 事务内发 MQ:先发消息再提交事务。事务一回滚,下游已经收到通知去干活了——幽灵消息,还得做补偿。
  2. 事务提交后发 MQ(afterCommit):提交成功到发消息之间进程崩溃,消息丢失。窗口很小,但支付和库存场景下一次都不能接受。
  3. 开 publisher confirm:confirm 只能证明”broker 收到了”,证明不了”业务成功与消息发出是原子的”——大头根本不在传输层。

所以答案是把发消息这个动作变成一次本地写库:业务单据和一条待发消息在同一个本地事务里写入。消息表就叫 Outbox(ss_bill_outbox,status=PENDING,payload 存 JSON)。事务提交成功,消息必然已经躺在表里;事务回滚,消息跟着消失。剩下的只是”怎么把表里的消息投出去”。

投递器:CAS 抢锁,集群安全

投递由独立的 OutboxPoller 完成,核心参数:

  • 每 5 秒扫一批,每批 100 条(@Scheduled(fixedDelay = 5000),用 fixedDelay 而非 fixedRate,防止上一轮耗时过长导致任务堆叠);
  • 扫描条件:PENDING 且到达 next_retry_at,或 PROCESSING 且锁已过期;
  • 每条记录用一条条件更新抢锁:
UPDATE ss_bill_outbox
SET locked_by = #{lockId}, locked_until = #{lockExpiry}
WHERE id = #{id}
  AND status = 'PENDING'
  AND (locked_until IS NULL OR locked_until < NOW())

这条 UPDATE 自带三重校验(PENDING + 锁为空 + 锁未过期),数据库行锁保证同一记录同一时刻只有一个实例能改成功——多实例部署不需要任何额外的分布式锁组件。锁超时 60 秒,实例崩溃后锁自然过期,别的实例接手。

失败处理的三层设计

投递失败不能无脑重试,我分了三层:

指数退避:重试延迟基数 30 秒、逐次翻倍、封顶 480 秒,最多 5 次,超限置 FAILED 等人工。

毒消息分类:OutboxRetryClassifier 遍历整条 cause 链判定——DB 死锁、连接超时、网络抖动这类基础设施瞬时异常视为可重试(重试后有机会自愈);而数据缺失、配置错误这类业务异常,重试一万次也不会成功,直接立即置 FAILED 转人工,不烧掉那 5 轮重试预算。判定纪律是”未知异常保守重试(有上限兜底),明确的业务错误快速失败”。

反审核作废:单据被反审核后,它审批时写入的 Outbox 消息若还是 PENDING/FAILED,必须显式置为独立终态 ABANDONED——否则轮询器晚一点把它投出去,就会对一张已反审核的单据重跑结算。

另外有个 StuckDetector 每 60 秒巡检一次:PROCESSING 卡住 5 分钟告警、30 分钟强制重置回 PENDING,同时盯着 PENDING 积压量——锁超时只保证”锁能被别人抢走”,它保证”被抢走之后这件事真的有人在管”。

踩过的坑

最疼的一个:失败后我用 updateById 回写重试状态,结果把内存对象里过期的 lockedBy 一并写回了数据库,锁释放了又被写回去,记录从此没人能抢。修法是所有状态推进一律走精确列的 UPDATE(只更新 retryCount、status、next_retry_at、locked_by=NULL),不用整对象回写。

另一个细节:消费每条记录必须走独立的 Service 方法让 @Transactional 代理生效,在同类内部直调事务注解是无效的。

收尾一句话

消息可靠性问题的第一性原理:先让”消息的存在”与”业务的成功”原子(同事务落库),再谈投递。投递可以慢、可以重试,但不能丢。

这个模式后来在渠道支付事件上被原样复用(ChannelOutboxPoller),证明它确实沉淀成了资产,而不是一次性代码。

← 全部文章 赵杰 · me.zhaojie.tech