OutBox模式详解:保障本地事务与消息发送原子性的“黄金方案“
目录① 导读卡片② 背景与目标为什么学学完能怎样③ 概念与原理什么是 OutBox 模式架构示意OutBox 解决的根本问题OutBox 不保证什么④ 逻辑与对比OutBox vs 其他方案⑤ 核心详解完整实现与代码第一步创建 outbox 表第二步业务服务写入 outbox第三步MessageRelay —— 独立的消息转发器第四步消费者需幂等⑥ 案例实战秒杀系统的库存扣减 事件发布场景不使用 OutBox 的隐患使用 OutBox 的正确做法完整流程图⑦ 避坑 最佳实践❌ 常见错误✅ 最佳实践⑧ 总结 路线图记住了什么下一步去哪① 导读卡片项目内容一句话定位解决本地数据库事务和发送消息之间原子性问题的经典模式分布式事务落地利器适合人群微服务开发者、需要保证数据最终一致性的后端工程师、面试进阶选手难度★★★☆☆中等阅读时长约 10 分钟前置知识理解微服务架构、消息队列MQ基本概念、Transactional 注解的作用② 背景与目标为什么学先看一段看起来很合理的代码Transactional public void deductStock(Long productId, Integer count) { inventoryMapper.updateStock(productId, count); // 扣库存 mqTemplate.send(stock.deducted, productId); // 发消息 }这段代码有两个隐藏的坑场景后果消息发送成功事务提交失败下游收到消息但库存没扣掉 → 数据不一致事务提交成功消息发送失败库存扣了但下游不知道 → 订单以为自己还没扣这两个问题本质上是一个问题本地事务和消息发送不是一个原子操作。OutBox事务性发件箱模式就是为解决这个问题而生的——用一张数据库表当发件箱把这两个操作统一到同一个本地事务里。学完能怎样彻底告别在事务里发消息的坑能独立设计一套可靠的异步事件发布方案面试被问怎么保证消息的可靠性投递时给出完整的方案能说清楚 OutBox 和事务消息、Saga 的区别和适用场景③ 概念与原理什么是 OutBox 模式OutBox 模式事务性发件箱Transactional Outbox的核心思想是不直接在本地事务里发消息而是在同一个事务里写业务表和 outbox 消息表。事务提交后由另一个独立的进程轮询 outbox 表把消息发到真正的 MQ。架构示意OutBox 解决的根本问题保证本地事务提交和消息发送到 MQ这两个操作的原子性。✅ 本地事务提交 → outbox 记录落盘 → MessageRelay 一定能看到 → 重试直到发送成功✅ 本地事务回滚 → outbox 记录不存在 → 不会发出脏消息OutBox 不保证什么OutBox 只负责从生产者到 MQ这一段不保证消费者一定能消费到。消费者能否收到并处理成功是MQ 可靠性 消费者幂等 消费确认机制要解决的事。④ 逻辑与对比OutBox vs 其他方案很多人把 OutBox 和分布式事务方案混为一谈其实它们解决的问题不同方案解决的问题适用范围OutBox本地事务 消息发送的原子性单个服务的写入 发消息事务消息RocketMQ同上但由 MQ 代为管理需要 MQ 支持事务消息TCC跨服务强一致性多个服务需要同时成功/回滚Saga跨服务长事务 补偿多步骤业务流程2PC/Seata AT跨服务强一致数据库层面协调关系图OutBox/事务消息 → 解决单服务内部的原子性 ↓ 是更复杂方案的基础 TCC/Saga/Seata → 解决跨服务的分布式事务⑤ 核心详解完整实现与代码第一步创建 outbox 表CREATE TABLE outbox ( id BIGINT NOT NULL AUTO_INCREMENT, aggregate_id VARCHAR(64) NOT NULL COMMENT 聚合根ID如订单ID, event_type VARCHAR(128) NOT NULL COMMENT 事件类型如 OrderPaid, payload JSON NOT NULL COMMENT 事件内容JSON, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2发送失败, retry_count INT NOT NULL DEFAULT 0 COMMENT 重试次数, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), INDEX idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键设计点status0表示待发送——写入时必须是 0不是 1payload存完整的 JSON 事件体下游反序列化即可消费retry_count控制重试上限防止死信无限重试索引(status, created_at)让 MessageRelay 能高效扫描第二步业务服务写入 outboxService public class InventoryService { Autowired private InventoryMapper inventoryMapper; Autowired private OutboxMapper outboxMapper; Transactional public void deductStock(Long productId, Integer count, Long orderId) { // 1. 业务操作扣减库存 inventoryMapper.deduct(productId, count); // 2. 写入 outbox 记录同一个事务 OutboxRecord record new OutboxRecord(); record.setAggregateId(String.valueOf(orderId)); record.setEventType(StockDeducted); MapString, Object payload new HashMap(); payload.put(productId, productId); payload.put(count, count); payload.put(orderId, orderId); record.setPayload(new ObjectMapper().writeValueAsString(payload)); record.setStatus(0); // 待发送不是 1 record.setRetryCount(0); outboxMapper.insert(record); } }⚠️ 关键setStatus(0)是待发送不是已发送如果写成 1MessageRelay 永远不会扫到这条记录。第三步MessageRelay —— 独立的消息转发器Component public class MessageRelay { Autowired private OutboxMapper outboxMapper; Autowired private RabbitTemplate rabbitTemplate; // 或 RocketMQ/RabbitMQ 等 private static final int BATCH_SIZE 100; private static final int MAX_RETRY 5; /** * 定时轮询Spring Scheduled * 生产环境建议用独立调度任务或 CDC 方案见最佳实践 */ Scheduled(fixedDelay 1000) // 每秒轮询一次 Transactional public void relayMessages() { // 1. 批量取出待发送的消息 ListOutboxRecord pendingRecords outboxMapper.selectPending( BATCH_SIZE, // 每次处理 100 条 MAX_RETRY // 重试超过 5 次的跳过 ); for (OutboxRecord record : pendingRecords) { try { // 2. 发送到 MQ rabbitTemplate.convertAndSend( stock.exchange, stock.deducted, record.getPayload() ); // 3. 更新状态为已发送 outboxMapper.updateStatus(record.getId(), 1); } catch (Exception e) { // 4. 发送失败增加重试次数 outboxMapper.incrementRetry(record.getId()); log.error(消息发送失败将重试: {}, record.getId(), e); } } } }第四步消费者需幂等Component public class OrderServiceConsumer { RabbitListener(queues stock.deducted.queue) public void handleStockDeducted(String payload) { StockDeductedEvent event parseEvent(payload); String orderId event.getOrderId(); // 幂等校验防止重复消费 if (orderService.isOrderStockDeducted(orderId)) { log.info(订单库存已扣减跳过: {}, orderId); return; } // 执行业务更新订单状态 orderService.markStockDeducted(orderId); } }⑥ 案例实战秒杀系统的库存扣减 事件发布场景秒杀系统中库存服务消费秒杀 MQ 消息后需要扣减库存本地数据库发布库存已扣减事件通知订单服务不使用 OutBox 的隐患// ❌ 错误做法 Transactional public void handleSeckillOrder(SeckillMessage msg) { inventoryMapper.deduct(msg.getProductId(), 1); // 扣库存 // 风险MQ 发送时网络闪断抛出异常 → 库存扣了但消息没发出去 mqTemplate.send(stock.deducted, msg.getOrderId()); }使用 OutBox 的正确做法// ✅ 正确做法 Transactional public void handleSeckillOrder(SeckillMessage msg) { // 1. 扣库存 inventoryMapper.deduct(msg.getProductId(), 1); // 2. 写 outbox——和扣库存在同一个事务里 outboxMapper.insert(new OutboxRecord( msg.getOrderId().toString(), StockDeducted, new ObjectMapper().writeValueAsString(msg), 0, // status 待发送 0 // retry 0 )); // 事务提交后MessageRelay 会可靠地把消息发出去 }完整流程图1. 消费秒杀 MQ 消息 │ ▼ 2. Transactional 开始 │ ├── 2.1 扣减库存inventory表 │ └── 2.2 插入 outbox 记录status0 │ ▼ 3. 事务提交 ✅ │ ▼ 4. MessageRelay独立线程Scheduled 每秒跑一次 │ ├── 4.1 SELECT * FROM outbox WHERE status0 LIMIT 100 │ ├── 4.2 发送到 MQ │ └── 4.3 UPDATE outbox SET status1 WHERE id? │ ▼ 5. 订单服务消费消息幂等处理⑦ 避坑 最佳实践❌ 常见错误错误后果正确做法outbox.status 初始值设为 1MessageRelay 永远扫不到消息死掉status0表示待发送定时轮询太频繁数据库压力大CPU 空转固定延迟 1~5 秒或改用 CDC不设重试上限脏数据永远处理不完设置MAX_RETRY5超限告警在 MessageRelay 里开启新事务消息发了但状态没更新确保发送和状态更新在一个事务里消息体太小缺少关键信息消费者还需额外查询payload放完整 JSON方便消费者✅ 最佳实践生产环境用 CDC 替代轮询Scheduled定时轮询在低并发时够用但高并发下建议用CDCChange Data Capture使用 Debezium、Canal 等工具监听 MySQL binlogoutbox 表写入后binlog 实时推送变更到 MQ性能更好延迟更低定时轮询 vs CDC消息幂等是必须的即使 OutBox 保证至少一次投递消费者仍然可能收到重复消息MQ 重试、消费者重启等所以幂等是必须的不是可选的。独立的 MessageRelay 进程不要把 MessageRelay 和业务服务放在同一个进程中。否则业务服务重启时MessageRelay 也会暂停消息积压。建议独立部署的定时任务服务或单独的 CDC 消费端监控告警监控 outbox 表中status0的记录数超过阈值如 1000 条积压触发告警监控retry_count超限的记录人工介入排查⑧ 总结 路线图记住了什么概念一句话OutBox 解决的问题保证本地事务和消息发送到 MQ 的原子性不解决的问题消费者能否成功消费那是 MQ 和幂等的范围核心机制同事务写 outbox 表 独立进程转发关键字段status0待发送不是 1推荐升级高并发用 CDC 替代轮询消费者必须做幂等处理防止重复消费下一步去哪在自己的项目里实际应用一次 OutBox 模式研究 Debezium Kafka 的 CDC 方案搭建对比 RocketMQ 事务消息和 OutBoxMQ 的区别尝试把 OutBox 和 Saga 模式结合起来设计可靠的跨服务流程思考为什么说 OutBox 是至少一次投递at-least-once而非恰好一次投递exactly-once