事务为什么总翻车

事务这块最常见的问题,不是不会加注解,而是加了以后以为一定生效。

真实业务里,事务最容易翻车的地方通常是下面这些:

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、复杂分支下可以直接上编程式事务