主题
事务与并发控制
下单扣库存,是最能体现"数据一致性"的场景。这一章讲透事务,让商城在并发下也不出错。
一、什么是事务
事务把一组操作打包成"要么全成功、要么全失败"的原子单元。下单时:
① 扣库存
② 建订单
③ 建订单明细
④ 清购物车任何一个失败,前面的必须回滚(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 分布式锁(防重复下单)。知道名字 + 一句话定位,就够面试铺垫。