Skip to content

事务与并发控制

下单扣库存,是最能体现"数据一致性"的场景。这一章讲透事务,让商城在并发下也不出错。

一、什么是事务

事务把一组操作打包成"要么全成功、要么全失败"的原子单元。下单时:

① 扣库存
② 建订单
③ 建订单明细
④ 清购物车

任何一个失败,前面的必须回滚(Rollback),否则会出现"钱扣了订单没建"的脏数据。

java
@Transactional
public Order create(Long userId, OrderCreateDTO dto) {
    // ① 扣库存(乐观锁)
    // ② 插入 order
    // ③ 插入 order_item
    // ④ 删除 cart_item
    // 只要抛出异常,前面全部回滚
}

二、ACID 四大特性(面试必问)

特性含义商城例子
A 原子性事务里的操作要么全成功要么全失败扣库存 + 建订单不分离
C 一致性事务前后数据满足约束库存不会变成负数
I 隔离性并发事务互不干扰两个用户同时买最后一个库存
D 持久性提交后数据永久保存断电重启不丢失

三、隔离级别(I)

并发事务会引起的问题:

问题说明
脏读读到别人未提交的数据
不可重复读同一事务内两次读同一条记录结果不同(别人改了)
幻读同一事务内两次查询返回行数不同(别人插入了)

MySQL InnoDB 的四个隔离级别:

隔离级别脏读不可重复读幻读默认
READ UNCOMMITTED可能可能可能
READ COMMITTED解决可能可能
REPEATABLE READ解决解决可能MySQL 默认
SERIALIZABLE全部解决性能最差
sql
-- 查看/设置隔离级别
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

MySQL 默认 REPEATABLE READ(用 MVCC 机制,实际能规避大部分幻读场景)。

四、并发超卖问题(下单重点)

两名用户同时下单最后一个库存,朴素写法会超卖:

java
// ❌ 并发下两个请求都读到 stock=1,都扣成功 → 库存变 -1
Product p = productMapper.selectById(id);
p.setStock(p.getStock() - 1);
productMapper.updateById(p);

方案一:乐观锁(本教程采用)

SQL 级条件更新,库存不足时影响行数为 0:

java
@Update("UPDATE product SET stock = stock - #{count} WHERE id = #{id} AND stock >= #{count}")
int reduceStock(@Param("id") Long id, @Param("count") Integer count);

// Service 中:返回 0 说明库存不够,抛异常回滚
int rows = productMapper.reduceStock(productId, count);
if (rows == 0) {
    throw new BizException("库存不足");
}

方案二:悲观锁(SELECT ... FOR UPDATE

sql
-- 查行时直接加锁,其他事务必须等它提交
SELECT * FROM product WHERE id = 1 FOR UPDATE;
方案优点缺点适用
乐观锁无锁、并发高冲突时要做重试读多写少、冲突少
悲观锁实现简单、保证一致性阻塞、并发低写多、冲突频繁

面试话术:本项目下单用乐观锁(条件更新)防止超卖,配合 @Transactional 保证一致性。量大时再引入 Redis 预扣 / 消息队列削峰。

五、事务失效的坑(面试高频)

@Transactional 不是随便加就有用:

正确做法
同类内部方法调用setStock()this.create() 不走代理 → 拆到不同类或注入自身
方法不是 public@Transactional 只在 public 方法上生效
异常被 catch 吞掉不要手动 catch 业务异常,让它抛出才能触发回滚
自增字段无事务只读不写不需要加
传播行为REQUIRED(默认,有事务就用,没有就新建)够用
java
// ❌ 错误:catch 掉异常,事务不会回滚
@Transactional
public Order create(...) {
    try {
        reduceStock();
        insertOrder();
    } catch (Exception e) {
        log.error(e);   // 吞掉了异常!
    }
    return order;
}

六、事务的边界(放对位置)

事务应该在 Service 层,而不是 Controller 层:

  • Controller:接收参数、调 Service、返回 → 无事务
  • Service:业务逻辑编排 → @Transactional
  • Mapper:单条数据操作 → MP 自带

事务范围越小越好(锁越少、性能越好)。下单这种"多表写入"必须包在事务里。

七、本章验收与面试题

  • [ ] 下单后库存、订单、明细一致,购物车被清空
  • [ ] 并发下单(用 Jmeter 模拟 10 线程)库存不为负
  • [ ] 说出 ACID 含义 + MySQL 默认隔离级别
  • [ ] 说出防超卖的乐观锁写法原理

进阶(了解)

分布式事务(Seata)、消息队列最终一致性、Redis 分布式锁(防重复下单)。知道名字 + 一句话定位,就够面试铺垫。

基于 MIT 协议发布,可自由学习与修改