你以为发消息和改库能保证最终一致?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 LOCKED(MySQL 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 + CDC(Debezium):这是现代 Outbox 的高级玩法,Poller 由 Debezium 监听 binlog 替代,从"轮询"升级为"事件驱动"。性能更好但复杂度更高,适合消息量极大的场景。
Outbox + 幂等表:见坑三,必须配套使用。
Outbox + 死信队列:超过重试上限的消息应该进 DLQ 而不是直接 FAILED,DLQ 里的消息需要人工介入或者单独的补偿任务。
实战选型清单
最后给一个选型参考:
| 场景 | 推荐方案 | |------|----------| | 中小规模(< 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 个模式用漫画 + 答题的方式讲,正在开发中。你要是觉得这类把分布式问题讲明白的内容有意思,搜一下「爪爪代码冒险记」能找到我。
