别再迷信“消息绝不丢”:Outbox + 幂等 + 补偿 + 对账的最终一致性工程实战适用技术栈:Java 17、Spring Boot 3.x、MyBatis、MySQL 8.0、Kafka同样适用于 RocketMQ、RabbitMQ、Pulsar 等具备至少一次投递语义的消息系统。前言:最终一致性不是一句“消息会重试”在分布式系统中,真正困难的从来不是“把一条消息发送出去”,而是回答下面这些问题:业务事务提交成功,但应用在发送消息前宕机,事件怎么办?MQ 已经接收消息,但应用还没来得及把 Outbox 更新为成功,怎么办?消费者完成业务处理,但在提交消费位点前宕机,消息再次到达怎么办?消费者调用第三方接口成功,本地状态却没有更新,如何判断是否需要重试?重试持续失败后,系统如何降级、告警和人工介入?上述机制全部存在时,为什么仍然需要对账?因此,工程上不能承诺“绝对不重复”“绝对不丢失”或“100% 自动恢复”。更准确的目标是:通过本地事务保证事件可追溯,通过至少一次投递保证事件可达,通过业务幂等消除重复副作用,通过补偿和对账把异常状态持续收敛到正确结果。这套机制通常由四部分组成:Outbox:解决业务数据与待发送事件的原子落库。幂等:解决至少一次投递带来的重复处理。补偿:解决暂时性失败、进程崩溃和状态卡死。对账:解决代码缺陷、人工操作、第三方异常等机制外差异。它们不是四个独立功能,而是一条完整的可靠性链路。目录一、从一次大促事故看清问题本质二、先建立正确认知:系统究竟能保证什么三、完整架构:四道防线如何协同四、第一道防线:事务 Outbox五、第二道防线:消费端业务幂等六、第三道防线:重试、补偿与死信治理七、第四道防线:业务对账与自动修复八、生产级数据库设计九、Spring Boot + Kafka 完整落地十、并发投递:不要让全局锁成为瓶颈十一、最容易写错的故障窗口十二、监控、告警与容量治理十三、从轮询 Outbox 演进到 CDC十四、方案边界与选型建议十五、上线前检查清单总结一、从一次大促事故看清问题本质假设某电商平台在用户支付成功后,需要完成三件事:锁定或扣减库存。发放用户积分。创建履约或物流任务。最初,订单服务在本地事务提交后同步调用三个下游服务:订单服务 ├── 调用库存服务 ├── 调用积分服务 └── 调用物流服务这种设计很快暴露出问题:库存接口超时,订单线程长时间占用。积分服务故障,拖垮订单服务连接池。第一个调用成功、第二个调用失败,产生部分成功。调用方超时,但下游实际上已成功,重试后出现重复扣减或重复发放。团队随后将同步调用改成直接发送 MQ:@TransactionalpublicvoidpaySuccess(LongorderId){orderMapper.updateStatus(orderId,"PAID");kafkaTemplate.send("order-paid",orderId.toString());}看起来解耦了,但这段代码至少存在两个危险窗口。1.1 消息成功,业务事务回滚发送 MQ 成功后,本地数据库更新因为异常回滚。消费者收到消息后,却找不到已支付订单,甚至错误地发放权益。1.2 业务事务成功,消息没有发送数据库提交成功后,应用进程在调用 MQ 前宕机。订单已经支付,但下游永远不知道。这就是典型的“双写一致性”问题:一次业务动作 ├── 写数据库 └── 写消息系统数据库与消息系统属于两个独立资源,不使用分布式事务时,无法通过一个普通本地事务让两者同时成功或同时失败。Outbox 的价值,就是把这次跨资源双写改造成一次数据库本地事务。二、先建立正确认知:系统究竟能保证什么2.1 Outbox 保证的是“事件可恢复”,不是天然只发送一次业务数据和 Outbox 事件在同一个本地事务中写入:BEGIN UPDATE payment_order ... INSERT outbox_event ... COMMIT只要事务提交成功,事件就一定存在于数据库中。即使应用随后宕机,后台 Relay 也能重新找到它并发送。但 Relay 存在一个无法消除的崩溃窗口:1. MQ 已确认接收消息 2. 应用尚未把 Outbox 状态更新为 SENT 3. 应用宕机 4. 恢复后再次发送同一事件因此,Outbox 通常提供的是:至少一次投递,而不是端到端精确一次。2.2 Kafka Producer 幂等不等于业务幂等Kafka Producer 的幂等能力主要解决同一个 Producer Session 内,因为网络重试造成的重复写入。它无法覆盖:应用重启后重新发送同一 Outbox 事件。不同实例同时领取到同一条事件。消费者完成业务后未提交 Offset,导致重新消费。同一业务动作生成了两个不同的消息 ID。所以必须区分三个概念:概念示例解决的问题message_id01J...标识某一条物理消息event_id01J...标识某一次领域事件idempotency_keyORDER_PAID:10086标识某个业务效果只能发生一次最稳妥的幂等键,通常来自业务语义,而不是每次都随机生成的 UUID。2.3 “Exactly Once”必须限定边界某些消息平台可以在特定范围内提供 Exactly Once,例如 Kafka 事务可以原子地完成:消费 Kafka 消息。处理结果写入另一个 Kafka Topic。提交消费 Offset。但如果处理结果要写 MySQL、调用支付渠道或请求第三方 HTTP 接口,系统又回到了跨资源一致性问题。因此,更严谨的表述是:消息系统内部可以实现特定边界内的精确一次,但跨数据库、MQ 和外部接口的端到端业务精确一次,通常仍依赖幂等、状态机与对账。三、完整架构:四道防线如何协同业务请求业务服务本地事务业务表outbox_eventOutbox RelayKafka / RocketMQ消费者消费事务consumed_event下游业务表补偿任务对账任务reconciliation_diff