你以为发消息和改库能保证最终一致?99% 的人写的代码都是“伪 Outbox“

发布时间:2026/6/26 23:41:25

你以为发消息和改库能保证最终一致?99% 的人写的代码都是“伪 Outbox“ 你以为发消息和改库能保证最终一致99% 的人写的代码都是伪 Outbox我见过太多团队的代码是这么写的java Transactional public void createOrder(Order order) { orderDao.insert(order); // 业务事务提交 }// 然后在 Controller 或 Service 层 orderService.createOrder(order); mqProducer.send(order.created, order); // 发送消息 看起来逻辑通顺订单入库了消息也发出去了调用方收到消息后该扣库存扣库存、该发短信发短信。但这套代码有 3 个一致性问题每一个都是生产事故的温床消息比订单先发出去——MQ 发送比事务提交还快消费者查订单发现不存在回滚自己的逻辑主业务却已经入库。消息发不出去——MQ 集群故障或网络抖动send()抛异常订单事务还没回滚事务回滚和消息发送是两个独立过程订单可能入库也可能没入看谁先完成。重复发送——MQ 发送成功但 ack 丢失消息重投消费者收到两条order.created消息。这些问题本质上都是分布式事务问题——本地数据库和远程消息中间件之间没有原子性保障。你想让它们最终一致就必须引入一个专门的模式Outbox 模式。Outbox 模式的核心思想Outbox 模式的中文叫事务消息表思路简单到不行把发消息这件事从远程调用 MQ改成在本地事务里插一行记录让消息发送跟业务变更在同一个数据库事务里原子提交。然后由一个独立的 Poller 进程把这条记录投递到 MQ投递成功后删除或标记。伪代码大概是这样java Transactional public void createOrder(Order order) { orderDao.insert(order); outboxDao.insert(new OutboxRecord( order.created, serialize(order), Instant.now() )); // 事务统一提交 }Component public class OutboxPoller { Scheduled(fixedDelay 1000) public void poll() { List records outboxDao.findUnsent(100); for (OutboxRecord record : records) { try { mqProducer.send(record.getTopic(), record.getPayload()); outboxDao.markAsSent(record.getId()); } catch (Exception e) { // 不删下次再试 log.error(send failed, will retry, e); } } } } 看起来就这么几行代码。但 99% 的人写出来的 Outbox 都是有问题的因为忽略了一个关键设计点消息投递的可靠性。下面 5 个坑是真实生产事故的复盘。坑一Poller 单实例 → 消息堆积后拖垮业务数据库最常见的错误实现Poller 直接查业务库扫描未发送记录。java SELECT * FROM outbox WHERE status 0 ORDER BY id LIMIT 100;如果消息产生速度是 1000/秒单次扫描 100 条那 Poller 必须 10 秒扫一次才能追上。问题来了——Poller 跑得太频繁每次SELECT都要扫全表或全索引即使有status索引outbox 表百万行之后查询也会变慢业务库的连接池被 Poller 占用。Poller 跑得太慢消息堆积延迟从秒级变成分钟级最终消费者完全跟不上生产速度。正确做法是把 outbox 表和业务表库表分离。Outbox 单独一个库Poller 走专用连接池互不干扰。如果 QPS 高到单库撑不住Outbox 还需要按业务拆分按 topic 分库分表。更进一步的做法是绕开轮询用SELECT ... FOR UPDATE SKIP LOCKEDMySQL 8.0/PostgreSQL让多个 Poller 并发拉取每条消息只被一个 Poller 处理避免重复投递或者锁竞争。sql SELECT * FROM outbox WHERE status 0 ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED;坑二投递失败不重试 → 消息永久丢失最致命的错误Poller 投递失败后什么都不做。java try { mqProducer.send(...); outboxDao.markAsSent(...); } catch (Exception e) { // 业务里这种代码太常见了 log.error(send failed, e); }MQ 集群抖动是常态一次 send 失败不等于消息没价值。如果直接吞掉异常下次 Poller 不再扫描这条记录假设你按 sent 状态过滤消息就永远丢了。正确做法是指数退避 最大重试次数java public void send(OutboxRecord record) { int attempts 0; long backoffMs 100; while (attempts MAX_RETRIES) { try { mqProducer.send(record.getTopic(), record.getPayload()); outboxDao.markAsSent(record.getId()); return; } catch (Exception e) { attempts; if (attempts MAX_RETRIES) { outboxDao.markAsFailed(record.getId(), e.getMessage()); alertService.send(Outbox message exceeded retry limit, record); return; } sleep(backoffMs); backoffMs Math.min(backoffMs * 2, 60_000); } } }注意几个细节 -最大重试次数不能无限——MQ 持续故障时无限重试会让 outbox 表爆炸增长。 -失败状态要单独记录——status FAILED区别于status SENT运维需要能查哪些消息始终没发出去。 -超过阈值要告警——不能等业务方反馈消息没了才查。坑三消息没去重 → 消费者收到重复消息Outbox 模式天然有一个至少一次at-least-once的投递语义Poller 在发送成功但更新状态前崩溃会导致消息重发。这不是 bug是设计取舍。但很多团队没意识到这一点消费者侧没做幂等结果就是订单状态被更新两次虽然业务上看起来无害但日志、监控、计费都会重复库存被扣两次用户投诉钱款多退短信发两次用户反感消费者侧的幂等设计有三种主流方案方案一业务唯一键。如果消息本身有业务唯一标识订单号、流水号消费者用这个 key 做INSERT ... ON DUPLICATE KEY UPDATE重复消息直接被唯一索引挡住。方案二消息去重表。在消费者库建一张processed_message(message_id, processed_at)每次处理前先插入依赖唯一索引去重。方案三状态机幂等。业务本身有状态流转如订单已创建 → 已支付 → 已发货重复消息到达时检查当前状态已经处理过的状态直接 ack。Outbox 表里也应该加一个业务唯一键字段让消费者能基于这个键做幂等判断。Outbox 不是消息发出去就完事它是消息可靠 消费者幂等的组合拳。坑四消息表没有 TTL → 业务库三年后还存着 10 年前的消息生产里见过一个极端案例某团队的 outbox 表跑了 3 年存了 7 亿条记录磁盘占用 800GB备份时间从 30 分钟延长到 6 小时。sql -- 错误从不清理 SELECT * FROM outbox WHERE status 1; -- 7亿条全是 SENT正确做法是消息投递成功后立即归档或删除sql -- 投递成功后立刻删最简单的方案 DELETE FROM outbox WHERE id ? AND status 1;或者按时间窗口定期清理sql DELETE FROM outbox WHERE status 1 AND sent_at NOW() - INTERVAL 7 DAY LIMIT 10000;如果业务需要保留消息记录做对账那就归档到冷存储OSS、Hive、ClickHouse不要留在业务库。坑五跟其他模式混着用时容易踩的雷Outbox 很少单独使用通常跟其他模式组合。组合时容易踩的雷Outbox 事务消息如 RocketMQ 事务消息这是双重保险但很多人搞不清如果用 RocketMQ 事务消息还需要 Outbox 吗——答案是如果你用 RocketMQ 事务消息确实可以不要 Outbox但 RocketMQ 事务消息自身有性能开销两阶段提交 反查Outbox 在通用性上更灵活。两者选一不要混搭。Outbox CDCDebezium这是现代 Outbox 的高级玩法Poller 由 Debezium 监听 binlog 替代从轮询升级为事件驱动。性能更好但复杂度更高适合消息量极大的场景。Outbox 幂等表见坑三必须配套使用。Outbox 死信队列超过重试上限的消息应该进 DLQ 而不是直接 FAILEDDLQ 里的消息需要人工介入或者单独的补偿任务。实战选型清单最后给一个选型参考| 场景 | 推荐方案 | |------|----------| | 中小规模 1k msg/s用 Kafka/RabbitMQ | 业务库内 Outbox 表 定时 Poller 消费者幂等 | | 大规模 10k msg/s| 独立 Outbox 库 SKIP LOCKED 并发 Poller CDC 加速 | | MQ 自带事务消息RocketMQ| 直接用事务消息不需要 Outbox | | 已有 Debezium/CDC 基础设施 | CDC Outbox 替代 Poller | | 不允许任何丢失金融场景| Outbox 本地消息表 定时对账双重保险 |写在最后Outbox 是个看起来简单、实际工程化细节巨多的模式。任何只讲插一行记录 Poller 发送的教程都是不及格的——它没告诉你 Poller 失败怎么办、消息堆积怎么办、消费者怎么幂等、outbox 表怎么清理。真要在生产用 Outbox上面 5 个坑至少要解决 3 个否则上线就是定时炸弹。下次有人跟你说我用了 Outbox 模式保证最终一致你可以反问一句你 outbox 表跟业务库分开了吗你的消费者幂等怎么做你的 Poller 失败重试策略是什么答不上来那就是个伪 Outbox。最近在做一个用卡皮巴拉讲设计模式的小程序「爪爪代码冒险记」23 个模式用漫画 答题的方式讲正在开发中。你要是觉得这类把分布式问题讲明白的内容有意思搜一下「爪爪代码冒险记」能找到我。

相关新闻