1. 秒杀券场景介绍
通过秒杀(Flash Sale)设计一种促销活动,特点是短时间内大量用户涌入系统,抢购有限的商品或优惠券。这种情况下的特点是:
- 商品稀缺:库存量远小于需求量(如100张优惠券面对10万用户)。
- 时间敏感:用户争抢的时间窗口极短,系统必须快速响应。
- 高并发请求:在同一时间点,可能有成千上万的用户同时发起请求,系统需要处理大量并发请求。
2.库存超卖问题
在高并发场景下,如何保证库存扣减的正确性和数据一致性是一大难题。
库存超卖 是指在高并发场景下,系统未能正确处理多个用户同时扣减库存,导致库存被扣减到负数。例如:
- 秒杀优惠券库存为1。
- 两个用户如A 和 B等多个用户几乎同时提交抢购请求。
- 系统中的业务逻辑如下:
int stock = queryStock(); // 查询库存,返回1 if (stock > 0) { // 判断库存是否大于0 updateStock(stock - 1); // 扣减库存,库存变为0 }
因此在执行
queryStock()
和updateStock()
之间,两个请求都读取到库存为1,判断通过后都执行扣减操作,导致库存最终变成 -1。
3.导致库存超卖的核心原因
-
请求并发导致的竞态条件(Race Condition)
- 多个请求同时操作数据库,读取到相同的库存值,执行扣减操作时没有加锁或条件判断不够严格,导致超卖。
-
查询与更新操作不具备原子性
- 数据库操作分为两步:先查询库存,再更新库存。
- 在高并发情况下,两个请求可以在查询与更新之间交替执行,导致数据不一致。
-
数据库事务隔离级别不足
- 数据库默认的隔离级别可能无法防止不可重复读,在秒杀场景下可能引发数据冲突。
-
分布式系统中的延迟问题
- 在缓存和数据库之间的数据同步存在延迟,导致不同节点读取到的库存数据不一致。
4.具体场景示例
假设有100张优惠券,面向10万用户进行抢购:
- 秒杀开始后,10万个请求瞬间到达服务器。
- 系统执行扣减库存逻辑:
- 请求A 和 请求B 同时读取到库存
stock = 1
。 - 两个请求分别判断
stock > 0
为真。 - 两个请求都执行
stock = stock - 1
,库存最终变成 -1。
- 请求A 和 请求B 同时读取到库存
- 数据库记录不一致,出现超卖现象。
5.乐观锁解决方案
乐观锁的核心思想是:在更新数据时检查数据是否被其他请求修改过,通过版本号或其他条件(如库存的值)进行判断,确保并发操作安全。
乐观锁实现步骤
- 读取数据时不加锁。
- 更新时进行条件判断:
- 使用
where
条件判断数据是否满足期望值。 - 如果条件不满足,说明数据已被其他请求修改,更新失败。
- 使用
代码案例
一种乐观锁变体的方式来解决超卖问题:
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // 扣减库存,stock = stock - 1
.eq("voucher_id", voucherId) // 筛选指定的voucher_id
.gt("stock", 0) // 确保库存 > 0
.update(); // 执行更新操作
首先通过更新语句,将 stock
的值减1。通过 where stock > 0
条件,确保只有库存大于0时才会执行扣减操作。如果多个请求同时扣减库存,把当前的查询到的stock与数据库stock 进行对比,此时
只有一个请求能满足 stock > 0
的条件,其他请求会因为条件不满足而失败。
乐观锁的优势
- 避免超卖:通过
where stock > 0
的条件保证库存不会被减成负数。 - 无需加锁:数据库级的条件判断实现了并发控制,不需要使用悲观锁(如
for update
)。 - 性能优越:相比于悲观锁,乐观锁更适合高并发场景,减少了锁带来的性能开销。