如何设计一个秒杀系统
秒杀已经成为电商不可缺少的一步分了,所谓 买到就是赚到,可以成功吸引到一大堆用户,那程序员面对这些用户该怎么办呢。我们该如何设计秒杀呢?
在设计秒杀前,我们需要先理解秒杀,秒杀其实主要就是解决两个问题:并发读和并发写。
- 并发读的核心优化理念是尽量减少用户到服务端来
"读"
数据,或者让他们读更少的数据; - 并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。
另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
然后我们升级到架构层面看,就是需要保证架构的几个常见的质量属性:高性能、高可用和一致性
- 高性能
秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。我们从设计数据的动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化等。
- 一致性
秒杀中商品减库存的实现方式同样关键。可想而知,有限数量的商品在同一时刻被很多倍的请求同时来减库存,减库存又分为 拍下减库存付款减库存,在大并发更新的过程中都要保证数据的准确
性。
- 高可用
虽然我们可以对系统进行优化,但现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB
来兜底,以便在最坏情况发生时仍然可以让我们不被开除。
初期架构轮廓
如何构建一个超大流量并发读写、高性能,以及高可用的系统,4要1不要
数据要尽量少
所谓 数据要尽量少,首先是指用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。
因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器做处理,而服务器在写网络时通常都要做压缩和字符编码,这些都非常消耗 CPU
,所以减少传输的数据量可以显著减少 CPU
的使用。例如,我们可以简化秒杀页面的大小,去掉不必要的页面装饰效果,等等。
其次,数据要尽量少 还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是 CPU
的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单、越小则越好。
请求数要尽量少
用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求.
比如说,这个页面依赖的 CSS/JavaScript
、图片
,以及 Ajax
请求等等都定义为额外请求,这些额外请求应该尽量少。因为浏览器每发出一个请求都多少会有一些消耗,例如建立连接要做三次握手,有的时候有页面依赖或者连接数限制,一些请求(例如 JavaScript
)还需要串行加载等。另外,如果不同请求的域名不一样的话,还涉及这些域名的 DNS
解析,可能会耗时更久,减少请求数可以显著减少以上这些因素导致的资源消耗。
例如,减少请求数最常用的一个实践就是合并 CSS
和 JavaScript
文件,把多个 JavaScript
文件合并成一个文件,在 URL
中用逗号隔开(https://g.xxx.com/tm/xx-b/4.0.94/mods/??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)
。这种方式在服务端仍然是单个文件各自存放,只是服务端会有一个组件解析这个 URL
,然后动态把这些文件合并起来一起返回。
路径要尽量短
所谓 路径,就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。
通常,这些节点可以表示为一个系统或者一个新的 Socket
连接(比如代理服务器只是创建一个新的 Socket
连接来转发请求)。每经过一个节点,一般都会产生一个新的 Socket
连接。
然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5
个节点,每个节点的可用性是 99.9%
的话,那么整个请求的可用性是:99.9%
的 5
次方,约等于 99.5%
。
所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能 减少中间节点可以减少数据的序列化与反序列化,并减少延时(可以减少网络传输耗时)。
要缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用RPC
变成 JVM
内部之间的方法调用。
依赖要尽量少
所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。
举个例子,比如说你要展示秒杀页面,而这个页面必须强依赖商品信息
、用户信息
,还有其他如优惠券
、成交列表
等这些对秒杀不是非要不可的信息(弱依赖)
,这些弱依赖
在紧急情况下就可以去掉。
要减少依赖,我们可以给系统进行分级,比如 0 级系统、1 级系统、2 级系统、3 级系统,0 级系统如果是最重要的系统,那么 0 级系统强依赖的系统也同样是最重要的系统,以此类推。
注意,0 级系统要尽量减少对 1 级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是 0 级系统,而优惠券是 1 级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个 1 级系统给拖垮。
不要有单点
系统中的单点可以说是系统架构上的一个大忌,因为单点意味着没有备份,风险不可控,我们设计分布式系统最重要的原则就是 消除单点。
- 那如何避免单点呢?
我认为关键点是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。
- 如何那把服务的状态和机器解耦呢?这里也有很多实现方式
例如把和机器相关的配置动态化,这些参数可以通过配置中心来动态推送,在服务启动时动态拉取下来,我们在这些配置中心设置一些规则来方便地改变这些映射关系。
应用无状态化是有效避免单点的一种方式,但是像存储服务本身很难无状态化,因为数据要存储在磁盘上,本身就要和机器绑定,那么这种场景一般要通过冗余多个备份的方式来解决单点问题。
如何才能做好动静分离
静态资源压缩+cdn+缓存
在商家创建完秒杀产品后, 缓存就有了, 如果修改内容, 就直接更新缓存, 秒杀开始后, 商家就不能再修改了
有针对性地处理好系统的"热点数据"
为什么要关注热点
首先,热点请求会大量占用服务器处理资源,虽然这个热点可能只占请求总量的亿分之一,然而却可能抢占 90%
的服务器资源,如果这个热点请求还是没有价值的无效请求,那么对系统资源来说完全是浪费。
热点数据 静态和动态
静态热点数据是可以预知的,例如一直去请求秒杀的商品都是相同的。
动态数据指某些数据并非我们提前知道的,例如某个商品由于某个娱乐新闻成为top 1
,这个时候商品的所带来的请求也会是top 1
, 对于这种动态的热点数据我们没有办法预知,只能做好限制和保护等操作
最重要最简单的方式就是独立出来一个集群,单独处理热点数据。
流量削峰这事应该怎么做?
实际上在现实生活中也有削峰的例子,例如北京开车限号、进京证、限时限行
方案有:
- 排队 上游洪水堆积,下游平稳放水,防止洪水伤害服务器
- 答题 抢购前先答题,实际上就类似于验证码,但不是验证码,现在验证码识别率高,防止非正常用户的请求造成服务器的过高负载,防作弊、延缓请求
- 分层过滤,每一层设置一些条件,可以是随机数、或者秒杀资格
减库存
付款减库存
即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
还有一个就是第三方支付的异步机制,有可能支付后没有库存,需要给用户退款
选用这种方案就需要牺牲用户的体验
下单扣库存
即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
设置订单有效时间,但是还会存在恶意下单,我们可以采用一些方案来进行制止
例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买 3 件),以及对重复下单不付款的操作进行次数限制等。还可以采用定金的营销形式来减少抢购时的流量
下单减库存 在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:
- 设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行
SQL
语句来报错 - 悲观锁
- 乐观锁 版本号机制
- 再有一种就是使用
sql
判断语句,例如这样的 SQL 语句
udpate goods set available = available - 1 where id = xx and available - 1 >= 0 ;
为了提高吞吐量,我们还可以根据商品 ID
进行分库分表设计, 将压力分布到其他的服务器; 还可以提前下好订单,将订单写入到 redis
的 list
,然后来一个 pop
一个
- 使用缓存处理库存
如果库存放缓存,缓存必须是高可用的,数据丢失怎么办,所以必须要多机房备份或者限流保护,如果出现极端情况,应当立马下架该商品
利用缓存来减轻数据库的压力,可以在缓存扣除后通过消息修改数据库,也或者设置多少时间去统一同步数据库
兜底方案的设计
没有人可以提前预估到所有情况
- 架构阶段:架构阶段主要考虑系统的可扩展性和容错性,要避免系统出现单点问题。例如多机房单元化部署,即使某个城市的某个机房出现整体故障,仍然不会影响整体网站的运转。
- 编码阶段:编码最重要的是保证代码的健壮性,例如涉及远程调用问题时,要设置合理的超时退出机制,防止被其他系统拖垮,也要对调用的返回结果集有预期,防止返回的结果超出程序处理范围,最常见的做法就是对错误异- 常进行捕获,对无法预料的错误要有默认处理结果。
- 测试阶段:测试主要是保证测试用例的覆盖度,保证最坏情况发生时,我们也有相应的处理流程。
- 发布阶段:发布时也有一些地方需要注意,因为发布时最容易出现错误,因此要有紧急的回滚机制。
- 运行阶段:运行时是系统的常态,系统大部分时间都会处于运行态,运行态最重要的是对系统的监控要准确及时,发现问题能够准确报警并且报警数据要准确详细,以便于排查问题。
- 故障发生:故障发生时首先最重要的就是及时止损,例如由于程序问题导致商品价格错误,那就要及时下架商品或者关闭购买链接,防止造成重大资产损失。然后就是要能够及时恢复服务,并定位原因解决问题。
降级
所谓降级,就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。它是一个有目的、有计划的执行过程,所以对降级我们一般需要有一套预案来配合执行。如果我们把它系统化,就可以通过预案系统和开关系统来实现降级。
- 降级方案可以这样设计
当秒杀流量达到 5w/s
时,把成交记录的获取从展示 20
条降级到只展示 5
条。从 20 改到 5
这个操作由一个开关来实现,也就是设置一个能够从开关系统动态获取的系统参数。
是一个不得已而为之的举措。
限流
客户端限流,好处可以限制请求的发出,通过减少发出无用请求从而减少对系统的消耗。缺点就是当客户端比较分散时,没法设置合理的限流阈值:如果阈值设的太小,会导致服务端没有达到瓶颈时客户端已经被限制;而如果设的太大,则起不到限制的作用。
服务端限流,好处是可以根据服务端的性能设置合理的阈值,而缺点就是被限制的请求都是无效的请求,处理这些无效的请求本身也会消耗服务器资源。
例如我们的系统最高支持 1w QPS
时,可以设置 8000
来进行限流保护。
拒绝服务
如果限流还不能解决问题,最后一招就是直接拒绝服务了。
当系统负载达到一定阈值时,例如 CPU
使用率达到 90%
或者系统 load
值达到 2*CPU
核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。
拒绝服务可以说是一种不得已的兜底方案,用以防止最坏情况发生,防止因把服务器压跨而长时间彻底无法提供服务。像这种系统过载保护虽然在过载时无法提供服务,但是系统仍然可以运作,当负载下降时又很容易恢复,所以每个系统和每个环节都应该设置这个兜底方案,对系统做最坏情况下的保护。
结语
我们针对不同场景有不同的架构,任何系统并非一蹴而就,当用户量不大的时候缓存
+数据库
就可以解决大量请求,我们还需要对自己系统的性能有个清晰的认识,每秒的 qps
tps
需要尽可能准确,做架构时就可以做出合适的方案。
如果当前设计满足不了业务的时候,我们需要对架构进行升级,可以将秒杀系统设计成为一个单独的系统,然后使用负载均衡分摊请求
对于秒杀的场景来说,不同 QPS
量级下瓶颈也会不一样,10w
级别可能瓶颈就在数据读取上,通过增加缓存一般就能解决,如果要到 100w
那么,可能服务端的网络可能都是瓶颈,所以要把大部分的静态数据放到 cdn
上甚至缓存在浏览器里
所以要做架构升级,还是主要要分析在预估的 QPS
下,整个系统的瓶颈会在什么地方,要针对这起瓶颈来重新设计架构方案