订单状态更新成功了,分账消息却没发出去:聊聊本地消息表的一致性坑

在交易系统里,很多异步链路看起来都很顺。

比如用户支付成功后,交易服务做两件事:

  1. 把订单状态从 PAID 推进到 WAIT_SHARE
  2. 发一条 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 消息,变成一条可以查询、可以重试、可以告警、可以补偿的数据记录。

在异步系统里,最可怕的不是失败。

最可怕的是失败之后,系统里没有任何证据。

相关推荐
亦暖筑序1 小时前
Java 8老系统SQL Agent实战:AI生成候选SQL,安全引擎拦截后再执行
java·人工智能·sql
CodeStats1 小时前
《源纹天书》卷一:归元初醒(第1-5章)
java
大囚长1 小时前
大模型服务端如何命中缓存
java·人工智能·缓存·dubbo
别叫我老干部1 小时前
一键给整个库造测试数据:外键、约束一个都不能少
后端·mysql
摇滚侠1 小时前
SpringMVC 入门到实战 拦截器 78-82
java·后端·spring·maven·intellij-idea
椰椰椰耶1 小时前
[SpringCloud][13]OpenFeign快速上手
后端·spring·spring cloud
磊 子1 小时前
C++移动语义和智能指针
java·开发语言·c++
JAVA面经实录9171 小时前
Elasticsearch 完整版完整知识体系
java·elasticsearch·搜索引擎·es
hikktn1 小时前
ORA-01861 日期格式错误的根治方案:从 SQL 层到 Java 层的标准化治理
java·python·sql