如何设计一个秒杀功能?
重点
针对秒杀场景,我们需要先和面试官说出以下几个需要解决的问题点:
- 瞬时流量的承接
- 防止超卖
- 预防黑产
- 避免对正常服务的影响
- 兜底方案
然后可以从前后端两个视角向面试官阐述整体的设计点:
首先是前端:
- 利用CDN缓存静态资源(秒杀页面的HTML、CSS、JS等),减轻服务器的压力
- 客户端限流,在前端随机限流,降低请求量
- 按钮防抖,防止用户重复多次点击发出大量请求
其次是后端:
- Nginx(或其他接入层)做统一接入,负载均衡与流量过滤、限流
- 业务端限流,可以自定义实现本地guava限流或利用sentinel等。
- 服务拆分,将秒杀功能拆分为独立的服务,避免对现有服务产生影响
- 秒杀数据的拆分和缓存缓存可以使用分布式缓存或本地缓存的方案,且需要缓存预热
- 精确地库存扣减,防止超卖发生
- 风控识别黑产,进行流量防控且需要动态黑名单机制
- 验证码、答题等手段预防脚本刷单
- 幂等操作,防止重复下单
- 业务手段降低并发量,例如通过预约、预售。
- 兜底方案,如果服务压力过大或者代码有漏洞,那么关闭秒杀直接返回秒杀结束,降低服务压力及时止损。
详细分析
瞬时流量的承接
一般情况下,秒杀的流量特性就是持续性短和大。
流量集中在活动即将开始的时候,会有很多用户开始持续性地刷新页面。前端资源的访问也需要损耗大量的资源,因此需要利用CDN缓存秒杀页面的一些静态资源,将这部分压力给到CDN厂商。
并且静态资源放在CDN厂商那之后,地理位置也距离用户更近,用户访问也就更快,体验上也更好。
秒杀页面可手动推给CDN预热。
秒杀流量还有个特点,就是大部分请求实际都是无效的,因为秒杀的商品库存往往都是个位数,二抢购的用户是其成千上万倍。
加入有100万请求来抢购一台iPhone,那么需要放这100万请求直接打到后端服务吗?显然不需要。
针对这个情况,我们就需要层层过滤请求。
例如前面提到的客户端限流,即在前端随机限流,降低请求量。说的更直白一些即部分用户点击抢购按钮,但是请求都发不到后端,直接前端代码返回秒杀结束。(如果预测量实在太大,可以这样操作,毕竟也是随机的)
如果前端请求发出来了,那么可以利用nginx统一接入,针对更大的流量可以在nginx前面再加lvs。
lvs四层转发请求达到多台nginx上,nginx再负载均衡到多台后端服务,且nginx有限流功能,例如ip限流,还可以配置黑名单等,其实已经拦截大量请求流量。
请求到达后端服务之前还可以再进行限流,比如使用sentinel再拦截一道。
最终请求打到后端服务,涉及到一些读取数据和写数据的操作。如果量级不大且数据库配置高,理论上可以用数据库来承接(数据库层面也有优化的)。
这时候也可以利用缓存来承接读写,可以用本地缓存或分布式缓存,如Redis。
请求链路可以如下:
浏览器(前端代码限流) -> CDN -> 动态请求 -> LVS -> 负载均衡 -> Nginx(负载均衡+限流+黑名单) -> sentinel -> 后端服务 -> redis/mysql
在量级不大的情况下,实际上的秒杀架构不需要如上图所示,例如不需要引入lvs、本地缓存之类的。
库存扣减设计
此时可以加锁,比如利用数据库的锁,针对这个场景数据库常用的是乐观锁。
update inventory set available_inventory = available_inventory - 1
where sku_id = 1 and available_inventory > 0;
数据库热点行问题解决方案
如果使用这个语句,在高并场景下,实际上就会产生热点行问题。
压测单台机子下单链路的并发只能达到70。单个扣减库存的接口并发只有200就把数据库CPU压满了。(各公司实际内部业务不用,仅供参考)
数据库补丁优化
如果数据库用的是阿里云的RDS,实际上有一个可落地的优化方案:Inventory hint + Returning。
在SQL表名前加/*+ COMMIT_ON_SUCCESS ROLLBACK_ON_FAIL TARGET_AFFECT_ROW(1)*/
update /*+ COMMIT_ON_SUCCESS ROLLBACK_ON_FAIL
TARGET_AFFECT_ROW(1)*/ inventory
set available_inventory = available_inventory - 1
where sku_id = 1 and available_inventory > 0;
Inventory hiint 原理简单介绍:
- COMMIT_ON_SUCCESS:当前语句执行成功就提交事务上下文。
- ROLLBACK_ON_FAIL:当前语句执行失败就回滚事务上下文。
- TARGET_AFFECT_ROW(NUMBER):如果当前语句影响行数是指定的就成功,否则语句失败
设置这几个hint,当前的语句会按照主键(或唯一键)分组,将相同行的请求分为一组,分组后仅组内第一条SQL需要抢锁,后续的都不需要申请锁,减少申请锁的流程。
然后组内第一条SQL已经遍历B+树查询到数据了,后续组内库存扣减直接改即可,不用再次查询。且组内SQL都修改完之后,仅需依次分组提交事务即可。
根据阿里云介绍,结合Inventory hint单行TPS可达3.1w。
还可以配合Returning使用:
CALL dbms_trans.returning("*", "update /*+ COMMIT_ON_SUCCESS ROLLBACK_ON_FAIL
TARGET_AFFECT_ROW(1)*/ inventory
set available_inventory = available_inventory - 1
where sku_id = 1 and available_inventory > 0;");
正常情况下,如果我们update扣减依次库存之后,如果想得知最新的库存,那么需要再执行一次select操作,而Returning可以直接返回实时的库存,减少一次查询。
利用Returning,我们可以得知实时的库存,发现没库存后,可以直接设置一个标志位,表明秒杀已经结束,快速fail请求,降低服务的压力。
还有一个Statement queue,关于这几个hint的详情,参考:参考链接
库存拆分
除了数据库补丁优化,从业务角度,我们可以将库存进行拆分。
上面举例是1个库存,但有时候的秒杀的库存会更多,例如1000个库存,此时就可以将这1000个库存拆分成100个小库存,每个小库存内有10个库存。
这样其实就是人为的把热点行拆分了,可以把小库存分散到不同的表或者库中,等于将并发度提升了10倍。
看起来挺简单,实际对于整个库存扣减流程的改造还是挺大的,例如粪桶的库存调配、创建库存是粪桶的库存分配,表的映射、库的映射等等。
插入库存扣减流水
既然直接update有热点航问题,那么就将update改为insert。
实际上用户的购买从更新库存变为插入流水,然后异步定时将流水库存同步到剩余库存中。
这个手段确实避免了热点行的问题,但插入数据不好控制总得数据量,容易导致超卖。
可以跟面试官提一下这个方案,跟他说清这个方案是有超卖问题,表明你知道这个思路,也知道这个方案的缺点。这个思路实际上在非限制库存的热点行场景可以使用。
缓存
利用缓存来承接热点数据是很多人都熟知的方案,例如使用Redis。
可以将库存提前同步到Redis中,然后利用redis + lua 脚本控制库存的扣减。
lua脚本的内容实际上很简单,用文字描述:
- 根据商品key获取库存
- 如果有则库存-1,返回新库存
- 如果没库存,则返回没库存
redis + lua 可以保证操作的原子性,且性能足够优秀,因此是一个非常高效的库存扣减方案。
然后redis扣减完毕以后,可以发送一个异步消息(消息队列削峰填谷),后端服务异步消息把数据库中的库存给扣了,实现最终一致性。
“redis 操作成功后,mq发送失败怎么办?”
因此,我们还需要一个准时实时对账机制,lua脚本内不仅要扣减库存,还需要利用zset增加流水,score设置为时间,定时拉取一段时间流水记录比对数据库的库存是否一致,如果不一致则补偿。
至于本地缓存,理论上性能更高,但方案上设计会更复杂,因为库存被分配到多个应用中。需要在秒杀预热的时候,给后端服务预分配好库存,然后应用各自承接库存扣减,也需要做好对账,防止意外的发生。
预防黑产
大一点的公司都会有风控机制,借助一些算法对用户的来源、行为数据等等进行分析,如果发现不法分子,则将其加入到黑名单中,脚本抢购实际上可以用验证码、答题等机制拦截,并且这种机制也可以打散用户的请求,降低瞬时流量高峰。
幂等设计
业务手段
预约
例如Nike设计就是抢购,预约一个比较长的时间段,例如15分钟,然后预约通过后等待最终的抽签结果即可。这样的设计通过一段时间的预约,可减少瞬时的压力,再异步通过后台实现抽签来间接解决秒杀的问题。
预售
例如现在的电商活动都搞定金预售
通过下定让用户感觉这个商品已经到手了,不需要再等到双十一或者618零点准时抢购,均摊了请求,减少准点抢购的压力。
避免对正常服务的影响
大部分公司秒杀都是和正常服务糅合在一起的,没有做区分。
如果成本允许,且为了避免对正常业务产生影响,则可以将秒杀单独剥离出一套,独立域名、独立服务部署等。
不过这样实现起来其实很麻烦,最终的数据还是需要同步的正常服务中的,成本比较大。
兜底手段
或许在真正的业务中,很少有人会做兜底方案,都仅考虑正向业务,但是兜底确实很重要!
所以在业务上的设计我们要尽量考虑异常极端情况,设计一个简单的兜底也没兜底好。
在面试中,那就得疯狂兜底!向面试官展示出你的方案面面俱到!
针对秒杀,其实最简单的方案就是加个开关:关闭秒杀,直接返回秒杀结束。
这个兜底是为了避免极端情况发生,严重影响正常业务的进行或产生资损。
因为秒杀对用户而言本身是一个可以接受失败的场景,没抢到很正常。只要用户来参加我们的活动,营销目的也达到了,所以在严重影响正常业务进行或者发现代码出现漏洞,被人薅羊毛的情况下,关闭秒杀是最好的选择。

浙公网安备 33010602011771号