单据审核通过的一瞬间要发生太多事:扣库存、记财务账、更新统计。最早我把这些塞在审核事务里同步做——事务越拉越长,任何一个下游抖一下,用户点”审核”就转圈失败;后来拆成 MQ 异步,又踩了另一个坑:业务事务提交了、消息没发出去(发 MQ 在事务提交前,回滚就丢;在提交后,进程崩溃就丢)。这就是我落地两套 Outbox 的起点:业务与消息同事务落库,由轮询器把消息投出去。丢消息的问题解决了,但”至少一次”投递天然意味着重复——这套架构真正的工作量,是把重复和卡死圈进可管理的边界里。
第一套:单据审批事件,消费在进程内
OutboxPoller 每 5 秒扫一批 ss_bill_outbox(每轮 100 条),处理审批通过的下游结算。多实例部署下同一个消息不能被两个实例同时消费,锁不走 Redis 走数据库 CAS:
UPDATE ss_bill_outbox SET status='PROCESSING', locked_by=#{lockId}, locked_until=#{lockExpiry}
WHERE id=#{id} AND status='PENDING'
影响行数为 0 就是没抢到,天然集群安全;locked_until 60 秒过期,实例崩溃后记录自动回到可抢状态。失败按指数退避重试:30s→60s→120s→240s→480s,最多 5 次,超限置 FAILED 等人工。
只做这些还不够,两个补丁是踩坑后加的。毒消息分类:配置错误、数据缺失这类异常重试一万次也不会成功,白白烧掉 5 轮退避还刷告警。OutboxRetryClassifier 沿异常 cause 链(最深 32 层,防环形链)判定:基础设施瞬时异常(连接断、死锁、超时)可重试且优先级最高;结构化错误码显式标记的业务/配置异常直接不可重试,立即 FAILED 等人修根因;全链未知的保守按可重试处理,反正有次数上限兜底。反审核作废:单据刚审批、outbox 消息还在排队,用户反审核了——这条消息若照常投递,会对一张已反审核的单据重跑结算。反审核流程显式调 abandonByBill 把该单据 PENDING/FAILED 状态的残留消息置为独立终态 ABANDONED(区别于 COMPLETED 的”结算成功”,账面可区分”做完了”和”不用做了”)。另有 StuckDetector 每 60 秒巡检:PROCESSING 超 5 分钟告警、超 30 分钟重置回 PENDING,PENDING 积压超 50 条或卡死记录≥3 也告警——卡死不再靠人肉翻日志发现。
第二套:渠道事件,消费跨进程
渠道域(商品分发、订单回流)是同一范式的另一端:ChannelOutboxPoller 同样每 5 秒、批 100、CAS 锁、同一套 30s→480s 退避,只是投递目标从进程内管道换成 RabbitMQ。投递时三件事写死:PERSISTENT 持久化投递模式、messageId 设为 outbox 主键、correlationId 设为业务幂等键——这三样是消费端去重的依据。
消费端是这套方案的另一半,三级防线:
- 手动 ack:业务处理成功才
basicAck,异常走重试与死信,不丢; - 幂等表:处理前先占位
ss_channel_idempotent,靠uk_idem(tenant_id, idempotent_key)唯一索引强约束,第二个并发消费者插入同 key 返回 false,直接跳过并 ack(已处理过,不该再投); - 死信队列:每个业务队列都挂
x-dead-letter-exchange,重试耗尽的消息落 DLQ 人工处置,不无限循环。
为什么是”生产端 At-least-once + 消费端幂等”
分布式消息里真正的 Exactly-once 投递要么不存在、要么贵到不值得。我的等式是:生产端保证消息迟早会到(同事务落表 + 退避重试 + 卡死检测),消费端保证到了多次只算一次(唯一索引占位)。两者合起来,业务语义上等效于精确一次(effectively-once)——重复投递仍然会发生,但每一次重复都在幂等表前停下。相比引入一个重量级事务协调器,这条路的每个组件都是数据库行、一条 UPDATE、一张唯一索引,可观测、可人工介入(FAILED 可认领重跑,ABANDONED 可审计)。
一句原则
别追求投递层的 Exactly-once,把力气花在让重复可被识别、卡死可被发现、毒消息可被分离——“至少一次 + 幂等”才是成本和正确性都在掌控内的解。