前言
微服务架构以其轻量级、易扩展、稳定性高等特点,近几年受到了热烈的追捧,从互联网项目到企业级系统,都纷纷开始采用微服务架构。一般情况下,我们会按照业务对服务进行拆分,再通过接口实现服务之间的相互调用,从而实现服务的独立部署和维护。
但随着服务数量的增多,也会遇到一些单体服务中不会出现的问题,服务雪崩就是其中的典型。今天我们就来聊一下服务雪崩产生的原因及其防治方法。
服务雪崩
定义
服务雪崩产生于服务堆积在同一个线程池中,因为所有的请求都是同一个线程池进行处理,这时候如果在高并发情况下,所有的请求全部访问同一个接口,这时候可能会导致其他服务没有线程进行接受请求,这就是服务雪崩。
产生过程
下面我们以五个服务为例(调用关系如下图所示),演示一下服务雪崩发生的过程:
第一阶段:服务E由于故障或负载过高,无法及时向上游服务C和D返回响应,服务C和D的请求线程处于等待状态;
第二阶段:服务C和D收不到E的响应,开始加大重试,同时又收到上游服务请求,导致更多的线程处于等待状态,最终线程池被耗尽,此时服务C和D也处于无法服务状态。
第三阶段:随着时间的推移,这种服务不可用的影响被逐渐放大,由于相同的原因,服务A和B也因为线程耗尽而无法对上游提供服务,于是整个调用链路崩溃,服务出现大面积瘫痪,服务雪崩就形成了。
产生原因
服务雪崩的每个阶段都可能由不同的原因造成, 比如造成服务不可用的原因有:
- 硬件故障
硬件故障可能为硬件损坏造成的服务器主机宕机,网络硬件故障造成的服务提供者的不可访问。 - 缓存击穿
缓存击穿一般发生在缓存应用重启, 所有缓存被清空时,以及短时间内大量缓存失效时大量的缓存不命中,使请求直击后端,造成服务提供者超负荷运行,引起服务不可用。 - 用户大量请求
在秒杀和大促开始前,如果准备不充分,用户发起大量请求也会造成服务提供者的不可用。在服务提供者不可用后,用户由于忍受不了界面上长时间的等待,而不断刷新页面甚至提交表单,导致后端服务压力雪上加霜。服务调用端的会存在大量服务异常后的重试逻辑。 - 程序BUG
如程序逻辑导致内存泄漏,JVM长时间Full GC等。 - 同步等待
服务间采用同步调用模式,同步等待造成的资源耗尽。
解决方案
雪崩效应之所以这么被重视是因为它极容易在被人们忽视的情况下发生,对微服务而言,服务实例成百上千,我们很难一个个服务地检查以保证每个服务的质量,并且很多情况下只有在达到一定压力问题才会暴露,常规的代码Reivew或是针对单个服务的压力测试未必可以发现问题,再则这些依赖服务未必都是我们自己的服务,如果说我们自己服务尚有一定的排查优化方法的话那么对三方服务依赖而言那几乎只能是凭经验了,只要我的依赖服务中存在一处不起眼的Bug,或是过少的连接池配置,抑或是网络波动都有可能引发雪崩。
怎么有效地避免呢?
服务隔离
从服务雪崩的过程我们可以知道,服务雪崩的根本原因是长期等待下游服务接口响应,而导致线程池被耗尽,从而无法处理上游请求。如果为每个下游服务接口创建一个独立的线程池,每个线程池互不影响,当某个下游服务接口无法响应时,只有其对应的线程池被耗尽,其他服务接口并不受到影响。
流量控制
当发现服务失败数量达到某个阈值,拒绝访问,限制更多流量的到来,防止过多失败的请求将资源耗尽。
缓存
对下游服务正常响应的数据进行缓存,之后一段时间内直接向上游返回缓存中的数据。这样可以有效降低对下游服务质量的敏感度,在一定程度上提升服务的稳定性。
服务熔断
当下游的服务因为某种原因突然变得不可用或响应过慢,上游服务为了保证自己整体服务的可用性,当目标服务超过设定最长等待时间未响应时,直接终止等待,快速释放资源。如果目标服务情况好转则恢复调用。
服务降级
在高并发情况下,为防止用户一直等待,可以使用服务降级的方式:对于简单的展示功能,如果有失败的请求,返回默认值;对于整个站点或客户端,如果服务器负载过高,将其他非核心业务停止,以让出更多资源给其他服务使用。
Hystrix
前面介绍了服务隔离、流量控制、缓存、熔断降级等解决方案,很多小伙伴可能感觉到头大:理想很丰满,现实很骨感,手头的需求都做不完,更别说再花时间去实现这些高大上的技术方案了。
很幸运的是,得益于伟大的开源精神,Netflix为我们带来了一款开源的基于Java语言开发的服务雪崩预防神器:Hystrix。
介绍
Hystrix的中文含义是豪猪, 因其背上长满了刺,而拥有自我保护能力。 Hystrix 是一个通过增加延迟容错和容错逻辑来控制分布式服务之间交互的一个库。Hystrix通过线程隔离,防止错误级联传递,导致服务雪崩,从而提高服务稳定性。
Hystrix的主要目标
- 通过隔离第三方客户端库访问依赖关系,防止和控制延迟和故障;
- 防止复杂分布式系统的级联失败;
- 快速响应失败并迅速恢复;
- 提供回滚以及友好降级;
- 实现近实时监控,告警和操作控制
Hystrix设计原则
- 防止单个依赖耗尽了服务容器的用户线程
- 降低负载以及快速失败,而不是排队
- 当可以阻止服务的失败时提供回退策略
- 使用隔离技术减少任意依赖的影响
- 通过近实时指标、监控和告警优化发现时间
- 在Hystrix的大多数方面,通过配置更改的低延迟和对动态属性更改的支持,使得可以在低延迟的情况下进行实时修改操作,从而优化恢复时间
- 防止整个依赖关系客户端执行中的故障,而不仅仅是网络流量
Hystrix如何做到上面的目标
- 所有外部的调用都封装到HystrixCommand或HystrixObservableCommand对象,这些对象通常在单独的线程下执行。
- 超时调用的时间,超过定义的阈值。有一个默认值,但是对于大多数的依赖,你可以自定义该属性使得略高于每个依赖测量的99.5%的性能。
- 为每一个依赖项维护一个线程池(或者信号),如果依赖项的线程池满了,新的依赖请求不会继续排队等待,而是马上被拒绝访问。
- 计算成功、失败、超时和线程拒绝的数量。
- 如果依赖服务的失败百分比超过阈值,则手动或自动启动断路器,在一段时间内停止对指定服务的所有请求。
- 为请求失败、被拒绝、超时或短路情况提供回退逻辑。
- 近乎实时地监控指标和配置更改。
Hystrix处理流程
Hystrix整个工作流如下:
- 构造一个 HystrixCommand或HystrixObservableCommand对象,用于封装请求,并在构造方法配置请求被执行需要的参数;
- 执行命令,Hystrix提供了4种执行命令的方法,后面详述;
- 判断是否使用缓存响应请求,若启用了缓存,且缓存可用,直接使用缓存响应请求。Hystrix支持请求缓存,但需要用户自定义启动;
- 判断熔断器是否打开,如果打开,跳到第8步;
- 判断线程池/队列/信号量是否已满,已满则跳到第8步;
- 执行HystrixObservableCommand.construct()或HystrixCommand.run(),如果执行失败或者超时,跳到第8步;否则,跳到第9步;
- 统计熔断器监控指标;
- 走Fallback备用逻辑
- 返回请求响应
从流程图上可知道,第5步线程池/队列/信号量已满时,还会执行第7步逻辑,更新熔断器统计信息,而第6步无论成功与否,都会更新熔断器统计信息。
Hystrix简单使用
第一步,继承HystrixCommand实现自己的command,在command的构造方法中需要配置请求被执行需要的参数,并组合实际发送请求的对象,代码如下:
public class QueryOrderIdCommand extends HystrixCommand<Integer> {
private final static Logger logger = LoggerFactory.getLogger(QueryOrderIdCommand.class);
private OrderServiceProvider orderServiceProvider;
public QueryOrderIdCommand(OrderServiceProvider orderServiceProvider) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("orderService"))
.andCommandKey(HystrixCommandKey.Factory.asKey("queryByOrderId"))
.andCommandPropertiesDefaults(HystrixCommandProperties.Setter()
.withCircuitBreakerRequestVolumeThreshold(10)//至少有10个请求,熔断器才进行错误率的计算
.withCircuitBreakerSleepWindowInMilliseconds(5000)//熔断器中断请求5秒后会进入半打开状态,放部分流量过去重试
.withCircuitBreakerErrorThresholdPercentage(50)//错误率达到50开启熔断保护
.withExecutionTimeoutEnabled(true))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties
.Setter().withCoreSize(10)));
this.orderServiceProvider = orderServiceProvider;
}
@Override
protected Integer run() {
return orderServiceProvider.queryByOrderId();
}
@Override
protected Integer getFallback() {
return -1;
}
}
第二步,调用HystrixCommand的执行方法发起实际请求。
@Test
public void testQueryByOrderIdCommand() {
Integer r = new QueryOrderIdCommand(orderServiceProvider).execute();
logger.info("result:{}", r);
}
执行命令的几种方法
Hystrix提供了4种执行命令的方法,execute()和queue() 适用于HystrixCommand对象,而observe()和toObservable()适用于HystrixObservableCommand对象。
execute()
以同步堵塞方式执行run(),只支持接收一个值对象。hystrix会从线程池中取一个线程来执行run(),并等待返回值。
queue()
以异步非阻塞方式执行run(),只支持接收一个值对象。调用queue()就直接返回一个Future对象。可通过 Future.get()拿到run()的返回结果,但Future.get()是阻塞执行的。若执行成功,Future.get()返回单个返回值。当执行失败时,如果没有重写fallback,Future.get()抛出异常。
observe()
事件注册前执行run()/construct(),支持接收多个值对象,取决于发射源。调用observe()会返回一个hot Observable,也就是说,调用observe()自动触发执行run()/construct(),无论是否存在订阅者。
如果继承的是HystrixCommand,hystrix会从线程池中取一个线程以非阻塞方式执行run();如果继承的是HystrixObservableCommand,将以调用线程阻塞执行construct()。
observe()使用方法:
- 调用observe()会返回一个Observable对象
- 调用这个Observable对象的subscribe()方法完成事件注册,从而获取结果
toObservable()
事件注册后执行run()/construct(),支持接收多个值对象,取决于发射源。调用toObservable()会返回一个cold Observable,也就是说,调用toObservable()不会立即触发执行run()/construct(),必须有订阅者订阅Observable时才会执行。
如果继承的是HystrixCommand,hystrix会从线程池中取一个线程以非阻塞方式执行run(),调用线程不必等待run();如果继承的是HystrixObservableCommand,将以调用线程堵塞执行construct(),调用线程需等待construct()执行完才能继续往下走。
toObservable()使用方法:
- 调用observe()会返回一个Observable对象
- 调用这个Observable对象的subscribe()方法完成事件注册,从而获取结果
需注意的是,HystrixCommand也支持toObservable()和observe(),但是即使将HystrixCommand转换成Observable,它也只能发射一个值对象。只有HystrixObservableCommand才支持发射多个值对象。
几种方法的关系
- execute()实际是调用了queue().get()
- queue()实际调用了toObservable().toBlocking().toFuture()
- observe()实际调用toObservable()获得一个cold Observable,再创建一个ReplaySubject对象订阅Observable,将源Observable转化为hot Observable。因此调用observe()会自动触发执行run()/construct()。
Hystrix总是以Observable的形式作为响应返回,不同执行命令的方法只是进行了相应的转换。