秒杀架构分析与实践
前言
秒杀系统相信很多人见过,比如京东或淘宝的秒杀,那么秒杀系统是如何实现的呢?如何设计一个秒杀系统呢?对于秒杀系统应该考虑哪些问题呢?让我们来一起探讨下这个问题:
秒杀业务分析
正常流程
查询商品;
创建订单;
扣减库存;
付款;
卖家发货;
业务特性
低廉价格;
大幅推广;
时间短;
瞬时并发量高;
一般是定时上架;
技术挑战
假设某网站秒杀活动只推出一件商品,预计会吸引 1 万人参加活动,也就说最大并发请求数是 10000,秒杀系统需要面对的技术挑战有:
对现有网站业务造成冲击
秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。
解决方案:将秒杀系统独立部署,甚至使用独立域名,使其与网站完全隔离。
高并发下的应用、数据库负载
用户在秒杀开始前,通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成负载压力。
解决方案:重新设计秒杀商品页面,不使用网站原来的商品详细页面,页面内容静态化,用户请求不需要经过应用服务。
突然增加的网络及服务器带宽
假设商品页面大小 200K(主要是商品图片大小),那么需要的网络和服务器带宽是 2G(200K×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。
解决方案:因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,需要将秒杀商品页面缓存在 CDN,同样需要和 CDN 服务商临时租借新增的出口带宽。
直接下单
秒杀的游戏规则是到了秒杀才能开始对商品下单购买,在此时间点之前,只能浏览商品信息,不能下单。而下单页面也是一个普通的 URL,如果得到这个 URL,不用等到秒杀开始就可以下单了。
解决方案:为了避免用户直接访问下单页面 URL,需要将 URL 动态化,即使秒杀系统的开发者也无法在秒杀开始前访问下单页面的 URL。办法是在下单页面 URL 加入由服务器端生成的随机数作为参数,在秒杀开始的时候才能得到。
控制秒杀商品页面购买按钮的点亮
购买按钮只有在秒杀开始的时候才能点亮,在此之前是灰色的。如果该页面是动态生成的,当然可以在服务器端构造响应页面输出,控制该按钮是灰色还是点亮,但是为了减轻服务器端负载压力,更好地利用 CDN、反向代理等性能优化手段,该页面被设计为静态页面,缓存在 CDN、反向代理服务器上,甚至用户浏览器上。秒杀开始时,用户刷新页面,请求根本不会到达应用服务器。
解决方案:使用 JavaScript 脚本控制,在秒杀商品静态页面中加入一个 JavaScript 文件引用,该 JavaScript 文件中包含秒杀开始标志为否;当秒杀开始的时候生成一个新的 JavaScript 文件(文件名保持不变,只是内容不一样),更新秒杀开始标志为是,加入下单页面的 URL 及随机数参数(这个随机数只会产生一个,即所有人看到的 URL 都是同一个,服务器端可以用 redis 这种分布式缓存服务器来保存随机数),并被用户浏览器加载,控制秒杀商品页面的展示。这个 JavaScript 文件的加载可以加上随机版本号(例如 xx.js?v=32353823),这样就不会被浏览器、CDN 和反向代理服务器缓存。
这个 JavaScript 文件非常小,即使每次浏览器刷新都访问 JavaScript 文件服务器也不会对服务器集群和网络带宽造成太大压力。
减库存的操作
有两种选择,一种是拍下减库存 另外一种是付款减库存;目前采用的“拍下减库存”的方式,拍下就是一瞬间的事,对用户体验会好些。
库存会带来“超卖”的问题
由于库存并发更新的问题,导致在实际库存已经不足的情况下,库存依然在减,导致卖家的商品卖得件数超过秒杀的预期。详见:扣库存的艺术
秒杀架构原则
尽量将请求拦截在系统上游
传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小【一趟火车其实只有 2000 张票,200w 个人来买,基本没有人能买成功,请求有效率为 0】。
读多写少的多使用缓存
这是一个典型的读多写少的应用场景【一趟火车其实只有 2000 张票,200w 个人来买,最多 2000 个人下单成功,其他人都是查询库存,写比例只有 0.1%,读比例占 99.9%】,非常适合使用缓存。
大型高并发系统架构
上图中描述了用户请求到服务器经历了三层的负载均衡,下边分别简单介绍一下这三种负载均衡。
1、OSPF(开放式最短链路优先)是一个内部网关协议(Interior Gateway Protocol,简称 IGP)
OSPF 通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF 会自动计算路由接口上的 Cost 值,但也可以通过手工指定该接口的 Cost 值,手工指定的优先于自动计算的值。
OSPF 计算的 Cost,同样是和接口带宽成反比,带宽越高,Cost 值越小。到达目标相同 Cost 值的路径,可以执行负载均衡,最多 6 条链路同时执行负载均衡。
2、LVS (Linux Virtual Server)
它是一种集群(Cluster)技术,采用 IP 负载均衡技术和基于内容请求分发技术。
调度器具有很好的吞吐率,将请求均衡地转移到不同的服务器上执行,且调度器自动屏蔽掉服务器的故障,从而将一组服务器构成一个高性能的、高可用的虚拟服务器。
3、Nginx
想必大家都很熟悉了,是一款非常高性能的 HTTP 代理/反向代理服务器,服务开发中也经常使用它来做负载均衡。
Nginx 实现负载均衡的方式主要有三种:
轮询
加权轮询
IP Hash 轮询
秒杀架构设计
秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心的是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。
商品页面中的购买按钮只有在秒杀活动开始的时候才变亮,在此之前及秒杀商品卖出后,该按钮都是灰色的,不可以点击。
下单表单也尽可能简单,购买数量只能是一个且不可以修改,送货地址和付款方式都使用用户默认设置,没有默认也可以不填,允许等订单提交后修改;
前端层设计
首先要有一个展示秒杀商品的页面,在这个页面上做一个秒杀活动开始的倒计时,在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。这里需要考虑两个问题:
秒杀页面的展示
我们知道一个 html 页面还是比较大的,即使做了压缩,http 头和内容的大小也可能高达数十 K,加上其他的 css, js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有 1G10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到 cdn 节点上分散压力,由于 CDN 节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜
倒计时
出于性能原因这个一般由 js 调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。
客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间,这里考虑一下性能问题,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前 web 服务器的时间发送给客户端就可以了,因此速度很快;
浏览器层请求拦截
(1)产品层面,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求;
(2)JS 层面,限制用户在 x 秒之内只能提交一次请求;
站点层设计
前端层的请求拦截,只能拦住小白用户(不过这是 99%的用户哟),高端的程序员根本不吃这一套,写个 for 循环,直接调用你后端的 http 请求,怎么整?
同一个 uid,限制访问频度,做页面缓存,x 秒内到达站点层的请求,均返回同一页面
同一个 item 的查询,例如手机车次,做页面缓存,x 秒内到达站点层的请求,均返回同一页面
如此限流,又有 99%的流量会被拦截在站点层。
服务层设计
站点层的请求拦截,只能拦住普通程序员,高级黑客,假设他控制了 10w 台肉鸡(并且假设买票不需要实名认证),这下 uid 的限制不行了吧?怎么整?
清楚的知道一列火车只有 2000 张车票,透 10w 个请求去数据库有什么意义呢?对于写请求,做请求队列,每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完”;
对于读请求,还用说么?cache 来抗,不管是 memcached 还是 redis,单机抗个每秒 10w 应该都是没什么问题的;
如此限流,只有非常少的写请求,和非常少的读请求会透到数据层去,又有 99.9%的请求被拦住了。
用户请求分发模块:使用 Nginx 或 Apache 将用户的请求分发到不同的机器上。
用户请求预处理模块:判断商品是不是还有剩余来决定是不是要处理该请求。
高并发带来的挑战
请求接口的合理设计
一个秒杀或者抢购页面,通常分为 2 个部分,一个是静态的 HTML 等内容,另一个就是参与秒杀的 Web 后台请求接口。
通常静态 HTML 等内容,是通过 CDN 的部署,一般压力不大,核心瓶颈实际上在后台请求接口上。这个后端接口,必须能够支持高并发请求,同时,非常重要的一点,必须尽可能“快”,在最短的时间里返回用户的请求结果。
为了实现尽可能快这一点,接口的后端存储使用内存级别的操作会更好一点。仍然直接面向 MySQL 之类的存储是不合适的,如果有这种复杂业务的需求,都建议采用异步写入。
要“快”
我们通常衡量一个 Web 系统的吞吐率的指标是 QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。
举个例子,我们假设处理一个业务请求平均响应时间为 100ms,同时,系统内有 20 台 Apache 的 Web 服务器,配置 MaxClients 为 500 个(表示 Apache 的最大连接数目)。
那么,我们的 Web 系统的理论峰值 QPS 为(理想化的计算方式):
咦?我们的系统似乎很强大,1 秒钟可以处理完 10 万的请求,5w/s 的秒杀似乎是“纸老虎”哈。实际情况,当然没有这么理想。在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加。
就 Web 服务器而言,Apache 打开了越多的连接进程,CPU 需要处理的上下文切换也越多,额外增加了 CPU 的消耗,然后就直接导致平均响应时间增加。因此上述的 MaxClient 数目,要根据 CPU、内存等硬件因素综合考虑,绝对不是越多越好。
可以通过 Apache 自带的 abench 来测试一下,取一个合适的值。然后,我们选择内存操作级别的存储的 Redis,在高并发的状态下,存储的响应时间至关重要。网络带宽虽然也是一个因素,不过,这种请求数据包一般比较小,一般很少成为请求的瓶颈。负载均衡成为系统瓶颈的情况比较少,在这里不做讨论哈。
那么问题来了,假设我们的系统,在 5w/s 的高并发状态下,平均响应时间从 100ms 变为 250ms(实际情况,甚至更多):
于是,我们的系统剩下了 4w 的 QPS,面对 5w 每秒的请求,中间相差了 1w。
然后,这才是真正的恶梦开始。举个例子,高速路口,1 秒钟来 5 部车,每秒通过 5 部车,高速路口运作正常。突然,这个路口 1 秒钟只能通过 4 部车,车流量仍然依旧,结果必定出现大塞车。(5 条车道忽然变成 4 条车道的感觉)。
同理,某一个秒内,20*500
个可用连接进程都在满负荷工作中,却仍然有 1 万个新来请求,没有连接进程可用,系统陷入到异常状态也是预期之内。
其实在正常的非高并发的业务场景中,也有类似的情况出现,某个业务请求接口出现问题,响应时间极慢,将整个 Web 请求响应时间拉得很长,逐渐将 Web 服务器的可用连接数占满,其他正常的业务请求,无连接进程可用。
更可怕的问题是,是用户的行为特点,系统越是不可用,用户的点击越频繁,恶性循环最终导致“雪崩”(其中一台 Web 机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环),将整个 Web 系统拖垮。
重启与过载保护
如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启。如果是 redis/memcache 这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。
秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在 CGI 入口层,快速将客户的直接请求返回。
同一个账号,一次性发出多个请求
部分用户通过浏览器的插件或者其他工具,在秒杀开始的时间里,以自己的账号,一次发送上百甚至更多的请求。实际上,这样的用户破坏了秒杀和抢购的公平性。
这种请求在某些没有做数据安全处理的系统里,也可能造成另外一种破坏,导致某些判断条件被绕过。例如一个简单的领取逻辑,先判断用户是否有参与记录,如果没有则领取成功,最后写入到参与记录中。
这是个非常简单的逻辑,但是,在高并发的场景下,存在深深的漏洞。多个并发请求通过负载均衡服务器,分配到内网的多台 Web 服务器,它们首先向存储发送查询请求,然后,在某个请求成功写入参与记录的时间差内,其他的请求获查询到的结果都是“没有参与记录”。这里,就存在逻辑判断被绕过的风险。
应对方案:
在程序入口处,一个账号只允许接受 1 个请求,其他请求过滤。不仅解决了同一个账号,发送 N 个请求的问题,还保证了后续的逻辑流程的安全。实现方案,可以通过 Redis 这种内存缓存服务,写入一个标志位(只允许 1 个请求写成功),成功写入的则可以继续参加。
多个账号,一次性发送多个请求
很多公司的账号注册功能,在发展早期几乎是没有限制的,很容易就可以注册很多个账号。因此,也导致了出现了一些特殊的工作室,通过编写自动注册脚本,积累了一大批“僵尸账号”,数量庞大,几万甚至几十万的账号不等,专门做各种刷的行为(这就是微博中的“僵尸粉“的来源)。
举个例子,例如微博中有转发抽奖的活动,如果我们使用几万个“僵尸号”去混进去转发,这样就可以大大提升我们中奖的概率。
这种账号,使用在秒杀和抢购里,也是同一个道理。例如,iPhone 官网的抢购,火车票黄牛党。
应对方案:
这种场景,可以通过检测指定机器 IP 请求频率就可以解决,如果发现某个 IP 请求频率很高,可以给它弹出一个验证码或者直接禁止它的请求:
1.弹出验证码,最核心的追求,就是分辨出真实用户。因此,大家可能经常发现,网站弹出的验证码,有些是“鬼神乱舞”的样子,有时让我们根本无法看清。
他们这样做的原因,其实也是为了让验证码的图片不被轻易识别,因为强大的“自动脚本”可以通过图片识别里面的字符,然后让脚本自动填写验证码。实际上,有一些非常创新的验证码,效果会比较好,例如给你一个简单问题让你回答,或者让你完成某些简单操作(例如百度贴吧的验证码)。
2.直接禁止 IP,实际上是有些粗暴的,因为有些真实用户的网络场景恰好是同一出口 IP 的,可能会有“误伤“。但是这一个做法简单高效,根据实际场景使用可以获得很好的效果。
多个账号,不同 IP 发送不同请求
所谓道高一尺,魔高一丈。有进攻,就会有防守,永不休止。这些“工作室”,发现你对单机 IP 请求频率有控制之后,他们也针对这种场景,想出了他们的“新进攻方案”,就是不断改变 IP。
有同学会好奇,这些随机 IP 服务怎么来的。有一些是某些机构自己占据一批独立 IP,然后做成一个随机代理 IP 的服务,有偿提供给这些“工作室”使用。还有一些更为黑暗一点的,就是通过木马黑掉普通用户的电脑,这个木马也不破坏用户电脑的正常运作,只做一件事情,就是转发 IP 包,普通用户的电脑被变成了 IP 代理出口。通过这种做法,黑客就拿到了大量的独立 IP,然后搭建为随机 IP 服务,就是为了挣钱。
应对方案:
说实话,这种场景下的请求,和真实用户的行为,已经基本相同了,想做分辨很困难。再做进一步的限制很容易“误伤“真实用户,这个时候,通常只能通过设置业务门槛高来限制这种请求了,或者通过账号行为的”数据挖掘“来提前清理掉它们。
僵尸账号也还是有一些共同特征的,例如账号很可能属于同一个号码段甚至是连号的,活跃度不高,等级低,资料不全等等。根据这些特点,适当设置参与门槛,例如限制参与秒杀的账号等级。通过这些业务手段,也是可以过滤掉一些僵尸号。
扣库存方案
下单减库存
当用户并发请求到达服务端时,首先创建订单,然后扣除库存,等待用户支付。
这种顺序是我们一般人首先会想到的解决方案,这种情况下也能保证订单不会超卖,因为创建订单之后就会减库存,这是一个原子操作。
但是这样也会产生一些问题:
在极限并发情况下,任何一个内存操作的细节都至关影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是可想而知的。
如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制 IP 和用户的购买订单数量,这也不算是一个好方法。
支付减库存
如果等待用户支付了订单在减库存,第一感觉就是不会少卖。但是这是并发架构的大忌,因为在极限并发情况下,用户可能会创建很多订单。
当库存减为零的时候很多用户发现抢到的订单支付不了了,这也就是所谓的“超卖”。也不能避免并发操作数据库磁盘 IO。
预扣库存
从上边两种方案的考虑,我们可以得出结论:只要创建订单,就要频繁操作数据库 IO。
那么有没有一种不需要直接操作数据库 IO 的方案呢,这就是预扣库存。先扣除了库存,保证不超卖,然后异步生成用户订单,这样响应给用户的速度就会快很多;那么怎么保证不少卖呢?用户拿到了订单,不支付怎么办?
我们都知道现在订单都有有效期,比如说用户五分钟内不支付,订单就失效了,订单一旦失效,就会加入新的库存,这也是现在很多网上零售企业保证商品不少卖采用的方案。
订单的生成是异步的,一般都会放到 MQ、Kafka 这样的即时消费队列中处理,订单量比较少的情况下,生成订单非常快,用户几乎不用排队。
扣库存艺术
在单机低并发情况下,我们实现扣库存通常是这样的:
为了保证扣库存和生成订单的原子性,需要采用事务处理,然后取库存判断、减库存,最后提交事务,整个流程有很多 IO,对数据库的操作又是阻塞的。
这种方式根本不适合高并发的秒杀系统。接下来我们对单机扣库存的方案做优化:本地扣库存。
我们把一定的库存量分配到本地机器,直接在内存中减库存,然后按照之前的逻辑异步创建订单。
改进过之后的单机系统是这样的:
这样就避免了对数据库频繁的 IO 操作,只在内存中做运算,极大的提高了单机抗并发的能力。
但是百万的用户请求量单机是无论如何也抗不住的,虽然 Nginx 处理网络请求使用 Epoll 模型,c10k 的问题在业界早已得到了解决。
但是 Linux 系统下,一切资源皆文件,网络请求也是这样,大量的文件描述符会使操作系统瞬间失去响应。
上面我们提到了 Nginx 的加权均衡策略,我们不妨假设将 100W 的用户请求量平均均衡到 100 台服务器上,这样单机所承受的并发量就小了很多。
然后我们每台机器本地库存 100 张火车票,100 台服务器上的总库存还是 1 万,这样保证了库存订单不超卖,下面是我们描述的集群架构:
问题接踵而至,在高并发情况下,现在我们还无法保证系统的高可用,假如这 100 台服务器上有两三台机器因为扛不住并发的流量或者其他的原因宕机了。那么这些服务器上的订单就卖不出去了,这就造成了订单的少卖。
要解决这个问题,我们需要对总订单量做统一的管理,这就是接下来的容错方案。服务器不仅要在本地减库存,另外要远程统一减库存。
有了远程统一减库存的操作,我们就可以根据机器负载情况,为每台机器分配一些多余的“Buffer 库存”用来防止机器中有机器宕机的情况。
我们结合下面架构图具体分析一下:
我们采用 Redis 存储统一库存,因为 Redis 的性能非常高,号称单机 QPS 能抗 10W 的并发。
在本地减库存以后,如果本地有订单,我们再去请求 Redis 远程减库存,本地减库存和远程减库存都成功了,才返回给用户抢票成功的提示,这样也能有效的保证订单不会超卖。
当机器中有机器宕机时,因为每个机器上有预留的 Buffer 余票,所以宕机机器上的余票依然能够在其他机器上得到弥补,保证了不少卖。
Buffer 余票设置多少合适呢,理论上 Buffer 设置的越多,系统容忍宕机的机器数量就越多,但是 Buffer 设置的太大也会对 Redis 造成一定的影响。
虽然 Redis 内存数据库抗并发能力非常高,请求依然会走一次网络 IO,其实抢票过程中对 Redis 的请求次数是本地库存和 Buffer 库存的总量。
因为当本地库存不足时,系统直接返回用户“已售罄”的信息提示,就不会再走统一扣库存的逻辑。
这在一定程度上也避免了巨大的网络请求量把 Redis 压跨,所以 Buffer 值设置多少,需要对系统的负载能力做认真的考量。
总结
互联网正在高速发展,使用互联网服务的用户越多,高并发的场景也变得越来越多。电商秒杀和抢购,是两个比较典型的互联网高并发场景。
虽然我们解决问题的具体技术方案可能千差万别,但是遇到的挑战却是相似的,因此解决问题的思路也异曲同工。
评论