最近在投资下单流程里遇到一个比较典型的问题:本地事务里订单、流水还没提交,MQ 消息已经发出去了。消费者收到消息后立刻查询订单或流水,结果查不到数据,或者读到的状态还是旧的,导致后续订单状态、资产包状态处理异常。
这个问题本质不是 MQ 不可靠,而是事务提交和消息发送的时序不对。
背景
投资下单流程大概是:
- 创建投资订单
- 创建资金流水,比如冻结、退款、出账等
- 更新资产包已投金额
- 创建合同
- 发送 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 还是旧值。
后来改成了定时任务处理:
- 每分钟扫描已满标但未触发放款的资产包
- 检查合同是否全部签署
- 条件满足后再发送满标放款消息
这样比在下单事务里立即发送更稳。
最佳实践
- 事务里不要直接发送依赖当前事务数据的 MQ。
- 使用
TransactionSynchronization.afterCommit()延后发送。 - 消费者查询数据库的数据,必须确保生产者事务已提交。
- 失败需要回滚主事务的操作,不要放 afterCommit。
- MQ 消费端要做幂等,不能假设消息只消费一次。
- 消费失败要抛异常,让 MQ 重试;不要吞异常。
- 对特别关键的消息,可以进一步使用本地消息表或 Outbox 模式。
总结
这类问题的关键不是 MQ,也不是数据库,而是时序:
发送 MQ 的时间早于事务提交时间
只要消费者依赖当前事务写入的数据,就必须保证:
事务提交成功后,再发送 MQ
在 Spring 里,简单场景可以用:
scss
TransactionSynchronizationManager.registerSynchronization(...)
把消息发送放到 afterCommit。
这样可以避免消费者读不到订单、流水、资产包状态,也能避免事务回滚后消息却已经发出的脏数据问题。