事务(@Transactional)笔记

事务(@Transactional)笔记

本项目数据写入绝大多数是单表 CRUD(MyBatis-Plus 继承方法,自带连接级原子性),真正需要本地数据库事务的是一次请求内对同库多张表做写入的地方,共 3 处:普通下单、秒杀建单、加购读改写。跨服务的库存扣减刻意不用分布式事务,而用「乐观锁 + 失败补偿回补」实现最终一致。本文基于真实代码梳理。

一、什么是事务及 ACID

特性 含义 本项目对应
原子性 Atomicity 一个业务动作的多条 SQL 要么全成功要么全回滚 订单主表 + 订单明细必须同生共死
一致性 Consistency 前后数据合法、不超卖 扣库存失败则不建单,失败回补库存
隔离性 Isolation 并发事务互不干扰(默认 RR 可重复读) 加分布式锁 + DB 乐观锁处理并发
终久性 Durability 提交后数据不丢 MySQL InnoDB redo log

二、本项目三处本地事务

2.1 普通下单(多表写入最典型)

mall-order/.../impl/OrderServiceImpl.add:

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
@DistributedLock(prefix = "lock:order:create", key = "#userId", waitTime = 0, leaseTime = 30,
                 message = "正在处理订单,请勿重复提交")
@Override
public void add(Order order) {
    // 1.Feign 拉取当前用户购物车已勾选商品(不在本库,不算本地事务范围)
    // 2.计算总价
    // 3.insert 订单主表 order
    // 4.循环 insert 多条 order_item
    // 5.Feign 清购物车(跨服务,不在本事务内)
    // 6.RabbitMQ 发延时消息做超时关单(不在事务内)
}

Propagation.REQUIRED:当前有事务就加入、没有就新建,是最常用、默认的传播行为。

为什么必须有事务:第 3 步成功、第 4 步批量插明细时若某条失败(字段超长、DB 抖动),没有事务会留下没有明细的孤儿订单;有事务则主表一并回滚,一切照旧。

2.2 秒杀建单(MQ 消费者调用)

mall-order/.../impl/SeckillServiceImpl.createSeckillOrder:

@Transactional(propagation = Propagation.REQUIRED, rollbackFor = Exception.class)
public void createSeckillOrder(SeckillMessage msg, SeckillGoods goods) {
    // Feign 查商品 → insert order → insert order_item
}

由 SeckillOrderListener 在 MQ 消费线程里调用。若两条 insert 之间异常,本地回滚;监听者捕获异常后执行库存回补(见第四节)。

2.3 加购读改写

mall-cart/.../impl/CartServiceImpl.add:

@DistributedLock(prefix = "lock:cart:add", key = "#userId + ':' + #cart.productId", ...)
@Transactional(rollbackFor = Exception.class)
public void add(Cart cart) {
    // select 有无同款 → update count 累加 / insert 新行
}

严格说这里只有一条写,事务不是必需,但与锁配合、也为后续”校验库存+写购物车”留原子性保障。


三、rollbackFor = Exception.class 的含义(高频面试)

Spring 声明式事务的默认回滚规则:

  • 默认只对 RuntimeException 和 Error 回滚;
  • 受检异常(checked Exception,如 IOException)默认不回滚。

本项目业务异常 ServiceException extends RuntimeException,默认就会回滚;但我们仍显式写 rollbackFor = Exception.class,让任何异常都回滚,避免将来方法里出现受检异常(比如文件处理、序列化)时事务静默提交一半数据。这是生产代码的稳妥写法。

反例:若写 noRollbackFor 或不写 rollbackFor 且抛了 checked 异常,即使方法报错,前面的 insert 也已提交——这是”事务没回滚”最常见的原因之一。

四、跨服务一致性:不用 Seata,用补偿

普通下单/秒杀的真实库存扣减发生在另一个微服务(mall-product,可能不同库),本地 @Transactional 管不到 Feign 调用。项目没有用 Seata 等分布式事务,秒杀链路是这样保证最终一致的(SeckillOrderListener):

Redis Lua 预减成功 → 发 MQ → 消费端:
  ① orderNo 幂等:订单已存在则直接返回(防消息重复)
  ② Feign 调 product-service 乐观锁扣真实库存
       update product set stock=stock-1 where id=? and stock>=1   -- 返回0=库存不足
  ③ 扣减成功 → @Transactional 建 order + order_item
  ④ 建单抛异常 → addStock 回补真实库存 + restoreStock 回补 Redis 库存/解除一人一单 + 标记失败
  ⑤ 全部成功 → Redis 结果 key 置成功,供前端轮询

这是经典 TCC/补偿型最终一致思想的简化版:不用锁住跨库资源,而是”先做,失败了反向补偿”。普通下单的库存校验目前还是 TODO(教学定位),秒杀链路则完整覆盖了失败回补。

五、事务什么时候会失效(必背 8 条)

结合本项目代码逐条说明:

  1. 自调用(同类 this 调用):同一个类里方法 A 没注解、直接 this.B() 调带注解的 B,不经过代理,事务/缓存/异步注解全失效。
  • 本项目 syncPayStatus 内部 this.paySuccess() 就是自调用——所以分布式锁加在两个对外入口上,而不是指望内部调用触发注解。
  1. 方法不是 public:Spring 默认用 JDK/CGLIB 代理,非 public 拦截不到。
  2. 异常被 catch 吞掉:方法内部 try{}catch{} 把异常消化了、没再抛出,代理感知不到异常,不会回滚。要回滚就得重新抛。
  3. 受检异常没写 rollbackFor:见第三节。
  4. 数据库引擎不支持事务:必须 InnoDB,MyISAM 不支持。
  5. 没被 Spring 管理:对象是自己 new 出来的,不是容器里的代理 Bean。
  6. 传播行为设错:如 NOT_SUPPORTed 以非事务方式执行,自然没有事务。
  7. 多线程:事务绑定在 ThreadLocal 的数据库连接上,新开线程里的 SQL 不在原事务中。MQ 消费者是独立线程,它有自己独立的新事务(createSeckillOrder 上的注解在消费线程自己生效),和生产者的事务毫无关系。

六、事务与分布式锁的顺序(重要设计)

加购和下单方法上同时有 @DistributedLock 和 @Transactional。切面设了 @Order(HIGHEST_PRECEDENCE),所以执行顺序是:

获取分布式锁 → 开事务 → 执行业务 → 提交/回滚事务 → 释放锁

必须锁在事务外层。若事务在锁外层,会出现”业务写完、锁先释放、事务还没提交”的窗口,别的实例拿到锁读到的仍是旧数据(不可重复读 / 丢更新),锁就白加了。详见《分布式锁笔记.md》。

七、传播行为速查(补充)

虽然项目只用了默认的 REQUIRED,面试可能追问:

传播行为 行为
REQUIRED(默认,项目在用) 有事务加入,没有新建
REQUIRES_NEW 总是新建独立事务,外层挂起(常用于日志/流水独立提交)
NESTED 嵌套事务,保存点,内层失败可只回滚到保存点
SUPPORTS 有事务就用,没有就非事务执行
NOT_SUPPORTED 以非事务方式执行,有则挂起
NEVER 强制非事务,有事务就报错
MANDATORY 必须在事务中调用,否则抛异常

八、小结(面试口径)

项目单表 CRUD 靠 MyBatis-Plus,不显式开事务;本地事务用在三处一次写多表的地方:普通下单(订单+明细)、秒杀建单、加购,都标 @Transactional(rollbackFor=Exception.class) 保证任何异常回滚。跨服务扣库存不用 Seata,用 DB 乐观锁 + 失败补偿回补实现最终一致。同时注意锁在事务外层、自调用/异常被吞/多线程会让事务失效。

上一篇
下一篇