事务还没提交就发了 MQ,消费方查库查不到:本地消息表怎么落地
订单服务创建一个订单,同一个方法里既写库又往 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(同库同事务 + 异步投递 + 消费幂等),要“少写代码”就用事务消息。三条路都绕不开消费端幂等——这一点没有捷径。
- 点赞
- 收藏
- 关注作者
评论(0)