1. 今天做了什么
- 使用乐观锁解决超卖问题
- 实现一人一单需求(synchronized + userId 锁)
- 学习
intern()方法在锁对象中的作用 - 学习通过
AopContext.currentProxy()解决同类调用@Transactional失效问题 - 测试发现集群模式下
synchronized锁失效
2. 核心知识点
乐观锁解决超卖问题
- 乐观锁认为线程安全问题小概率发生,不添加锁,让线程并行执行,提高效率,在更新时判断是否与之前查到的数据一致,一致则直接更新
- 使用
.eq("stock", seckillVoucher.getStock())失败率高,因为第一个线程更新数据以后,其余已经经过判断的线程会全部失败 - 因为库存只要大于 0 即可扣减,所以改为
.gt("stock", 0),只要保证扣减时 stock > 0,就不会出现超卖
1 | boolean success = seckillVoucherService.update() |
关于 .gt("stock", 0) 是否还算乐观锁的思考
- 使用
.eq("stock", seckillVoucher.getStock())是乐观锁的思想,将现有数据与旧数据比较,一致则更新 - 改用
.gt("stock", 0)算是基于当前业务的变体,不是经典乐观锁,因为它没有比较旧数据或版本号 - 悲观锁里使用
.gt("stock", 0)是一种兜底方案,防止锁失效 - 都是基于 MySQL 数据库原子条件更新,MySQL 会为每行数据添加行锁,修改同一行数据的操作会串行执行
一人一单需求
- 添加一个查询并判断,会出现线程问题:同一用户高并发访问,在订单未保存之前的线程都可以通过判断,导致一人多单
- 为查询用户订单到最后写入订单添加锁
- 锁粒度选择:
- 使用类锁或者 this 锁,会导致不同用户的请求也会串行执行,降低效率
- 使用 userId 的值作为锁,可以只锁单个用户,让同一用户的多次请求串行,不同用户不影响
- 注意必须先提交事务再释放锁
锁对象:userId.toString().intern()
- 内容相同的字符串,
intern()后返回同一个引用 intern()的作用:- 如果字符串常量池里已经有该字符串,返回池中那个引用
- 如果没有,把字符串的引用放入池中,并返回它
- 该锁只能保证单机 JVM 内有效,分布式部署时会失效
解决 @Transactional 失效的第二个方案:获取代理对象
1 | IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy(); |
- 使用前提:
- 启动类添加
@EnableAspectJAutoProxy(exposeProxy = true) - 引入依赖:
- 启动类添加
1 | <dependency> |
完整实现:synchronized + AopContext 代理
1 |
|
集群模式下 synchronized 锁失效
- synchronized 的本质是 JVM 级别的锁,集群模式下每个进程都有各自的 JVM
- 后续学习分布式锁后解决
3. 深度思考
.eq("stock", seckillVoucher.getStock())是乐观锁思想,但失败率高;改用.gt("stock", 0)是业务变体,不是经典乐观锁- 悲观锁和乐观锁方案中都使用了
.gt("stock", 0),但作用不同:乐观锁方案中作为主防超卖手段,悲观锁方案中作为兜底 - 底层都依赖 MySQL 行锁:修改同一行数据的 UPDATE 会串行执行
4. 踩过的坑
- 使用
.eq("stock", seckillVoucher.getStock())失败率高,大量线程判断通过但更新失败,导致库存无法卖完 - 同一类中调用
@Transactional方法会失效,因为 this 不是代理对象 - 集群模式下
synchronized锁会失效,每个 JVM 有独立的类对象和锁
5. 遗留问题 / 想不通
- 集群模式下的锁失效问题,待学习分布式锁后解决