超卖问题

什么是超卖

超卖是指卖出的商品数量超过了实际库存数量,这种情况通常发生在高并发的电商网站上,比如双十一、618等大促销活动。超卖的原因主要是多线程并发操作数据库时,没有对库存数量进行加锁,导致多个线程同时读取库存数量,然后都判断库存数量足够,最后都执行了减库存操作,导致库存数量变为负数。

如何解决超卖问题

加锁是解决超卖问题的常用方法

锁的种类

  • 悲观锁:悲观锁认为线程安全问题一定会发生,所以在操作数据之前先加锁,操作完成后再释放锁。串行化处理,效率低。

  • 乐观锁:乐观锁认为线程安全问题不一定会发生,所以在操作数据之前不加锁,只是在更新数据时判断数据是否被其他线程修改过,如果没有修改过则更新成功,否则更新失败。

对于超卖问题,乐观锁更适合,因为超卖问题不是一定会发生的,只有在多个线程同时读取库存数量时才会发生,所以只需要在更新库存数量时判断库存数量是否足够即可。

乐观锁的实现方式

关键在于判断数据是否被其他线程修改过,一般有两种方式:

alt text

  • 版本号:给数据表增加一个版本号字段,每次更新数据时将版本号加1,更新数据时判断版本号是否一致,如果一致则更新成功,否则更新失败。

alt text

  • CAS(Compare And Swap):使用CAS指令更新数据,CAS指令是一种原子操作,可以保证数据的一致性。每次更新数据时,先读取数据的值,然后比较数据的值是否和预期值一致,如果一致则更新数据,否则更新失败。

注意,如果单纯使用CAS指令更新数据,可能会导致ABA问题,即数据的值被修改两次,但是版本号没有变化,所以需要使用版本号来解决ABA问题。

ABA问题

如果库存值从A变成B再变成A,那么CAS操作会认为库存值没有变化,但实际上库存值已经发生了变化,这就是ABA问题。

代码实现

这里单纯使用CAS指令更新数据,不考虑ABA问题。

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1. 查询代金券库存
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);

// 2. 判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
// 秒杀未开始
return Result.fail("秒杀未开始");
}

// 3. 判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
// 秒杀已结束
return Result.fail("秒杀已结束");
}

// 4. 判断库存是否足够
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足");
}

// 5. 扣减库存(CAS)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId) // where voucher_id = #{voucherId}
.gt("stock", 0) // and stock > 0
// .eq("stock", voucher.getStock()) // where stock = #{stock}
.update(); // 数据库锁 不会存在都为1然后都修改的情况
if (!success) {
// 扣减库存失败
return Result.fail("库存不足!");
}

// 6. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 6.1 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 6.2 用户id
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
// 6.3 代金券id
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);

// 7. 返回订单id
return Result.ok(orderId);
}

注意,如果条件判断是要库存和之前的库存相等,这样会导致成功率低,未卖出问题,即当100个请求进入时,其中一个请求先执行完修改了库存,剩余99个因为这个条件判断失败都不会执行,这样就会导致未卖出问题。

所以这里使用了gt("stock", 0),即库存大于0时才执行扣减库存操作。保证了实现卖完即止。

注意,为什么不会存在库存为1,然后都修改的情况,因为这里的update是Mysql提供锁来进行隔离,不会存在都为1然后都修改的情况。

可优化方向

  • 数据库锁:使用数据库锁来保证数据的一致性,比如行锁、表锁、读锁、写锁等。
  • 缓存:将库存数量缓存到Redis中,减少数据库的访问次数。
  • 消息队列:使用消息队列来异步处理订单,减少数据库的压力。