首页
学习
活动
专区
圈层
工具
发布

告警:MyBatis-Plus中慎用@Transactional注解,坑的差点被开了...

库存扣了,订单表也 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,不是帮你兜住事务边界。事务边界这个东西,写错了不会立刻炸,最容易在并发、消费者快、数据库慢的时候冒出来。

这种坑别等告警来教。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/Oz5IVMhGBWmL_GEZde2AFKSw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券