【面试题】电商秒杀如何解决超卖问题?

视频文稿
问题描述
电商秒杀的超卖问题,其实往大了说主要就是要解决常见的购物网站上经常出现的团购、秒杀、特价等活动会出现的一个共同问题:短时间内大量并发请求的情况下,需要保证商品的销售量不会超过库存量。类似的问题也可以改成 12306 抢票如何保证不抢超过票数等,其实解决的思路都是类似的。
产生超卖现象的主要原因,就是购买过程其实最起码会涉及到两步:(1)检查库存是否已售空或足够用户购买。(2)未售空的话,扣除用户购买的库存数量。这就是一个典型的“读后写”的情况。
如果没有对并发请求进行类似锁的控制,或者说是原子性地去“读后写”访问库存数量的话,就会出现多个请求检查库存时是有库存的,而实际进入扣除库存逻辑时的请求数量已经超过了库存的情况,导致最后库存变为负数。
举例就是两个并发请求在剩余一个库存的时候请求服务器,执行检查逻辑的时候都发现还有一个库存,可以购买,然后真正扣除的时候,各扣一个导致最后库存为 -1。
问题解析
作为一个典型的高并发问题,我们解决问题的关键其实也就是前面提到的:让并发请求原子性地去完成对库存的“读后写”,这里可以是用锁控制并发请求,也可以是其他使得并发请求最终顺序访问库存数据的方式。
那么我们可以看看对于不同层级的系统,我们有什么方式可以处理。
数据库层
涉及到库存,必然涉及到数据库,那么我们可以优先看看数据库层如何处理。
数据库层我们可以实现锁的逻辑。首先就是可以基于 MySQL 的行锁实现悲观锁的作用,直接使用 SELECT ... FOR UPDATE 语句来直接原子性的完成读后写操作。SQL 代码类似如下:
select stock from goods_table where id = 1 for update;
update goods_table set stock = stock - 1 where id = 1;
这样并行请求的访问数据库的事务就将变成串行访问,从而避免了超卖问题。
当然,数据库层也可以实现类似 CAS 的乐观锁,方法就是在表中增加一个版本号字段,或者直接把库存作为版本号,然后就可以在查询和更新时使用类似下面的语句:
select stock from goods_table where id = 1;
update goods_table
set stock = stock - 1
where id = 1 and stock = 123;
但数据库的锁效率较低,以上两个方案都很容易导致整个系统性能瓶颈卡在数据库。尤其是乐观锁方案在高并发情况下的大量重试是我们无法接受的,所以真正在较大并发时,是不会使用这两种解决方案的。
那么比较现实的处理方式是:我们可以通过引入缓存的思路来优化数据库的性能瓶颈。比如使用 Redis 来对库存数量进行缓存,每次库存操作先在 Redis 验证后再访问数据库。这里对 Redis 中库存的读后写可以使用 lua 脚本进行实现,以保证原子性。这样利用 Redis 单线程高性能的特性,可以满足现实场景下基本的业务需求。这是最常见的一个解决方案。
需要注意的是对于 Redis 集群,机器的增加不会提升我们秒杀业务支持的并发量,而仅仅是保证 Redis 的稳定性。因为每次存储库存的 key 所在的槽的主节点都是集群中特定的某一台 Redis 机器,瓶颈依然在这一台机器上。
单体 Web 服务
接着我们可以看看简单基础的单体 Web 服务系统中我们如何处理,可以对后面集群场景的分析有所启发。
因为Web 服务层是单体,我们可以直接对多个并发的请求进程在代码上使用多线程的锁进行控制:
(1)使用 notify/wait、synchronized、Lock 等等各种经典的多线程悲观锁。
(2)使用 CAS 实现乐观锁。但同样大量并发时的重试请求锁的问题,也使得这个解决方案不太现实。
除了锁,还有一个特别的利用异步的思路,就是我们使用一个显式的并发队列,比如 ConcurrentLinkedQueue,每个并发请求来的时候我们先把请求用户和购买数量入队。真正抢商品的任务工作线程异步执行去消费队列中的记录,由它真正去数据库扣减库存。那么只要这个工作线程是单线程的,就不会出现超卖。
但很显然,我们的单体服务也是很难用于真正的秒杀场景的。毕竟基于单体 Web 服务架构的代码控制锁的设计很难进行水平扩容(即增加机器数量以接受更大并发访问量)。以上分析主要是让大家领会到解决问题的核心思路。
Web 服务集群
那么当我们的服务是集群时,我们应该如何处理超卖问题呢?
之前代码上锁的思路,我们扩展一下,在当前集群场景下就可以变成使用分布式锁。这样逻辑上其实就和使用 Java 代码的悲观锁类似,只是锁的逻辑使用分布式锁进行替换。那么即使我们使用 Redis 来实现分布式锁的话,锁导致大量请求等待占用系统资源的问题也是存在的,因为这里的瓶颈依然是在数据库访问速度。此外,同样需要注意水平扩容 Redis 集群无法提高并发量的问题。所以其实这个方案应该也是比较少使用。
而代码通过队列异步处理的思路,我们可以通过应用消息队列在集群场景下防止超卖。要做的就是把单体的并发队列相关逻辑改为使用消息队列。然后保证消费者的单线程性即可防止超卖。
我个人感觉消息队列的实现应该是在可以在高并发场景下支持大访问并发量的。这样充分利用了消息队列异步、解耦、削峰的好处,使得用户并发的请求可以尽快返回给前端,并不会像有锁时在等待访问数据库的过程中占用服务器资源。但这个方案下,查询剩余库存的问题就需要考虑,是同样引入缓存?还是在单线程的消费者处维护剩余库存?抑或是在消息队列先积压到超过库存数量再统一消费处理?设计起来相对复杂。实际实践中,需要根据用户规模、消息队列响应时间、设施成本、后续运维复杂度等因素综合考虑是否有必要使用该解决方案。
总结
通过上面的分析,我们可以清楚的体会到,超卖问题的实质,其实就是解决高并发下对某个数据的读后写操作不相互干扰、保证原子性的问题。那么我们就有“使用锁直接控制请求”和“并发入队然后异步单线程处理”两种思路,对于“数据库层和服务层”、“单体和集群场景”我们也就会有相应的不同实现。基于此,大家应该可以在回答类似问题的时候保持一个清晰的思路进行回答。
各个方法对比的话,最常见的解决方案就是 Redis 缓存的方案了,充分利用 Redis 单线程高性能的特性,足以面对大部分的情况。其次就是消息队列的方式,应该也是可以比较完美地解决问题,但就是方案相对复杂一些,且需要单独考虑查询剩余库存的问题在这种解决方案下如何实现。
其他单体和基于数据库的方法,在超卖问题上我基本给了不及格的推荐指数,主要就是它们虽然都能防止库存扣超的问题,但在这个实际的业务问题上不太适合。原因就是要避免最后瓶颈集中在数据库上,并且要考虑机器的水平扩容问题。之所以介绍,主要还是介绍一下并发场景上的解决思路,以后遇到其他的问题可以自己自行举一反三进行分析。
真正面试的话,估计直接回答 Redis 缓存的解决方案即可。