随着服务框架和服务治理体系的逐步成熟,服务化已成为系统设计的趋势。随着业务复杂度的增长,依赖的服务也逐步增长,出现了很多因为服务调用出现异常问题而致使的重大事故,如:
1)系统依赖的某个服务发生延迟或者故障,数秒内致使全部应用资源(线程,队列等)被耗尽,形成所谓的雪崩效应 (Cascading Failure),致使整个系统拒绝对外提供服务。
2)系统遭受恶意爬虫袭击,在放大效应下没有对下游依赖服务作好限速处理,最终致使下游服务崩溃。
容错是一个很大的话题,受篇幅所限,本文将介绍仅限定在服务调用间经常使用的一些容错模式。html
服务容错的设计有个基本原则,就是“Design for Failure”。为了不出现“千里之堤溃于蚁穴”这种状况,在设计上须要考虑到各类边界场景和对于服务间调用出现的异常或延迟状况,同时在设计和编程时也要考虑周到。这一切都是为了达到如下目标:
1)一个依赖服务的故障不会严重破坏用户的体验。
2)系统能自动或半自动处理故障,具有自我恢复能力。
基于这个原则和目标,衍生出下文将要介绍的一些模式,可以解决分布式服务调用中的一些问题,提升系统在故障发生时的存活能力。java
所谓模式,其实就是某种场景下一类问题及其解决方案的总结概括,每每能够重用。模式能够指导咱们完成任务,做出合理的系统设计方案,达到事半功倍的效果。而在服务容错这个方向,行业内已经有了很多实践总结出来的解决方案。web
超时模式
是一种最多见的容错模式,在工程实践中大量存在。常见的有设置网络链接超时时间,一次RPC的响应超时时间等。在分布式服务调用的场景中,它主要解决了当依赖服务出现创建网络链接或响应延迟,不用无限等待的问题,调用方能够根据事先设计的超时时间中断调用,及时释放关键资源,如Web容器的链接数,数据库链接数等,避免整个系统资源耗尽出现拒绝对外提供服务这种状况。算法
重试模式
通常和超时模式结合使用,适用于对于下游服务的数据强依赖的场景(不强依赖的场景不建议使用!),经过重试来保证数据的可靠性或一致性,经常使用于因网络抖动等致使服务调用出现超时的场景。与超时时间设置结合使用后,须要考虑接口的响应时间分布状况,超时时间能够设置为依赖服务接口99.5%响应时间的值,重试次数通常1-2次为宜,不然会致使请求响应时间延长,拖累到整个系统。数据库
一些实现说明:编程
public class RetryCommand<T> { private int maxRetries = 2;// 重试次数 默认2次 private long retryInterval = 5;//重试间隔时间ms 默认5ms private Map<String, Object> params; public RetryCommand() { } public RetryCommand(long retryInterval, int maxRetries) { this.retryInterval = retryInterval; this.maxRetries = maxRetries; } public T command(Map<String, Object> params){ //Some remote service call with timeout serviceA.doSomethingWithTimeOut(timeout); } private final T retry() throws RuntimeException { int retryCounter = 0; while (retryCounter < maxRetries) { try { return command(params); } catch (Exception e) { retryCounter++; if (retryCounter >= maxRetries) { break; } } } throw new RuntimeException("Command failed on all of " + maxRetries + " retries"); } //省略 }
public class RetryCommand<T> { private int maxRetries = 2;// 重试次数 默认2次 private long retryInterval = 5;//重试间隔时间ms 默认5ms private Map<String, Object> params; public RetryCommand() { } public RetryCommand(long retryInterval, int maxRetries) { this.retryInterval = retryInterval; this.maxRetries = maxRetries; } public T command(Map<String, Object> params){ //Some remote service call with timeout serviceA.doSomethingWithTimeOut(timeout); } private final T retry() throws RuntimeException { int retryCounter = 0; while (retryCounter < maxRetries) { try { return command(params); } catch (Exception e) { retryCounter++; if (retryCounter >= maxRetries) { break; } } } throw new RuntimeException("Command failed on all of " + maxRetries + " retries"); } //省略 }
限流模式,经常使用于下游服务容量有限,但又怕出现突发流量猛增(如恶意爬虫,节假日大促等)而致使下游服务因压力过大而拒绝服务的场景。常见的限流模式有控制并发和控制速率,一个是限制并发的数量,一个是限制并发访问的速率。缓存
public class SemaphoreTest { private static final int THREAD_COUNT = 30; private static ExecutorService threadPool = Executors .newFixedThreadPool(THREAD_COUNT); private static Semaphore s = new Semaphore(10); public static void main(String[] args) { for (int i = 0; i < THREAD_COUNT; i++) { threadPool.execute(new Runnable() { @Override public void run() { try { s.acquire(); System.out.println("save data"); s.release(); } catch (InterruptedException e) { e.printStack(); } } }); } threadPool.shutdown(); } }
public class SemaphoreTest { private static final int THREAD_COUNT = 30; private static ExecutorService threadPool = Executors .newFixedThreadPool(THREAD_COUNT); private static Semaphore s = new Semaphore(10); public static void main(String[] args) { for (int i = 0; i < THREAD_COUNT; i++) { threadPool.execute(new Runnable() { @Override public void run() { try { s.acquire(); System.out.println("save data"); s.release(); } catch (InterruptedException e) { e.printStack(); } } }); } threadPool.shutdown(); } }
在代码中,虽然有30个线程在执行,可是只容许10个并发的执行。Semaphore的构造方法Semaphore(int permits) 接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示容许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用Semaphore的acquire()获取一个许可证,使用完以后调用release()归还许可证,还能够用tryAcquire()方法尝试获取许可证。网络
在Wikipedia上,令牌桶算法是这么描述的:并发
令牌桶控制的是一个时间窗口内经过的数据量,在API层面咱们常说的QPS、TPS,正好是一个时间窗口内的请求量或者事务量,只不过期间窗口限定在1s罢了。 以一个恒定的速度往桶里放入令牌,而若是请求须要被处理,则须要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。令牌桶的另一个好处是能够方便的改变速度,一旦须要提升速率,则按需提升放入桶中的令牌的速率。框架
在咱们的工程实践中,一般使用Guava中的Ratelimiter来实现控制速率,如咱们不但愿每秒的任务提交超过两个:
//速率是每秒两个许可 final RateLimiter rateLimiter = RateLimiter.create(2.0); void submitTasks(List tasks, Executor executor) { for (Runnable task : tasks) { rateLimiter.acquire(); // 也许须要等待 executor.execute(task); } }
//速率是每秒两个许可 final RateLimiter rateLimiter = RateLimiter.create(2.0); void submitTasks(List tasks, Executor executor) { for (Runnable task : tasks) { rateLimiter.acquire(); // 也许须要等待 executor.execute(task); } }
在咱们的工程实践中,偶尔会遇到一些服务因为网络链接超时,系统有异常或load太高出现暂时不可用等状况,致使对这些服务的调用失败,可能须要一段时间才能修复,这种对请求的阻塞可能会占用宝贵的系统资源,如:内存,线程,数据库链接等等,最坏的状况下会致使这些资源被消耗殆尽,使得系统里不相关的部分所使用的资源也耗尽从而拖累整个系统。在这种状况下,调用操做可以当即返回错误而不是等待超时的发生或者重试多是一种更好的选择,只有当被调用的服务有可能成功时咱们再去尝试。
熔断器模式能够防止咱们的系统不断地尝试执行可能会失败的调用,使得咱们的系统继续执行而不用等待修正错误,或者浪费CPU时间去等到长时间的超时产生。熔断器模式也可使咱们系统可以检测错误是否已经修正,若是已经修正,系统会再次尝试调用操做。 下图是个使用熔断器模式的调用流程:
能够从图中看出,当超时出现的次数达到必定条件后,熔断器会触发打开状态,客户端的下次调用将直接返回,不用等待超时产生。
在熔断器内部,每每有如下几种状态:
1)闭合(closed)状态:该状态下可以对目标服务或方法进行正常的调用。熔断器类维护了一个时间窗口内调用失败的次数,若是某次调用失败,则失败次数加1。若是最近失败次数超过了在给定的时间窗口内容许失败的阈值(能够是数量也能够是比例),则熔断器类切换到断开(Open)状态。此时熔断器设置了一个计时器,当时钟超过了该时间,则切换到半断开(Half-Open)状态,该睡眠时间的设定是给了系统一次机会来修正致使调用失败的错误。
2)断开(Open)状态:在该状态下,对目标服务或方法的请求会当即返回错误响应,若是设置了fallback方法,则会进入fallback的流程。
3)半断开(Half-Open)状态:容许对目标服务或方法的必定数量的请求能够去调用服务。 若是这些请求对服务的调用成功,那么能够认为以前致使调用失败的错误已经修正,此时熔断器切换到闭合状态(而且将错误计数器重置);若是这必定数量的请求有调用失败的状况,则认为致使以前调用失败的问题仍然存在,熔断器切回到断开方式,而后开始重置计时器来给系统必定的时间来修正错误。半断开状态可以有效防止正在恢复中的服务被忽然而来的大量请求再次拖垮。
在咱们的工程实践中,熔断器模式每每应用于服务的自动降级,在实现上主要基于Netflix开源的组件Hystrix来实现,下图和代码分别是Hystrix中熔断器的原理和定义,更多了解能够查看Hystrix的源码:
public interface HystrixCircuitBreaker { /** * Every {@link HystrixCommand} requests asks this if it is allowed to proceed or not. * <p> * This takes into account the half-open logic which allows some requests through when determining if it should be closed again. * * @return boolean whether a request should be permitted */ public boolean allowRequest(); /** * Whether the circuit is currently open (tripped). * * @return boolean state of circuit breaker */ public boolean isOpen(); /** * Invoked on successful executions from {@link HystrixCommand} as part of feedback mechanism when in a half-open state. */ public void markSuccess(); }
public interface HystrixCircuitBreaker { /** * Every {@link HystrixCommand} requests asks this if it is allowed to proceed or not. * <p> * This takes into account the half-open logic which allows some requests through when determining if it should be closed again. * * @return boolean whether a request should be permitted */ public boolean allowRequest(); /** * Whether the circuit is currently open (tripped). * * @return boolean state of circuit breaker */ public boolean isOpen(); /** * Invoked on successful executions from {@link HystrixCommand} as part of feedback mechanism when in a half-open state. */ public void markSuccess(); }
在造船行业,每每使用此类模式对船舱进行隔离,利用舱壁将不一样的船舱隔离起来,这样若是一个船舱破了进水,只损失一个船舱,其它船舱能够不受影响,而借鉴造船行业的经验,这种模式也在软件行业获得使用。
线程隔离(Thread Isolation)就是这种模式的常见的一个场景。例如,系统A调用了ServiceB/ServiceC/ServiceD三个远程服务,且部署A的容器一共有120个工做线程,采用线程隔离机制,能够给对ServiceB/ServiceC/ServiceD的调用各分配40个线程。当ServiceB慢了,给ServiceB分配的40个线程因慢而阻塞并最终耗尽,线程隔离能够保证给ServiceC/ServiceD分配的80个线程能够不受影响。若是没有这种隔离机制,当ServiceB慢的时候,120个工做线程会很快所有被对ServiceB的调用吃光,整个系统会所有慢下来,甚至出现系统中止响应的状况。
这种Case在咱们实践中常常遇到,如某接口因为数据库慢查询,外部RPC调用超时致使整个系统的线程数太高,链接数耗尽等。咱们可使用舱壁隔离模式,为这种依赖服务调用维护一个小的线程池,当一个依赖服务因为响应慢致使线程池任务满的时候,不会影响到其余依赖服务的调用,它的缺点就是会增长线程数。
不管是超时/重试,熔断器,仍是舱壁隔离模式,它们在使用过程当中都会出现异常状况,异常状况的处理方式间接影响到用户的体验,针对异常状况的处理也有一种模式支撑,这就是回退(fallback)模式。
在超时,重试失败,熔断或者限流发生的时候,为了及时恢复服务或者不影响到用户体验,须要提供回退的机制,常见的回退策略有:
自定义处理:在这种场景下,可使用默认数据,本地数据,缓存数据来临时支撑,也能够将请求放入队列,或者使用备用服务获取数据等,适用于业务的关键流程与严重影响用户体验的场景,如商家/产品信息等核心服务。
故障沉默(fail-silent):直接返回空值或缺省值,适用于可降级功能的场景,如产品推荐之类的功能,数据为空也不太影响用户体验。
快速失败(fail-fast):直接抛出异常,适用于数据非强依赖的场景,如非核心服务超时的处理。
在实际的工程实践中,这四种模式既能够单独使用,也能够组合使用,为了让读者更好的理解这些模式的应用,下面以Netflix的开源组件Hystrix的流程为例说明。
图中流程的说明:
服务容错模式在系统的稳定性保障方面应用不少,学习模式有助于新人直接利用熟练软件工程师的经验,对于提高系统的稳定性有很大的帮助。服务容错的目的主要是为了防微杜渐,除此以外错误的及时发现和监控其实同等重要。随着技术的演化,新的模式在不断的学习与实践中沉淀出来,在构建一个高可用高性能的系统目标以外,让系统愈来愈有弹性(Resilience)也是咱们新的追求。
参考:
https://tech.meituan.com/service_fault_tolerant_pattern.html
Netflix Hystrix Wiki(https://martinfowler.com/bliki/CircuitBreaker.html)
Martin Fowler. CircuitBreaker(https://martinfowler.com/bliki/CircuitBreaker.html) Hanmer R. Patterns for Fault Tolerant Software. Wiley, 2007. Nygard M. 发布!软件的设计与部署. 凃鸣 译. 人民邮电出版社, 2015.