在交易系统里,很多异步链路看起来都很顺。
比如用户支付成功后,交易服务做两件事:
- 把订单状态从
PAID推进到WAIT_SHARE; - 发一条 MQ,通知分账服务创建分账单。
流程看起来很自然:
rust
订单状态更新成功 -> 发送分账消息 -> 分账服务消费 -> 创建分账单
但线上最怕的,恰恰就是这种"看起来很顺"的链路。
因为它中间有一个很隐蔽的裂缝:
本地数据库事务,和 MQ 消息发送,不是一个原子动作。
只要这个裂缝里发生一次宕机、超时、网络抖动,或者 Broker 短暂不可用,就可能出现一个很难受的状态:
订单已经是待分账;
但是分账服务永远收不到消息。
从数据库看,订单没问题。
从 MQ 看,消息链路没动。
从业务看,订单卡住了。
这种问题不一定高频,但一旦发生,就很像异步链路中间被剪断了一刀。
一、现场还原:订单已经待分账,但分账单一直没生成
假设有这样一条支付成功后的链路。
用户支付成功后,交易服务收到支付回调,然后执行:
markdown
1. 更新订单状态:PAID -> WAIT_SHARE
2. 发送 MQ:通知分账服务创建分账单
分账服务收到消息后,再创建分账单:
ini
WAIT_SHARE 订单 -> 创建分账单 -> share_status = INIT
正常情况下,数据应该是这样的:
ini
order_status = WAIT_SHARE
share_order = 已创建
但某次线上排查时,发现有一批订单卡住了:
ini
order_status = WAIT_SHARE
share_order = 不存在
分账服务消费日志 = 没有这笔订单
从交易服务日志看,订单状态确实已经更新成功。
但从分账服务消费日志、消息轨迹和业务消费记录看,都没有找到这笔订单对应的分账创建消息。
这时问题就变成了:
订单状态已经推进了,为什么下游分账链路没动?
二、第一种常见写法:事务里直接发 MQ
很多代码一开始会这么写:
ini
@Transactional(rollbackFor = Exception.class)
public void handlePaySuccess(PaySuccessEvent event) {
String orderNo = event.getOrderNo();
int rows = orderMapper.updateStatus(
orderNo,
OrderStatus.PAID,
OrderStatus.WAIT_SHARE
);
if (rows == 0) {
log.info("订单状态已变化,orderNo={}", orderNo);
return;
}
ShareCreateMessage message = buildShareCreateMessage(orderNo);
kafkaTemplate.send("share-create-topic", message);
log.info("支付成功处理完成,orderNo={}", orderNo);
}
这段代码看起来很合理。
订单状态更新和 MQ 发送都写在一个方法里,而且方法上还加了 @Transactional。
但这里有一个误区:
@Transactional只能管数据库事务,管不了 Kafka 消息。
kafkaTemplate.send() 默认是异步发送。这里重点不是它是否阻塞,而是:Kafka 消息发送不受 MySQL 本地事务控制。
也就是说,消息一旦进入发送链路,就不会因为外层数据库事务回滚而自动撤回。
这会带来第一个问题:消息可能早于事务提交被消费者看到。
三、消息先出去了,但事务最后回滚了
把时间线拉开看:
| 时间点 | 交易服务 | Kafka | 分账服务 | MySQL |
|---|---|---|---|---|
| T1 | 开启事务 | - | - | 订单状态 = PAID |
| T2 | 更新订单为 WAIT_SHARE | - | - | 事务未提交 |
| T3 | 发送分账消息 | 消息进入发送链路 | - | 事务未提交 |
| T4 | 分账服务消费消息 | - | 查询订单 | 可能仍读到 PAID |
| T5 | 交易服务后续异常 | - | - | 事务回滚 |
| T6 | 分账服务继续处理 | - | 发现订单状态不对 | 订单仍是 PAID |
这时分账服务会很尴尬。
它明明收到了"创建分账单"的消息,但查数据库发现订单并没有进入 WAIT_SHARE。
原因不是分账服务错了,而是消息跑得比数据库事务提交更快。
更极端一点,如果分账服务没有校验订单状态,直接创建分账单,就会出现:
订单状态没变;
分账单却生成了。
这就是事务内直接发 MQ 的第一个坑:
数据库事务还没提交,消息已经可能被下游消费了。
四、那我事务提交后再发 MQ,不就行了吗?
很多人意识到上面的问题后,会把代码改成这样:
ini
public void handlePaySuccess(PaySuccessEvent event) {
String orderNo = event.getOrderNo();
Boolean needSendMessage = transactionTemplate.execute(status -> {
int rows = orderMapper.updateStatus(
orderNo,
OrderStatus.PAID,
OrderStatus.WAIT_SHARE
);
return rows > 0;
});
if (!Boolean.TRUE.equals(needSendMessage)) {
log.info("订单状态未推进,不发送分账消息,orderNo={}", orderNo);
return;
}
ShareCreateMessage message = buildShareCreateMessage(orderNo);
kafkaTemplate.send("share-create-topic", message);
log.info("支付成功处理完成,orderNo={}", orderNo);
}
这次顺序看起来更安全了:
先提交数据库事务;
再发送 MQ。
这样至少不会出现"事务回滚了,但消息已经出去了"的问题。
但它又打开了另一个窗口:
数据库已经提交成功,但 MQ 还没来得及发送。
如果就在这个窗口里服务宕机,问题就来了。
五、真正的断点:DB 提交成功,MQ 没发出去
时间线可能是这样的:
| 时间点 | 交易服务 | MySQL | Kafka |
|---|---|---|---|
| T1 | 开启本地事务 | 订单状态 = PAID | - |
| T2 | 更新订单状态为 WAIT_SHARE | 事务中 | - |
| T3 | 提交事务成功 | 订单状态 = WAIT_SHARE | - |
| T4 | 准备发送分账消息 | 订单状态 = WAIT_SHARE | - |
| T5 | 服务宕机 / 进程被 kill / 网络异常 | 订单状态 = WAIT_SHARE | 消息没发出 |
| T6 | 服务重启 | 订单状态 = WAIT_SHARE | 仍然没有消息 |
这就是最麻烦的地方。
数据库已经认了:
这笔订单需要分账。
但 MQ 不知道:
分账服务需要处理这笔订单。
于是订单就卡在了 WAIT_SHARE。
从技术上看,这不是数据库的问题,也不是 Kafka 的问题。
这是两个系统之间缺少一个可靠的交接记录。
六、还有一种情况:MQ 其实成功了,但本地以为失败了
更烦的是,MQ 发送也不是简单的"成功 / 失败"。
比如生产者发送消息后,Broker 实际已经写入成功,但生产者因为网络抖动没有收到 ack。
在本地看来,它可能是一次发送失败。
如果这时直接重试,就可能发出两条业务内容相同的消息。
比如 Kafka 里出现两条消息:
ini
messageId = SHARE_CREATE_O1001
messageId = SHARE_CREATE_O1001
下游分账服务就会收到两次创建分账单请求。
所以这个问题有两面:
不重试,可能丢消息;
重试,可能重复消息。
这也是为什么前面一直强调消费者幂等。
因为在异步链路里,生产端很难靠一次发送就保证:
既不丢,也不重。
更现实的目标是:
消息可以重复,但不能凭空丢;
业务可以重试,但不能重复生效。
七、根因:业务状态和异步消息没有被同一个事务记录下来
回到最开始的问题。
为什么订单状态更新成功了,分账消息却没发出去?
根因不是代码少写了一个重试。
而是这两个动作没有一个共同的持久化边界:
更新订单状态:写 MySQL
发送分账消息:写 Kafka
它们分别属于两个系统。
本地事务只能保证 MySQL 里的操作一致:
订单状态更新成功
资金流水插入成功
本地业务记录插入成功
但它不能保证 Kafka 一定收到了消息。
所以,只要代码写成:
先写 DB,再发 MQ
中间就一定有窗口。
这个窗口也许只有几毫秒。
但线上系统不需要很长的窗口。一次发布重启、一次容器重启、一次网络抖动,就够了。
八、一个更稳的方向:把"要发的消息"也先落到本地
要解决这个问题,核心思路不是让 Kafka 加入 MySQL 事务。
更现实的做法是:
把"我要发送一条 MQ 消息"这件事,也作为本地事务的一部分先记录下来。
这个方案通常叫 本地消息表 ,也叫 Outbox 模式。
Outbox 可以简单理解成业务系统里的"发件箱"。
不是直接把消息扔给 MQ,而是先把消息放进本地数据库:
rust
写业务数据 -> 写本地消息表 -> 后台发送 MQ -> 标记消息已发送
支付成功时,不是直接发 MQ,而是在同一个数据库事务里做两件事:
markdown
1. 更新订单状态:PAID -> WAIT_SHARE
2. 插入一条本地待发送消息
比如:
scss
@Transactional(rollbackFor = Exception.class)
public void handlePaySuccess(PaySuccessEvent event) {
String orderNo = event.getOrderNo();
int rows = orderMapper.updateStatus(
orderNo,
OrderStatus.PAID,
OrderStatus.WAIT_SHARE
);
if (rows == 0) {
log.info("订单状态已变化,orderNo={}", orderNo);
return;
}
OutboxMessage message = OutboxMessage.builder()
.messageId("SHARE_CREATE_" + orderNo)
.bizNo(orderNo)
.topic("share-create-topic")
.eventType("SHARE_CREATE")
.body(toJson(buildShareCreateMessage(orderNo)))
.status(MessageStatus.INIT)
.retryCount(0)
.build();
outboxMessageMapper.insert(message);
}
这段代码的意义是:
只要订单状态成功变成
WAIT_SHARE,本地一定会有一条SHARE_CREATE待发送消息。
这样即使服务在事务提交后立刻宕机,消息也不会无声消失。
因为它已经落库了。
九、本地消息表怎么流转?
本地消息表至少需要几个状态:
INIT:待发送
SENDING:发送中
SENT:已发送
FAIL:发送失败
表结构可以简化成这样:
sql
CREATE TABLE t_outbox_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(128) NOT NULL,
biz_no VARCHAR(128) NOT NULL,
topic VARCHAR(128) NOT NULL,
event_type VARCHAR(64) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(32) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_retry_time DATETIME,
last_error VARCHAR(512),
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_message_id (message_id),
KEY idx_status_retry_time (status, next_retry_time)
);
然后由一个独立的发送任务扫描:
markdown
1. 扫描 INIT / FAIL 且到达重试时间的消息
2. 抢占消息并标记为 SENDING
3. 发送 Kafka
4. 发送成功后标记 SENT
5. 发送失败后标记 FAIL,并更新 retry_count 和 next_retry_time
伪代码:
scss
public void dispatchOutboxMessages() {
List<OutboxMessage> messages = outboxMessageMapper.queryPendingMessages(100);
for (OutboxMessage message : messages) {
boolean locked = outboxMessageMapper.markSending(message.getId());
if (!locked) {
continue;
}
try {
// 这里用 get() 是为了简化说明:等待发送结果后再更新本地消息状态。
// 实际项目里也可以用回调处理发送成功 / 失败。
kafkaTemplate.send(message.getTopic(), message.getBody()).get();
outboxMessageMapper.markSent(message.getId());
} catch (Exception e) {
outboxMessageMapper.markFail(
message.getId(),
e.getMessage(),
nextRetryTime(message.getRetryCount())
);
}
}
}
这里有两个关键点。
第一,markSending 不能是简单 update。
它应该是带状态条件的抢占更新,比如:
ini
UPDATE t_outbox_message
SET status = 'SENDING',
update_time = NOW()
WHERE id = #{id}
AND status IN ('INIT', 'FAIL')
这样可以避免多个发送任务并发扫描时,重复抢到同一条消息。
第二,发送任务失败不要紧。
只要本地消息表还在,就可以继续重试。
系统从:
消息可能丢在内存里,失败后无迹可查
变成了:
消息可查询、可重试、可告警、可补偿
十、把时间线重新拉开看
用了本地消息表后,刚才那个窗口会变成这样:
| 时间点 | 交易服务 | MySQL | Kafka |
|---|---|---|---|
| T1 | 开启本地事务 | 订单 = PAID | - |
| T2 | 更新订单为 WAIT_SHARE | 事务中 | - |
| T3 | 插入本地消息 SHARE_CREATE | 事务中 | - |
| T4 | 提交事务成功 | 订单 = WAIT_SHARE,消息 = INIT | - |
| T5 | 服务宕机 | 数据仍然在 | - |
| T6 | 服务恢复,发送任务扫描 | 查到 INIT 消息 | - |
| T7 | 发送 MQ 成功 | 消息 = SENT | Kafka 收到消息 |
| T8 | 分账服务消费 | - | 创建分账单 |
这时即使 T5 宕机,消息也不会丢。
因为"要发送消息"这件事已经和订单状态一起提交到了 MySQL。
这就是本地消息表最核心的价值:
它不保证 MQ 立刻发送成功,但保证消息不会无记录地消失。
十一、但 Outbox 也不是银弹
本地消息表解决的是"DB 成功但 MQ 没消息"的问题。
但它也会引入新的边界。
1. 可能重复发送
发送任务可能出现这种情况:
| 时间点 | 动作 |
|---|---|
| T1 | Kafka 发送成功 |
| T2 | 标记本地消息 SENT 前服务宕机 |
| T3 | 服务恢复后再次扫描到这条消息 |
| T4 | 再次发送 Kafka |
所以 Outbox 不能保证只发送一次。
它更接近:
消息至少会被发出去一次。
这就要求下游消费者必须幂等。
比如分账服务创建分账单时,依然要靠:
share_order_no 唯一索引
order_no + share_type 唯一索引
消费记录状态
来防止重复业务影响。
2. SENDING 状态也可能卡住
如果消息被标记成 SENDING 后,发送任务刚好宕机,这条消息可能长时间停留在发送中。
所以 SENDING 也要有超时恢复机制。
比如:
SENDING 超过 5 分钟仍未完成;
说明发送任务可能已经异常退出;
可以重新置为 FAIL,等待下一轮重试。
否则本地消息表只是把问题从"消息丢了",变成了"消息卡在 SENDING"。
3. 发送有延迟
本地消息表一般依赖扫描任务。
这意味着消息不是业务事务提交后立刻就发出去。
会有一定延迟。
对于大多数最终一致性场景,这个延迟可以接受。
但如果业务强依赖实时性,就要调整扫描频率、批量大小、并发度,甚至结合 binlog 推送方案。
不过这里要注意:追求实时性不能把可靠性打穿。
你可以让消息发得更快,但不能回到"DB 提交后裸发 MQ,失败就丢"的老路。
4. 本地消息表也需要治理
消息表不是写进去就完了。
它需要处理:
失败重试次数
退避策略
最大重试上限
异常告警
人工介入
历史归档
重复调度控制
否则时间长了,本地消息表自己会变成新的问题。
比如某类消息一直发送失败,如果没有最大重试次数和告警,它会一直占用扫描资源。
这类消息应该在多次失败后进入异常状态,比如:
FAIL_MAX_RETRY
然后告警或者转人工处理。
5. 业务状态和消息状态都要能对账
Outbox 不是为了让系统永远不出错。
它是为了让问题可见。
比如可以定期检查:
订单状态 = WAIT_SHARE
但没有 SHARE_CREATE 消息
SHARE_CREATE 消息 = SENT
但分账单不存在
SHARE_CREATE 消息 = FAIL
且超过最大重试次数
SHARE_CREATE 消息长时间停留在 SENDING
这些数据都应该能被扫描出来。
否则只是把"消息丢失"换成了"消息卡住"。
十二、那 Kafka 事务消息能不能解决?
Kafka 本身也有事务能力,可以保证 Kafka 内部的生产和消费链路在一定范围内具备事务语义。
但这里的问题是:
MySQL 本地事务
Kafka 消息发送
两者跨了不同系统。
Kafka 的事务不能直接把 MySQL 更新也纳入同一个原子提交里。
所以在普通业务系统里,更常见、也更容易落地的方案仍然是:
本地事务 + 本地消息表 + 异步发送 + 消费者幂等 + 补偿对账
这套方案不追求理论上的全局强一致。
它追求的是:
订单为什么卡住,能查到;
消息有没有生成,能查到;
发送失败了几次,能查到;
下一次什么时候重试,能查到;
重复消息来了,下游能兜住。
这更符合大多数交易系统里的最终一致性要求。
十三、总结
订单状态更新成功了,分账消息却没发出去,本质上不是 MQ 偶发失败这么简单。
真正的问题是:
本地数据库事务和 MQ 消息发送之间,没有一个共同的持久化边界。
事务内直接发 MQ,可能导致:
消息先被消费,数据库事务最后却回滚。
事务提交后再发 MQ,又可能导致:
数据库已经提交,服务却在发送 MQ 前宕机。
所以更稳的做法是:
在本地事务里同时写业务数据和待发送消息;
再由异步任务负责把消息可靠发送出去;
发送失败可以重试;
发送重复由消费者幂等兜底;
卡住的数据通过补偿和对账发现。
本地消息表的价值,不是让系统从此没有异常。
而是把原本"无声消失"的 MQ 消息,变成一条可以查询、可以重试、可以告警、可以补偿的数据记录。
在异步系统里,最可怕的不是失败。
最可怕的是失败之后,系统里没有任何证据。