day36 秒杀实现与悲观锁解决超卖

1. 今天做了什么

  • 实现秒杀券下单功能
  • 分析高并发下的超卖问题
  • 使用悲观锁(synchronized)解决超卖问题
  • 学习了编程式事务 TransactionTemplate 的用法

2. 核心知识点

秒杀券表设计

  • 普通券和秒杀券都存在同一张表,用一个字段做区分
  • 秒杀券还有一张独立表(tb_seckill_voucher)存秒杀券特有的信息:库存、开始时间、结束时间

秒杀下单流程

  1. 查询秒杀券信息
  2. 判断时间是否在秒杀时间内(开始时间、结束时间校验)
  3. 判断库存是否足够
  4. 扣减库存
  5. 创建订单
  6. 返回订单id

超卖问题

  • 现象:卖出超过库存数量的商品
  • 原因:线程1判断库存后、尚未完成扣减之前,线程2也进行了判断;高并发下很多线程几乎同时判断库存,都认为充足,导致超卖

悲观锁

  • 认为线程安全问题一定会发生,加锁让线程串行执行
  • synchronized、Lock 以及 Redis 实现的都是悲观锁
  • 保证了线程安全,但串行执行影响性能

正确实现:锁 + 编程式事务

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
@Autowired
private TransactionTemplate transactionTemplate;

@Override
public Result seckillVoucher(Long voucherId) {
synchronized(VoucherOrderServiceImpl.class){
return transactionTemplate.execute(status -> doSeckillVoucher(voucherId));
}
}

public Result doSeckillVoucher(Long voucherId) {
//查询秒杀信息
//判断时间是否在秒杀时间内
//判断秒杀是否结束
//判断库存是否足够
//更新库存
boolean success = seckillVoucherService.update()
.setSql("stock=stock-1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足");
}
//创建订单
//返回订单id
}
  • 关键点:锁的释放一定要晚于事务提交
  • 更新操作添加 .gt("stock", 0) 也可防止超卖(CAS 乐观锁兜底)

错误示范1:锁释放早于事务提交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
synchronized(VoucherOrderServiceImpl.class){
//查询秒杀信息
//判断时间是否在秒杀时间内
//判断秒杀是否结束
//判断库存是否足够
//更新库存
}
//创建订单
//返回订单id
return Result.ok(orderId);
}
  • 错误原因:锁的释放早于事务的提交,释放锁之后才会提交事务,所以在提交事务之前,其他线程查询到的都是未扣减的旧库存

错误示范2:同类调用导致 @Transactional 失效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public Result seckillVoucher(Long voucherId) {
synchronized(VoucherOrderServiceImpl.class){
return doSeckillVoucher(voucherId);
}
}

@Transactional
public Result doSeckillVoucher(Long voucherId) {
//查询秒杀信息
//判断时间是否在秒杀时间内
//判断秒杀是否结束
//判断库存是否足够
//更新库存
//创建订单
//返回订单id
return Result.ok(orderId);
}
  • 错误原因:@Transactional 是基于动态代理实现的,其他类注入的实际是代理对象,所以可以进行事务管理;而在同一个类中,seckillVoucher 中调用 doSeckillVoucher 实际上是 this.doSeckillVoucher(voucherId),this 不是代理对象,导致 @Transactional 失效

事务的两种写法

  1. 声明式事务:@Transactional
  2. 编程式事务:
1
2
3
4
5
6
7
8
9
TransactionStatus status = transactionManager.getTransaction(...); // 开启
try {
Result result = doSeckillVoucher(voucherId);
transactionManager.commit(status); // 提交
return result;
} catch (Exception e) {
transactionManager.rollback(status); // 回滚
throw e;
}
  • Spring 提供了 TransactionTemplate 来封装以上代码:
1
2
3
4
5
6
7
8
Result result = transactionTemplate.execute(status -> {
// 业务逻辑
return Result.ok(orderId);
});

transactionTemplate.executeWithoutResult(status -> {
// 业务逻辑
});
  • 编程式事务,只要有 spring-boot-starter-jdbc 或 mybatis-plus-boot-starter,就已经包含了 spring-tx 和事务相关类,不需要额外加依赖,只要配置好数据库的数据源即可

3. 深度思考

4. 踩过的坑

  • 锁的释放时机必须晚于事务提交,否则释放锁后其他线程能读到未扣减的旧库存,仍会超卖
  • 同一个类中方法调用 @Transactional 会失效,因为 this 不是代理对象

5. 遗留问题 / 想不通