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

1. 今天做了什么

  • 使用乐观锁解决超卖问题
  • 实现一人一单需求(synchronized + userId 锁)
  • 学习 intern() 方法在锁对象中的作用
  • 学习通过 AopContext.currentProxy() 解决同类调用 @Transactional 失效问题
  • 测试发现集群模式下 synchronized 锁失效

2. 核心知识点

乐观锁解决超卖问题

  • 乐观锁认为线程安全问题小概率发生,不添加锁,让线程并行执行,提高效率,在更新时判断是否与之前查到的数据一致,一致则直接更新
  • 使用 .eq("stock", seckillVoucher.getStock()) 失败率高,因为第一个线程更新数据以后,其余已经经过判断的线程会全部失败
  • 因为库存只要大于 0 即可扣减,所以改为 .gt("stock", 0),只要保证扣减时 stock > 0,就不会出现超卖
1
2
3
4
5
6
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
// .eq("stock", seckillVoucher.getStock()) 失败率高,无法卖完
.gt("stock", 0) // 只要 stock>0 即可
.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
2
IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
return iVoucherOrderService.createVoucherOrder(voucherId);
  • 使用前提:
    1. 启动类添加 @EnableAspectJAutoProxy(exposeProxy = true)
    2. 引入依赖:
1
2
3
4
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>

完整实现:synchronized + AopContext 代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Override
public Result seckillVoucher(Long voucherId) {
//查询秒杀信息
//判断时间是否在秒杀时间内
//判断秒杀是否结束
//判断库存是否足够
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足");
}
//一人一单
Long userId = UserHolder.getUser().getId();
synchronized(userId.toString().intern()) {
//获取代理对象
IVoucherOrderService iVoucherOrderService = (IVoucherOrderService) AopContext.currentProxy();
return iVoucherOrderService.createVoucherOrder(voucherId);
}
}

@Transactional
public Result createVoucherOrder(Long voucherId) {
//一人一单查重、扣减库存、创建订单
}

集群模式下 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. 遗留问题 / 想不通

  • 集群模式下的锁失效问题,待学习分布式锁后解决