流量削峰
为什么削峰
首先我们说一下为什么要削峰,不削峰会有什么影响呢?
类似于公交车一样,早高峰,晚高峰是上车人最多的时候,除了这两个时间段白天运营的时候人是比较少的。这就导致了资源浪费。
我见过这么一种情况,每周五中学放假的时候,我们县城的中巴车在某一个站点拉不过来了,就会调度其他站点的中巴车去支援爆满的这个站点。如果不调度的话就会影响用户体验和中巴车超载出现意外风险。
系统也是一样的,如果按照最大量配置的话,服务器的资源就是一笔非常大的开销。而且削峰的话也是对服务器处理更加平稳。下面我们主要从排队,答题,分层过滤这几种方案梳理一下
降级,限流和拒绝服务也算是削峰的一种,在下面的高可用中会介绍的。
排队
利用排队的思想,我们最先想到的肯定还是消息队列了。与其把流量都堆积在服务端,不如把流量都打到消息队列,根据服务端的处理能力不断的消费消息队列中的数据。达到处理并发的能力。
利用消息队列的确可以缓解很多流量,但是以双十一那种超大并发流量,积压达到了存储空间的上限,我想消息队列也有宕机的情况。
除了消息队列,类似的方法还有很多
- 利用线程池加锁等待也是一种常用的排队方式
- 先进先出、先进后出等常用的内存排队算法的实现方式;
- 把请求序列化到文件中,然后再顺序地读文件来恢复请求等方式。
其实很多时候,处理方案大差不差,就类似于MySQL的binlog机制,就是把一步操作变成两步操作。增加的那一步操作就起到了缓存的作用。
答题
答题我们可以理解成验证码,一般秒杀活动主要针对于稀缺物品。比如原价1000元的,现在只买100元。只出售100件。如果是正常用户还好,就是怕那种秒杀器恶意抢票或者抢东西之类的。
采用答题机制,主要解决了两类问题
- 为了防止部分买家使用秒杀器
- 为了延缓购买请求
原本 1秒下单购买就够了,现在把请求延迟到2-3秒这也是流量削峰的一种手段。
这样一来,请求峰值基于时间分片了。这个时间的分片对服务端处理并发非常重要,会大大减轻压力。而且,由于请求具有先后顺序,靠后的请求到来时自然也就没有库存了,因此根本到不了最后的下单步骤,所以真正的并发写就非常有限了。这种设计思路目前用得非常普遍,如当年支付宝的“咻一咻”、微信的“摇一摇”都是类似的方式
分层过滤
分层过滤就好比漏斗一样,我们应一层一层筛选,CDN => 前端 => 后端 => DB
过滤了之后,最终到DB之后的量是比原来小很多的。
分层过滤的核心思想是:在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。而要达到这种效果,我们就必须对数据做分层的校验。
分层的校验的基本原则:
- 将动态请求的读数据缓存(Cache)在 Web 端,过滤掉无效的数据读;
- 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
- 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
- 对写数据进行强一致性校验,只保留最后有效的数据。
分层校验的目的是: 在读系统中,尽量减少由于一致性校验带来的系统瓶颈,但是尽量将不影响性能的检查条件提前,如用户是否具有秒杀资格、商品状态是否正常、用户答题是否正确、秒杀是否已经结束、是否非法请求、营销等价物是否充足等;在写数据系统中,主要对写的数据(如“库存”)做一致性检查,最后在数据库层保证数据的最终准确性(如“库存”不能减为负数)。
服务端优化
罪魁祸首
聊到服务端优化,我们可以先从影响服务端的性能入手
- CPU主频
- 磁盘IOPS
我们一般用 QPS
和 RT
来衡量服务端性能
QPS:每秒请求数
RT:响应时间
正常情况下 RT
越短,QPS
自然也就会越多,这在单线程处理的情况下看起来是线性的关系,即我们只要把每个请求的响应时间降到最低,那么性能就会最高。
提到了单线程肯定要聊多线程,通过多线程,来处理请求。这样理论上就变成了“总 QPS =(1000ms / 响应时间)× 线程数量”,这样性能就和两个因素相关了,一个是一次响应的服务端耗时,一个是处理请求的线程数。下面分别介绍一下两者的关系。
首先,我们先来看看响应时间和 QPS 有啥关系。
对于大部分的 Web 系统而言,响应时间一般都是由 CPU 执行时间和线程等待时间(比如 RPC、IO 等待、Sleep、Wait 等)组成,即服务器在处理一个请求时,一部分是 CPU 本身在做运算,还有一部分是在各种等待。
真正对性能有影响的是 CPU 的执行时间。 因为 CPU 的执行真正消耗了服务器的资源。经过实际的测试,如果减少 CPU 一半的执行时间,就可以增加一倍的 QPS。也就是说,我们应该致力于减少 CPU 的执行时间。
其次,我们再来看看线程数对 QPS 的影响。
单看总QPS计算公式,你会觉得线程数越多 QPS 也就会越高。其实不是这样的,线程不是越多越好,线程越多,CPU的调度成本就会越高,而且每个线程也都会耗费一定内存。
总 QPS =(1000ms / 响应时间)× 线程数量
那么到底设计多少个线程合适呢?可以参考下列计算公式。
默认配置:线程数 = 2 * CPU 核数 + 1
实践后:线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 数量
最好的办法是通过性能测试来发现最佳的线程数。要提升性能我们就要减少 CPU 的执行时间,另外就是要设置一个合理的并发线程数,通过这两方面来显著提升服务器的性能。
了解了如何快速提升性能,下面我们聊一下应该怎么发现系统那里最消耗CPU资源呢?
如何寻找罪魁祸首
对服务器来说,CPU,内存,磁盘,网络都有可能影响瓶颈。我们大方向是秒杀,它的瓶颈更多地发生在CPU上。
- 我们可以通过使用Profiler 和 Yourkit 这两个工具来寻找每个函数的CPU执行时间最多的那一个,然后做一些针对性的优化。
- 我们还可以通过 jstack 定时地打印调用栈,如果某些函数调用频繁或者耗时较多,那么那些函数就会多次出现在系统调用栈里,这样相当于采样的方式也能够发现耗时较多的函数。
方案优化
知道了哪些瓶颈下面就开始优化了
对 Java 系统来说,可以优化的地方很多,这里我重点说一下比较有效的几种手段,供你参考,它们是:减少编码、减少序列化、Java 极致优化、并发读优化。接下来,我们分别来看一下。
- 减少编码: Java编码运行慢是它的硬伤,只要涉及到字符串操作都比较耗CPU资源,不管它是磁盘IO还是网络IO,都需要将字符转成字节,而这个就需要编码。每个字符的编码都需要查表,而查表是非常耗资源的。所以减少字符到字节的转换是非常有成效的,减少编码就可以大大提升性能。
- 减少序列化: 序列化也是Java性能的硬伤, 减少 Java 中的序列化操作也能大大提升性能。又因为序列化往往是和编码同时发生的,所以减少序列化也就减少了编码。序列化大部分是在 RPC 中发生的,因此避免或者减少 RPC 就可以减少序列化。有一种新的方案,就是可以将多个关联性比较强的应用进行“合并部署”,而减少不同应用之间的 RPC 也可以减少序列化的消耗
- 第三方服务器优化: 剩下的就是优化第三方服务器了,比如nginx,apache。在处理大并发的 HTTP 请求时要弱一点,所以一般我们都会对大流量的 Web 系统做静态化改造,让大部分请求和数据直接在 Nginx 服务器或者 Web 代理服务器上直接返回。
- 并发读优化: 我们可以采用应用层的 LocalCache,即在秒杀系统的单机上缓存商品相关的数据。首先我们要划分成动态数据和静态数据分别进行处理,像商品的标题,描述这些本身不变的数据会在秒杀开始之前全量推送到秒杀机器上,并一直缓存到秒杀结束。像库存这类动态数据,会采用“被动失效”的方式缓存一定时间(一般是数秒),失效后再去缓存拉取最新的数据。
第三点直接返回的好处是 减少数据的序列化与反序列化
第四点我们把库存放入缓存,就会涉及到缓存,数据库数据不一致情况导致超卖。这里就要用到分层校验原则了,读可以读但是写的时候就必须限制啦。
电商减库存场景
电商减库存场景最严重的问题就是超卖啦。第一次接触秒杀的话可能觉得只要判断为0就停止不就好了嘛,其实没那么简单。
下单和付款这是两个阶段。产生的影响是 下单扣库存还是付款扣库存呢?
如果下单就扣,万一不付款呢?
如果付款再扣,那客户已经下单成功了,付款失败了体验就变差了!
减库存方式
- 下单减库存,即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是你要知道,有些人下完单可能并不会付款。
- 付款减库存,即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。但因为付款时才减库存,如果并发比较高,有可能出现买家下单后付不了款的情况,因为可能商品已经被其他人买走了。
- 预扣库存,这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 10 分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
减库存的问题
如果下单减库存 ,正常用户还好。如果不是正常用户的话就会占着茅坑不拉屎。秒杀黄金期间过后再申请退款。这样的活动就没啥意义了。
如果付款减库存 ,假如100件商品,可能出现300件购买成功,因为下单时不会减库存,所以也就可能出现下单成功数远远超过真正库存数的情况,这尤其会发生在做活动的热门商品上。这样一来,就会导致很多买家下单成功但是付不了款,买家的购物体验自然比较差。
如果采用预扣库存 ,这也是当下比较流行的一种方式,我们可以给他设置一个过期时间,一旦到期后不付款就会自动恢复库存。这种方案确实可以在一定程度上缓解上面的问题。但是存在另外一种情况。心怀不轨的人下单10分钟后,再次下单,这样非法人员就会一直占用库存名额。
针对预扣库存的问题,我们可以给经常下单不付款的买家进行识别打标(可以在被打标的买家下单时不减库存)、给某些类目设置最大购买件数(例如,参加活动的商品一人最多只能买 3 件),以及对重复下单不付款的操作进行次数限制等。
针对“库存超卖”这种情况,在 10 分钟时间内下单的数量仍然有可能超过库存数量,遇到这种情况我们只能区别对待:对普通的商品下单数量超过库存数量的情况,可以通过补货来解决;但是有些卖家完全不允许库存为负数的情况,那只能在买家付款时提示库存不足。
大型秒杀是如何抉择的
在大并发场景中,下单减库存比预扣库存,付款减库存更优一些,主要是逻辑更简单,速度更快。
“下单减库存” 在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;再有一种就是使用 CASE WHEN 判断语句,例如这样的 SQL 语句:
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END
大型秒杀后的优化
大并发中库存是个关键数据,也是热点数据,可以通过把库存数据放到缓存中大大提升读性能。
如果秒杀商品的减库存逻辑非常单一,比如没有复杂的 SKU 库存和总库存这种联动关系的话,完全可以利用Redis作为存储库存数据。
如果减库存比较复杂还是老老实实的用MySQL吧,毕竟数据的一致性才是最重要的。
放到MySQL中就会引发另一个问题。同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争 InnoDB 行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。
这就可能引发一个问题,就是单个热点商品会影响整个数据库的性能, 导致 0.01% 的商品影响 99.99% 的商品的售卖。
解决思路: 我们可以采用前面的隔离思想,把这个热点商品单独用一个数据库中。但是这个也有个缺点就是在维护上比较麻烦。比如数据迁移等。
咳咳。迁移到单独的数据库但是还是没有解决并发锁问题呀!要解决并发锁有两种办法:
- 应用层做排队 。按照商品维度设置队列顺序执行,这样能减少同一台机器对数据库同一行记录进行操作的并发度,同时也能控制单个商品占用数据库连接的数量,防止热点商品占用太多的数据库连接。
- 数据库层做排队 。应用层只能做到单机的排队,但是应用机器数本身很多,这种排队方式控制并发的能力仍然有限,所以如果能在数据库层做全局排队是最理想的。阿里的数据库团队开发了针对这种 MySQL 的 InnoDB 层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。
数据更新问题除了前面介绍的热点隔离和排队处理之外,还有些场景(如对商品的 lastmodifytime 字段的)更新会非常频繁,在某些场景下这些多条 SQL 是可以合并的,一定时间内只要执行最后一条 SQL 就行了,以便减少对数据库的更新操作。
高可用
高可用架构主要分两个阶段
- 前期: 系统架构
- 中后期: 降级,限流,拒绝服务
下面我们分别介绍一下
系统架构
做各种防护手段,不如把自己的系统架构设计的优秀一些。我们可以从这几个方面入手
- 架构阶段: 架构阶段主要考虑系统的可扩展性和容错性,要避免系统出现单点问题。例如多机房单元化部署,即使某个城市的某个机房出现整体故障,仍然不会影响整体网站的运转。
- 编码阶段: 编码最重要的是保证代码的健壮性,例如涉及远程调用问题时,要设置合理的超时退出机制,防止被其他系统拖垮,也要对调用的返回结果集有预期,防止返回的结果超出程序处理范围,最常见的做法就是对错误异常进行捕获,对无法预料的错误要有默认处理结果。
- 测试阶段: 测试主要是保证测试用例的覆盖度,保证最坏情况发生时,我们也有相应的处理流程。
- 发布阶段: 发布时也有一些地方需要注意,因为发布时最容易出现错误,因此要有紧急的回滚机制。
- 运行阶段: 运行时是系统的常态,系统大部分时间都会处于运行态,运行态最重要的是对系统的监控要准确及时,发现问题能够准确报警并且报警数据要准确详细,以便于排查问题。
- 故障发生: 故障发生时首先最重要的就是及时止损,例如由于程序问题导致商品价格错误,那就要及时下架商品或者关闭购买链接,防止造成重大资产损失。然后就是要能够及时恢复服务,并定位原因解决问题。
降级
所谓“降级”,就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。它是一个有目的、有计划的执行过程,所以对降级我们一般需要有一套预案来配合执行。如果我们把它系统化,就可以通过预案系统和开关系统来实现降级。
执行降级无疑是在系统性能和用户体验之间选择了前者,降级后肯定会影响一部分用户的体验,例如在双 11 零点时,如果优惠券系统扛不住,可能会临时降级商品详情的优惠信息展示,把有限的系统资源用在保障交易系统正确展示优惠信息上,即保障用户真正下单时的价格是正确的。所以降级的核心目标是牺牲次要的功能和用户体验来保证核心业务流程的稳定,是一个不得已而为之的举措。
限流
如果说降级是牺牲了一部分次要的功能和用户的体验效果,那么限流就是更极端的一种保护措施了。限流就是当系统容量达到瓶颈时,我们需要通过限制一部分流量来保护系统,并做到既可以人工执行开关,也支持自动化保护的措施。
总体来说,限流既可以是在客户端限流,也可以是在服务端限流。下面我们分别聊一下两种优缺点
- 客户端限流: 好处可以限制请求的发出,通过减少发出无用请求从而减少对系统的消耗。缺点就是当客户端比较分散时,没法设置合理的限流阈值:如果阈值设的太小,会导致服务端没有达到瓶颈时客户端已经被限制;而如果设的太大,则起不到限制的作用。
- 服务端限流: 好处是可以根据服务端的性能设置合理的阈值,而缺点就是被限制的请求都是无效的请求,处理这些无效的请求本身也会消耗服务器资源。
在限流的实现手段上来讲,基于 QPS 和线程数的限流应用最多,最大 QPS 很容易通过压测提前获取,例如我们的系统最高支持 1w QPS 时,可以设置 8000 来进行限流保护。线程数限流在客户端比较有效,例如在远程调用时我们设置连接池的线程数,超出这个并发线程请求,就将线程进行排队或者直接超时丢弃。
限流无疑会影响用户的正常请求,所以必然会导致一部分用户请求失败,因此在系统处理这种异常时一定要设置超时时间,防止因被限流的请求不能 fast fail(快速失败)而拖垮系统。
拒绝服务
如果限流还不能解决问题,最后一招就是直接拒绝服务了。
当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。例如秒杀系统,我们在如下几个环节设计过载保护:
在最前端的 Nginx 上设置过载保护,当机器负载达到某个值时直接拒绝 HTTP 请求并返回 503 错误码,在 Java 层同样也可以设计过载保护。
拒绝服务可以说是一种不得已的兜底方案,用以防止最坏情况发生,防止因把服务器压跨而长时间彻底无法提供服务。像这种系统过载保护虽然在过载时无法提供服务,但是系统仍然可以运作,当负载下降时又很容易恢复,所以每个系统和每个环节都应该设置这个兜底方案,对系统做最坏情况下的保护。
结尾
大概总结了
- 流量削峰的三种方案
- 从CPU,内存,IO入手分析服务端的性能优化,比如优化方案。
- 以电商库存为例,深入分析了多种减库存数量带来的影响
- 从高可用结构的前期,中期,后期的逐一深入分析进行优化。
首先感谢许令波老师的秒杀课 经过了一周的学习,对秒杀系统的理念,思想也有了初步的认识。大概的概念就是这些了,秒杀系列的《2万聊聊什么是秒杀系统(下)》 预计会在半个月后发出。第三篇打算梳理一些代码的优化成果。