秒杀:系统设计
引入
秒杀就是在同一个时刻有大量的请求争抢购买同一个商品并完成交易的过程。
从整体上看,秒杀主要是要解决两个问题:一个是并发读,一个是并发写
- 并发读的核心优化理念是尽量减少用户到服务端来读数据,或者让它们读更少的数据
- 并发写的处理原则也是一样,它要求我们在数据库层面独立出来一个库,做特殊的处理、
另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
从架构师的角度来看,要想打造并维护一个超大流量并发读写、高性能、高可用的系统,在整个用户请求路径上从浏览量到服务端我们要遵循几个原则,就是要保证用户请求的数据尽量少、请求数尽量少、路径尽量短、依赖尽量少,并且不要有单点。
秒杀的整体架构可以概况为“稳、准、快”
- “稳”,就是整个系统架构要满足高可用,流量符合预期时要稳定,就是超出预期也不能掉链子
- “准”,就是秒杀 10 台 iPhone,那就只能成交 10 台,多一台少一台都不行。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性。
- “快”,就是系统的性能足够高,否则你怎么支撑这
么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度来看“稳、准、快”就对应了架构上的高可用、一致性、高性能的要求。秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统
架构原则:“4 要 1 不要”
要想构建一个超大流量并发读写、高性能以及高可用的系统,需要满足“4要1不要”这几个要素
数据要尽量少
所谓“数据要尽量少”,首先是用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据。
为何“数据要尽量少”?因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据要需要服务器做处理,而服务器在写网络时通常需要压缩和字符编码,这些都非常消耗CPU,所以减少传输的数据量可以尽量减少CPU的使用。比如我们可以简化秒杀页面的大小,去掉不必要的页面装修效果,等等。
其次,“数据要尽量少”还要求系统依赖的数据能少则少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调度其他服务会涉及到数据的序列化和反序列化,这也是CPU的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单,越小越好
请求数据要尽量少(减少CPU消耗)
用户请求的页面返回后,浏览器渲染这个页面还有包含其他额外请求,比如,这个页面依赖的CSS、图片等都是额外请求,这些额外请求要尽量少。因为浏览器每发出一个请求都多少会有一些消耗,比如建立连接需要三次握手,可能有页面依赖或者连接数限制、如果不同请求你域名不一样的话还会涉及DNS解析等。所以,减少请求数可以显著减少资源消耗
路径经历短(减少节点消耗)
所谓“路径”,就是用户发出请求到返回数据这个过程中,需要经过的中间的节点数。
通常,这些节点可以表示为一个系统或者一个新的socket连接(比如代理服务器只是创建了一个新的socket连接来转发请求)。每经过一个节点,一般都会产生一个新的socket连接
然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5个节点,每个节点的可用性是 99.9% 的话,那么整个请求的可用性是:99.9% 的 5 次方,约等于 99.5%。
所以,缩短请求路径不仅可以增加可用性,同样可以提升性能(通过减少数据的序列化和反序列),并减少延迟(网络传输耗时)
要缩短访问路径有一种方法,就是多个相互强依赖的应用合并部署在一起,把远程调用(RPC)改为内部方法调用。
依赖尽量少(减少加载时间)
所谓依赖,指的是完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。
举个例子,比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。
要减少依赖,我们可以给系统进行分级,比如 0 级系统、1 级系统、2 级系统、3 级系统,0 级系统如果是最重要的系统,那么 0 级系统强依赖的系统也同样是最重要的系统,以此类推。
注意,0 级系统要尽量减少对 1 级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是 0 级系统,而优惠券是 1 级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个 1 级系统给拖垮。
不要有单点(要有备份)
单点就是没有备份,挂了系统没法正常服务了
系统中的单点可以说是系统架构上的一个大忌,因为单点意味着没有备份,风险不可控,我们设计分布式系统最重要的原则就是“消除单点”。
那如何避免单点呢?关键是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。
如何把服务的状态和机器解耦呢?有很多实现方式,比如把机器相关的配置动态化,通过配置中心来动态推送。
应用无状态是有效避免单点的一种方式,但是像存储服务本身很难无状态化,因为数据要存储在磁盘上,本身就要和机器绑定,那么这种场景一般要通过冗余多个备份来解决。
注意:是“尽量”而不是“绝对”
你要努力的方向,具体操作时还是要密切结合实际的场景和具体条件来进行
不同场景下的不同架构案例
如果你想快速搭建一个简单的秒杀系统,只需要把你的商品购买页面增加一个“定时上架”功能,仅在秒杀开始时才让用户看到购买按钮,当商品的库存卖完了也就结束了
但随着请求量的增加(比如从 1w/s 到了 10w/s 的量级),这个简单的架构很快就遇到了瓶颈,因此需要做架构改造来提升系统性能。这些架构包括:
- 把秒杀系统独立出来单独打造了一个系统,这样可以做针对性优化
- 在系统部署上也独立做了一个机器集群,这样秒杀的大流量就不会影响到正常的商品购买寄存的机器负载
- 将热点数据(比如库存数据)单独放在系统缓存系统中,以提高“读”性能
- 增加秒杀答题,防止有秒杀器抢单。
此时的系统架构如下。最重要的是,秒杀详情成为了一个独立的新系统,另外核心的一些数据也放到了cache中,其他的关联系统也都以独立集群的方式进行部署。
然而这个架构仍然支持不了超过 100w/s 的请求量,所以为了进一步提升秒杀系统的性能,我们又对架构做进一步升级,比如:
- 对页面进行彻底的动静分离,使得用户秒杀时不需要刷新整个页面,只需要点击按钮,借此把页面刷新的数据降到最少
- 在服务端对秒杀商品进行本地缓存,不需要再调用依赖系统的后台服务获得数据,甚至不需要去公共的缓存集群中查询数据,这样不仅可以减少系统调用,而且能够避免压垮公共缓存集群
- 增加系统限流保护,防止最坏情况发生
系统架构如下:对页面进行了进一步的静态化,秒杀过程中不需要刷新整个页面,而只需要向服务端请求很少的动态数据。而且,最关键的详情和交易系统都增加了本地缓存,来提前缓存秒杀商品的信息,热点数据库也做了独立部署,等等
从前面的几次升级来看,其实越到后面需要定制的地方越多,也就是越“不通用”。例如,把秒杀商品缓存在每台机器的内存中,这种方式显然不适合太多的商品同时进行秒杀的情况,因为单机的内存始终有限。所以要取得极致的性能,就要在其他地方(比如,通用性、易用性、成本等方面)有所牺牲。
如何才能做好动静分离(没啥干货)
现在不都是前后端分离嘛, 不知道为啥还要将动静分离拿出来说事, 前端页面部署CDN早就标配了, 对于秒杀操作, 没有哪个平台是让用户刷新页面的, 都是到时间后点击按钮来秒杀,而且这个缓存策略不应该是第一次访问不到再查询缓存, 应该在商家创建完秒杀产品后, 缓存就有了, 如果修改内容, 就直接更新缓存, 秒杀开始后, 商家就不能再修改了, 不想抬杠, 反正我认为这篇文章没啥干货
秒杀的场景中,对于系统的要求其实就三个字:快、准、稳。
那怎么才能“块”起来呢?抽象起来讲,只有两点,一点是提高单词请求的效率,一点是减少没有必要的请求。“动静分离”就是朝着这个大方向去的,分离之后,客户端将大幅度减少请求的数据量。
什么是“动静分离”
所谓“动静分离”就是把用户请求的数据(比如HTML页面)划分为“动态数据”和“静态数据”。
简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和URL、浏览者、时间、地址相关,以及是否含有cookie等私密数据。比如说:
- 很多媒体类的网站,某一篇文章的内容不管是谁访问,它都是一样的。所以它是一个典型的镜头数据,但是它是个动态页面
- 我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。
注意,我们说的“静态数据”,不能仅仅理解为传统意义上的完全存在磁盘上的HTML页面,它也可能是经过java系统产生的页面,但是它输出的页面本身不包含上面所说的因素。也就是所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据中是否含有访问者相关的个性化数据。
另外注意,页面中的“不包含”,指的是“页面中的HTML源码中不含有”
分离了动静数据之后,我们就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据的“访问效率”自然就高了
那么,怎样对静态数据做缓存呢?
- 第一,应该尽量缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的有三种:用户浏览器里,CND里,或者服务端的Cache中。应该根据情况,把它们尽量缓存到离用户最近的地方。
- 第二,****
- 第三,让谁来缓存静态数据也很重要。不同语言写的 Cache 软件处理缓存数据的效率也各不相同。以 Java 为例,因为 Java 系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet 容器解析 HTTP 协议较慢),所以你可以不在 Java 层做缓存,而是直接在 Web 服务器层上做,这样你就可以屏蔽 Java 语言层面的一些弱点;而相比起来,Web 服务器(如 Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。
存储在浏览器或 CDN 上,有多大区别?
区别很大!因为在CDN上,我们可以做主动失效,而在用户的浏览器里就不可控,如果用户不主动刷新,就很难把消息推送给用户的浏览器
另外,在什么地方把静态数据和动态数据合并渲染出一个完整的页面也很关键:
- 在用户浏览器端合并,那么服务端可以减少渲染整个页面的CPU消耗
- 在服务端合并的话,就要考虑缓存的数据是否进行gzip压缩了,如果压缩了就可以减少缓存的数据量,但是进行页面合并渲染是就要先解压然后叶子完整的页面输出给用户;如果缓存未压缩的数据,这样不用解压静态数据,但是会增加缓存容量
如何做动静分离的改造
可以先从下面5个方面来分离出动态内容:
- URL唯一化:商品详情系统天然就可以做到URL唯一化,比如每个商品都由ID来标识,那么么 .htm?id=xxxx 就可以作为唯一的 URL 标识。为什么URL要唯一呢?因为我们要缓存整个HTTP连接,那么一什么作为Key呢?就以URL为缓存的Key,例如以id=xxx这个格式进行区分
- 分离浏览器相关的因素。包括是否已经登录,以及登录身份等,这些相关因素我们可以拆分出来,通过动态请求来获取
- 分离时间因素:服务器输出时间也通过动态请求获取
- 异步化地域因素:详情页面上与地域相关的因素做成异步方式获取,当然你可以通过动态请求方式获取,不过这里通过异步获取更合适
- 去掉cookie。服务端输出的页面包含的cookie可以通过代码软件来删除,如 Web 服务器 Varnish 可以通过 unset req.http.cookie 命令去掉 Cookie。注意,这里说的去掉Cookie 并不是用户端收到的页面就不含 Cookie 了,而是说,在缓存的静态数据中不含有 Cookie。
分离出动态内容之后,如何组织这些内容页就很关键了。注意,因为这其中很多动态内容都会被页面中其他模块用到,比如判断用户是否登录,用户ID是否匹配等,所以这个时候我们应该将这些信息JSON化,以便前端获取
可以用缓存处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。
- ESI 方案(或者 SSI):在web代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面的时候就已经是一个完整的页面了。这种方式对服务端性能有影响,但是用户体验较好
- CSI方案:即单独发起一个异步JavaScript请求,以向服务端获取动态内容。这种方式服务端性能更进,但是用户端页面可能会延时,体验较差
动静分离的几种架构方案
前面我们通过改造把静态数据和动态数据做了分离,那么如何在系统架构上进一步对这些动态和静态数据重新组合,再完整地输出给用户呢?
这就涉及对用户请求路径进行合理的架构了。根据架构上的复杂度,有 3 种方案可选:
- 实体机单机部署
- 统一cache层
- 上CND
实体机单机部署
这种方案是将虚拟机改为实体机,以增大cache的容量,并且采用了一致性hash分组方式来提升命中率。这里将cache分层若干组,是希望能够达到命中率和访问热点的平衡。hash分组越少,缓存的命中率也会越高,但是也会使得单个商品集中在一个分组中,容易导致cache被击穿。所以我们应该适当增加单个相同的分组,以平衡访问热点和命中率的问题。
实体机单机部署有以下几个优点:
- 没有网络瓶颈,而且能使用大内存;
- 既能提升命中率,又能减少 Gzip 压缩;
- 减少 Cache 失效压力,因为采用定时失效方式,例如只缓存 3 秒钟,过期即自动失效。
这个方案中,虽然把通常只需要虚拟机或者容器运行的 Java 应用换成实体机,优势很明显,它会增加单机的内存容量,但是一定程度上也造成了 CPU 的浪费,因为单个的 Java进程很难用完整个实体机的 CPU。
另外就是,一个实体机上部署了 Java 应用又作为 Cache 来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。如果你的公司里,没有更多的系统有类似需求,那么这样做也比较合适,如果你们有多个业务系统都有静态化改造的需求,那还是建议把 Cache 层单独抽出来公用比较合理,如下面的方案 2 所示。
同一cache层
但是把单机的cache统一分离出来,形成一个单独的cache集群。
将 Cache 层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此外,它还有一些优点。
- 单独一个cache层,可以减少多个应用接入时使用cahce的成本。这样接入的应用只要维护自己的系统就好,不需要单独维护cache,而只需要关心如何使用即可
- 同一的cache的方案更易于维护,比如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护也方便
- 可以共享内存,最大化利用内存,不同系统之间的内存可以动态转换,从而能够有效应对各种攻击
这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:
- cache层内部交换网络成为瓶颈
- 缓存服务器的网卡也会是瓶颈
- 机器少风险大,挂掉一台就会影响很大一部分缓存数据。
要解决上面的问题,可以再对cache做hash分组,即一组cache缓存的内容相同,这样能够避免热点数据过度几种而导致新的瓶颈产生
上CDN
在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将 Cache 进一步前移到 CDN 上,因为 CDN 离用户最近,效果会更好。
但是要想这么做,有以下几个问题需要解决。
- 失效问题:静态数据是“相对不变”的。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证CDN可以在秒级时间内,让分布在全国各地的Cache同时失效,这对CDN的失效系统要求很高
- 命中率问题:cache最重要的一个衡量指标就是“高命中率”,不然cache的存在就失去了意义。如果把数据放在全国的CDN上,必然导致Cache失散,而Cache 分散又会导致访问请求命中同一个 Cache 的可能性降低,那么命中率就成为一个问题。
- 发布更新问题:如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简单高效,而且需要有问题时快速回滚和排查文件简便
从前面的分析来看,将商品详情系统放到全国的所有 CDN 节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那是否可以选择若干个节点来尝试实施呢?当然可以,但是这样的节点需要满足几个条件:
- 靠近访问量比较集中的地区
- 离主站相对较远
- 节点到主站之间网络比较好,而且稳定
- 节点容量比较大,不会占用其他CDN太多资源
最后,还有一点也很重要,那就是:节点不要太多。
基于上面几个因素,选择 CDN 的二级 Cache 比较合适,因为二级 Cache 数量偏少,容量也更大,让用户的请求先回源的 CDN 的二级 Cache 中,如果没命中再回源站获取数据,部署方式如下图所示
使用 CDN 的二级 Cache 作为缓存,可以达到和当前服务端静态化 Cache 类似的命中率,因为节点数不多,Cache 不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种 CDN 化方案。
除此之外,CDN 化部署方案还有以下几个特点:
- 把整个页面缓存在用户浏览器中;
- 如果强制刷新整个页面,也会请求 CDN;
- 实际有效请求,只是用户对“刷新抢宝”按钮的点击。
这样就把 90% 的静态数据缓存在了用户端或者 CDN 上,当真正秒杀时,用户只需要点击特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。
如何处理好系统的“热点数据”
为什么要关注“热点数据”
因为热点会对系统产生一系列的影响。
首先,热点请求会大量占用服务器处理资源,虽然这个热点可能只占请求总量的亿分之一,但是却可能抢占90%的服务器资源,如果这个热点请求是没有价值的无效请求,那么对系统资源来说完全是浪费。
其次,即使这些热点是有效的请求,我们也要识别出来做有效的优化,从而用更低的代价来支撑这些热点的请求
什么是热点
热点分为热点操作和热点数据
- “热点操作”,比如大量的刷新页面、大量的添加购物车、大量的点单等。对系统来说,这些操作可以抽象为“读请求”和“写请求”,这两种热点请求的处理思路大相径庭,读请求的优化空间要大一些,而写请求的瓶颈一般在存储层,优化的思路是根据CAP理论做平衡
- “热点数据”,就是用户的热点请求对应的数据。又分为“静态热点数据”和“动态热点数据”‘
- “静态热点数据”,就是能够提前预测的热点数据。比如,我们可以通过卖家报名的方式提前筛选出来,通过报名系统对这些热点商品进行打标。另外,我们还可以通过大数据分析来提前发现热点商品,比如我们分析历史成交记录、用户的购物车记录,来发现哪些商品可能更热门、更好卖,这些都是可以提前分析出来的热点。
- “动态热点数据”,就是不能提前预测到的,系统运行过程中临时产生的热点。比如,卖家在抖音上做了广告,然后商品一下就火了,导致它在短时间内被大量购买。
由于热点操作是用户的行为,我们不好改变,但是能够做一些限制和保护,所以本节主要针对热点数据来做优化
发现热点数据
发现静态热点数据
静态热点数据可以通过商业手段,例如强制让卖家通过报名参加的方式提前把
热点商品筛选出来,实现方式是通过一个运营系统,把参加活动的商品数据进行打标,然后通过一个后台系统对这些热点商品进行预处理,如提前进行缓存。但是这种通过报名提前筛选的方式也会带来新的问题,即增加卖家的使用成本,而且实时性较差,也不太灵活。
除了提前报名筛选,还可以根据技术手段提前预测。比如对买家每条访问的商品进行大数据计算,然后统计出Top N的商品,我们可以认为这些 TOP N 的商品就是热点商品。
发现动态热点数据
我们可以通过卖家报名或者大数据预测这些手段来提前预测静态热点数据,但这其中有一个痛点,就是实时性较差,如果我们的系统能在秒级内自动发现热点商品那就完美了。
能够动态地实时发现热点不仅对秒杀商品,对其他热卖商品也同样有价值,所以我们需要想办法实现热点的动态发现功能。
这里给出一个动态热点发现系统的具体实现。
- 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品中的热点key,如nginx、缓存、RPC服务框架等中间件
- 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链上的各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统。提前做好保护。比如,对于大促高峰期,详情系统是最早知道的,在统一接入层上 Nginx 模块统计的热点 URL。
- 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。
下面给出了一个图,其中用户访问商品时经过的路径有很多,我们主要是依赖前面的导购页面(包括首页、搜索页面、商品详情、购物车等)提前识别哪些商品的访问量高,通过这些系统中的中间件来收集热点数据,并记录到日志中。
我们通过部署在每台机器上的 Agent 把日志汇总到聚合和分析集群中,然后把符合一定规则的热点数据,通过订阅分发系统再推送到相应的系统中。你可以是把热点数据填充到Cache 中,或者直接推送到应用服务器的内存中,还可以对这些数据进行拦截,总之下游系统可以订阅这些数据,然后根据自己的需求决定如何处理这些数据。
打造热点发现系统时,需要注意的事项:
- 这个热点服务后台抓取热点数据日志最好采用异步的方式,因为“异步”一方面便于保证通用性,一方面不影响业务系统和中间件产品的主流程
- 热点服务发现和中间件自身的热点保护模块并存,每个中间件和应用还需要保护自己。热点服务台提供热点数据的收集和订阅服务,便于把各个系统的热点数据透明出来。
- 热点发现要做到接近实时(3s 内完成热点数据的发现),因为只有做到接近实时,动态发现才有意义,才能实时地对下游系统提供保护。
处理热点数据
处理热点数据一般有三种思路:优化、限制、隔离
优化
优化热点数据最有效的方法是缓存热点数据,如果热点数据做了动静分离,那么可以长期缓存静态数据,但是,缓存热点数据更多的是“临时”缓存,即不管是静态数据还是动态数据,都用一个队列短暂的缓存数秒钟,由于队列长度有限,可以采用LRU缓存算法替换
限制
限制更多是一种保护机制。限制方法有很多,比如对缓存商品的ID做一致性hash,然后根据hash做分桶,每个分桶设置一个处理队列,这样可以把热点数据限制在一个请求队列里,防止因某些热点商品占用太多服务器资源,而使得其他请求始终得不到服务器的处理资源
隔离
秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让1%的请求影响到另外99%,隔离出来后也更方便对这1%的请求做针对性优化。
具体到“秒杀”业务,我们可以在以下几个层次实现隔离。
- 业务隔离:把秒杀做成一种营销活动,卖家要参加秒杀这种营销活动需要单独报名,从技术上来说,卖家报名后对我们来说就有了已知热点,因此可以提前做好预热。
- 系统隔离:系统隔离指的是运行时隔离,可以通过分组部署的方式和另外99%分开,秒杀可以申请单独的域名,目的也是让请求落在不同的集群中
- 数据隔离:秒杀所调用的数据大部分都是热点数据,比如会启用单独的 Cache 集群或者MySQL 数据库来放热点数据,目的也是不想 0.01% 的数据有机会影响 99.99%数据。
当然了,实现隔离有很多种办法。比如,你可以按照用户来区分,给不同的用户分配不同的Cookie,在接入层,路由到不同的服务接口中;再比如,你还可以在接入层针对 URL 中的不同 Path 来设置限流策略。服务层调用不同的服务接口,以及数据层通过给数据打标来区分等等这些措施,其目的都是把已经识别出来的热点请求和普通的请求区分开。
流量削峰怎么做
为什么要削峰
服务器的处理资源是恒定的,你用和不用它的处理能力是一样的,所以出现峰值的话,很容易导致忙到处理不过来,闲的时候又没有什么要处理。但是由于要保证服务质量,我们的很多处理资源只能按照忙的时候来预估,而这会导致资源的浪费
削峰的存在,一是可以让服务端处理变得更加平稳,二是可以节省服务器的资源成本。
削峰从本质上来说就是更多地延缓用户请求的发出,以便减少和过滤掉一些无效请求,它遵从“请求数要尽量少”的原则。
排队、答题、分层过滤,这几种方式都是无损(即不会损失用户的发出请求)的实现方案,当然还有些有损的实现方案,比如限流和机器负载保护等一些强制措施也能达到削峰保护的目的
排队
最简单的方法是用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑的将消息推送出去。在这里,消息队列就像“水库”一样, 拦蓄上游的洪水,削减进入下
游河道的洪峰流量,从而达到减免洪水灾害的目的。
但是,如果流量峰值持续一段时间达到了消息队列的处理上限,例如本机的消息积压达到了存储空间的上限,消息队列同样也会被压垮,这样虽然保护了下游的系统,但是和直接把请求丢弃也没多大的区别。就像遇到洪水爆发时,即使是有水库恐怕也无济于事。
除了消息队列,类似的排队方法还有很多:
- 利用线程池加锁等待也是一种常见的排队方式
- 先进先出,先进后出等常用的内存排队算法的实现方式
- 把请求序列化到文件中,然后在顺序的读文件(比如基于MySQL binlog的同步机制)来恢复请求等方式
可以看到,这些方式都有一个共同特征,就是把“一步的操作”变成“两步的操作”,其中增加的一步操作用来起到缓冲的作用。
说到这里你可能会说,这样一来增加了访问请求的路径啊,并不符合我们介绍的“4 要 1不要”原则。没错,的确看起来不太合理,但是如果不增加一个缓冲步骤,那么在一些场景下系统很可能会直接崩溃,所以最终还是需要你做出妥协和平衡。
当请求被丢进消息队列以后,是就直接返回给用户吗? 那用户怎么知道请求
是否成功了呢?
如果是同步的就要等待消息被正确投递后才返回结果,但大部分就是异步的,寄发送后即返回,然后由消息队列保证最后最终被投递,这个要由消息队列自己来承诺sla
如果有后面不买了,但是已经入了队列了,怎么办。还是说队列放所有请求,这样的话是不是浪费啊
入了队列不处理就超时了,队列的大小不应该和秒杀商品数关联
队列被打满了,直接丢包吗?
作者回复: 有多种处理方式,一种是丢弃
还有可以把队列序列化到文件,然后再慢慢消化
答题
为什么要增加答题功能呢?这主要是为了增加购买的复杂度,从而达到两个目的。
- 第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊
- 第二个目的是延缓请求,起到对请求流量的削峰作用,从而让系统能够更好的支持瞬时的流量高峰。
上图所示,整个秒杀答题的逻辑主要分为 3 部分。
- 题库生成模块,这个部分主要就是生成一个个问题和答案,其实题目和答案本身并不需要很复杂,重要的是能够防止由机器来算出结果,即防止秒杀器来答题。
- 题库的推送模块,用于在秒杀答题前,把题目提前推送给详情系统和交易系统。题库的推送主要是为了保证每次用户请求的题目是唯一的,目的也是防止答题作弊。
- 题目的图片生成模块,用于把题目生成为图片格式,并且在图片里增加一些干扰因素。这也同样是为防止机器直接来答题,它要求只有人才能理解题目本身的含义。这里还要注意一点,由于答题时网络比较拥挤,我们应该把题目的图片提前推送到 CDN 上并且要进行预热,不然的话当用户真正请求题目时,图片可能加载比较慢,从而影响答题的体验。
实真正答题的逻辑比较简单,很好理解:当用户提交的答案和题目对应的答案做比较,如果通过了就继续进行下一步的下单逻辑,否则就失败。我们可以把问题和答案用下面这样的key 来进行 MD5 加密:
问题 key:userId+itemId+question_Id+time+PK
答案 key:userId+itemId+answer+PK
验证的逻辑如下图所示:
注意,这里面的验证逻辑,除了验证问题的答案以外,还包括用户本身身份的验证,例如是否已经登录、用户的 Cookie 是否完整、用户是否重复频繁提交等。
除了做正确性验证,我们还可以对提交答案的时间做些限制,例如从开始答题到接受答案要超过 1s,因为小于 1s 是人为操作的可能性很小,这样也能防止机器答题的情况。
分层过滤
对请求进行分层过滤,从而过滤掉一些无效的请求。分层过滤其实是采用“漏斗”式来设计处理的。
假如请求分别经过 CDN、前台读系统(如商品详情系统)、后台系统(如交易系统)和数据库这几层,那么:
- 大部分数据和流量在用户浏览器或者CDN上获取,这一层可以拦截大部分数据的读取
- 经过第二层(即前台系统)时数据尽量走cache,过滤一些无效的请求
- 再到第三次(后台系统),主要做数据的二次校验,对系统做好保护和限流,这样数据量和请求就进一步减少
- 最后在数据层完成数据的强一致性校验
这样就像漏斗一样,尽量把数据量和请求量一层一层地过滤和减少了。
分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。而要达到这种效果,我们就必须对数据做分层的校验。其基本原则是:
- 将动态请求的读数据缓存(cache)在web段,过滤掉无效的数据读
- 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求
- 对写请求做限流保护,将超出系统承载能力的请求过滤掉
- 对写数据进行强一致性校验,只保留最后有效的数据
分层校验的目的是:
- 在读系统中,尽量减少由于一致性校验带来的系统瓶颈,但是尽量将不影响性能的检查条件提前,如用户是否有秒杀资格,商品状态是否正常,用户答题是否正确,秒杀是否已经结束,是否是非法请求等等
- 在写系统中,主要对写的数据(比如库存)做一致性检查,最后在数据库层保证数据的最终准确性(比如库存不能为负数)
小结
削峰的三种方法:
- 通过队列来缓冲请求,即控制请求的发出
- 更加通用
- 适用于内部上下游系统之间调用请求不平缓的场景。由于内部系统的服务质量要求不能随意丢弃请求,所以使用消息队列能起到很好的削峰和缓存作用
- 通过答题来延长请求发出的时间,在请求发出后承接请求时进行控制,最后对不符合条件的请求进行过滤
- 适用于秒杀等应用场景,在请求发起端就控制请求的速度,因为越到后面无效请求越到,所以配合分层拦截的方式,可以进一步减少无效请求对系统资源的消耗
- 对请求进行分层过滤。
- 适用于交易性的写请求,比如减库存或者拼车之类的场景,在读的时候需要知道还有没有库存或者是否还有剩余空座位,但是由于库存和座位又是不停变化的,所以读的数据是否一定要非常准确呢?其实不一定,你可以放一些请求过去,然后在真正减的时候再做强一致性保证,这样既过滤一些请求又解决了强一致性读的瓶颈。
不过,在削峰的处理方式上除了采用技术手段,其实还可以采用业务手段来达到一定效果,例如在零点开启大促的时候由于流量太大导致支付系统阻塞,这个时候可以采用发放优惠券、发起抽奖活动等方式,将一部分流量分散到其他地方,这样也能起到缓冲流量的作用。
分层过滤,既然是请求,为什么有些是无效呢?
无效请求是针对没发再抢到商品的人来说的
如何提高系统的性能
服务端的性能,一般用QPS(Query Per Second,每秒请求数)以及响应时间(Respose Time,RT)来衡量,。
一般来说,响应时间越短,一秒钟处理的请求数越多,这在单线程处理的情况下看起来是线性关系,也就是只要把每个请求的响应时间降到最低,那么性能就会越高。
但是响应时间总有一个极限,不可能无限下降,所以又出现了另外一个维度,即通过多线程,来处理请求,这样理论上 总 Q P S = ( 1000 m s / 响 应 时 间 ) ∗ 线 程 总QPS = (1000ms/响应时间)*线程 总QPS=(1000ms/响应时间)∗线程,这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求的线程数。
响应时间和QPS的关系
对于大部分Web系统而言,响应时间一般是由CPU执行的时间和线程等待时间(比如RPC、IO等待、sleep、wait等)组成,即服务器在处理一个请求时,一部分是CPU本身在做运算,一部分是各种等待。
那为什么我们不去减少这种等待时间呢?因为经过实际测试发现,减少线程等待时间对提升性能的影响没有那么大,它并不是线程的提升关系,这点在很多代理服务器上可以做验证。
如果代理服务器本身没有对CPU消耗,我们在每次给代理服务器代理的请求增加个延时,即增加响应时间,但是这对代理服务器本身的吞吐量并没有多大的影响,但是代理服务器本身的资源并没有被消耗,可以通过增加代理服务器的处理线程数,来弥补响应时间对代理服务器QPS的影响。
其实,真正对性能有影响的是CPU的执行时间。这也很好理解,因为CPU的执行真正消耗了服务器的资源。经过实际的测试,如果减少了一般CPU执行时间,就可以增加一倍的QPS。
也就是说,我们应该致力于减少CPU的执行时间。
线程数对QPS的影响。
单看“总 QPS”的计算公式,你会觉得线程数越多 QPS 也就会越高,但这会一直正确吗?显然不是,线程数不是越多越好,因为线程本身也消耗资源,也受到其他因素的制约。比如,线程越多系统的线程切换成本就会越高,而且每个线也都会消耗一定内存。
那么,设置什么样的线程数最合理呢?其实很多多线程的场景都有一个默认配置,即“线程数 = 2 *
CPU 核数 + 1”。除去这个配置,还有一个根据最佳实践得出来的公式:
线 程 数 = [ ( 线 程 等 待 时 间 + 线 程 C P U 时 间 ) / 线 程 C P U 时 间 ] × C P U 数 量 线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 数量 线程数=[(线程等待时间+线程CPU时间)/线程CPU时间]×CPU数量
当然,最好的办法是通过性能测试来发现最佳的线程数。
换句话说,要提升性能我们就要减少CPU的执行时间,以及设置一个合理的并发线程数。
现在,知道了如何来快速提升性能,那应该怎么发现系统哪里最消耗 CPU 资源呢?
如何发现瓶颈
就服务器而言,会出现瓶颈的地方有很多,比如CPU、内存、磁盘以及网络等都可能会成为瓶颈。此外,不同的系统对瓶颈的关注度也不一样,对缓存系统而言,制约它的是内存;对存储型系统来说IO更容易成为瓶颈;秒杀场景的瓶颈多半在CPU上。
那么,如何发现 CPU 的瓶颈呢?其实有很多 CPU 诊断工具可以发现 CPU 的消耗,最常用的就是 JProfiler 和 Yourkit 这两个工具,它们可以列出整个请求中每个函数的 CPU 执行时间,可以发现哪个函数消耗的 CPU 时间最多,以便你有针对性地做优化。
当然还有一些办法也可以近似地统计 CPU 的耗时,例如通过 jstack 定时地打印调用栈,如果某些函数调用频繁或者耗时较多,那么那些函数就会多次出现在系统调用栈里,这样相当于采样的方式也能够发现耗时较多的函数。
虽说秒杀系统的瓶颈大部分在 CPU,但这并不表示其他方面就一定不出现瓶颈。例如,如果海量请求涌过来,你的页面又比较大,那么网络就有可能出现瓶颈。
怎么简单的判断CPU是不是瓶颈呢?一个方法就是当QPS达到极限时,你的服务器的CPU使用率是不是超过了95%,如果没有,那么表示CPU还有提升的空间,要么是有锁限制,那么是有过多的本地IO等待发生
如何优化系统
事实上,有两个地方特别影响性能,一是服务端在处理数据时不可避免地
存在字符到字节的相互转化,二是 HTTP 请求时要做 Gzip 压缩,还有网络传输的耗时,这些都和数据大小密切相关
再次,就是数据分级,也就是要保证首屏为先、重要信息为先,次要信息则异步加载,以这种方式提升用户获取数据的体验。
最后就是要减少中间环节,减少字符到字节的转换,增加预处理(提前做字符到字节的转换)去掉不需要的操作。
此外,要做好优化,你还需要做好应用基线,比如性能基线(何时性能突然下降)、成本基线(去年双 11 用了多少台机器)、链路基线(我们的系统发生了哪些变化),你可以通过这些基线持续关注系统的性能,做到在代码上提升编码质量,在业务上改掉不合理的调用,在架构和调用链路上不断的改进。
秒杀系统“减库存”设计的核心逻辑
目标:千万不要超卖
减库存有哪几种方式
在正常的电商平台购物场景中,用户的实际购买过程一般分为两步:下单和付款。系统是用户下单了就算这个商品卖出去了,还是等到用户真正付款了才算卖出了呢?这的确是个问题!
总结来说,减库存操作一般有如下几个方式:
- 下单减库存:
- 描述:即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
- 问题:正常情况下,买家下单后付款的概率会很高,所以不会有太大问题。但是有一种场景例外,就是当卖家参加某个活动时,此时活动的有效时间是商品的黄金售卖时间,如果有竞争对手通过恶意下单的方式将该卖家的商品全部下单,让这个商品的库存减为0,那么这款商品就不能正常售卖了。要知道,这些恶意下单的人是不会真正付款的,这正是“下单减库存”方式的不足之处。
- 付款减存款:
- 描述:即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现下单了但是付不了款的情况,因为可能商品已经被其他人买走了
- 问题:超卖。假如有 100 件商品,就可能出现 300 人下单成功的情况,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。
- 预扣库存:
- 描述:买家下单后,库存为其保留一定的时间(比如10分钟),超过这个时间,库存就会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
- 问题:这种方案确实可以在一定程度上缓解上面的问题。但是否就彻底解决了呢?其实没有!
- 针对恶意下单这种情况,虽然把有效的付款时间设置为 10 分钟,但是恶意买家完全可以在 10分钟后再次下单,或者采用一次下单很多件的方式把库存减完。针对这种情况,解决办法还是要结合安全和反作弊的措施来制止。例如,给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买 3 件),以及对重复下单不付款的操作进行次数限制等。
- 针对“库存超卖”这种情况,在 10 分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通商品下单超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。
大型秒杀活动如何减库存
目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。而具体到秒杀这个场景,应该采用哪种方案比较好呢?
由于参见秒杀的场景,一般都是“抢到就是挣到”,所以成功下单后却不付钱的情况比较少,再加上卖家对秒杀商品的库存有着严格的限制,所以秒杀商品采用“下单减库存”更加合理。另外,理论上由于“下单减库存”比“预扣库存”以及涉及第三方支付的“付款减付款”在逻辑上更加简单,所以性能上更占优势。
“下单减库存”在数据一致性上,主要就是保证大并发请求时库存不能为负数,也就是要保证数据库中的库存字段不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,既保证减后库存不能为负数,否则就回滚;另一种方法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于0时会直接执行SQL语句来报错;再一种是通过case where判断语句,比如UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END
秒杀减库存的极致优化
在交易环节中,“库存”是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能
解决大并发读问题,可以采用 LocalCache(即在秒杀系统的单机上缓存商品相关的数据)和对数据进行分层过滤的方式,但是像减库存这种大并发写无论如何还是避免不了,这也是秒杀场景下最为核心的一个技术难题。
秒杀商品和普通商品的减库存还是有些差异的,例如商品数量比较少,交易时间段也比较短,因此这里有一个大胆的假设,即能否把秒杀商品减库存直接放到缓存系统中实现,也就是直接在缓存中减库存或者在一个带有持久化功能的缓存系统(如 Redis)中完成呢?
如果你的秒杀商品的减库存逻辑非常单一,比如没有复杂的 SKU 库存和总库存这种联动关系的话,就完全可以。但是如果有比较复杂的减库存逻辑,或者需要使用事务,你还是必须在数据库中完成减库存。
由于MySQL存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争InnoDB行锁,而并发读越高时等待线程就会越多,TPS(Transaction Per Second)就会下降,响应时间就会上升,数据库的吞吐量就会严重受影响。
这可能会引发一个问题,就是单个热点商品会影响整个数据库的性能,导致0.01%的商品引发99.99%的商品的售卖。一个解决思路是隔离,把热点数据放在单独的热点库中,但是这无疑会带来维护上的麻烦,比如要做热点数据的动态迁移以及单独的数据库等。
而分离热点数据到单独的数据库还是没有解决并发锁的问题,那应该怎么解决呢?有两种方法:
- 应用层排队:按照商品维度设置队列顺序执行,这样就能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量
- 数据库层做排队:应用层只能做单机排队,但是应用机器树本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库做全局排队是最理想的。阿里的数据库团队开发了针对这种 MySQL 的 InnoDB 层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。