我为什么把“数据库事务”从注解变成了敬畏
我为什么把“数据库事务”从注解变成了敬畏
刚学 Spring 时,我觉得事务管理简单得可笑——在方法上加个 @Transactional,完事。什么隔离级别、传播行为、回滚规则,全是“面试才会问的东西”。直到那次库存超卖事故,我才明白:事务不是注解,是契约;不是配置,是责任。
那是一个秒杀活动,100 件商品,结果卖出去了 137 单。运营找过来时,我第一反应是“不可能,我有 @Transactional”。但数据不会说谎——数据库里 137 条成功订单,库存变成了 -37。
一、我的第一个错误:以为 @Transactional 是万能的
当时的代码大概是这样:
@Transactional
public Result seckill(Long itemId, Long userId) {
Item item = itemMapper.selectById(itemId);
if (item.getStock() > 0) {
item.setStock(item.getStock() - 1);
itemMapper.updateById(item);
orderMapper.insert(new Order(userId, itemId));
return Result.success();
}
return Result.fail("已售罄");
}
看起来天衣无缝:有事务,有校验,有更新。但问题出在事务隔离级别上。默认是 READ COMMITTED,两个并发事务可以同时读到 stock = 1,都通过校验,都扣减,都提交。结果就是超卖。
我以为事务保证了一致性,但它只保证了“原子提交”,没保证“并发安全”。
二、我的第二个错误:把异常吞了
还有一次,一个复杂的下单流程,我调用了三个服务,其中一个抛了 RuntimeException,但被我 catch 住了:
@Transactional
public void createOrder(Order order) {
try {
inventoryService.deduct(order); // 扣库存
paymentService.charge(order); // 扣款
pointsService.add(order.getUserId()); // 加积分
} catch (Exception e) {
log.error("下单失败", e);
// 没抛出去!
}
}
我以为 catch 住就万事大吉,结果 paymentService.charge() 抛了异常,我 catch 后没重新抛出,事务管理器感知不到异常,事务正常提交了。库存扣了,钱没扣,积分加了。财务发现时已经过去了三天,对账对出了十几万的差额。
事务回滚的唯一触发条件是:异常抛出到事务代理层。 你吞了异常,事务就不知道要回滚。
三、我的第三个错误:事务里调了 RPC
最惨的一次,我在事务方法里调用了库存服务的 RPC 接口:
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
inventoryRpc.deduct(order.getItemId()); // RPC 调用
// 后面还有逻辑...
}
结果库存服务扣减成功了,但后面的逻辑抛了异常,事务回滚了,订单没创建。但库存服务那边已经扣了——分布式操作,本地事务管不了。用户下单失败,但库存少了,客服又炸了。
四、这些坑教会我的事
1. 并发安全不能只靠事务
对于库存扣减这种高并发场景,我后来用了三种手段组合:
- 数据库层:
UPDATE inventory SET stock = stock - 1 WHERE item_id = ? AND stock > 0,让数据库保证原子性 - Redis 预扣:先用 Lua 脚本在 Redis 里预扣,成功再落库
- 乐观锁:
UPDATE ... WHERE version = ?,失败就重试
事务解决的是“要么全做要么全不做”,不是“多人同时做怎么办”。并发安全要靠锁、CAS、唯一约束这些机制。
2. 异常要么处理,要么抛出去,别吞
现在我的规矩是:@Transactional 方法里,所有异常必须抛到方法外。如果确实需要 catch 做补偿逻辑,最后要 throw new RuntimeException(e) 或者声明 rollbackFor:
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
try {
inventoryService.deduct(order);
paymentService.charge(order);
} catch (Exception e) {
log.error("下单失败", e);
throw e; // 让事务回滚
}
}
3. 事务要短,RPC 要出去
事务方法里不做这些事:
- 调用外部 RPC/HTTP 接口
- 发消息(除非用事务消息)
- 耗时计算
- 循环操作
事务越短,锁持有时间越短,并发度越高,出问题的概率越低。我把 RPC 调用移到了事务方法外面,用本地消息表或事务消息保证最终一致。
4. 理解传播行为
@Transactional(propagation = Propagation.REQUIRED) 是默认的,但不是万能的。有些场景需要 REQUIRES_NEW——比如操作日志记录,主事务失败了我也要记日志:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordLog(Log log) {
logMapper.insert(log);
}
五、一个心态的转变
以前我觉得事务是框架的事,加了注解就高枕无忧。现在明白:事务是你在分布式系统里能抓住的最后一根稻草,但它不是魔法。 它管得了数据库里的多表操作,管不了 RPC 调用;它保证得了原子提交,保证不了并发安全;它默认只认 RuntimeException,checked exception 要你明确告诉它。
如果你现在的代码里还有 @Transactional 下面藏着 RPC 调用、藏着 try-catch 吞异常、藏着长事务,今晚就改。不用一步到位,但每修一个,你的数据就多一分安全。毕竟,超卖的库存能补,扣错的钱能退,但用户对系统的信任,丢了就难回来了。
- 点赞
- 收藏
- 关注作者
评论(0)