day37 乐观锁解决超卖、一人一单

day37 乐观锁解决超卖、一人一单与集群锁失效

一、乐观锁解决超卖问题

1.1 乐观锁思路

乐观锁:认为线程安全问题小概率发生,不添加锁,让线程并行执行,提高效率。在更新时判断数据是否与之前查询到的一致,一致则更新。

与悲观锁的对比:

维度 悲观锁 乐观锁
核心思想 认为冲突一定会发生,先加锁再操作 认为冲突概率低,不加锁,更新时判断
并发性 串行执行,吞吐量低 并行执行,吞吐量高
适用场景 写多读少,冲突概率高 读多写少,冲突概率低
本项目代表 synchronized + 编程式事务 .gt("stock", 0) 条件更新

1.2 方案一:与旧值比较

最初的乐观锁写法是用查询到的旧库存值作为条件:

1
2
3
4
5
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.eq("stock", seckillVoucher.getStock()) // WHERE stock = 旧库存值
.update();

等价 SQL:

1
2
3
UPDATE tb_seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ? AND stock = ?;

问题:假设库存为 100,100 个线程同时查询到 stock=100 并通过判断,但只有第一个线程更新成功(stock 变为 99),其余 99 个线程因为 stock = 100 条件不再匹配而全部失败。失败率极高,导致库存无法卖完。

1.3 方案二:stock > 0 条件更新

既然库存只要大于 0 就可以扣减,不需要精确匹配旧值,改为:

1
2
3
4
5
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 只要 stock > 0 即可
.update();

等价 SQL:

1
2
3
UPDATE tb_seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ? AND stock > 0;

MySQL 的单行 UPDATE 是原子操作,且 InnoDB 会对被更新的行加行锁,修改同一行数据的操作会串行执行。加上 stock > 0 条件,可以从根本上杜绝超卖。

1.4 .gt("stock", 0) 是乐观锁吗

这是随笔中的核心思考,结论如下:

写法 是否乐观锁 说明
.eq("stock", seckillVoucher.getStock()) 是 比较旧数据,一致才更新,符合乐观锁思想
.gt("stock", 0) 否(业务变体) 没有比较旧数据或版本号,只约束下限

注意:.gt("stock", 0) 不是经典乐观锁,也不是 MyBatis-Plus 的 @Version 版本号乐观锁。它是基于业务特点的数据库原子条件更新:利用 UPDATE 语句本身的原子性和 MySQL 行锁,在 WHERE 条件中约束 stock > 0,防止扣减到负数。

在悲观锁方案中,.gt("stock", 0) 的作用是兜底,防止锁失效后超卖。在乐观锁方案中,它作为主防超卖手段。两者的底层都依赖 MySQL 行锁机制。


二、一人一单

2.1 需求与问题

需求:同一个秒杀券,一个用户只能下一单。

并发问题:在”查询用户是否已下单 → 判断 → 写入订单”这个流程中,如果同一用户高并发访问,在订单未保存之前,多个线程都可以通过”未下单”的判断,导致一人多单。

1
2
3
4
线程1:查询订单(count=0) → 判断未下单 → (还没写入)
线程2:查询订单(count=0) → 判断未下单 → (还没写入)
...
多个线程都通过判断,各自写入订单 → 一人多单

2.2 锁粒度选择

加锁的目的是防止同一用户并发下单,因此锁粒度应该精确到用户级别。

锁对象 粒度 问题
VoucherOrderServiceImpl.class 类级别,所有请求串行 不同用户也串行,吞吐量极差
this 实例级别 同上,粒度太粗
userId.toString().intern() 用户级别 同一用户串行,不同用户并行,粒度精确

2.3 intern() 方法

userId 是 Long 类型,userId.toString() 每次调用会 new 一个新的 String 对象,不同线程拿到的是不同对象,无法互斥。必须使用 intern():

  • 如果字符串常量池中已有该字符串,返回池中的引用
  • 如果没有,将当前字符串的引用放入池中,并返回

这样内容相同的字符串经过 intern() 后返回的是同一个引用,保证同一用户加的是同一把锁。

2.4 锁与事务的顺序

与 day36 的教训一致:锁的释放必须晚于事务提交。

如果锁在 createVoucherOrder 方法内部加(方法上有 @Transactional),则锁释放在方法内部,事务提交在方法返回后由代理完成,锁释放早于事务提交,其他线程在窗口期内能读到旧数据。

因此正确做法是将锁放在 seckillVoucher 方法中,包裹整个 createVoucherOrder 调用。


三、AopContext 获取代理对象

3.1 问题回顾

day36 的错误示范二:同一类中 seckillVoucher 调用 this.doSeckillVoucher(),因为 this 不是代理对象,@Transactional 不会生效。

3.2 解决方案:AopContext.currentProxy()

通过 AopContext.currentProxy() 获取当前类的代理对象,再通过代理对象调用方法,使 @Transactional 生效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Override
public Result seckillVoucher(Long voucherId) {
// ... 前置校验(查询券、时间、库存) ...
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 一人一单查重
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
// 乐观锁扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}

3.3 使用前提

AopContext.currentProxy() 默认不可用,需要两个配置:

  1. 启动类添加注解:
1
2
3
4
5
6
7
8
@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}

exposeProxy = true 将当前代理对象放入线程上下文,AopContext.currentProxy() 才能从中取出。默认为 false,不开启会抛出 IllegalStateException: Cannot find current proxy。

  1. 引入依赖:
1
2
3
4
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
  1. 接口声明:createVoucherOrder 方法必须在 IVoucherOrderService 接口中声明,代理对象才能拦截到该方法。

3.4 同类调用 @Transactional 失效的方案对比

方案 做法 优缺点
编程式事务 TransactionTemplate.execute() 灵活,无代理依赖,代码侵入性高
AopContext 代理 AopContext.currentProxy() 保留声明式事务,需配置 exposeProxy
注入自身代理 @Autowired 自身接口 有循环依赖风险
拆分 Service 将方法移到另一个 Service 类 结构清晰,但增加类数量

本项目在 day36 使用了编程式事务,day37 改用 AopContext 方案,两种都可以解决同类调用事务失效问题。


四、集群模式下 synchronized 锁失效

4.1 问题现象

经过测试,在集群模式(多实例部署)下,synchronized 锁会失效,仍会出现一人多单。

4.2 根本原因

1
2
3
4
5
6
7
8
9
服务器A (JVM-1)                    服务器B (JVM-2)
┌─────────────────────┐ ┌─────────────────────┐
│ VoucherOrderServiceImpl │ │ VoucherOrderServiceImpl │
│ .class → 对象A │ │ .class → 对象B │
│ userId.intern() → 池A │ │ userId.intern() → 池B │
│ synchronized(池A) │ │ synchronized(池B) │
└─────────────────────┘ └─────────────────────┘
↓ ↓
两把不同的锁,互不感知

synchronized 是 JVM 进程内锁。集群模式下每个服务器有独立的 JVM,字符串常量池也是各自的,锁对象互不感知,不同服务器的线程可以同时进入临界区。

4.3 解决方向

单机方案无法满足集群需求,需要跨进程的分布式锁,后续使用 Redis 分布式锁解决。

注意:当前基于 synchronized 的一人一单方案仅限单机单实例部署。生产环境的集群部署必须替换为 Redis 分布式锁。


今日总结

模块 核心要点
乐观锁方案一 .eq("stock", 旧库存值) 比较旧数据,失败率高,库存无法卖完
乐观锁方案二 .gt("stock", 0) 只约束下限,利用 MySQL 行锁原子更新防超卖
.gt("stock", 0) 定性 不是经典乐观锁/CAS,是基于业务的数据库原子条件更新;悲观锁中作兜底
一人一单 查重 + 加锁,锁粒度精确到 userId,用 intern() 保证同一用户同一把锁
intern() 字符串常量池机制,内容相同的字符串返回同一引用,保证锁对象一致
AopContext 方案 AopContext.currentProxy() 获取代理对象,解决同类调用 @Transactional 失效;需 exposeProxy = true + aspectjweaver 依赖
锁与事务顺序 锁必须包裹整个事务方法调用,确保事务提交后才释放锁
集群锁失效 synchronized 是 JVM 内锁,集群多实例各自独立 JVM,锁互不感知,需 Redis 分布式锁