当前位置: 首页 > news >正文

你以为发消息和改库能保证最终一致?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 个一致性问题,每一个都是生产事故的温床:

  1. 消息比订单先发出去——MQ 发送比事务提交还快,消费者查订单发现不存在,回滚自己的逻辑,主业务却已经入库。
  2. 消息发不出去——MQ 集群故障或网络抖动,send()抛异常,订单事务还没回滚(事务回滚和消息发送是两个独立过程),订单可能入库也可能没入,看谁先完成。
  3. 重复发送——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 个模式用漫画 + 答题的方式讲,正在开发中。你要是觉得这类把分布式问题讲明白的内容有意思,搜一下「爪爪代码冒险记」能找到我。

http://www.jsqmd.com/news/1084429/

相关文章:

  • 服务器安全加固实战:防火墙、SSH密钥与漏洞扫描三件套
  • 飞书文档批量导出工具:3步实现企业知识库自动化迁移的终极方案
  • 微信群消息自动转发工具:告别手动复制的智能解决方案
  • 质量管理-IPQC是指什么?
  • Jetson JetPack 6.0 新特性与迁移指南
  • CNKI-download:告别手动收集,3分钟掌握知网文献批量下载终极技巧
  • N_m3u8DL-RE:跨平台流媒体下载工具的终极指南 [特殊字符]
  • K老答——其实一直都在
  • qBittorrent搜索插件终极指南:一键解锁20+种子搜索引擎
  • 【JAVA毕设源码分享】基于SpringBoot+Vue的学生交流互助平台的设计与实现(程序+文档+代码讲解+一条龙定制)
  • Windows窗口置顶神器:AlwaysOnTop让你的重要信息永不遮挡
  • 2026年独家揭秘:口碑爆表!舆情公关哪家强?
  • WPS安装教程详细步骤WPS2025下载安装配置教程
  • 2026手机证件照背景颜色选择保姆级教程,证件照背景颜色标准实操指南
  • 中走丝线切割机床加工精度能到多少?看懂Ra和μm就够了
  • 10.2 真创新 vs 包装概念
  • 【职场】职场上最可怕的不是黑暗,而是Zero Tolerance
  • K老答——所见皆漏
  • Java 求职面试:音视频场景下的技术探讨
  • WordPress站长必读:钓鱼邮件攻击链深度解析与防御指南
  • qmcdump:深度解析QQ音乐加密文件解密技术原理与实践指南
  • 广义模型论:稳定性理论与Borel复杂性分析的交叉研究
  • 金相显微镜在PCB切片分析中的深度应用
  • 基于约束位置偏移的飞机着陆调度优化与轨迹规划实践
  • 构建微信消息路由引擎:wechat-forwarding 架构解析与实战应用
  • 实测 Paperxie 科研绘图模块:先看样例再出图,全学科论文配图不用再啃 Origin
  • 文件存在磁盘上到底长什么样?一文吃透 Linux 磁盘文件系统核心原理
  • 不让你用“+”,还能算出两数之和?这道LeetCode经典题暴露了程序员对底层原理的理解深度
  • 上位机YOLO推理优化实录:我是怎么把CPU推理速度提上去的
  • 记录AI学习之路Day12:AIGC