事务未提交就发送 MQ,导致消费者读不到订单数据的问题

最近在投资下单流程里遇到一个比较典型的问题:本地事务里订单、流水还没提交,MQ 消息已经发出去了。消费者收到消息后立刻查询订单或流水,结果查不到数据,或者读到的状态还是旧的,导致后续订单状态、资产包状态处理异常。

这个问题本质不是 MQ 不可靠,而是事务提交和消息发送的时序不对

背景

投资下单流程大概是:

  1. 创建投资订单
  2. 创建资金流水,比如冻结、退款、出账等
  3. 更新资产包已投金额
  4. 创建合同
  5. 发送 MQ,让消费者继续处理订单状态或资产包状态

其中有一个队列:

arduino 复制代码
public static final String QUEUE_ORDER_PAY = "order_pay";

这个队列并不是"通知支付系统冻结余额"的队列。

更准确地说,它是:

资金流水处理结果消息队列,用于根据冻结、退款、出账等流水结果更新投资订单状态。

比如:

  • 冻结成功后,更新投资订单状态
  • 退款失败后,更新退款状态
  • 出账失败后,更新投资失败状态
  • 继续推动资产包状态流转

问题代码

之前代码类似这样:

ini 复制代码
@Transactional(rollbackFor = Exception.class)
public BoResult createAssetOrder(...) {
    InvestAssetOrder investOrder = new InvestAssetOrder();
    baseMapper.insert(investOrder);

    CreateFlowVo createFlowVo = new CreateFlowVo(
        uid,
        InvestUserFlowType.FREEZE,
        payAmount,
        JSON.toJSONString(investOrderPayBO),
        investAsset.getInvestTitle(),
        null
    );

    InvestUserFlow freezeFlow = investUserFlowService.createFlow(createFlowVo);

    messageProducer.sendMessage(
        InvestConsts.QUEUE_ORDER_PAY,
        new InvestOrderPayDTO(...),
        -1
    );

    investAssetMapper.updateById(investAsset);

    return BoResult.success(orderVO);
}

看起来没问题,但这里有一个隐藏时序:

lua 复制代码
事务开始
  insert invest_asset_order
  insert invest_user_flow
  send MQ
事务提交

问题就在于:MQ 可能在事务提交前就被消费者消费。

消费者收到消息后去查:

ini 复制代码
InvestAssetOrder order = investAssetOrderMapper.selectById(orderId);
InvestUserFlow flow = investUserFlowMapper.selectById(flowId);

这时本地事务还没提交,消费者查不到数据,或者查到旧数据。

为什么会出问题

数据库事务和 MQ 发送不是一个原子操作。

本地事务里的数据在提交前,对其他线程、其他服务、MQ 消费者通常是不可见的。

所以这段代码:

ini 复制代码
insert order;
insert flow;
send mq;
commit;

在消费者视角里可能变成:

复制代码
收到 MQ
查询订单
订单不存在
查询流水
流水不存在
处理失败

这就是典型的"消息先于事务提交被消费"的问题。

更隐蔽的问题:异常后数据回滚,但消息已发出

还有一种更危险的情况:

scss 复制代码
@Transactional(rollbackFor = Exception.class)
public void createAssetOrder() {
    insert order;
    insert flow;

    send mq;

    createContract(); // 这里异常

    throw new BusinessException(...);
}

如果 send mq 已经成功,但后面创建合同失败,事务回滚了。

数据库里订单和流水都没了,但是 MQ 已经发出去了。

消费者收到消息后会处理一条根本不存在的业务数据。

这会导致:

  • 消费者查不到订单
  • 订单状态更新失败
  • 资金流水后处理异常
  • MQ 反复重试
  • 甚至出现脏状态

解决办法:事务提交后再发送 MQ

如果 MQ 只是依赖本地事务成功后的后续处理,最简单可靠的做法是:

注册事务提交后的回调,在 afterCommit 里发送 MQ。

可以封装一个工具方法:

typescript 复制代码
private void registerAfterCommit(Runnable task) {
    if (!TransactionSynchronizationManager.isSynchronizationActive()) {
        task.run();
        return;
    }

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                task.run();
            }
        }
    );
}

然后把发送 MQ 改成:

scss 复制代码
InvestUserFlow freezeFlow = investUserFlowService.createFlow(createFlowVo);

registerAfterCommit(() -> sendOrderPayMessage(freezeFlow, investOrderPayBO));

这样执行顺序就变成了:

sql 复制代码
事务开始
  insert invest_asset_order
  insert invest_user_flow
  update invest_asset
  create contract
事务提交
afterCommit
  send MQ

消费者收到消息时,订单和流水已经提交,查询就稳定了。

修改后的代码示例

scss 复制代码
@Transactional(rollbackFor = Exception.class)
public BoResult createAssetOrder(...) {
    baseMapper.insert(investOrder);

    InvestOrderPayBO investOrderPayBO = new InvestOrderPayBO(
        investOrder.getId(),
        investOrder.getInvestId(),
        uid,
        InvestOrderStatusEnums.FUND_SUCCESS.getCode(),
        InvestOrderStatusEnums.INVEST_FAIL.getCode()
    );

    CreateFlowVo createFlowVo = new CreateFlowVo(
        uid,
        InvestUserFlowType.FREEZE,
        payAmount,
        JSON.toJSONString(investOrderPayBO),
        investAsset.getInvestTitle(),
        null
    );

    InvestUserFlow freezeFlow = investUserFlowService.createFlow(createFlowVo);

    registerAfterCommit(() -> sendOrderPayMessage(freezeFlow, investOrderPayBO));

    investOrder.setInvestTransactionId(freezeFlow.getId());
    baseMapper.updateById(investOrder);

    investAsset.setInvestRemainAmount(
        investAsset.getInvestRemainAmount() - investOrder.getInvestAmount()
    );
    investAsset.setInvestSoldAmount(
        investAsset.getInvestSoldAmount() + investOrder.getAmount()
    );
    investAssetMapper.updateById(investAsset);

    vnContractInfoService.createPendingContracts(investOrder.getId());

    return BoResult.success(orderVO);
}

发送消息的方法:

typescript 复制代码
private void sendOrderPayMessage(InvestUserFlow freezeFlow, InvestOrderPayBO investOrderPayBO) {
    InvestOrderPayDTO dto = new InvestOrderPayDTO(
        freezeFlow.getId(),
        freezeFlow.getInvestUserId(),
        InvestUserFlowType.FREEZE,
        true,
        "",
        JSON.parseObject(JSON.toJSONString(investOrderPayBO))
    );

    messageProducer.sendMessage(InvestConsts.QUEUE_ORDER_PAY, dto, -1);
}

哪些 MQ 应该 afterCommit

一般来说,只要 MQ 消费者依赖当前事务提交的数据,都应该放到 afterCommit

比如:

  • 创建订单后发送订单状态处理消息
  • 创建资金流水后发送流水结果处理消息
  • 更新资产包金额后发送资产包状态消息
  • 创建合同记录后发送签约通知消息
  • 退款流水创建后发送退款后处理消息

这些消息的消费者通常都会查询数据库。如果事务没提交,消费者就可能查不到。

哪些操作不能放 afterCommit

不是所有东西都适合放 afterCommit。

比如这次合同创建逻辑:

ini 复制代码
vnContractInfoService.createPendingContracts(investOrder.getId());

这个不能放到 afterCommit。

因为业务要求是:合同创建失败,下单事务必须回滚。

如果把它放到 afterCommit,就会变成:

erlang 复制代码
订单提交成功
afterCommit 创建合同
合同失败
订单无法回滚

最终会出现订单成功但合同没创建的脏数据。

所以判断标准是:

  • 失败需要回滚主事务:放事务内
  • 失败不应该影响主事务,只做后续通知:放 afterCommit

满标消息也要注意

之前还有一个类似问题:投资下单时,如果刚好满标,代码直接在事务里发送满标消息。

less 复制代码
if (investAsset.getInvestSoldAmount().compareTo(investAsset.getInvestAllAmount()) >= 0) {
    messageProducer.sendMessage(...);
}

这也有风险。

因为资产包金额更新还没提交,消费者收到满标消息后,可能读到的 invest_sold_amount 还是旧值。

后来改成了定时任务处理:

  1. 每分钟扫描已满标但未触发放款的资产包
  2. 检查合同是否全部签署
  3. 条件满足后再发送满标放款消息

这样比在下单事务里立即发送更稳。

最佳实践

  1. 事务里不要直接发送依赖当前事务数据的 MQ。
  2. 使用 TransactionSynchronization.afterCommit() 延后发送。
  3. 消费者查询数据库的数据,必须确保生产者事务已提交。
  4. 失败需要回滚主事务的操作,不要放 afterCommit。
  5. MQ 消费端要做幂等,不能假设消息只消费一次。
  6. 消费失败要抛异常,让 MQ 重试;不要吞异常。
  7. 对特别关键的消息,可以进一步使用本地消息表或 Outbox 模式。

总结

这类问题的关键不是 MQ,也不是数据库,而是时序:

复制代码
发送 MQ 的时间早于事务提交时间

只要消费者依赖当前事务写入的数据,就必须保证:

复制代码
事务提交成功后,再发送 MQ

在 Spring 里,简单场景可以用:

scss 复制代码
TransactionSynchronizationManager.registerSynchronization(...)

把消息发送放到 afterCommit

这样可以避免消费者读不到订单、流水、资产包状态,也能避免事务回滚后消息却已经发出的脏数据问题。

相关推荐
大橙子打游戏6 小时前
Fable5不能用了,但是依然能让 AI 纯靠截图玩通宝可梦
后端
Jason_chen6 小时前
Linux 3.0 总线机制与故障排查详解
后端
成都第一深情IZZO6 小时前
Spring Boot 动态数据源在事务中切库失效问题排查
后端
_遥远的救世主_6 小时前
稳定性工程:SLO 量化、降级收敛与故障兜底体系
后端
_遥远的救世主_6 小时前
多区域架构:边缘节点、核心节点与跨区域写冲突
后端
ServBay6 小时前
你跟高级 C# 工程师的区别,就是这8个开发技巧
后端·c#·.net
卷无止境6 小时前
Python CLI 应用开发最佳实践全面指南
后端
_遥远的救世主_6 小时前
租户架构与资源治理:隔离模型选择、Noisy Neighbor 治理与成本边界
后端
用户9000434815316 小时前
Python并发编程:多线程与多进程的实战指南
后端