存在的问题
- 瞬时流量的承接
- 防止超卖
- 预防黑产
- 避免对正常服务的影响
- 兜底方案
思路
首先是前端:
- 利用 CDN 缓存静态资源(秒杀页面的 HTML、CSS、JS 等),减轻服务器的压力
- 客户端限流,在前端随机限流,降低请求量
- 按钮防抖,防止用户重复多次点击发出大量请求
其次是后端:
- Nginx(或其他接入层)做统一接入,负载均衡与流量过滤、限流
- 业务端限流,可以自定义实现本地 guava 限流或利用 sentinel 等
- 服务拆分,将秒杀功能拆分为独立的服务,避免对现有服务产生影响
- 秒杀数据的拆分和缓存,缓存可以使用分布式缓存或本地缓存方案,且需要缓存预热
- 精准地库存扣减,防止超卖发生
- 风控识别黑产,进行流量防控且需要动态黑名单机制
- 验证码、答题等手段预防脚本刷单
- 幂等操作,防止重复下单
- 业务手段降低并量发,例如通过预约、预售。
瞬时流量的承接
一般情况下,秒杀的流量特性就是持续性短和大。
流量集中在活动即将开始的时候,会有很多用户开始持续性地刷新页面。前端资源的访问也需要损耗大量的资源,因此需要利用 CDN 缓存秒杀页面的一些静态资源,将这部分压力给到 CDN 厂商。
并且静态资源放在 CDN 厂商那之后,地理位置也距离用户更近,用户访问也就更快,体验上也更好!

秒杀流量还有个特点,就是大部分请求实际都是无效的,因为秒杀的商品库存往往都是个位数,而抢购的用户是其成千上万倍。
假设有 100 万的请求来抢购一台 iPhone,那么需要放这 100 万请求直接打到后端服务吗?显然不需要。
针对这个情况,我们就需要层层过滤请求。
例如前面提到的客户端限流,即在前端随机限流,降低请求量。说的更直白一些即部分用户点击抢购按钮,但是请求都发不到后端,直接前端代码返回秒杀结束。(如果预测量是在太大,可以这样操作,毕竟也是随机的)
如果前端请求发出来了,那么可以利用 nginx 统一接入,针对更大的流量可以在 nginx 前面再加 lvs。
lvs 四层转发请求打到多台 nginx 上,nginx 再负载均衡到多台后端服务,且 nginx 有限流功能,例如 ip 限流,还可以配置黑名单等等,其实已经可以拦截大量请求流量。
请求到达后端服务之前还可以再进行限流,比如使用 sentinel 再拦截一道。
最终请求打到后端服务,涉及到一些读取数据和写数据的操作。如果量级不大且数据库配置高,理论上可以用数据库来承接(数据库层面也是有优化的,后面介绍)。
这时候也可以利用缓存来承接读写,可以用本地缓存或分布式缓存,如 Redis。
最终一个相对而言比较完整的请求链路如下:

超卖(详情可见库存系统设计)
正常扣库存思路

并发超卖

加锁
分布式锁+数据库锁(悲观锁或者乐观锁(CAS+版本号防止ABA问题))
当然这又会产生数据库热点行问题
update inventory set available_inventory = available_inventory - 1
where sku_id = 1 and available_inventory > 0 and version=version+1;
数据库热点行问题解决
库存拆分
例如 1000 个库存,此时就可以将这 1000 个库存拆分成 100 个小库存,每个小库存内有 10 个库存。这样其实就是人为的把热点行拆分了,可以把小库存分散到不同的表或者库中,等于将并发度提升了 10 倍。
看起来挺简单,实际对于整个库存扣减流程的改造还是挺大的,例如分桶的库存调配、创建库存时分桶的库存分配、表的映射、库的映射等等。

插入库存扣减流水
既然直接 update 有热点行问题,那么就将 update 改为 insert 。
实际上用户的购买从更新库存变成插入流水,然后异步定时将流水库存同步到剩余库存中。
这个手段确实避免了热点行的问题,但插入数据不好控制总的数据量,容易导致超卖。这种方案实际上在非限制库存的热点行场景可以使用。
缓存+对账
利用缓存来承接热点数据是很多人都熟知的方案,例如使用 Redis。
可以将库存提前同步到 Redis 中,然后利用 redis + lua 脚本控制库存的扣减。
lua 脚本的内容实际上很简单:
- 根据商品 key 获取库存
- 如果有则库存-1,返回新库存
-
如果没库存,则返回没库存
redis + lua 可以保证操作的原子性,且性能足够优秀,因此是一个非常高效的库存扣减方案。
然后 redis 扣减完毕之后,可以发送一个异步消息(消息队列削峰填谷),后端服务异步消费把数据库中的库存给扣了,实现最终一致性。
image.png
此外,我们还需要一个准实时对账机制,lua 脚本内不仅要扣减库存,还需要利用 zset 增加流水,score 设置为时间。定时拉取一段时间流水记录比对数据库的库存是否一致,如果不一致则补偿。
至于本地缓存,理论上性能更高,但是方案设计上会更复杂,因为库存被分配到多个应用中。需要在秒杀预热的时候,给后端服务预分配好库存,然后应用各自承接库存扣减,也需要做好对账,防止意外的发生。
预防黑产
根据风控机制,借助一些算法对用户的来源、行为数据等等进行分析,如果发现不法分子,则将其加入到黑名单中。
脚本抢购实际上可以用验证码、答题等机制拦截,并且这种机制也可以打散用户的请求,降低瞬时流量高峰。
幂等设计
可以看如何保障幂等篇
业务手段
预约
例如 Nike 设计就是抢购,预约有一个比较长的时间段,例如 15 分钟。然后预约通过后等待最终抽签结果即可。
这样的设计通过一段时间的预约,可减少瞬时的压力,再异步通过后台实现抽签来间接解决秒杀的问题。
预售
例如现在的电商活动都搞定金预售。
通过下定让用户感觉这个商品已经到手了,不需要再等到双十一或者 618 零点准时抢购,均摊了请求,减少准点抢购的压力。
避免对正常服务的影响
大部分公司秒杀都是和正常服务糅合在一起的,没有做区分。
如果成本允许,且为了避免对正常业务产生影响,则可以将秒杀单独剥离出一套,独立域名、独立服务器部署等。
不过这样实现起来其实很麻烦,最终的数据还是需要同步的正常服务中的,成本比较大。
兜底方案
或许在真正的业务中,很少有人会做兜底方案,都仅考虑正向业务,但是兜底确实很重要!
所以在业务上的设计我们要尽量考虑异常极端情况,设计一个简单的兜底也比没兜底好。
针对秒杀,其实最简单的方案就是加个开关:关闭秒杀,直接返回秒杀结束。
这个兜底是为了避免极端情况发生,严重影响正常业务的进行或产生资损。
因为秒杀对用户而言本身是一个可以接受失败的场景,没抢到很正常。只要用户来参加我们的活动,营销目的也达到了,所以在严重影响正常业务进行或者发现代码出现漏洞,被人薅羊毛的情况下,关闭秒杀是最好的选择!
