day37 乐观锁解决超卖、一人一单与集群锁失效
一、乐观锁解决超卖问题
1.1 乐观锁思路
乐观锁:认为线程安全问题小概率发生,不添加锁,让线程并行执行,提高效率。在更新时判断数据是否与之前查询到的一致,一致则更新。
与悲观锁的对比:
| 维度 | 悲观锁 | 乐观锁 |
|---|---|---|
| 核心思想 | 认为冲突一定会发生,先加锁再操作 | 认为冲突概率低,不加锁,更新时判断 |
| 并发性 | 串行执行,吞吐量低 | 并行执行,吞吐量高 |
| 适用场景 | 写多读少,冲突概率高 | 读多写少,冲突概率低 |
| 本项目代表 | synchronized + 编程式事务 |
.gt("stock", 0) 条件更新 |
1.2 方案一:与旧值比较
最初的乐观锁写法是用查询到的旧库存值作为条件:
1 | boolean success = seckillVoucherService.update() |
等价 SQL:
1 | UPDATE tb_seckill_voucher |
问题:假设库存为 100,100 个线程同时查询到 stock=100 并通过判断,但只有第一个线程更新成功(stock 变为 99),其余 99 个线程因为 stock = 100 条件不再匹配而全部失败。失败率极高,导致库存无法卖完。
1.3 方案二:stock > 0 条件更新
既然库存只要大于 0 就可以扣减,不需要精确匹配旧值,改为:
1 | boolean success = seckillVoucherService.update() |
等价 SQL:
1 | UPDATE tb_seckill_voucher |
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 | 线程1:查询订单(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 |
|
3.3 使用前提
AopContext.currentProxy() 默认不可用,需要两个配置:
- 启动类添加注解:
1 |
|
exposeProxy = true 将当前代理对象放入线程上下文,AopContext.currentProxy() 才能从中取出。默认为 false,不开启会抛出 IllegalStateException: Cannot find current proxy。
- 引入依赖:
1 | <dependency> |
- 接口声明:
createVoucherOrder方法必须在IVoucherOrderService接口中声明,代理对象才能拦截到该方法。
3.4 同类调用 @Transactional 失效的方案对比
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 编程式事务 | TransactionTemplate.execute() |
灵活,无代理依赖,代码侵入性高 |
| AopContext 代理 | AopContext.currentProxy() |
保留声明式事务,需配置 exposeProxy |
| 注入自身代理 | @Autowired 自身接口 |
有循环依赖风险 |
| 拆分 Service | 将方法移到另一个 Service 类 | 结构清晰,但增加类数量 |
本项目在 day36 使用了编程式事务,day37 改用 AopContext 方案,两种都可以解决同类调用事务失效问题。
四、集群模式下 synchronized 锁失效
4.1 问题现象
经过测试,在集群模式(多实例部署)下,synchronized 锁会失效,仍会出现一人多单。
4.2 根本原因
1 | 服务器A (JVM-1) 服务器B (JVM-2) |
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 分布式锁 |