秒杀系统设计一
随着互联网流量的增大,秒杀瞬时流量达到百万甚至千万OPS,秒杀系统也从商品详情系统独立出来成为一个独立的系统。
那么,如何才能更好地理解秒杀系统呢?在我看来,秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
而从一个架构师的角度来看,要想打造并维护一个超大流量并发读写、高性能、高可用的系统,在整个用户请求路径上从浏览器到服务端我们要遵循几个原则,就是要保证用户请求的数据尽量少、请求数尽量少、路径尽量短、依赖尽量少,并且不要有单点。
其实,秒杀的整体架构可以概括为“稳、准、快”几个关键字。
所谓“稳”,就是整个系统架构要满足高可用,流量符合预期时肯定要稳定,就是超出预期时也同样不能掉链子,你要保证秒杀活动顺利完成,即秒杀商品顺利地卖出去,这个是最基本的前提。
然后就是“准”,就是保证库存的准确。一旦库存不对,那平台就要承担损失,所以“准”就是要求保证数据的一致性。
最后再看“快”,“快”其实很好理解,它就是说系统的性能要足够高,否则你怎么支撑这么大的流量呢?不光是服务端要做极致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点,整个系统就完美了。
所以从技术角度上看“稳、准、快”,就对应了我们架构上的高可用、一致性和高性能的要求,我们的专栏也将主要围绕这几个方面来展开,具体如下。
- 高性能。 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键。
- 一致性。 秒杀中商品减库存的实现方式同样关键。有限数量的商品在同一时刻被很多倍的请求同时来减库存,在大并发更新的过程中都要保证数据的准确性,其难度可想而知。
- 高可用。 虽然介绍了很多极致的优化思路,但现实中总难免出现一些我们考虑不到的情况,所以要保证系统的高可用和正确性,我们还要设计一个 PlanB 来兜底,以便在最坏情况发生时仍然能够从容应对。
综述,秒杀系统本质上就是一个满足大并发、高性能和高可用的分布式系统。
架构原则:“4 要 1 不要”
要构建一个超大流量并发读写、高性能,以及高可用的系统,这其中有哪些要素需要考虑。把这些要素总结为“4 要 1 不要”。
数据要尽量少
所谓“数据要尽量少”,首先是指用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。
为啥“数据要尽量少”呢?因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器做处理,而服务器在写网络时通常都要做压缩和字符编码,这些都非常消耗 CPU,所以减少传输的数据量可以显著减少 CPU 的使用。
其次,“数据要尽量少”还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是 CPU 的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单、越小则越好。
请求数要尽量少
用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求。因为浏览器每发出一个请求都多少会有一些消耗,另外,如果不同请求的域名不一样的话,还涉及这些域名的 DNS 解析。所以减少请求数可以显著减少以上这些因素导致的资源消耗。
路径要尽量短
所谓“路径”,就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。
通常,这些节点可以表示为一个系统或者一个新的 Socket 连接(比如代理服务器只是创建一个新的 Socket 连接来转发请求)。每经过一个节点,一般都会产生一个新的 Socket 连接。
然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5 个节点,每个节点的可用性是 99.9% 的话,那么整个请求的可用性是:99.9% 的 5 次方,约等于 99.5%。
所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。
要缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用(RPC)变成 JVM 内部之间的方法调用。
依赖要尽量少
所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。
比如要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。
要减少依赖,我们可以给系统进行分级,比如 0 级系统、1 级系统、2 级系统、3 级系统,0 级系统如果是最重要的系统,那么 0 级系统强依赖的系统也同样是最重要的系统,以此类推。
不要有单点
系统中的单点可以说是系统架构上的一个大忌,因为单点意味着没有备份,风险不可控,我们设计分布式系统最重要的原则就是“消除单点”。
那如何避免单点呢?关键点是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。
但是架构是一种平衡的艺术,而最好的架构一旦脱离了它所适应的场景,一切都将是空谈。这里所说的几点都只是一个个方向,应该尽量往这些方向上去努力,但也要考虑平衡其他因素。
要让秒杀系统达到高性能,一方面是提高单次请求的效率,一方面是减少没必要的请求。下面通过动静分离和热点数据两方面讲解。
动静分离
“动静分离”,其实就是把用户请求的数据划分为“动态数据”和“静态数据”。
简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie 等私密数据。比如说:
- 很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以它就是一个典型的静态数据,但是它是个动态页面。
- 我们如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。
分离了动静数据,我们就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据的“访问效率”自然就提高了。
如何对静态数据做缓存呢?我在这里总结了几个重点。
第一,你应该把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN 上或者在服务端的 Cache 中。你应该根据情况,把它们尽量缓存到离用户最近的地方。
第二,静态化改造就是要直接缓存 HTTP 连接。相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据,如下图所示,Web 代理服务器根据请求 URL,直接取出对应的 HTTP 响应头和响应体然后直接返回,这个响应过程简单得连 HTTP 协议都不用重新组装,甚至连 HTTP 请求头也不需要解析。
第三,让谁来缓存静态数据也很重要。不同语言写的 Cache 软件处理缓存数据的效率也各不相同。例如Web 服务器(如 Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。
动静分离的改造
从以下 5 个方面来分离出动态内容。
- URL 唯一化。商品详情系统天然地就可以做到 URL 唯一化,比如每个商品都由 ID 来标识。
- 分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相关因素我们可以单独拆分出来,通过动态请求来获取。
- 分离时间因素。服务端输出的时间也通过动态请求获取。
- 异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过动态请求方式获取,只是这里通过异步获取更合适。
- 去掉 Cookie。服务端输出的页面包含的 Cookie 可以通过代码软件来删除。注意,这里说的去掉 Cookie 并不是用户端收到的页面就不含 Cookie 了,而是说,在缓存的静态数据中不含有 Cookie。
前面我们介绍里用缓存的方式来处理静态数据。而动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。
- ESI 方案(或者 SSI):即在 Web 代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。
- CSI 方案。即单独发起一个异步 JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。
动静分离的几种架构方案
根据架构上的复杂度,有 3 种方案可选:
- 实体机单机部署;
- 统一 Cache 层;
- 上 CDN。
方案 1:实体机单机部署
这种方案是将虚拟机改为实体机,以增大 Cache 的容量,并且采用了一致性 Hash 分组的方式来提升命中率。这里将 Cache 分成若干组,是希望能达到命中率和访问热点的平衡。Hash 分组越少,缓存的命中率肯定就会越高,但短板是也会使单个商品集中在一个分组中,容易导致 Cache 被击穿,所以我们应该适当增加多个相同的分组,来平衡访问热点和命中率的问题。
这里我给出了实体机单机部署方案的结构图,如下:
Nginx+Cache+Java 结构实体机单机部署实体机单机部署有以下几个优点:
- 没有网络瓶颈,而且能使用大内存;
- 既能提升命中率,又能减少 Gzip 压缩;
- 减少 Cache 失效压力,因为采用定时失效方式,例如只缓存 3 秒钟,过期即自动失效。
这个方案中,虽然把通常只需要虚拟机或者容器运行的 Java 应用换成实体机,优势很明显,它会增加单机的内存容量,但是一定程度上也造成了 CPU 的浪费,因为单个的 Java 进程很难用完整个实体机的 CPU。
另外就是,一个实体机上部署了 Java 应用又作为 Cache 来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。如果你的公司里,没有更多的系统有类似需求,那么这样做也比较合适,如果你们有多个业务系统都有静态化改造的需求,那还是建议把 Cache 层单独抽出来公用比较合理。
方案 2:统一 Cache 层
所谓统一 Cache 层,就是将单机的 Cache 统一分离出来,形成一个单独的 Cache 集群。统一 Cache 层是个更理想的可推广方案,该方案的结构图如下:
统一 Cache将 Cache 层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此外,它还有一些优点。
- 单独一个 Cache 层,可以减少多个应用接入时使用 Cache 的成本。这样接入的应用只要维护自己的 Java 系统就好,不需要单独维护 Cache,而只关心如何使用即可。
- 统一 Cache 的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护升级也比较方便。
- 可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应对各种攻击。
这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:
- Cache 层内部交换网络成为瓶颈;
- 缓存服务器的网卡也会是瓶颈;
- 机器少风险较大,挂掉一台就会影响很大一部分缓存数据。
要解决上面这些问题,可以再对 Cache 做 Hash 分组,即一组 Cache 缓存的内容相同,这样能够避免热点数据过度集中导致新的瓶颈产生。
方案 3:上 CDN
在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将 Cache 进一步前移到 CDN 上,因为 CDN 离用户最近,效果会更好。
但是要想这么做,有以下几个问题需要解决。
- 失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一下。谈到静态数据时,我说过一个关键词叫“相对不变”,它的言外之意是“可能会变化”。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也是,我们需要保证 CDN 可以在秒级时间内,让分布在全国各地的 Cache 同时失效,这对 CDN 的失效系统要求很高。
- 命中率问题。Cache 最重要的一个衡量指标就是“高命中率”,不然 Cache 的存在就失去了意义。同样,如果将数据全部放到全国的 CDN 上,必然导致 Cache 分散,而 Cache 分散又会导致访问请求命中同一个 Cache 的可能性降低,那么命中率就成为一个问题。
- 发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。
从前面的分析来看,将商品详情系统放到全国的所有 CDN 节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施呢?答案是“可以”,但是这样的节点需要满足几个条件:
- 靠近访问量比较集中的地区;
- 离主站相对较远;
- 节点到主站间的网络比较好,而且稳定;
- 节点容量比较大,不会占用其他 CDN 太多的资源。
- 节点不要太多。
基于上面几个因素,选择 CDN 的二级 Cache 比较合适,因为二级 Cache 数量偏少,容量也更大,让用户的请求先回源的 CDN 的二级 Cache 中,如果没命中再回源站获取数据,部署方式如下图所示:
CDN 化部署方案使用 CDN 的二级 Cache 作为缓存,可以达到和当前服务端静态化 Cache 类似的命中率,因为节点数不多,Cache 不是很分散,访问量也比较集中,这样也就解决了命中率问题,同时能够给用户最好的访问体验,是当前比较理想的一种 CDN 化方案。
除此之外,CDN 化部署方案还有以下几个特点:
- 把整个页面缓存在用户浏览器中;
- 如果强制刷新整个页面,也会请求 CDN;
- 实际有效请求,只是用户对“刷新抢宝”按钮的点击。
这样就把 90% 的静态数据缓存在了用户端或者 CDN 上,当真正秒杀时,用户只需要点击特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。