记得在三年前公司因为业务发展需要,就曾经将单体应用迁移到分布式框架上来。当时就遇到了这样一个问题:系统仅有一个控制单元,它会调用多个运算单元,如果某个运算单元(作为服务提供者)不可用,将导致控制单元(作为服务调用者)被阻塞,最终导致控制单元崩溃,进而导致整个系统都面临着瘫痪的风险。
那个时候还不知道这其实就是服务的雪崩效应,雪崩效应好比就是蝴蝶效应,说的都是一个小因素的变化,却往往有着无比强大的力量,以至于最后改变整体结构、产生意想不到的结果。雪崩效应也是我们目前研发的产品直面的一道坎,下面我们来看有哪些场景会引发雪崩,又如何避免?对于无法避免的雪崩效应,我们又有哪些应对措施?
1. 星火燎原
1.1农民眼中的微服务
近年来,微服务就象一把燎原的大火,窜了出来并在整个技术社区烧了起来,微服务架构被认为是IT软件服务化架构演进的目标。为什么微服务这么火,微服务能给企业带来什么价值?
1.1.1以种植农作物的思想来理解微服务
我们以耕种为例来看如何充分利用一块田地的:
- 先在地里种植了一排排玉米;
- 后来发现玉米脚下空地可以利用,再间隔一段距离再种上豆角,豆角长大后顺着玉米杆往上爬,最后紧紧地缠绕在玉米杆上;
- 再后来发现每排玉米之间的空隙地还可以再种些土豆,土豆蔓藤以后会交织在一起,肆虐在玉米脚下吞食营养物质;
表面看来一块土地得到了充分利用,实际上各农作物得不到充分的光照和适宜的营养,如此一来加大了后期除草、松土、施肥、灌溉及收割的成本。
下面的耕植思路是不是更好点呢? 一整块地根据需要分配为若干大小土地块,每块地之间清晰分界,这样就有了玉米地、土豆地、豆角地,再想种什么划块地再耕作就可以了。
这样种植好处很多,比如玉米、豆角和土豆需要的营养物质是不一样的,可由专业技术人员施肥;玉米,豆角和土豆分离,避免豆角藤爬上玉米,缠绕玉米不能自由生长。土豆又汲取玉米需要的营养物质等等问题。
软件系统实现与农作物的种植方式其实也很类似,传统的应用在扩展性,可靠性,维护成本上表现都不尽人意。如何充分利用大量系统资源,管理和监控服务生命周期都是头疼的事情,软件系统设计迫切需要上述的“土地分割种植法”。微服务架构应运而生:在微服务系统中,各个业务系统间通过对消息(字符序列)的处理都非常友好的RestAPI进行消息交互。如此一来,各个业务系统根据Restful架构风格统一成一个有机系统。
1.2 微服务架构下的冰山
泰坦尼克号曾经是世界最大的客轮,在当时被称为是”永不沉没“的,但却在北大西洋撞上冰山而沉没。我们往往只看到它浮出水面的绚丽多彩,水下的基础设施如资源规划、服务注册发现、部署升级,灰度发布等都是需要考虑的因素。
1.2.1 优势
- 复杂应用分解:复杂的业务场景可被分解为多个业务系统,每个业务系统的每个服务都有一个用消息驱动API定义清楚的边界。
- 契约驱动:每个业务系统可自由选择技术,组建技术团队利用Mock服务提供者和消费者,并行开发,最终实现依赖解耦。
- 自由扩展:每个系统可根据业务需要独自进行扩展。
- 独立部署:每个业务系统互相独立,可根据实际需要部署到合适的硬件机器上。
- 良好隔离:一个业务系统资源泄漏不会导致整个系统宕掉,容错性较好。
1.2.2 面临的挑战
- 服务管理:敏捷迭代后的微服务可能越来越多,各个业务系统之间的交互也越来越多,如何做高效集群通信方案也是问题。
- 应用管理: 每个业务系统部署后对应着一个进程,进程可以启停。如果机器掉电或者宕机了,如何做无缝切换都需要强大的部署管理机制。
- 负载均衡:为应对大流量场景及提供系统可靠性,同一个业务系统也会做分布式部署即一个业务实例部署在多台机器上。如果某个业务系统挂掉了,如何按需做自动伸缩分布式方案方案也需要考虑。
- 问题定位:单体应用的日志集中在一起,出现问题定位很方便,而分布式环境的问题定界定位,日志分析都较为困难。
- 雪崩问题:分布式系统都存在这样一个问题,由于网络的不稳定性,决定了任何一个服务的可用性都不是 100% 的。当网络不稳定的时候,作为服务的提供者,自身可能会被拖死,导致服务调用者阻塞,最终可能引发雪崩效应。
Michael T. Nygard 在精彩的《Release It!》一书中总结了很多提高系统可用性的模式,其中非常重要的两条是:使用超时策略和使用熔断器机制。
- 超时策略:如果一个服务会被系统中的其它部分频繁调用,一个部分的故障可能会导致级联故障。例如,调用服务的操作可以配置为执行超时,如果服务未能在这个时间内响应,将回复一个失败消息。然而,这种策略可能会导致许多并发请求到同一个操作被阻塞,直到超时期限届满。这些阻塞的请求可能会存储关键的系统资源,如内存、线程、数据库连接等。因此,这些资源可能会枯竭,导致需要使用相同的资源系统的故障。在这种情况下,它将是优选的操作立即失败。设置较短的超时可能有助于解决这个问题,但是一个操作请求从发出到收到成功或者失败的消息需要的时间是不确定的。
- 熔断器模式:熔断器的模式使用断路器来检测故障是否已得到解决,防止请求反复尝试执行一个可能会失败的操作,从而减少等待纠正故障的时间,相对与超时策略更加灵活。
一年一度的双十一已经悄然来临,下面将介绍某购物网站一个Tomcat容器在高并发场景下的雪崩效应来探讨Hystrix的线程池隔离技术和熔断器机制。
2. 从雪崩看应用防护
2.1 雪崩问题的本质:Servlet Container在高并发下崩溃
我们先来看一个分布式系统中常见的简化的模型。Web服务器中的Servlet Container,容器启动时后台初始化一个调度线程,负责处理Http请求,然后每个请求过来调度线程从线程池中取出一个工作者线程来处理该请求,从而实现并发控制的目的。
Servlet Container是我们的容器,如Tomcat。一个用户请求有可能依赖其它多个外部服务。考虑到应用容器的线程数目基本都是固定的(比如Tomcat的线程池默认200),当在高并发的情况下,如果某一外部依赖的服务(第三方系统或者自研系统出现故障)超时阻塞,就有可能使得整个主线程池被占满,增加内存消耗,这是长请求拥塞反模式(一种单次请求时延变长而导致系统性能恶化甚至崩溃的恶化模式)。
更进一步,如果线程池被占满,那么整个服务将不可用,就又可能会重复产生上述问题。因此整个系统就像雪崩一样,最终崩塌掉。
2.2 雪崩效应产生的几种场景
- 流量激增:比如异常流量、用户重试导致系统负载升高;
- 缓存刷新:假设A为client端,B为Server端,假设A系统请求都流向B系统,请求超出了B系统的承载能力,就会造成B系统崩溃;
- 程序有Bug:代码循环调用的逻辑问题,资源未释放引起的内存泄漏等问题;
- 硬件故障:比如宕机,机房断电,光纤被挖断等。
- 线程同步等待:系统间经常采用同步服务调用模式,核心服务和非核心服务共用一个线程池和消息队列。如果一个核心业务线程调用非核心线程,这个非核心线程交由第三方系统完成,当第三方系统本身出现问题,导致核心线程阻塞,一直处于等待状态,而进程间的调用是有超时限制的,最终这条线程将断掉,也可能引发雪崩;
2.3 雪崩效应的常见解决方案
针对上述雪崩情景,有很多应对方案,但没有一个万能的模式能够应对所有场景。
- 针对流量激增,采用自动扩缩容以应对突发流量,或在负载均衡器上安装限流模块。
- 针对缓存刷新,参考Cache应用中的服务过载案例研究
- 针对硬件故障,多机房容灾,跨机房路由,异地多活等。
- 针对同步等待,使用Hystrix做故障隔离,熔断器机制等可以解决依赖服务不可用的问题。
通过实践发现,线程同步等待是最常见引发的雪崩效应的场景,本文将重点介绍使用Hystrix技术解决服务的雪崩问题。后续再分享流量激增和缓存刷新等应对方案。
3. 隔离和熔断
Hystrix 是由Netflix发布,旨在应对复杂分布式系统中的延时和故障容错,基于Apache License 2.0协议的开源的程序库,目前托管在GitHub上。
Hystrix采用了命令模式,客户端需要继承抽象类HystrixCommand并实现其特定方法。为什么使用命令模式呢?使用过RPC框架都应该知道一个远程接口所定义的方法可能不止一个,为了更加细粒度的保护单个方法调用,命令模式就非常适合这种场景。
命令模式的本质就是分离方法调用和方法实现,在这里我们通过将接口方法抽象成HystricCommand的子类,从而获得安全防护能力,并使得的控制力度下沉到方法级别。
Hystrix核心设计理念基于命令模式,命令模式UML如下图:
可见,Command是在Receiver和Invoker之间添加的中间层,Command实现了对Receiver的封装。那么Hystrix的应用场景如何与上图对应呢?
API既可以是Invoker又可以是Reciever,通过继承Hystrix核心类HystrixCommand来封装这些API(例如,远程接口调用,数据库的CRUD操作可能会产生延时),就可以为API提供弹性保护了。
3.1 资源隔离模式
Hystrix之所以能够防止雪崩的本质原因,是其运用了资源隔离模式,我们可以用蓄水池做比喻来解释什么是资源隔离。生活中一个大的蓄水池由一个一个小的池子隔离开来,这样如果某一个水池的水被污染,也不会波及到其它蓄水池,如果只有一个蓄水池,水池被污染,整池水都不可用了。软件资源隔离如出一辙,如果采用资源隔离模式,将对远程服务的调用隔离到一个单独的线程池后,若服务提供者不可用,那么受到影响的只会是这个独立的线程池。
(1)线程池隔离模式:使用一个线程池来存储当前的请求,线程池对请求作处理,设置任务返回处理超时时间,堆积的请求堆积入线程池队列。这种方式需要为每个依赖的服务申请线程池,有一定的资源消耗,好处是可以应对突发流量(流量洪峰来临时,处理不完可将数据存储到线程池队里慢慢处理)。这个大家都比较熟悉,参考Java自带的ThreadPoolExecutor线程池及队列实现。线程池隔离参考下图:
线程隔离的优点:
- 请求线程与依赖代码的执行线程可以完全隔离第三方代码;
- 当一个依赖线程由失败变成可用时,线程池将清理后并立即恢复可用;
- 线程池可设置大小以控制并发量,线程池饱和后可以拒绝服务,防止依赖问题扩散。
线程隔离的缺点:
- 增加了处理器的消耗,每个命令的执行涉及到排队(默认使用SynchronousQueue避免排队)和调度;
- 增加了使用ThreadLocal等依赖线程状态的代码复杂性,需要手动传递和清理线程状态。
(2)信号量隔离模式:使用一个原子计数器来记录当前有多少个线程在运行,请求来先判断计数器的数值,若超过设置的最大线程个数则丢弃该类型的新请求,若不超过则执行计数操作请求来计数器+1,请求返回计数器-1。这种方式是严格的控制线程且立即返回模式,无法应对突发流量(流量洪峰来临时,处理的线程超过数量,其他的请求会直接返回,不继续去请求依赖的服务),参考Java的信号量的用法。
Hystrix默认采用线程池隔离机制,当然用户也可以配置 HystrixCommandProperties为隔离策略为ExecutionIsolationStrategy.SEMAPHORE。
信号隔离的特点:
- 信号隔离与线程隔离最大不同在于执行依赖代码的线程依然是请求线程,该线程需要通过信号申请;
- 如果客户端是可信的且可以快速返回,可以使用信号隔离替换线程隔离,降低开销。
线程池隔离和信号隔离的区别见下图,使用线程池隔离,用户请求了15条线程,10条线程依赖于A线程池,5条线程依赖于B线程池;如果使用信号量隔离,请求到C客户端的信号量若设置了15,那么图中左侧用户请求的10个信号与右边的5个信号量需要与设置阈值进行比较,小于等于阈值则执行,否则直接返回。
建议使用的场景:根据请求服务级别划分不同等级业务线程池,甚至可以将核心业务部署在独立的服务器上。
3.2 熔断器机制
熔断器与家里面的保险丝有些类似,当电流过大时,保险丝自动熔断以保护我们的电器。假设在没有熔断器机制保护下,我们可能会无数次的重试,势必持续加大服务端压力,造成恶性循环;如果直接关闭重试功能,当服务端又可用的时候,我们如何恢复?
熔断器正好适合这种场景:当请求失败比率(失败/总数)达到一定阈值后,熔断器开启,并休眠一段时间,这段休眠期过后熔断器将处与半开状态(half-open),在此状态下将试探性的放过一部分流量(Hystrix只支持single request),如果这部分流量调用成功后,再次将熔断器闭合,否则熔断器继续保持开启并进入下一轮休眠周期。
建议使用场景:Client端直接调用远程的Server端(server端由于某种原因不可用,从client端发出请求到server端超时响应之间占用了系统资源,如内存,数据库连接等)或共享资源。
不建议的场景如下:
- 应用程序直接访问如内存中的数据,若使用熔断器模式只会增加系统额外开销。
- 作为业务逻辑的异常处理替代品。
总结思考
本文从自己曾经开发的项目应用的分布式架构引出服务的雪崩效应,进而引出Hystrix(当然了,Hystrix还有很多优秀的特性,如缓存,批量处理请求,主从分担等,本文主要介绍了资源隔离和熔断)。主要分三部分进行说明:
第一部分:以耕种田地的思想引出软件领域设计的微服务架构, 简单的介绍了其优点,着重介绍面临的挑战:雪崩问题。
第二部分:以Tomcat Container在高并发下崩溃为例揭示了雪崩产生的过程,进而总结了几种诱发雪崩的场景及各种场景的应对解决方案,针对同步等待引出了Hystrix框架。
第三部分:介绍了Hystrix背景,资源隔离(总结了线程池和信号量特点)和熔断机制工作过程,并总结各自使用场景。
如Martin Fowler 在其文中所说,尽管微服务架构未来需要经历时间的检验,但我们已经走在了微服务架构转型的道路上,对此我们可以保持谨慎的乐观,这条路依然值得去探索。