库存扣了,订单表也 insert 了,消息也发出去了。
消费者那边一查订单:没有。
再看生产日志,MyBatis-Plus 的 SQL 都正常执行,没有异常,updateById返回也是 1。这个时候还盯着 Mapper 看,就有点浪费时间了,我第一眼就不信是 MyBatis-Plus 把 SQL 写丢了。
出问题的代码长这样,味儿很熟:
@Service
publicclass PayOrderService {
privatefinal OrderMapper orderMapper;
privatefinal StockMapper stockMapper;
privatefinal OrderEventSender eventSender;
public PayOrderService(OrderMapper orderMapper,
StockMapper stockMapper,
OrderEventSender eventSender) {
this.orderMapper = orderMapper;
this.stockMapper = stockMapper;
this.eventSender = eventSender;
}
@Transactional(rollbackFor = Exception.class)
public void paySuccess(Long orderId) {
OrderEntity order = orderMapper.selectById(orderId);
if (order == null || order.getPayStatus() == 1) {
return;
}
order.setPayStatus(1);
order.setPaidAt(LocalDateTime.now());
orderMapper.updateById(order);
int rows = stockMapper.freezeStock(order.getSkuId(), order.getBuyCount());
if (rows != 1) {
thrownew IllegalStateException("freeze stock failed, orderId=" + orderId);
}
eventSender.sendPaid(orderId);
}
}
看着没啥问题吧?
事务包住订单更新和库存冻结,异常回滚,挺标准。
坑就在最后一行:
eventSender.sendPaid(orderId);
这行代码执行的时候,数据库事务还没提交。
日志其实已经说得挺明白了,只是当时没往这个方向看:
2026-xx-xx 10:14:31.207 DEBUG update t_order set pay_status=1 where id=98123
2026-xx-xx 10:14:31.211 DEBUG update t_stock set frozen=frozen+2 where sku_id=7788
2026-xx-xx 10:14:31.214 INFO send paid event, orderId=98123
2026-xx-xx 10:14:31.219 INFO consumer receive paid event, orderId=98123
2026-xx-xx 10:14:31.221 WARN order not found or unpaid, orderId=98123
2026-xx-xx 10:14:31.245 DEBUG transaction commit
消费者比事务提交还快。
这种问题最烦的地方在于,它不是每次都出。机器忙一点、MQ 慢一点,正常;消费者快一点、数据库压力小一点,就撞上了。
@Transactional本身没错,错的是把“事务内动作”和“事务后动作”混在一起写。
我一般会把这种代码拆开。
事务里只做数据库必须一致的东西,发消息、调接口、删缓存这种动作,等提交以后再做。
比如这样:
@Service
publicclass PayOrderService {
privatefinal OrderMapper orderMapper;
privatefinal StockMapper stockMapper;
privatefinal OrderAfterCommitPublisher publisher;
public PayOrderService(OrderMapper orderMapper,
StockMapper stockMapper,
OrderAfterCommitPublisher publisher) {
this.orderMapper = orderMapper;
this.stockMapper = stockMapper;
this.publisher = publisher;
}
@Transactional(rollbackFor = Exception.class)
public void markPaid(Long orderId) {
OrderEntity order = orderMapper.selectById(orderId);
if (order == null) {
thrownew IllegalArgumentException("order not exists, id=" + orderId);
}
if (order.getPayStatus() == 1) {
return;
}
int changed = orderMapper.markPaid(orderId, LocalDateTime.now());
if (changed != 1) {
thrownew IllegalStateException("order pay status changed, id=" + orderId);
}
int stockChanged = stockMapper.freezeStock(order.getSkuId(), order.getBuyCount());
if (stockChanged != 1) {
thrownew IllegalStateException("stock not enough, skuId=" + order.getSkuId());
}
publisher.afterCommit(() -> publisher.publishPaid(orderId));
}
}
这个afterCommit不复杂,别搞一大套框架味的封装,够用就行:
@Component
publicclass OrderAfterCommitPublisher {
privatefinal OrderEventSender sender;
public OrderAfterCommitPublisher(OrderEventSender sender) {
this.sender = sender;
}
public void afterCommit(Runnable job) {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
job.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
job.run();
}
}
);
}
public void publishPaid(Long orderId) {
sender.sendPaid(orderId);
}
}
这样消息一定在事务提交后发。
当然,这还不是最稳的方案。真要扛线上,订单支付这种场景我更愿意落一张本地消息表,跟订单状态放在同一个事务里,后面定时任务扫出去。
别嫌土,这东西出事的时候好查。
@Transactional(rollbackFor = Exception.class)
public void markPaidWithOutbox(Long orderId) {
int changed = orderMapper.markPaid(orderId, LocalDateTime.now());
if (changed != 1) {
return;
}
OutboxEvent event = new OutboxEvent();
event.setBizKey("ORDER_PAID:" + orderId);
event.setTopic("order.paid");
event.setBody("{\"orderId\":" + orderId + "}");
event.setStatus(0);
event.setCreatedAt(LocalDateTime.now());
outboxMapper.insert(event);
}
后面补一个任务:
public void flushPaidEvent() {
List<OutboxEvent> events = outboxMapper.pickWaiting(100);
for (OutboxEvent event : events) {
try {
sender.send(event.getTopic(), event.getBody());
outboxMapper.markSent(event.getId(), LocalDateTime.now());
} catch (Exception e) {
outboxMapper.markFailed(event.getId(), e.getMessage());
}
}
}
这个方案不花哨,但是有账可查。
还有一个坑也挺常见:@Transactional加了,但根本没生效。
比如自己调自己:
@Service
public class RefundService {
public void refund(Long orderId) {
checkRefund(orderId);
doRefund(orderId);
}
@Transactional(rollbackFor = Exception.class)
public void doRefund(Long orderId) {
// update refund_order
// update account_balance
}
}
这个doRefund()是同一个类里直接调用,没走 Spring 代理,事务不会开。
线上看日志,甚至还能看到 SQL 都执行了,但中间异常以后前面的更新没回滚。这个时候别骂 MyBatis-Plus,先看调用链。
我一般直接拆到另一个 Service:
@Service
publicclass RefundTxService {
privatefinal RefundMapper refundMapper;
privatefinal AccountMapper accountMapper;
public RefundTxService(RefundMapper refundMapper, AccountMapper accountMapper) {
this.refundMapper = refundMapper;
this.accountMapper = accountMapper;
}
@Transactional(rollbackFor = Exception.class)
public void refundMoney(Long orderId, BigDecimal amount) {
refundMapper.markRefunding(orderId);
accountMapper.addBalance(orderId, amount);
}
}
外层再调用它。
还有一种更阴的,异常被吃掉了:
@Transactional(rollbackFor = Exception.class)
public void importUser(List<UserRow> rows) {
for (UserRow row : rows) {
try {
userMapper.insert(toEntity(row));
} catch (Exception e) {
log.warn("import user failed, mobile={}", row.getMobile(), e);
}
}
}
这段代码不会因为某一行失败而回滚。
因为异常被 catch 掉了,事务管理器根本不知道你失败了。你想部分成功,那就别包一个大事务;你想全失败,那 catch 后必须抛出去。
我更喜欢写清楚:
@Transactional(rollbackFor = Exception.class)
public void importUserStrict(List<UserRow> rows) {
for (UserRow row : rows) {
if (row.getMobile() == null) {
throw new IllegalArgumentException("mobile empty, rowNo=" + row.getRowNo());
}
userMapper.insert(toEntity(row));
}
}
@Transactional在 MyBatis-Plus 项目里可以用,但别随手往 Service 上一贴就完事。
几个位置我会特别小心:
远程调用不要放事务里。
MQ、删缓存、发通知,不要默认认为它们能看到你刚 update 的数据。
大批量导入不要一个事务包到底,连接会被你占着,回滚日志也难看。
同类方法互调,先怀疑事务没生效。
catch 了异常还想回滚,那就别只打日志。
MyBatis-Plus 只是帮你少写 SQL,不是帮你兜住事务边界。事务边界这个东西,写错了不会立刻炸,最容易在并发、消费者快、数据库慢的时候冒出来。
这种坑别等告警来教。