在应用运行过程中如何趋利避害,避免蝴蝶效应?请听金融老兵慢慢道来……
1、概述
分布式系统中经常会出现某个服务提供方不可用导致服务排队,如果没有及时解决很可能产生连锁反应,导致系统负载增加、请求大量超时,最后导致大量的服务器或者软件模块无法正常工作, 这种现象被称为服务雪崩效应。为了应对服务雪崩, 常见的做法是服务降级,那么如何进行服务降级?如何在减少人工干预的情况下自动进行降级?本文将从:相关概念、问题产生的根本原因、如何预防、如何处置等方面进行阐述。
在阐述应当方法前我,我们先来澄清几个概念。
系统负载:系统负载即System Load:指系统CPU繁忙程度的度量,即有多少进程在等待被CPU调度。linux或unix系统通过top查看,也可以通过uptime查看平均负载(Load Average):一段时间内系统的平均负载,如下面作者的mac的执行uptime后显示的1分钟平均负载,5分钟平均负载,15分钟平均负载分别是2.17、3.90、3.74
21:30 up 6 days, 52 mins, 2 users, load averages: 2.17 3.90 3.74
在把docker和其他相关应用停了以后update结果如下:
21:40 up 6 days, 1:01, 2 users, load averages: 1.48 2.28 2.93
过载:当前负载已经超过了系统的最大处理能力。例如,系统每秒能够处理的请求是100个,但实际每秒的请求量却是10000个,就可以判定系统出现了过载。
过载保护:剔除过载或者已经超时(过时)的请求,从而保证系统尽可能能用。
雪球:对于时延敏感的服务,当外部请求超过系统处理能力,如果系统没有做相应保护,可能导致历史累计的超时请求达到一定规模,像雪球一样形成恶性循环。由于系统处理的每个请求都因为超时而无效,系统对外呈现的服务能力为0,且这种情况下不能自动恢复。
服务雪崩效应:因服务提供者的不可用导致服务调用者的不可用,并将不可用逐渐放大的过程。
熔断:这种模式主要是参考电路熔断,如果一条线路电压过高,保险丝会熔断,防止火灾。放到我们的系统中,如果某个目标服务调用慢或者有大量超时,此时,熔断该服务的调用,对于后续调用请求,不在继续调用目标服务,直接返回,快速释放资源。如果目标服务情况好转则恢复调用。
隔离:隔离设计对应的单词是 Bulkheads,中文翻译为隔板。但其实,这个术语是用在造船上的,也就是船舱里防漏水的隔板。这就像对系统请求按类型划分成一个个小岛的一样,当某个小岛被火少光了,不会影响到其他的小岛。
上面几个词的定义和理解有助于掌握本文要传递的观点,这也是我们采取措施规避生产问题的本质。
2、系统过载
系统过载是一切问题的根源,如果不存在过载,当前系统可以很好支撑业务并保证几个九的高可用,就不再需要做优化处理了,所以载讲熔断和隔离前,我们先来理清系统过载的各个方面。
2.1过载的现象
在我们的工程实践中,偶尔会遇到一些服务由于网络连接超时,系统有异常或load过高出现暂时不可用等情况,导致对这些服务的调用失败,可能需要一段时间才能修复,这种对请求的阻塞可能会占用宝贵的系统资源,如:内存,线程,数据库连接等等,最坏的情况下会导致这些资源被消耗殆尽,使得系统里不相关的部分所使用的资源也耗尽从而拖累整个系统。
比如, 大家都在元旦的凌晨零点零分发朋友圈, 表达新年愿望,那么, 在短短的几秒内,微信后台的请求量就是平时的n倍,如果不提前做好压测、扩容、柔性等准备,那么系统失败率就会上升,此时用户那边看到失败后,就立即重试,不断重试,请求量又翻倍,雪球又在滚,直到最后服务失败率急剧上升。
2.2过载的原因
“过载”的出现,不同系统模型的具体原因都会有所不同,但究其根本原因,可以归结为两点:
(1)处理能力的下降,如:硬件故障、程序BUG、缓存穿透等;
(2)请求量的上升,如:超预期的用户大量请求、用户提交失败后的重试、代码逻辑重试。
2.3过载的后果
“过载”的出现,会导致部分服务不可用,如果处置不当,极有可能引起服务完全不可用,乃至雪崩。
一般服务框架都会设置并发数和等待队列,当出现处理能力的下降或者请求量大幅增加,导致处理能力小于请求量的情况下,请求就会在等待队列中堆积,最终会导致请求处理延迟大到不可接受的程度,最终会导致处理的都是无效的,造成服务不可用。
2.4过载的处置
一般通过对比服务设计处理能力和外部请求量大小来识别过载,当请求量超过处理能力的80%,则判定为过载,触发过载处理。80%只是个经验值,触及到这个量,就应该告警,考虑优化扩容事宜。我们可以考虑对处理能力进行计算。而请求量,则是由前面一段时间所统计得到。
过载往往发展的很快,一般在接到过载预警后很短时间机会蔓延,所以第一时间处理很重要,那么如何处理呢?一般的处理方法如下:
服务调用者降级服务,返回默认值:比如库存调用超时,返回默认现货;
返回兜底数据:通常是异常或错误发生时最后适用的数据,比如预备的静态页面、默认的数据;
返回缓存数据;
返回空值;
服务自动扩容,通过横向扩展过载的服务(通常这种处理方式并不能解决问题,详见案例部分说明);
流量控制,放弃部分调用。
案例:某业务出现了一个性能瓶颈, 业务高峰期, 流量飙升到前一时刻的3-4倍, 失败率迅速上升, 最开始以为是坏人每天准时刷我们的系统, 也猜测是不是有脚本在跑, 查了很长时间。 后来发现是数据库瓶颈的问题, 于是进行了sql优化, 还是不能解决问题, mysql怎么会那么慢呢? 最后发现, 是qps到了瓶颈, 需要对mysql进行扩容, 扩容后, 就一切OK了。原来:后台的mysql出现了qps瓶颈, 后台服务的延时上升, 单纯扩容后台逻辑层机器没有什么用。 延时上升导致客户端失败率增加, 而客户端又有自动重试3次的逻辑, 所以流量急剧飙升, 请求流量的飙升又导致了后台mysql的更大压力, 于是雪崩。 好在后台框架有过载保护机制, 勉强能提供小部分正常服务。
2.5过载的恢复
生产问题发生后,一般会分析当前系统存在的瓶颈,然后从以下几个方面来进行优化处理:
业务模型优化:能够保证系统在过载时,提供较高的稳定处理能力;
系统瓶颈代码优化;
限流、熔断、预警等参数优化;
增加相关资源;
资源实现自动弹性伸缩;
服务或模块拆分,实现隔离;
增加熔断机制。
2.6过载的预防
提前预测、提前行动:最好解决问题的办法就是避免问题发生,常见预防的方法如下:
优化服务处理流程,减少依赖影响;
降低处理资源消耗;
提升自身处理能力;
分离处理模块;
请求量分流,降低单服请求量;
在前端控制请求频率,缓解后端压力;
缓冲区的使用,可以帮我们抵挡请求量的抖动;
当处理请求超出一定阈值时,及时告警,做好扩容,优化等其他准备;
在系统部署上线之前,预估好系统的处理能力,限定最大同时能够处理的请求量、流量或者链接数。
一般对于服务依赖的保护主要有3种解决方案:
(1)熔断模式
(2)隔离模式
(3)限流模式
熔断模式和隔离模式都属于出错后的容错处理机制,而限流模式则可以称为预防模式。限流模式主要是提前对各个类型的请求设置最高的QPS阈值,若高于设置的阈值则对该请求直接返回,不再调用后续资源。这种模式不能解决服务依赖的问题,只能解决系统整体资源分配问题,因为没有被限流的请求依然有可能造成雪崩效应。
本文将从隔离和熔断两个方面来讲,限流涉及到的内容较多,后面将单独一片文章来讲。
3、隔离模式
Bulkheads(隔离模式):是通过资源隔离减少风险的方式,源自货船为了进行防止漏水和火灾的扩散,会将货仓分隔为多个, 如下图所示。
软件系统的隔离推荐的方法是进行服务拆分,让每个小业务独立运行,即便是其中一个业务宕掉,其他服务依然可以正常运行。
案例:在一个高度服务化的系统中,我们实现的一个业务逻辑通常会依赖多个服务,比如: 商品详情展示服务会依赖商品服务, 价格服务, 商品评论服务。 如图所示:
调用三个依赖服务会共享商品详情服务的线程池。如果其中的商品评论服务不可用, 就会出现线程池里所有线程都因等待响应而被阻塞, 从而造成服务雪崩,如图所示:
通过将每个依赖服务分配独立的线程池进行资源隔离, 从而避免服务雪崩。
如下图所示, 当商品评论服务不可用时, 即使商品服务独立分配的20个线程全部处于同步等待状态,也不会影响其他依赖服务的调用。
隔离设计:
隔离的方式一般使用两种:
(1)线程池隔离模式:使用一个线程池来存储当前的请求,线程池对请求作处理,设置任务返回处理超时时间,堆积的请求堆积入线程池队列。这种方式需要为每个依赖的服务申请线程池,有一定的资源消耗,好处是可以应对突发流量(流量洪峰来临时,处理不完可将数据存储到线程池队里慢慢处理)
(2)信号量隔离模式:使用一个原子计数器(或信号量)来记录当前有多少个线程在运行,请求来先判断计数器的数值,若超过设置的最大线程个数则丢弃改类型的新请求,若不超过则执行计数操作请求来计数器+1,请求返回计数器-1。这种方式是严格的控制线程且立即返回模式,无法应对突发流量(流量洪峰来临时,处理的线程超过数量,其他的请求会直接返回,不继续去请求依赖的服务)
在服务接口设计时应该遵循建立单一接口,不要建立臃肿庞大的接口的原则,即:
客户端不应该依赖它不需要的接口
类间的依赖关系应该建立在最小的接口上
4、熔断
熔断(Circuit Breaker)模式:会处理一些需要一定时间来重连远程服务和远端资源的错误。该模式可以提高一个应用的稳定性和弹性。熔断模式可以防止应用重复的尝试调用容易失败的操作,当熔断模式判断错误会持续的时候,它会令操作不再持续等待,以免继续浪费CPU资源。当然,熔断模式也令应用本身可以发现错误有没有被修复。如果发生的问题已经被修复了,应用可以重新尝试去调用服务
熔断模式的目的和重试模式(Retry)的目的是不同的。Retry模式令应用不断的重试调用,直到最后成功。而Circuit-Breaker模式是阻止应用继续尝试无意义的请求。应用可以同时使用两种模式,但在使用的时候应该注意重试逻辑,可以在Circuit-Breaker发现错误短时间无法修复的情况下直接不再继续重试。
熔断模式可以按照:关闭、打开、半开,三个状态来模仿一个断路器的实现,如下图所示,熔断器模式定义了熔断器开关相互转换的逻辑:
熔断器开关由关闭到打开的状态转换是通过当前服务健康状况和设定阈值比较决定的(PS:服务的健康状况 = 请求失败数 / 请求总数)。
关闭:应用的请求已经路由到了这个操作。代理应该维护最近一段时间的错误信息,如果调用操作失败,那么大力增加这个错误信息的数量。如果这个错误数量超过给定时间的阈值,代理进入到打开状态。这个时候,代理启动一个超时的Timer,当Timer过期了,代理则进入半开状态。超时Timer的目的是为了给系统一段时间来自我修复之前碰到的问题。
打开:令可能失败的外部调用操作立刻失败,所有的外部调用直接抛异常给应用。
半开:只有一定数量的应用请求可以进行操作的调用。如果这些请求成功了,那么就假定之前发成的错误已经被系统自动修复了,而Circuit-Breaker转换成关闭状态(同时重置错误计数器)。如果任何请求失败了,那么Circuit-Breaker会假定错误仍然在存在,Circuit-Breaker会重新转换成打开状态,并重启超时Timer给系统更多的时间来自我修复错误。
熔断器的开关能保证服务调用者在调用异常服务时, 快速返回结果, 避免大量的同步等待。熔断器能在一段时间后继续侦测请求执行结果, 提供恢复服务调用的可能。实现Circuit-Breaker模式可以增加系统的稳定性和弹性,当系统从错误恢复的时候,可以尽可能所有失败对系统性能的影响。Circuit-Breaker模式可以通过拒绝外部调用来保证服务的响应时间,而不是等待操作的超时(或者持续阻塞)。如果Circuit-Breaker在每一次状态改变的时候触发一些事件的话,这个状态的改变也可以用来监视Circuit-Breaker保护模块的健康状态,或者是对监控Circuit-Breaker的管理员发出警告,Circuit-Breaker已经进入了打开状态。
Circuit-Breaker模式可以很好的定制并适配很多可能的错误。举例来说,开发者可以应用一个增长的超时Timer,也可以直接令Circuit-Breaker在处于打开状态几秒,如果错误在之后还没有解决,就超时几分钟等等。在有些场景下,打开状态的Circuit-Breaker也可以不抛出异常而是返回默认值来改善应用的响应。
何时使用熔断(Circuit-Breaker)模式:当需要阻止应用不断尝试调用远端服务或者访问共享资源,并且这些请求很容易失败的时候使用Circuit-Breaker模式很合适。
什么场景不适合使用该模式:
当用来处理访问本地资源,比如内存中的数据结构的时候,不适合使用。在这种场景下,Circuit-Breaker只会给应用带来额外的负担。
将Circuit-Breaker作为处理应用中的业务逻辑中的异常处理的一部分也是不合适的。
5.总结
每个系统,都有自己的最大处理能力,后台技术人员对此必须很清楚,且要注意自我保护,不然就会被雪球压垮,出现雪崩。
限于篇幅关于隔离部分本文只讲述了应用的隔离即从线程好进程模型考虑如何做隔离设计,其实在进行架构设计时隔离还要以下几个层面的内容:容器的隔离(避免资源竞争)、系统隔离(避免单点、避免过度依赖底层资源如IO、存储、数据库等)、硬件隔离(避免硬件单点等)、网络隔离(避免网络阻塞)、机房隔离(可以随时进行流量切换);关于熔断部分描述了原理关于实现框架描述的不够,不过网上已经很多熔断器Hystrix相关博文内容。
下一篇文章将大话分布式缓存那些事,敬请关注公众号。我们团队认真的每周至少两篇金融科技相关原创文章更新。
领取专属 10元无门槛券
私享最新 技术干货