事务还没提交就发了 MQ,消费方查库查不到:本地消息表怎么落地

举报
yd_271851511 发表于 2026/11/05 10:11:14 2026/11/05
【摘要】 在 @Transactional 方法里 save 完直接发消息,消费者偶发查不到数据,重试三次进死信。根因是消息先于事务提交到达消费端,回滚时更会出现“消息发了、数据没有”。本文对比 afterCommit、本地消息表和事务消息三种做法,并给出一份可直接用的 outbox 表结构。

订单服务创建一个订单,同一个方法里既写库又往 MQ 发一条“订单已创建”的消息,下游要拿这条消息去发券、发短信、建索引。压测和日常都没问题,但线上每天总有几十条消息在消费端报“订单不存在”,重试三次之后进死信队列,得人工补。


一、先确定时序


把两边的日志按 traceId 拉出来对齐,时间线很典型:生产端 10:00:00.120 打印“消息已发送”,10:00:00.135 打印“事务提交”;消费端 10:00:00.128 收到消息,10:00:00.130 查库,`select * from orders where id=?` 返回空。差了 7ms。


结论很清楚:消息在事务提交之前就发出去了,消费端在另一个连接上看不到未提交的数据(读已提交隔离级别)。这不是偶发,是必然,只是大部分时候消费端的处理耗时大于提交耗时,所以看不出来。


更麻烦的是另一半:如果事务在发完消息之后回滚了,消息已经进了 MQ,数据却没有,消费端会永远查不到,重试也没用。这种“多发”比“少发”更难处理,因为它会持续污染下游。


二、三种做法,逐个说清楚


**做法一:afterCommit 再发。** 用 Spring 的 `TransactionSynchronizationManager.registerSynchronization`,在 `afterCommit` 回调里发消息。代码大概是这样:


```java

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {

    @Override

    public void afterCommit() {

        mqProducer.send(topic, orderId, payload);

    }

});

```


这能解决“提前发”和“回滚还发”两个问题,改动最小,适合先止血。但它留下一个新窗口:事务提交成功、发消息失败(网络抖动、broker 重启、进程被 kill)。这时候数据有了、消息没了,下游永远收不到。生产端如果只 catch 打日志,这个单就静默丢了。


**做法二:本地消息表(outbox)。** 核心思路是把“要发的消息”和“业务数据”放在同一个本地事务里落库,再异步把表里的记录投递出去。因为两者同库同事务,要么都成功要么都失败,不存在中间态。


表结构不用复杂,够用就行:


```sql

create table mq_outbox (

  id bigint primary key auto_increment,

  msg_key varchar(64) not null, -- 业务唯一键,做幂等

  topic varchar(128) not null,

  payload text not null,

  status tinyint not null default 0, -- 0待发 1已发 2失败

  retry_count int not null default 0,

  next_retry_at datetime not null default current_timestamp,

  created_at datetime not null default current_timestamp,

  sent_at datetime null,

  unique key uk_msg_key (msg_key),

  key idx_status_retry (status, next_retry_at)

);

```


投递侧用定时任务扫 `status=0 and next_retry_at<=now()`,取一批(比如 200 条),逐条发送,成功后置 1,失败则 `retry_count+1`、`next_retry_at` 指数退避(1s、5s、30s、2min、10min)。注意几点:

1. 用 `select ... for update skip locked` 或者按 id 分段捞,避免多实例重复扫同一批;

2. 发送和置状态不是原子的,可能“发出去了但状态没改”,所以消费端**必须幂等**,靠 `msg_key` 去重;

3. 表要有清理策略,`status=1` 且超过 7 天的记录归档或删除,否则单表会一直涨。


**做法三:事务消息。** RocketMQ 支持半消息 + 回查:先发半消息(对消费者不可见),本地事务执行完再 commit/rollback,broker 收不到确认就回查本地事务状态。它把“本地事务”和“消息可见性”绑在了一起,不需要自己维护表,但要求 MQ 支持,而且要自己实现回查接口(按 msgKey 查业务表是否存在)。如果你们用的 MQ 不支持事务消息,这条路直接排除。


三、消费端也别只靠重试


上面三种做法都解决“生产端不丢”,但消费端的顺序问题还在:消息到了,数据可能还在主从延迟里,或者事务刚好在另一个未提交的连接里。所以消费端要有三件事:


1. **幂等**:按 `msg_key` 或业务唯一键去重,重复消息直接 ack。别用“先查再插”,并发下会双写,用唯一索引兜底。


2. **可重试的失败语义**:查不到数据属于“暂时性失败”,应该延迟重试(比如 5s、30s、2min),而不是立刻重试三次进死信。重试次数用完后要能人工重放,死信队列要有人看。


3. **兜底对账**:每天跑一次“业务表 vs 下游状态”的对账,把漏掉的消息补发。这条是最后一道防线,前面做得再好也会有边角案例。


四、我们最后的选择


短期先上了 afterCommit(半天改完,把“提前发”和“回滚发”止住),同时补了消费端的延迟重试和幂等。两周后把核心链路换成了本地消息表,outbox 表挂在订单库,投递任务单独部署,扫表间隔 1s,批量 200。上线后死信从每天几十条降到 0,压测 3000 TPS 下投递延迟 p99 在 400ms 以内。


没有选事务消息,是因为团队里有两个 MQ(一个自建 RocketMQ、一个云上实例),统一成本比写一张 outbox 表高。如果只有一个支持事务消息的 MQ,做法三其实更省事。


五、一句话总结


`@Transactional` 里直接发消息一定是错的,afterCommit 是最小修复但仍有丢消息窗口;要“不丢”就上 outbox(同库同事务 + 异步投递 + 消费幂等),要“少写代码”就用事务消息。三条路都绕不开消费端幂等——这一点没有捷径。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。