我为什么把“数据库事务”从注解变成了敬畏

举报
逆行时光 发表于 2026/11/07 10:38:52 2026/11/07
【摘要】 我为什么把“数据库事务”从注解变成了敬畏刚学 Spring 时,我觉得事务管理简单得可笑——在方法上加个 @Transactional,完事。什么隔离级别、传播行为、回滚规则,全是“面试才会问的东西”。直到那次库存超卖事故,我才明白:事务不是注解,是契约;不是配置,是责任。那是一个秒杀活动,100 件商品,结果卖出去了 137 单。运营找过来时,我第一反应是“不可能,我有 @Transac...

我为什么把“数据库事务”从注解变成了敬畏

刚学 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 吞异常、藏着长事务,今晚就改。不用一步到位,但每修一个,你的数据就多一分安全。毕竟,超卖的库存能补,扣错的钱能退,但用户对系统的信任,丢了就难回来了。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。