秒杀系统架构优化思路

179 阅读12分钟

本文主要节选和总结自沈剑大佬的 秒杀系统架构优化思路,以及以及拉勾教育潘新宇老师的《23讲搞定后台架构实战》第 16 章节。

一、业务概述

扣减功能的一个极端案例,超热点扣减,就是秒杀。秒杀业务两个特点:并发流量高,超卖容忍度极低。

一般秒杀都是一件商品一个订单,业务逻辑简单一些。

秒杀系统,库存只有有限份,所有人会在集中的时间读和写这些数据,多个人读一个库存数据。

例如:很多年前小米手机每周二的秒杀,可能手机只有1万部,但瞬时进入的流量可能是几百几千万。瞬时流量非常多,都读相同的库存。读写冲突,锁非常严重,这是秒杀业务难的地方

二、优化方向

优化方向有两个(今天就讲这两个点):

(1)将请求尽量拦截在系统上游(不要让锁冲突落到数据库上去),也可以称为限流。传统秒杀系统之所以容易挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量非常小。以12306为例,一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0。

(2)充分利用缓存,秒杀买票,这是一个典型的读多写少的应用场景,大部分请求是车次查询,车票查询,下单和支付才是写请求。一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%,非常适合使用缓存来优化。

三、各层次优化细节

第一层,客户端怎么优化(浏览器层,APP层)

(a)产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;

(b)JS层面,限制用户在x秒之内只能提交一次请求;

问题:无法避免被抓包,然后用脚本进行循环调用

第二层,站点层面的请求拦截

站点层面,对uid进行请求计数和去重,甚至不需要统一存储计数,直接站点层内存存储(这样计数会不准,但最简单),比如一个uid,5秒只准透过1个请求,其余未被处理的请求直接返回一个缓存好的页面。

为了避免黑产使用大量用户 id 不一样的肉鸡发起请求,还需要增加源 IP 的计数和限流。除了源 ip,还可以增加对源主机的 mac 地址的限流。限流算法可以采用令牌桶算法或者漏桶算法。

第三层 服务层来拦截(反正就是不要让请求落到数据库上去)

对于写请求,做请求队列,每次只透有限的写请求去数据层(下订单,支付这样的写业务),比如:1w部手机,只透 1k 个下单请求去db,如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完”。

对于读请求,redis 缓存。数据库更新后也更新 redis 缓存。

数据库层

如果是缓存扣减

正常逻辑是每件商品库存只存在于一个缓存分片,需要为秒杀商品做一些定制化逻辑。

弄个白名单存储所有的秒杀商品,对于白名单的商品,在每个缓存分片都存一份,总库存值平摊到每个缓存分片,每个缓存分片的库存值是一小部分,分片库存值之和是总库存(总库存100,10个分片,那么每个分片库存值设置为 10,每个缓存分片的 key 是 sku_1, sku_2)。

在处理秒杀请求时,不只是固定地命中某一个缓存分片,而是在每次请求时轮询或者随机选择缓存集群中的每一个缓存分片。

缓存分片库存不足则直接返回无库存。

如果是数据库扣减

每次透到数据库层的请求都是可控的,db基本就没什么压力了,单机也能扛得住,还是那句话,库存是有限的,透这么多请求来数据库没有意义。

全部透到数据库,100w个下单,0个成功,请求有效率0%。透3k个到数据,全部成功,请求有效率100%。

四、问题

问:如果站点层的请求计数放在内存里,用户请求落到了其他的站点服务器怎么办

1、在nginx层做4层或者7层均衡,让一个uid的请求尽量落到同一个机器上。

2、加大计数限制,比如从 5s 内处理一个请求,改成 30s 内只处理一个请求

问:肉鸡拥有的资源多,秒杀成功的概率大,怎么让秒杀更加公平?肉鸡把队列被撑爆了怎么办?

比较难保证用户是均衡进来的,如果可以牺牲一些用户体验的话,对于肉鸡,有个方式是使用验证码

事前封杀已知的黑产账户,秒啥时增加源IP地址或者源Mac的限流

队列成本很低,容量可以很大,一般很难挤爆。

问:按你的架构,其实压力最大的反而是站点层,假设真实有效的请求数有1000万,不太可能限制请求连接数吧,那么这部分的压力怎么处理?

每秒钟的并发可能没有1kw,假设有1kw,解决方案2个:

(1)站点层是可以通过加机器水平扩容的,最不济 1k 台机器。

(2)如果机器不够,抛弃请求,抛弃50%(50%直接返回稍后再试),原则是要保护系统,不能让所有用户都失败。

问:如果队列处理失败,如何处理?系统需要自动重试吗?

答:处理失败返回下单失败,让用户自己重试,架构设计原则之一是“fail fast”。

问:服务层过滤的话,队列是服务层统一的一个队列?还是每个提供服务的服务器各一个队列?如果是统一的一个队列的话,需不需要在各个服务器提交的请求入队列前进行加锁控制?

答:可以不用统一一个队列,这样的话每个服务透过更少量的请求(总票数/服务个数),这样简单。统一一个队列又复杂了。

问:秒杀成功但是未支付或者取消订单,如何对剩余库存做及时的控制更新?大量恶意用户下单锁库存而不支付如何处理呢?

答:数据库里一个状态,未支付。如果超过时间,例如20分钟,库存会重新会恢复(这个就是大家熟知的“回仓”),给用户的提示是,开动秒杀后,45分钟之后再试试看,说不定又有票哟~

问:不同的用户浏览同一个商品落在不同的缓存实例显示的库存完全不一样,怎么做缓存数据一致或者是允许脏读?

答:目前的架构设计,请求落到不同的站点上,数据可能不一致(页面缓存不一样),这个业务场景能接受。但数据库层面真实数据是没问题的。

问:就算出于业务优化考虑“3k张火车票,只透3k个下单请求去db”那这3K个订单就不会发生拥堵了吗?

答:(1)数据库抗 3k 个写请求还是没问题的;

(2)如果3k扛不住,服务层可以控制透过去的并发数量,根据压测情况来吧,3k只是举例;

问:对于大型系统的秒杀,比如 12306 ,同时进行的秒杀活动很多,如何分流?

答:按照业务进行垂直拆分

问:用户请求使用请求队列后变成异步的了吗,如何控制能够将响应结果返回正确的请求方?

答:用户层面肯定是同步的(用户的http请求是夯住的),服务层面可以同步可以异步。怎么是怎么实现的,还有点不是很明白?

问:还有哪些优化方向

1、根据促销的意图增加业务维度的限流,比如如果想激活很久没下单的老用户,可以设置不活跃用户跟活跃用户的请求处理比例,比如 100:50,每秒钟处理100个不活跃用户的请求,50个活跃用户的请求,在实现上,可以使用令牌桶算法,各配置一个令牌桶,非活跃用户的令牌桶数量为 100,活跃用户的令牌桶设置为 50。

2、业务上允许的话,随机丢失一定比例的请求,比如 10%

3、部署隔离,便于快速扩容,流量不影响正常业务

4、分时分段售票,将流量摊匀到各个时间段,

5、数据粒度的优化,流量大的时候,做一个粗粒度的“有库存”“无库存”缓存即可,不用关心具体剩余库存

6、一些业务逻辑的异步:例如下单业务与 支付业务的分离。

问:站点层的页面缓存能不能用 cdn 来做?这样能抗更大并发

cdn返回的是静态资源,秒杀都是返回的是动态的页面。

问:将写请求先放在redis中然后再异步刷到db里面是不是更好一点

问:如果多个相同用户的请求都到达服务层的请求队列,数据库怎么处理一致性问题

(1)服务层对请求按照订单 id 或者用户id去重

问:数据库只能部署在一个机房当中,怎么确保多机房部署的服务到数据库公平性?

接入层能做到多个机房根据容量分配负载

问:并发扣减怎么保证一致性

(1)服务层对请求按照订单 id 或者用户id去重

(2)更新数据库时使用 CAS 保证一致性

问:服务层的请求队列需要加锁吗

需要,可以选个加锁的队列数据结构,或者读写之前先加锁。

问:为什么说秒杀业务对超卖的容忍度极低?为什么严防黑产?

秒杀在绝大部分场景里是一种高品质商品的低价营销活动,如一元抢 iPhone,基本上是亏本营销,赔本赚吆喝,而且秒杀的商品原价一般都不低,所以不允许出现大面积的超卖。

秒杀通常是基于低价商品的营销活动,抢到商品后转售会有很大的盈利空间。因此,秒杀会吸引来大批的黄牛和黑产用户,所以需要严厉打击和拦截这些恶意用户。

问:如果用缓存做扣减,能不能通过“增加缓存副本以及使用本地缓存”的方式来应对呢?

缓存副本或者本地缓存里的商品数量均是原始分片的数据镜像,不能被拿来进行扣减的,否则就会出现数据错乱,甚至超卖的现象。

其次,本地缓存里的数据是非持久化数据,易丢失。即使将本地缓存持久化至宿主机的磁盘,也会因磁盘故障、不满足 ACID 等原因而导致数据丢失。

问:集中式限流 、单机限流、本地限流的区别

集中式限流

集中式是指在一个单独的限流应用存储一个总的限流阈值。需要限流的应用每次请求到来时都需要远程访问限流应用,来判断当前是否达到限流值。

缺点:

  • 每次请求增加一次远程调用限流服务的网络开销;
  • 集中式限流可能不精准
  • 限流服务需要做高可用,否则成为单点隐患

单机限流

在每个服务进程实例部署一个限流进程,配置平台根据服务的实例数量计算每个实例的限流阈值,把阈值下发到实例的限流进程,服务通过请求本地的限流进程来判断当前是否达到限流值。

缺点

限流架构复杂一些,需要引入分发,服务扩容时需要重新计算每个机器的限流阈值,还需要在扩容机器里同步启动限流进程。

优点:

比集中式限流更加准确和实时。

本地限流

在服务进程内部维护计数器,进行限流。优点是系统简单,容易编码,缺点是限流代码无法复用,且限流代码耦合在服务进程内部,增加了服务进程的维护成本。

实际使用

在实际的应用中,推荐采用单机维度的限流器,因为它会更加精准和实时。如果是比较简单的限流,或者定制化字段的限流,则在服务进程内部维护计数器会更方便些。

问:为什么集中式限流可能不准确

(此答案来自 chatgpt,实际原因待考证)

限流组件与被限流组件之间的网络延迟可能会导致被限流组件在实际上已经发出了更多的请求,而限流组件并没有及时收到这些请求,从而无法准确地控制流量速率。这种情况下,被限流组件可能会发生超时、重试等问题,导致整个系统的响应时间和可用性下降。

完整参考

秒杀系统架构优化思路

16 | 秒杀场景:热点扣减如何保证命中的存储分片不挂?