服务容错保护——Spring Cloud Hystrix

(git上的源码:https://gitee.com/rain7564/spring_microservices_study/tree/master/forth-spring-cloud-hystrix)

几乎每一个系统,特别是分布式系统,都会有调用失败的情况,最有效的办法是通过提升代码的质量减少错误的次数,但有些情况确实无法避免的,比如远程服务不可用了,从而影响接下来的业务,所以在应用出现错误的时候,如何更好的应对这些错误是整个应用中非常重要的部分。然而,在搭建一个弹性系统时,大多数软件工程师只在整个架构中的一部分或一些关键服务有实现这些逻辑;他们会把注意力集中在应用各层中做数据冗余,比如使用集群服务器、负载均衡或者将架构中的多个部分隔离到多个地方。这些方法虽然考虑到了将系统组件化,但也只考虑了搭建弹性系统需要考虑的一部分。这些实现,当一个服务宕掉,可以发现该服务并避开,但当一个只是运行慢而还没有宕掉,则很难发现并避开这个服务,直到该服务彻底宕掉。因为:

  1. 一般情况下,只要服务并没有完全不可用,其它服务还是会继续消费该服务。
  2. 远程调用一般都是同步的,并且很难中断。开发者一般都是调用一个远程调用然后等待远程服务的返回,并没有设置timeout来避免调用因长时间没有返回被挂起。
  3. 服务处理能力弱化,有很大概率会出现雪崩效应。假设有ServiceA、ServiceB、ServiceC,ServiceA调用ServiceB,ServiceB调用ServiceC,当ServiceC处理能力降低,而ServiceB照常一直发送请求给ServiceC,但一直得不到返回,所以被挂起的远程调用越来越多,直到ServiceB所在容器的资源被大量消耗,ServiceB处理能力也变弱了,ServiceB处理能力变弱也会影响到ServiceA以及其他消费ServiceB的服务,直到这些服务也因为资源被消耗变得不可用,最终整个系统瘫痪。

综上,性能差的远程服务带来的潜在问题是它们不仅难以检测,还能引发连锁反应。在整个应用程序生态系统中,若没有保障措施,一个单一的性能差的服务可能会迅速影响到多个应用。基于云计算或微服务的应用程序特别容易受到这种问题的影响,因为这些应用程序是由大量的细粒度的,使用不同的服务来完成用户的服务。

什么是客户端弹性机制(client-side resiliency patterns)

客户端弹性机制的作用主要是,当远程资源(远程调用或者数据库访问)宕掉或处理能力弱时,能降低消费方受到的影响,避免也因此宕掉。这些机制的目标是让消费方能“快速结束调用(fail fast)”,不会大量占用可用资源,如数据库连接、线程池等,并避免受到上游服务的影响。客户端弹性机制包括:

  • Client-side load balancing:客户端负载均衡
  • Circuit breaker:熔断器
  • FallBack:回退机制
  • Bulkhead:舱壁机制
    下图展示了这些模式在微服务中起的作用:


    客户端弹性机制

这些机制都是在服务客户端实现的,逻辑上,这些实现是处于资源消费方与资源之间。

Client-side load balancing——客户端负载均衡

关于客户端负载均衡,前两章介绍服务发现(Netflix Eureka)时已经介绍过。
客户端负载均衡的机制是:客户端从服务发现代理(如Netflix Eureka)获取其他服务实例的IP等位置信息,然后缓存起来并定时刷新。当服务消费端需要调用其他服务,客户端负载均衡器会从维护的可用服务实例池返回目标服务的一个可用实例的具体位置,从而消费端能够准确定位并访问。

因为客户端负载均衡器处在服务客户端和服务消费端之间(客户端可以理解为服务;消费端则为服务的某个方法,该方法中调用了远程接口,即该方法消费了远程资源),所以负载均衡器能检测到哪个远程服务实例抛异常或者处理能力弱,若检测到,则会将该可能已经出问题的服务实例从可用服务实例池中移除,避免再次访问。

Netflix的Ribbon库就实现了这样的机制,能开箱即用而不用额外的配置。因为在服务发现已经介绍过Netflix Ribbon的基本使用方法,这里就不过多赘述。

Circuit breaker——熔断器

Circuit breaker机制的作用与电气领域中的熔断器原理类似。在电气系统中,熔断器能检测线路中的电流是否过载,若已过载熔断器则能够切断线路连接,从而保证熔断器下游的线路不会因电流过高被损坏甚至烧毁。

而这里所说的Circuit breaker机制的作用是,当访问远程服务时,熔断器能监控这些访问。如果这些访问在规定的timeout内没有得到响应,熔断器会强制打断此次访问。另外,熔断器还会监控所有访问某一资源的远程访问(这里的"所有",可以是相同服务的不同实例,也可以是不同服务的不同实例,只要调用了同一个接口;重点在访问的是同一资源),如果在规定的时间内失败次数超过一个阈值,熔断器就会起作用,即会阻止该资源的消费者继续消费该可能已经不可用的资源,转而去消费其它可用资源。

当然熔断器不可能这么简单,这里只简单介绍它的大致原理,详细的原理,下文会给出。

Fallback processing——回退处理

回退机制的作用是,当一个远程访问失败后,服务消费端会执行另一段代码,这段代码可以返回一个已定义好的结果,当然也可以做其它逻辑,而不是硬生生地抛异常。比如现在调用远程接口获取用户数据,而且失败了,那么此时可以选择返回一个用户对象,其中的数据为空,而不是抛出异常,毕竟抛异常的用户体验很不好。

Bulkheads——舱壁机制

舱壁机制的作用与船只的舱壁作用类似。船的舱壁能将船体内部分隔成若干舱室,当发生海损事故船只进水时,只有那些外板受损的舱室进水,而其它的舱室由于有了舱壁的阻挡而不受影响,船只整体浮力损失减小,所以不易沉没,提高船只的生存力。

同样,可以将同一远程调用分到同一线程池中,这样能降低因为一个响应缓慢的调用不断地消耗可用资源而拖垮整个应用的风险。这些线程成就好比船只的一个个舱室,当一个响应缓慢的调用所属的线程池满了,那么接下来的调用只能进入一个队列排队等待,而不会去占用其它可用资源,也就不会影响到其它模块的正常运行。

上面已经抽象说明了几种客户端弹性机制的作用,接下来用一个简单的场景来更深入理解这几种机制,如熔断器。该场景中的应用和服务之间的联系如下图:
image.png

上图中,应用A和B直接与服务A通信;服务A从数据库查询获取数据、调用服务B的接口;服务B从另一个数据库查询获取数据、调用第三方服务C提供的接口,且服务C极其依赖于一个内网存储设备(NAS)来将数据写入文件共享系统;另外,应用C调用服务C的接口。
在周末,一个网络管理员对NAS的配置做了在他看来无关紧要的变更,变更后,服务C看起来还是运行得很好;但是在星期一早上,个别磁盘子系统的出现写入极其缓慢的情况。
开发服务B的开发者没有事先预想到调用服务C的接口会出问题,比如服务C处理速度慢长时间未响应,所以将操作数据源B与调用服务C接口的逻辑都编写在同一个事务中。当服务C执行效率变低,服务B的连接池中的数据库连接数量爆增,因为服务C接口的访问并没有得到及时响应,所以这些数据库连接必须一直保持连接状态。
最后,因为服务B的可用资源被迅速消耗,没有足够的资源处理来自服务A的访问,所以导致服务A也会消耗服务A所处容器的可用资源。最终,应用A、B、C都会被迫停止响应。

在上面的场景中,在访问分布式资源时都可以加入熔断器的实现。比如,当服务C处理效率急剧下降时,如果在访问服务C的时候有熔断器的实现,那么这些访问在长时间(相对正常响应时间)未得到响应时,熔断器会将其打断,而不会一直占用系统资源。如果服务B暴露了许多端点(endpoints),那么只有那一个或多个需要与服务C通信的端点不可用,而剩余其他端点则能继续响应用户的请求。
熔断器的角色就好比是处于应用和远程服务的中间人。在上面提到的场景中,熔断器的实现能保护应用A、B、C不会因为可用资源不足而变得完全不可用。

再来看一个场景,如下图:
image.png

上图中,服务B并不是直接访问服务C,服务B和服务C之前实现了熔断器,服务B将真正的接口调用委托给熔断器,熔断器将其包装在一个线程中。所以服务B不再一直等待访问得到响应,而是熔断器监控这个线程并且在线程运行时间过长时可以终止此次访问。

上图的三个场景,第一个场景是理想情况,熔断器会维护一个定时器,远程服务调用能在规定时间内得到响应,服务B能继续正常运作。
第二个场景,服务C性能变低,服务B对服务C的访问无法在定时器结束之前得到响应,熔断器会将此次访问切断。因此服务B会得到一个错误返回,不再继续等待服务C的响应,也就会释放之前占用的资源。另外,当熔断器监控到某一个远程调用(如端点P)因timeout而得到错误返回,那么熔断器会开始对所有访问这一服务实例的指定端点(端点P)的失败次数进行监控,当在规定的时间内,失败次数达到一个指定的阈值,熔断器会将其标记为不可用。
第三个场景,访问某一服务C实例的端点P在规定的时间内的失败次数超过阈值,该端点被标记为不可用。所以当服务B在此访问了该端点,会立即得到一个错误结果,而没有发出访问端点P的请求,又因为有回退机制,那么会返回一个预定的结果。最后,熔断器在规定的时间后,默认5s,会放行部分请求进行尝试,根据结果确认服务C是否已经恢复正常。

综上,熔断器机制让应用在远程调用上能具备如下能力:

  • Fail fast:当一个远程服务退化,处理能力下降,应用访问该服务时能让远程调用迅速失败返回,这样能有效防止等待远程调用的响应而一直占有资源,也就提升了应用的鲁棒性。
  • Fail gracefully:因为有了定时器和迅速失败,熔断器提供了让应用拥有优雅返回错误结果的能力。即在迅速失败后,执行了预先定义好的逻辑,该逻辑能产生一个友好的结果并返回,而不是返回一个硬生生的错误结果。
  • Recover seamlessly:熔断器作为一个“中间人”,它可以在不需要人为干预的情况下,定期检查远程服务/资源是否已恢复正常,能够再次被访问。

一个云应用,特别是拥有几百上千微服务的大型云应用,这种不需要人为干预的服务恢复,是一个极为关键的技术,因为它能明显减少服务重启的次数,由于不需要人为的操作,也能降低运维员工或系统工程师的错误操作导致更大的故障的风险。

Spring Cloud Hystrix

熔断器、回退机制、舱壁机制的实现,需要有非常丰富的多线程开发经验。不过Netflix的Hystrix库已经帮我们实现,而且Spring Cloud已将Hystrix集成到Spring Cloud Hystrix中,所以可以使用它来让我们的应用变得更加健壮。

接下来,我们会学习如何:

  • 在pom文件引入Spring Cloud Hystrix的启动依赖
  • 使用Hystrix提供的注解将远程调用通过熔断器包装起来
  • 自定义熔断器来应对不同的需求
  • 实现远程调用失败后回退策略
  • 自定义线程池来实现舱壁机制

license服务引入Spring Cloud Hystrix

在上一节的license服务的pom文件中加入如下依赖:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-hystrix</artifactId>
</dependency>

引入该依赖后,还无法让你的应用具备熔断器能力,必须在服务启动类再加一个注解——@EnableCircuitBreaker。该注解告诉Spring Cloud,我们将在服务中使用Hystrix实现熔断器。

@SpringBootApplication
@EnableEurekaClient
@EnableFeignClients
@EnableCircuitBreaker
public class Application {
    
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @LoadBalanced
    @Bean
    public RestTemplate getRestTemplate(){
        return new RestTemplate();
    }
}

如果忘记在启动类加上该注解,那么应用中的所有Hystrix熔断器都将无法起作用,而且在服务启动时,控制台会出现警告或错误信息。所有第一件事就是加上该注解。

使用Hystrix实现熔断器

下面,我们会使用Hystrix实现两种不同类型的熔断器。第一种,在服务license、organization中的所有数据库操作,都使用Hystrix熔断器包装;第二种是,包装license服务对organization服务的调用。但是这两种情况,Hystrix熔断器的实现是一样的。下图展示了这两种类型:


image.png
Hystrix熔断器的简单使用

首先,license服务从数据库获取数据的访问,使用同步的Hystrix熔断器包装。这些同步调用,在SQL语句执行完后会将结果返回,或者在定时器超时之后强制返回。

Spring Cloud Hystrix使用@HystrixCommand注解标记一个方法,然后这些方法会被Hystrix熔断器管理。当Spring framework看到这个注解,它会动态生成一个代理将方法包装起来,并添加到指定的线程池中,由线程池统一管理。

在LicenseService添加getLicensesByOrg()方法,并使用注解@HystrixCommand:

@HystrixCommand
public List<License> getLicensesByOrg(String organizationId){
    return licenseRepository.findByOrganizationId(organizationId);
}

这看似不多的代码,较之前可能会编写的逻辑,只是多了一个@HystrixCommand注解,但是在该注解下,却有很多逻辑包含在里边。每一次getLicenseByOrg方法调用,都会被包装在一个Hystrix熔断器中。若一次调用花费的时间超过1000毫秒(默认),则该调用会被打断。

在数据库能正常工作的情况下,上面的示例的运行结果会很尴尬,根本看不出熔断器的效果。所以,在验证熔断器效果之前,将代码略做修改,创建让熔断器“生效”的环境。如下:

@HystrixCommand
public List<License> getLicensesByOrg(String organizationId){
    randomlyRunLong();
    return licenseRepository.findByOrganizationId(organizationId);
}

private void randomlyRunLong(){
    Random rand = new Random();
    int randomNum = rand.nextInt((3 - 1) + 1) + 1;
    if (randomNum==3) sleep();
}

private void sleep(){
    try {
        Thread.sleep(11000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

然后在LicenseController加入一个端点:

@RequestMapping(value="/",method = RequestMethod.GET)
public List<License> getLicenses(@PathVariable("organizationId") String organizationId) {
    return licenseService.getLicensesByOrg(organizationId);
}

最后,启动eureka服务,config-server服务,license服务,organization服务无所谓,暂时用不到;然后用postman访问:http://localhost:10000/v1/organizations/e254f8c-c442-4ebe-a82a-e2fc1d1ff78a/licenses/。当你访问多次时,中间可能会出现类似如下图的结果:

image.png

出现这种结果是因为调用getLicensesByOrg方法花费的时间过长,所以Hystrix熔断器强制切断此次调用,并得到一个错误返回,观察上图,可以看到,抛的异常是:com.netflix.hystrix.exception.HystrixRuntimeException,所以该异常是Hystrix熔断器在timeout后抛出的,而不是getLicensesByOrg中的逻辑执行错误抛出的。

使用默认的Hystrix熔断器包装服务间的调用的使用方法与上面展示一样,只要在远程调用的业务方法上加上注解@HystrixCommand即可。在LicenseService添加方法getOrganization,如下:

@HystrixCommand
private Organization getOrganization(String organizationId) {
    return organizationRestClient.getOrganization(organizationId);
}

然而,虽然注解@HystrixCommand很容易使用,但在使用时还需要特别注意,因为上面所列举的两段代码,@HystrixCommand注解都是使用的默认配置。若都使用默认配置,会有很多弊端。比如,所有远程调用都会由同一个线程池,这样会引起许多问题。在后文讲解舱壁机制时会说明如何让不同的远程调用由不同的线程池进行管理。所以在开发过程中,一般会根据实际情况使用自定义的配置。

自定义熔断器的超时时间

修改getLicensesByOrg方法的@HystrixCommand注解,如下:

@HystrixCommand(
    commandProperties= {
        @HystrixProperty(name="execution.isolation.thread.timeoutInMilliseconds", value="12000")
    }
)
public List<License> getLicensesByOrg(String organizationId){
    randomlyRunLong();
    return licenseRepository.findByOrganizationId(organizationId);
}

Hystrix允许我们通过注解@HystrixCommand的属性commandProperties来自定义熔断器,commandProperties可以包含一个@HystrixProperty数组。上面的代码中,配置execution.isolation.thread.timeoutInMilliseconds属性来自定义Hystrix熔断器的超时时间为12s。重启license服务后,如果再次多次访问http://localhost:10000/v1/organizations/e254f8c-c442-4ebe-a82a-e2fc1d1ff78a/licenses/,都会得到正确的查询结果。因为熔断器的超时时间为12s,而程序的睡眠时间为11s,所以正常情况下,不会因定时器超时,数据库访问被强制中断。

很明显,上面把超时时间设置成12s是为了验证的需要。而在分布式环境中,一般不需要去该这个配置,因为微服务间的调用都是很快的。如果出现需要增大该超时时间才能让应用正常运行,这就代表该远程调用存在潜在的性能问题,此时我们要把持住,不要受增大超时时间的诱惑,转而去进行性能优化。如果实在是无法进行优化,比如访问的是第三方提供的接口,那就只能增大超时时间了,但是增大多少也要多加注意。

回退处理

熔断器机制设计得最巧妙的地方就是,熔断器作为远程资源消费者与远程资源本身的一个中间人,这样的设计有利于开发者能够中断远程调用甚至可以在中断后执行另一种预定义的逻辑并返回一个友好的结果。

在Hystrix中,回退机制与熔断器可以结合在一起使用,即在熔断器中断远程访问后,可以选择进行回退处理而不是得到一个错误返回。使用Hystrix,这种强制中断后进行的回退策略的实现也是极为简单的。下面我们来实现一个简单的回退处理策略,即license服务访问数据库失败后,返回一个不携带license可用信息的License对象。如下:

@HystrixCommand(fallbackMethod = "buildFallbackLicenseList")
public List<License> getLicensesByOrg(String organizationId){
    randomlyRunLong();
    return licenseRepository.findByOrganizationId(organizationId);
}

private List<License> buildFallbackLicenseList(String organizationId){
    List<License> fallbackList = new ArrayList<>();
    License license = new License()
            .withId("0000000-00-00000")
            .withOrganizationId( organizationId )
            .withProductName(
                    "Sorry no licensing information currently available");
    fallbackList.add(license);
    return fallbackList;
}

上面的代码中,首先将之前@HystrixCommand配置的超时时间去掉,再配置一个新的属性fallbackMethod,该属性代表在熔断器中断远程访问后进行回退处理,可以看到fallbackMethod的值为"buildFallbackLicenseList",所以处理的逻辑(方法)是:与getLicensesByOrg方法处在同一个类中的方法buildFallbackLicenseList()。

实际上,要使用Hystrix实现一个回退策略,需要两个步骤。第一个是在注解@HystrixCommand中加入一个属性fallbackMethod,该属性的值是一个方法名,代表熔断器在中断远程访问后会调用的方法。
第二个是定义一个方法,该方法的签名与fallbackMethod属性的值相同,并且该方法必须与添加了@HystrixCommand注解的方法处在相同的类中。另外,该方法的参数列表必须与添加了@HystrixCommand注解的方法一致,在执行回退处理时,熔断器会传入相应的值。

上面的代码中,回退处理的逻辑是只返回一个“空”的License对象,这是最简单的做法。当然,在生产环境中,可以将逻辑改成从另一个数据源获取数据。如果是这样,那就要注意了,从另一个数据源获取数据,也属于远程调用,所以也要加上@HystrixCommand注解来“保护”该二级方案。

在生产环境中,回退处理的实现是一件值得花费时间和精力去做好的事情。比如,用户获取最新数据,但查询超时了,此时,即使返回的是旧数据也比返回一个错误给人的体验更好。

现在,我们已经实现一个简单的回退处理策略,重启license服务,然后多次访问http://localhost:10000/v1/organizations/e254f8c-c442-4ebe-a82a-e2fc1d1ff78a/licenses/,直到出现类似如下图的结果:

image.png

出现这个结果,证明熔断器在远程调用超时后中断了调用并进行了预定义的回退处理。回退处理就介绍到这里,接下来介绍舱壁机制。

舱壁机制的实现

在一个基于微服务的应用程序中,您通常需要调用多个微服务完成一个特定任务。不使用舱壁模式,这些调用默认是使用相同的线程来执行调用的,这些线程Java容器为处理所有请求预留的。在高服务器请求的情况下,一个性能较低的服务会“霸占”java容器中绝大多数线程,而其它性能正常的服务的请求则需要等待线程资源的释放。最后,整个java容器会崩溃。舱壁模式能将远程调用隔离在各个远程调用自己的线程池中,因此单个性能出问题的服务能得到控制,java容器也不会崩溃。

Hystrix将远程服务的请求托管在一个线程池中。即默认情况下,所有Hystrix命令(@HystrixCommand)共享同一个线程池来处理这些请求。该线程池中持有10个线程来处理各种远程服务请求,可以是REST服务调用、数据库访问等。如下图所示:


image.png

@HystrixCommand的默认配置适用于只有少量远程调用的应用。幸运的是,Hystrix提供了简单易用的方法实现舱壁来隔离不同的远程资源调用。下图说明了Hystrix将不同的远程调用隔离在不同的“舱室”(线程池)中:


image.png

实现这种隔离的线程池,需要使用到@HystrixCommand注解提供的其他属性。接下来,我们会:

  1. 为方法getLicensesByOrg()设置一个隔离的线程池
  2. 设置该线程池的线程数
  3. 设置队列的容量,该队列的作用是当线程池中的线程都处于工作状态,接下来的请求会进入该队列。
    下面的代码演示了如何自定义舱壁:
@HystrixCommand(
        fallbackMethod = "buildFallbackLicenseList",
        threadPoolKey = "licenseByOrgThreadPool",
        threadPoolProperties = {
            @HystrixProperty(name = "coreSize",value="30"),
            @HystrixProperty(name="maxQueueSize", value="10")
        }
)
public List<License> getLicensesByOrg(String organizationId){
    randomlyRunLong();
    return licenseRepository.findByOrganizationId(organizationId);
}

上面代码中涉及到几个新的@HystrixCommand暴露的属性。第一个是threadPoolKey,这对于Hystrix来说是新建一个线程池的信号,threadPoolKey的值则是线程池的标识。如果只是配置了threadPoolKey,那么Hystrix会使用默认配置来初始化该线程池。

而若要自定义新建的线程池,则需要使用另一个属性:threadPoolProperties。该属性接收一个@HystrixProperty数组,这些HystrixProperty就是用来配置新建的线程池。比如,可以使用coreSize来配置线程池的容量。
当然也可以设置一个队列,来应对当线程池繁忙的情况。通过maxQueueSize来设置该队列的容量。当请求的数量超过队列的容量,其它的请求会迅速失败返回,直到队列又有空闲的“位置”。

对于属性maxQueueSize,有亮点需要注意。第一,如果value值设置为-1,Hystrix会使用SynchronousQueue来实现该队列。同步队列意味着,当线程池繁忙时,就不再接收其它请求,直接迅速失败返回,可以粗略理解为该队列不存在。当设置一个大于1的值时,Hystrix会创建一个LinkedBlockingQueue,这样会让后到的请求排队等候线程池的线程完成请求处理。
第二,Hystrix允许我们使用SizeRejectionThreshold属性来动态变更队列的容量,但该属性只有在maxQueueSize的value值大于0的时候才能生效。而maxQueueSize属性的值只能在线程池初始化时设置,所以当maxQueueSize为-1时,将无法再变更队列的容量,因为队列是同步队列。

最后,我们应该如何设置一个合适的线程池容量呢?Netflix建议:
每秒处理请求的峰值 × 99%平均响应时间 + 缓冲线程数

然而,在服务正式部署之前,我们是无法知道服务的性能为几何。这里有一个指标可以作为参考,当目标远程资源正常的情况下,调用还会出现超时,那么线程池的容量就需要调整了。

更多@HystrixCommand的配置可参考https://github.com/Netflix/Hystrix/wiki/Configuration

深入了解Hystrix;微调Hystrix

到目前为止,我们只是了解了如何使用Hystrix配置最简单的熔断器、舱壁实现。接下来我们会深入了解Hystrix并学习如何真正自定义一个Hystrix熔断器。在此之前,需要牢记的是,Hystrix做的远比中断一个超时调用多。Hystrix还会监控调用失败的次数,如果失败率超过一个阈值,Hystrix会自动让以后的远程调用请求在到达远程资源之前迅速失败返回;也就是说,只要是该请求访问的是不可用资源,Hystrix会直接迅速失败返回,连发起远程调用的机会都不给你,谁叫你托管在Hystrix呢。

Hystrix这样实现由两个原因。第一,如果一个远程资源存在性能问题,迅速失败返回能避免远程调用在超时后熔断器再去中断它。这样能有效降低发起调用的应用程序或服务耗尽可用资源而崩溃。
第二,这样有利于远程服务性能的恢复。设想一下,现在一个远程资源服务的请求量突然剧增,出现短暂性的性能问题,导致发起远程调用的服务发起的大量请求都超时,失败请求的比例超过阈值,这时熔断器打开,接下来的请求都会迅速失败返回,即不再访问远程服务而是直接失败返回,这时远程服务就有足够的“喘息”时间来处理剧增的请求,当处理完后,远程服务性能恢复,Hystrix熔断器的无缝恢复机制感知到后,会将熔断器关闭(无缝恢复如何实现的下文会说明)。

在学习如何配置熔断器之前,必须了解Hystrix熔断器是在何时开启、何时关闭的。下图展示了当Hystrix熔断器监听到第一个失败调用后开启、关闭的判定流程:


image.png

当Hystrix命令遇到一个远程资源调用失败,它会开启一个10s的计时器,计时器被用来检测该服务调用失败的频率。该时间窗(计时器)是可以配置的,默认是10s。在时间窗开启后结束前,会统计接下来的每一个失败调用。如果失败调用的频数小于一个预设的值,Hystrix不会采取进一步措施,认为之前的失败调用属于正常可控的。比如,设置这个预设值为20,在10s内,Hystrix统计到的失败调用数为15,那么Hystrix会放行接下来的远程调用请求。

在时间窗结束前,失败调用频数达到预设的值,Hystrix会开始统计远程调用整体失败率。如果失败率超过阈值,该阈值默认为50%,Hystrix会触发熔断器,让接下来的请求都迅速失败返回,防止继续访问可能已出现故障的远程资源。如果,10s时间窗结束,总体失败率未达到或超过阈值,那么Hystrix会重置只统计的数据。直到监听到又一个失败请求,Hystrix会再次开启一个时间窗。稍后还会讲解Hystrix通过放行部分远程调用请求尝试,确认远程资源故障是否已修复,实现“无缝恢复”。

熔断器有打开,肯定就会有关闭,不然只要熔断器一打开,那么对应的那个远程资源岂不是直接被判死刑,永远拉入黑名单,就算远程资源已恢复也不会放行,人家跟你什么仇什么怨。很明显,这肯定是不合理的。在说明Hystrix何时关闭熔断器时,会涉及到一个叫熔断器“半开”状态的概念。

何为“半开”状态?熔断器被触发后,Hystrix会开启另一个默认为5s长度也可设置的休眠时间窗,当时间窗结束后,熔断器会从开启状态转换为半开状态。熔断器半开状态下,Hystrix会允许请求尝试访问,若此时访问继续失败,熔断器有进入开启状态,并继续等待下一个休眠时间窗结束后,会再进入半开状态,一直循环重复,知道请求成功;若请求成功,熔断器会被重置为关闭状态。

Hystrix就是这样来控制熔断器的开启与关闭的。

下面,开始自定义熔断器的配置,代码如下:

@HystrixCommand(
    fallbackMethod = "buildFallbackLicenseList",
    threadPoolKey = "licenseByOrgThreadPool",
    threadPoolProperties = {
        @HystrixProperty(name = "coreSize",value="30"),
        @HystrixProperty(name="maxQueueSize", value="10")
    },
    commandProperties = {
        @HystrixProperty(name="circuitBreaker.requestVolumeThreshold", value = "10"),
        @HystrixProperty(name="circuitBreaker.errorThresholdPercentage", value="75"),
        @HystrixProperty(name="circuitBreaker.sleepWindowInMilliseconds", value="7000"),
        @HystrixProperty(name="metrics.rollingStats.timeInMilliseconds",value="15000"),
        @HystrixProperty(name="metrics.rollingStats.numBuckets",value="5")
    }
)
public List<License> getLicensesByOrg(String organizationId){
    randomlyRunLong();
    return licenseRepository.findByOrganizationId(organizationId);
}

可以看到,Hystrix支持通过配置commandProperties属性来自定义熔断器,该属性接收一个HystrixProperty对象数组,数组的元素可通过配置注解@HystrixProperty实现。上面的代码,总共配置了5个@HystrixProperty,作用分别如下:

  • circuitBreaker.requestVolumeTheshold:控制10s(默认)时间窗内失败频数达到的阈值,若达到或超过,会进行总体失败率统计;
  • circuitBreaker.errorThresholdPercentage:总体失败率阈值。在失败调用频数超过circuitBreaker.requestVolumeTheshold设置的值后,若统计的总体失败率达到或超过该阈值,熔断器会开启;
  • circuitBreaker.sleepWindowInMilliseconds:休眠时间窗长度。该属性控制熔断器打开后,会开启多长的休眠时间窗。休眠时间窗结束后,Hystrix会允许部分远程访问尝试。
  • metrics.rollingStats.timeInMilliseconds:该属性是Hystrix用来控制监听到第一个失败调用后打开的时间窗长度,默认是10000ms,即10s。
  • metrics.rollingStats.numBuckets:设置了将时间窗划分成桶的数量。该时间窗指上一个属性设置的时间窗,而不是休眠时间窗。Hystrix在该时间窗内,会收集各个桶的度量指标,根据收集到的统计分析,最后确定远程资源是否不可用。另外,timeInMilliseconds参数的设置必须能被numBuckets参数整除,不然会抛出异常。比如,当timeInMilliseconds为10000,numBuckets可以是10,页可以是20,但不能是7;若numBuckets为10,那么每个桶的时间长度为1s。

不同粒度的Hystrix配置

实际上,Hystrix为我们提供的配置内容和配置方式远不止这些,它提供了非常丰富和灵活的配置方法。Hystrix属性的配置有四个不同的优先级别(优先级由低到高):

  • Hystrix提供的默认值
  • 整个应用的的全局配置
  • 类级别的局部配置
  • 线程池级别的特殊配置

这四个级别的优先级是由低到高,优先级高的会覆盖优先级低的。

Hystrix的每一个属性配置都会有一个默认值,并应用到每一个@HystrixCommand注解。除非在类级别或线程池级别对部分配置进行覆盖,否则都会使用默认值。

Hystrix允许我们设置类级别的默认配置,这些配置会被该类中的所有@HystrixCommand共享。类级别配置通过一个类级别注解@DefaultProperties进行设置,比如,你想配置类MyService中的所有托管给Hystrix的远程调用的超时时间为10s,你可以这样配置:

@DefaultProperties(
    commandProperties = {
        @HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "10000")
    }
)    
class MyService{...}

除非在线程池级别对配置显式覆盖,否则所有线程池的配置都会继承应用级别的默认值或类级别的默认值。

注:在上面所有演示代码中,都使用硬编码的方式对Hystrix属性进行配置,但在生产环境中,Hystrix属性的value值有很大概率需要微调,比如超时时间、线程池数量等,所以Hystrix的配置会托管在Spring Cloud Config服务器。使用这样的解决方案,当你需要微调某些属性值时,可以在修改后刷新配置、重启服务实例,而不用重新编译、重新部署应用。

线程上下文和Hystrix

当执行一个@HystrixCommand,Hystrix允许以两种不同的隔离策略运行,分别为:THREAD(线程)和SEMAPHORE(信号量)。Hystrix默认选择THREAD,此时Hystrix command(命令)会保护运行在被隔离起来的线程池中的线程,这些线程与父线程之间不会共享上下文。这意味着Hystrix可以在有必要的情况(如超时)下随时中断由Hystrix控制的线程。

当使用基于SEMAPHORE的隔离策略,Hystrix同样会管理被注解@HystrixCommand保护的分布式调用,但不会创建一个新的线程;当调用超时,Hystrix将会中断父线程。在同步容器服务器环境(Tomcat),中断父线程会产生一个无法捕获的异常,这样会导致意想不到的后果,因为开发者无法捕获异常并处理。

我们可以通过设置注解@HystrixCommand的属性commandProperties来定制不同的隔离策略。比如,如果想设置Hystrix 命令的隔离策略为SEMAPHORE,那么可以这样配置:

@HystrixCommand(
    commandProperties = {
        @HystrixProperty(name="execution.isolation.strategy", value="SEMAPHORE")
    }
)
...

大多数情况下,Hystrix团队建议我们使用默认的隔离策略——THREAD,这样能让@HystrixCommand保护的线程与父线程保持较高的隔离级别。而SEMAPHORE隔离模式属于轻量级,因此,当服务会有较高访问量且服务是运行在异步I/O容器中,如Netty,则应当选择SEMAPHORE。

Hystrix仪表盘——Hystrix Dashboard

Hystrix仪表盘的相关内容,请看Hystrix仪表盘——Hystrix dashboard

ThreadLocal和Hystrix

上文已经提到,默认情况下(THREAD隔离策略),Hystrix线程池中的线程与父线程间不会共享上下文,换句话说,父线程的一些请求数据,如token,不会传递给Hystrix命令管理的线程。由于本教程属于入门教程,所以暂时先不将太多,等以后的进阶教程会详讲。不过在Git上的源码已经有解决方法,有兴趣的童鞋可以看看,当然Git上的相关代码只是解决方案的一种,还有其他的,因涉及到Hystrix的高级编程,会在以后给出。以下的内容可以先跳过。

需要添加Git上的相关代码为:

  1. 在LicenseServiceController.getLicenses(String organizationId)、LicenseService.getLicensesByOrg(String organizationId)这两个方法加入日志打印语句;
  2. 然后添加/utils下的三个类,UserContext、UserContextFilter、UserContextHolder;

到这里,添加代码后,启动license服务,访问http://localhost:10000/v1/organizations/e254f8c-c442-4ebe-a82a-e2fc1d1ff78a/licenses/,且携带一个请求头:tmx-correlation-id:TEST-CORRELATION-ID,如下:

image.png
。请求完成后,可以看到控制台的输出如下:
image.png

到这里添加的代码,只是验证请求头tmx-correlation-id没有被传递到被@HystrixCommand注解的getLicensesByOrg()方法中,因为getLicensesByOrg被Hystrix新建的线程包裹着。

接下来,就是真正的解决方案。在license的目录添加hystrix包,包中有三个类,分别为:DelegatingUserContextCallable、ThreadLocalAwareStrategy、ThreadLocalConfiguration。最后重启服务。再次访问,可以看到控制台的输出为:


image.png

可以看到getLicensesByOrg()方法也能拿到请求头的内容了。

上面的代码,涉及到HystrixConcurrencyStrategy的使用,Hystrix的高级编程还包括HystrixRequestContext等的使用。通过这些可以实现请求缓存、请求合并等提高性能的功能。有兴趣的朋友可以自行先了解。

下一节将继续讲解:服务网关——Spring Cloud Zuul。

完!

推荐阅读更多精彩内容