事务为什么总翻车
事务这块最常见的问题,不是不会加注解,而是加了以后以为一定生效。
真实业务里,事务最容易翻车的地方通常是下面这些:
1、同类方法调用,事务根本没生效
2、异常被吃掉,事务没有回滚
3、事务里混了远程调用,把数据库连接长时间占住
4、异步线程、批量处理和事务边界没切清楚
这篇只讲业务代码里经常踩的坑,不展开讲事务基础概念。
同类调用不会生效
这是最经典的一类坑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Service public class CouponService {
public void sendBatch(List<Long> userIds) { for (Long userId : userIds) { sendOne(userId); } }
@Transactional(rollbackFor = Exception.class) public void sendOne(Long userId) { couponMapper.insert(userId); userPointMapper.increase(userId, 10); } }
|
很多人看到 sendOne 上有事务,就以为循环调用时每次都会开启事务。
实际上这是同类内部调用,没有走到 Spring 代理,事务不会按预期生效。
更常见的改法有两种。
第一种,拆到单独的 Service:
1 2 3 4 5 6 7 8 9
| @Service public class CouponTxService {
@Transactional(rollbackFor = Exception.class) public void sendOne(Long userId) { couponMapper.insert(userId); userPointMapper.increase(userId, 10); } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @Service public class CouponService {
private final CouponTxService couponTxService;
public CouponService(CouponTxService couponTxService) { this.couponTxService = couponTxService; }
public void sendBatch(List<Long> userIds) { for (Long userId : userIds) { couponTxService.sendOne(userId); } } }
|
第二种,显式获取代理对象再调用,但这种方式侵入性更强,一般不如拆 Service 清晰。
异常吞掉就不会回滚
很多代码里最容易出现这种写法:
1 2 3 4 5 6 7 8 9
| @Transactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { try { orderMapper.insert(buildOrder(request)); stockService.lock(request.getSkuId(), request.getCount()); } catch (Exception e) { log.error("create order failed", e); } }
|
看起来加了事务,实际上异常被吞掉以后,Spring 感知不到失败,事务可能照样提交。
更稳的写法是:
1 2 3 4 5 6 7 8 9 10
| @Transactional(rollbackFor = Exception.class) public void createOrder(OrderRequest request) { try { orderMapper.insert(buildOrder(request)); stockService.lock(request.getSkuId(), request.getCount()); } catch (Exception e) { log.error("create order failed, request={}", request, e); throw new BizException("create order failed", e); } }
|
如果你确实不能往外抛,也要显式标记回滚:
1 2 3 4 5 6 7 8 9 10 11 12
| @Transactional(rollbackFor = Exception.class) public Result createOrder(OrderRequest request) { try { orderMapper.insert(buildOrder(request)); stockService.lock(request.getSkuId(), request.getCount()); return Result.success(); } catch (Exception e) { TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); log.error("create order failed", e); return Result.fail("create order failed"); } }
|
事务里别包远程调用
很多接口一上来先开事务,后面把数据库写入、RPC 调用、MQ 发送、日志落库全塞进去。
1 2 3 4 5 6 7
| @Transactional(rollbackFor = Exception.class) public void confirm(OrderConfirmRequest request) { orderMapper.updateStatus(request.getOrderId(), OrderStatus.CONFIRMING); remoteCouponFacade.freeze(request.getCouponId()); remoteRiskFacade.check(request.getUserId()); orderMapper.updateStatus(request.getOrderId(), OrderStatus.CONFIRMED); }
|
这种写法最大的问题是:事务持有数据库连接的时间太长。
如果远程调用一慢,数据库连接池就会跟着被拖住。
更接近真实业务的改法通常是:
1、先把本地状态推进到一个中间态
2、提交事务
3、事务外执行远程调用
4、按结果做确认或补偿
例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| public void confirm(OrderConfirmRequest request) { markConfirming(request.getOrderId()); try { remoteCouponFacade.freeze(request.getCouponId()); remoteRiskFacade.check(request.getUserId()); markConfirmed(request.getOrderId()); } catch (Exception e) { markConfirmFailed(request.getOrderId(), e.getMessage()); throw new BizException("confirm failed", e); } }
@Transactional(rollbackFor = Exception.class) public void markConfirming(Long orderId) { orderMapper.updateStatus(orderId, OrderStatus.CONFIRMING); }
@Transactional(rollbackFor = Exception.class) public void markConfirmed(Long orderId) { orderMapper.updateStatus(orderId, OrderStatus.CONFIRMED); }
|
这类场景里,事务边界越短,系统越稳。
异步线程不是同一个事务
主线程开了事务,不代表异步线程也在同一个事务里。
1 2 3 4 5 6 7 8
| @Transactional(rollbackFor = Exception.class) public void handle(OrderRequest request) { orderMapper.insert(buildOrder(request));
CompletableFuture.runAsync(() -> { orderLogMapper.insert(buildLog(request)); }, executor); }
|
这段代码里,异步线程的数据库写入和主线程事务没有关系。
如果主线程回滚了,异步线程那条日志可能已经写成功。
更合理的处理方式一般是两种:
1、异步逻辑本来就允许最终一致,那就接受它独立提交
2、如果必须和主事务一致,改成事务提交后再发消息或执行回调
1 2 3 4 5 6 7 8 9 10
| @Transactional(rollbackFor = Exception.class) public void handle(OrderRequest request) { orderMapper.insert(buildOrder(request)); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { executor.execute(() -> orderLogService.saveLog(request)); } }); }
|
批量处理别开大事务
批量任务里最容易出现的坏味道就是“为了图省事,整批开一个事务”。
1 2 3 4 5 6 7
| @Transactional(rollbackFor = Exception.class) public void batchSend(List<Long> userIds) { for (Long userId : userIds) { couponMapper.insert(userId); pointMapper.increase(userId, 10); } }
|
这种写法的问题很直接:
1、一条失败,整批回滚
2、事务时间过长,锁和连接占用时间太久
3、失败后很难定位是第几条数据出的问题
更稳的思路通常是分批、分段、单条事务收口:
1 2 3 4 5 6 7 8 9 10 11 12
| public void batchSend(List<Long> userIds) { List<List<Long>> partitions = Lists.partition(userIds, 100); for (List<Long> partition : partitions) { for (Long userId : partition) { try { couponTxService.sendOne(userId); } catch (Exception e) { log.error("send coupon failed, userId={}", userId, e); } } } }
|
复杂分支可以用编程式事务
声明式事务确实更简单,但有些分支逻辑特别复杂时,编程式事务反而更清晰。
1 2 3 4 5 6 7 8 9 10 11 12 13
| public Result adjustStock(AdjustRequest request) { return transactionTemplate.execute(status -> { try { stockMapper.lock(request.getSkuId(), request.getCount()); stockLogMapper.insert(buildLog(request)); return Result.success(); } catch (Exception e) { status.setRollbackOnly(); log.error("adjust stock failed", e); return Result.fail("adjust stock failed"); } }); }
|
这类写法适合:
1、同一个方法里有多段事务分支
2、你需要在方法内直接返回成功失败结果
3、异常不适合继续往外抛
常见坑
1、只在 public 方法上加事务这条规则被忽略
private 方法、protected 方法、同类内部调用,很多时候都不会按预期生效。
2、默认只回滚 RuntimeException,却抛了受检异常
如果不显式写 rollbackFor,部分异常不会触发回滚。
3、事务里混远程调用、文件 IO、长耗时计算
事务一长,数据库连接和锁资源就跟着一起被占住。
4、批量任务事务粒度过大
整批回滚看起来安全,实际很容易拖垮系统吞吐。
5、事务成功提交了,但后置动作没做
例如事务里只写了本地库,消息没发出去,最后还以为整条链路已经完成。
收一下
事务真正容易踩坑的地方,不是注解会不会写,而是事务边界到底切得对不对。
常用实践里最值得保留的核心是:
1、同类调用不要幻想事务自动生效
2、异常被吃掉以后,要么重抛,要么显式回滚
3、事务里尽量只放本地短操作,不要包长耗时远程调用
4、异步线程和主事务天然是两回事
5、批量处理要缩小事务粒度
6、复杂分支下可以直接上编程式事务