秒杀场景的超卖问题及优化
超卖问题
秒杀业务逻辑:

超卖的场景:

假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
原因:判断库存是否充足,与下单减库存这两个步骤不是原子的,并发情况下会出现超卖问题。
悲观锁的方式解决
在秒杀时进行加锁。获得到锁的线程可以进行购买,串行化执行数据。
乐观锁的方式解决
一般来说,乐观锁的方式是通过加上版本号,在操作数据时,判断版本号是否被修改,如果被修改则操作失败,没有被修改则操作成功。
对于秒杀场景的库存问题,不需要加上版本号。只需在完整的sql语句中判断库存是否被修改过,如果修改过则秒杀失败,如果没有修改过则秒杀成功。 但是这样会导致高并发的场景下大部分时候都会失败。如果100个人同时拿到值为100的库存,在秒杀时只有一个人能够成功,其他人在购买时库存已经被修改过了,会购买失败。
所以,在sql语句中不是判断库存是否被修改过,而是判断库存是否大于0。
在数据库中,增删改的操作会在表上加锁,不会出现同时操作数据库的现象。
在单点服务器场景下,加锁(悲观锁或乐观锁)可以解决线程安全问题,但是在集群服务器场景下就不行了。
因为加的锁属于JVM内部,在单个JVM内部可以实现互斥,但是在多个JVM之间锁就失效了。
所以需要使用分布式锁来解决这个问题。
分布式锁
分布式锁的核心是:满足分布式场景下多线程可见并且互斥的锁。
所以分布式锁需要满足的条件有:
- 多线程可见
- 互斥
- 高可用:程序不易崩溃
- 高性能:加锁本来就导致性能降低,所以要求分布式锁本身获取和释放的性能较高
- 安全:不易出现死锁等问题。
使用redis作为分布式锁能够满足上述条件。
核心思路:使用setnx命令实现加锁,del实现解锁,设置过期时间确保安全性。
误删别人的锁问题
但是设置了过期时间后,容易出现误删别人的锁。当线程的业务逻辑阻塞时,锁过期了。另一个线程获得锁后,当前线程完成了,把另一个线程的锁删除了。

解决:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
分布式锁的原子性问题
在判断当前锁是自己的锁后,要进行删除时,锁过期了,另一个线程获得了锁,这时删掉了另一个线程的锁。
解决:通过lua脚本解决原子性问题。

上述redis分布式锁的问题

解决:使用Redisson提供的一系列分布式锁。
Redission分布式锁

-
可重入:采用hash结构存储锁,key表示锁是否存在,field表示持有锁的线程,value表示锁计数器。
-
可重试:采用信号量和发布订阅,实现等待,唤醒,获取锁失败重试的机制
-
超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间。
问题:在redis集群中,主机负责写,从机负责读,如果主机在写入锁后,还未来得及同步到从机,这时主机宕机了,就会出现锁失效的问题。
解决Redisson分布式锁主从一致性问题
使用Redisson中的MutiLock锁。
加锁时,写入redis集群中的每一个节点中,只有全部写入成功,才是加锁成功。
当某个节点宕机时,加锁数量<集群节点数量,加锁失败。这样就保证了加锁的可靠性。
MutiLock加锁的原理: 在给定的时间内,尝试将锁加入到一个List集合中。 while循环尝试获取锁放入List集合。如果给定的时间内全部获得锁,则加锁成功。

Redis优化秒杀

上述过程串行的执行,且有很多操作去访问数据库,这样会导致程序运行的很慢,而且数据库的压力很大。
优化方案: 将耗时较短的判断库存、校验一人一单的逻辑判断,通过redis快速判断。 如果redis判断返回成功,那么一定会秒杀成功。所以可以直接先返回给用户返回成功信息,再在后台线程慢慢执行数据库操作。
逻辑:
-
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
-
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-
如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
-
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能


秒杀业务的优化思路: 1. 先利用redis完成库存余量、一人一单的逻辑判断,完成秒杀。 2. 再将下单的业务放入阻塞队列,开启一个线程异步存入数据库。
基于阻塞队列的异步秒杀存在的问题:
- 内存限制:阻塞队列存储的数据满了不能再存入,或者存入数据过多导致OOM
- 数据安全问题:基于内存保存秒杀的信息,如果服务宕机,会导致数据丢失。
Redis消息队列实现异步秒杀
基于Redis的Stream结构作为消息队列,实现异步秒杀
- 创建一个Stream类型的消息队列,名为stream.orders
- 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
- 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
浙公网安备 33010602011771号