如何使用Hystrix实现服务请求隔离?

分类: Java

保留所有版权,请引用而不是转载本文(原文地址 https://yeecode.top/blog/130/ )。

概述

在串联连接的模块中,如果一个模块失效,则它的前置模块因为无法获取后置模块的服务,也会失效。因此,失效会在串联的模块中向前蔓延。

假设系统中节点N1会调用节点N2、N3、N4三个节点提供的不同服务,如图所示。

服务串联示意图

在不考虑隔离的情况下,节点N1的工作过程通常如伪下面代码所示。

public Result service(Request request) {
    Result result = new Result();
    result.append(n2.service(request));
    result.append(n3.service(request));
    result.append(n4.service(request));
    return result;
}

当节点N2失效时,n2.service(request)操作将会阻塞,从而导致节点N1中的service操作会被阻塞。于是,大量的请求拥塞在节点N1上,使得节点N1的并发线程数急剧升高,最终导致节点N1内存耗尽而失效。

有一种措施可以避免失效的蔓延,那就是隔离。

我们可以使用线程池将节点N1和后方的节点N2、N3、N4隔离起来。具体措施是在N1中为调用节点N2、N3、N4的操作各设立一个线程池,每次需要调用它们的服务时,从线程池中取出一个线程操作,而不是使用N1节点的主线程操作。实现流程的伪代码如下。

public Result service(Request request) {
    Result result = new Result();

    // 从调用N2节点的专用线程池中取出一个线程
    Thread n2ServiceThread = n2ServiceThreadPool.get(); 
    if (n2ServiceThread != null) {
        // 使用获得的线程调用N2节点的服务
        n2ServiceThread.start();
        // 获得N2节点给出的结果,并汇总入N1节点的处理流程中
        result.append(n2ServiceThread.get()); 
    }
    
    // 省略对N3、N4节点的调用流程
    
    return result;
}

这样,当N2节点失效时,会使得N1节点中的调用线程阻塞,进而导致N1节点中操作N2节点的线程池被占满。之后,这一结果不会再继续扩散,不会对其他线程池造成影响,从而保证节点N1的资源不会被耗尽。

这种操作将N2节点失效引发的影响隔离在了节点N1的一个线程池中,提升了节点N1的稳定性。

环境配置

通过Hystrix就可以方便地实现这一操作。接下来,我们给出简单的示例。

首先,准备SpringBoot项目,然后在其中引入适用于SpringBoot的hystrix包:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
    <version>2.2.7.RELEASE</version>
</dependency>

注意,如果这时候启动提示如下的错误,则基本是SpringBoot版本与hystrix版本不匹配导致的。因为hystrix是基于SpringCloud的,而SpringCloud和SpringBoot存在版本对应关系。如果对应不一致,则可能导致出现下面的错误。

java.lang.NoSuchMethodError: 'void org.springframework.boot.builder.SpringApplicationBuilder.<init>(java.lang.Object[])'

这里我们给出一个可用的对应版本,即SpringBoot的2.3.5.RELEASE和spring-cloud-starter-netflix-hystrix的2.2.7.RELEASE。它们这一对是肯定没有问题的。

然后,我们需要在SpringBoot的主类上增加@EnableHystrix注解,以启用Hystrix。如下所示。

@SpringBootApplication
@EnableHystrix
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

环境配置就是这么简单,接下来我们就可以写业务代码了。

功能实现

我们编写三个查询方法,分别查询用户名、年龄、位置,对每个方法都设置500ms的超时时间,如下所示。

@HystrixCommand(
        commandKey = "IsolationService_queryUserNameById",
        fallbackMethod = "fallBackForName",
        commandProperties = {
                // 超时判定时间设置为500ms
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "500"),
        }
)
String queryUserNameById(int i) {
    System.out.println("正在查询用户名,用户编号为" + i);
    return "易哥";
}

@HystrixCommand(fallbackMethod = "fallBackForAge",
        commandProperties = {
                // 超时判定时间设置为500ms
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "500"),
        }
)
String queryUserAgeById(int i) {
    System.out.println("正在查询用户年龄,用户编号为" + i);
    return "18";
}

@HystrixCommand(fallbackMethod = "fallBackForLocation",
        commandProperties = {
                // 超时判定时间设置为500ms
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "500"),
        }
)
String queryUserLocationById(int i) throws InterruptedException {
    // 故意延时,模拟堵塞
    Thread.sleep(1000);
    System.out.println("正在查询用户位置,用户编号为" + i);
    return "西子湖畔";
}

同时,我们也为上述方法指定备用方法,如下所示。以便于上述方法发生错误时调用。


String fallBackForName(int i) {
    System.out.print("生成默认用户名信息,用户编号为" + i);
    return "平台用户";
}

String fallBackForAge(int i) {
    System.out.print("生成默认年龄信息,用户编号为" + i);
    return "年富力强";
}

String fallBackForLocation(int i) {
    System.out.print("生成默认位置信息,用户编号为" + i);
    return "太阳系";
}

然后,我们使用如下的代码上述三个方法。

/**
 * 演示Hystrix的隔离功能
 * 该方法会同时调用三个不同的目标方法
 *
 * @return 3个目标方法返回值的拼接结果
 * @throws Exception 运行中发生的异常
 */
@RequestMapping("/isolation")
public String isolation() throws Exception {
    return "姓名:" + isolationService.queryUserNameById(1) + "\r\n" +
            "年龄:" +isolationService.queryUserAgeById(1) + "\r\n" +
            "位置:" +isolationService.queryUserLocationById(1) + "\r\n";
}

可以在控制台上得到如下输出:

正在查询用户名,用户编号为1
正在查询用户年龄,用户编号为1
生成默认位置信息,用户编号为1

可见,对于存在延时的queryUserLocationById方法,会触发备用方法的调用,进而最终得到如下的返回值:

姓名:易哥
年龄:18
位置:太阳系

整个返回过程很快,并不需要5秒的等待。即上游调用方并没有受到queryUserLocationById的5秒延迟的影响。

通过以上操作,就验证了Hystrix的隔离功能。

隔离策略

关于隔离,大家常疑惑的点在于隔离策略的选择。这里我主要是摘抄参考了《高性能架构之道(第二版)》这本书,如果要细致学习的话,十分建议大家去看下这本书。

线程隔离需要将请求放入到线程中执行,因此比信号量隔离占用资源更多,这一点很好理解。大家疑惑点主要是原方法发生超时后,两种隔离策略的表现有何不同。下面我们来着重介绍。

只要使用了线程池,Hystrix就可以判断原方法的执行超时,并在原方法执行超时后,不等待原方法全部执行完毕而直接启用备用线程。在这个过程中,我们还可以通过Hystrix的execution.isolation.thread.interruptOnTimeout配置来决定是直接中断原方法还是继续执行原方法中的后续内容(但是返回值不会被采用,因为已经采用了备用方法的返回值)。这点,在书中有示例介绍。

那如果使用信号量,原方法将还备用方法在一个线程中运行,那首先面临的第一个问题是:Hystrix还能够识别出原方法的超时么?

答案是肯定的。Hystrix依旧可以判断出原方法执行超时,并调用备用方法,最终也会采用备用方法的返回值。

那Hystrix能够在原方法执行超时后,立刻调用备用方法,即做到快速失败么?

答案是否定的。因为备用方法的执行也要使用原方法的线程,因此必须要等原方法执行结束后,备用方法才可执行。也正因为如此,虽然信号量隔离能够识别超时,但因为不能快速失败,所以意义不大。

我们可以使用下面的代码验证上述结论。示例中,两个原方法的执行时间都为5000ms,超时时间均设置为50ms,分别采用线程隔离模式和信号量隔离模式。

@HystrixCommand(
        fallbackMethod = "fallBackForQueryUserNameById",
        commandProperties = {
                // 采用线程隔离模式
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_STRATEGY, value = "THREAD"),
                // 超时判定时间设置为30ms
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "50"),
        }
)
String queryUserNameByIdWithThread(int i) throws InterruptedException {
    System.out.println("正在使用线程模式查询用户名,用户编号为" + i);
    Thread.sleep(5000);
    return "易哥" + i;
}

@HystrixCommand(
        fallbackMethod = "fallBackForQueryUserNameById",
        commandProperties = {
                // 采用信号量隔离模式
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_STRATEGY, value = "SEMAPHORE"),
                // 超时判定时间设置为30ms
                @HystrixProperty(name = HystrixPropertiesManager.EXECUTION_ISOLATION_THREAD_TIMEOUT_IN_MILLISECONDS, value = "50"),
        }
)
String queryUserNameByIdWithSemaphore(int i) throws InterruptedException {
    System.out.println("正在使用信号量模式查询用户名,用户编号为" + i);
    Thread.sleep(5000);
    return "易哥" + i;
}

String fallBackForQueryUserNameById(int i) {
    return "平台用户" + i;
}

通过外部调用依次触发上述两个原方法5次,并记录每次的响应时间,可以得到下面的结果。

平台用户1 (+61ms)
平台用户2 (+61ms)
平台用户3 (+63ms)
平台用户4 (+61ms)
平台用户5 (+61ms)
平台用户1 (+5003ms)
平台用户2 (+5004ms)
平台用户3 (+5005ms)
平台用户4 (+5012ms)
平台用户5 (+5000ms)

可见两种隔离方式在原方法执行超时后,都会启用备用方法。但只有采用线程隔离的方式,才能做到快速失败。

参数详解

以上实现的是最简单的隔离示例,也仅仅使用了最简单的参数,而其他参数都是用的默认值。但是这个作为示例是可以的,真正使用时,大家还是要对Hystrix的各个参数有详细的了解,至少也要知道大体的含义和默认值。否则,后续使用中,可能会遇到不少百思不得其解的地方。

总之,Hystrix要比这个强大的多。大家还是要好好学一下。

下面是我对@HystrixCommand参数的介绍,主要是摘抄参考了《高性能架构之道(第二版)》这本书。上面的示例也是参考的书里的。下面的参数我这里列的不是很全,如果要细致学习的话,十分建议大家去看下这本书。

HystrixCommand的主要参数如下:

  • groupKey 组名。通过为命令指定一个组名,我们就可以将多个指令分到同一个组中。这样,我们可以使用配置文件为该组设置参数,而不用在每个指令中一一设定参数。通过组名,我们也可以对指标汇总分类。默认为该注解所处类的类名。
  • commandKey 指令名。用来唯一指定一个指令。这样,我们可以使用配置文件为某个指令设置参数,也可以在指标展示时知道指标具体来自哪个指令。默认为该注解所在的方法的方法名。如果程序中注解所在方法的名字不是唯一的,那强烈建议通过commandKey指定一个唯一的名字,以防止多个指令间配置信息的互相干扰。
  • fallbackMethod 备用方法名。用来指定该方法的备用方法,当该方法发生错误或超时时,将执行该备用方法。注意,备用方法必须和该方法有相同的入参类型和返回值类型。
  • commandProperties 命令参数。在这里可以为该指令设置多个命令参数。具体命令参数的介绍详见书中的命令参数小节。
  • ignoreExceptions 要忽略的异常。通常,当原方法执行抛出异常时,Hystrix会自动帮我们调用备用方法。通过该参数指定一些异常,就可以让Hystrix遇到这些异常时,将它们抛出而不触发备用方法。
  • raiseHystrixExceptions 异常包装设置。该配置只有两个选项,要么为空,要么填写RUNTIME_EXCEPTION。如果填写RUNTIME_EXCEPTION,那原方法执行中抛出的所有未被忽略(参见ignoreExceptions配置)的异常会被包装进HystrixRuntimeException中抛出。因为有些场景下,外部调用方需要捕获的异常的类型是相同的,以便于处理。默认值为空,这种情况下原方法的异常会被正常抛出而不被包装。
  • defaultFallback 默认备用方法。它比备用方法(参见fallbackMethod配置)的优先级更低,因此只在没有配置备用方法的时候生效。默认备用方法不允许有入参,且返回值类型必须要和原方法一致。

其中,执行器参数如下;

  • execution.isolation.strategy 隔离策略,有以下两种策略,默认值为THREAD。
    • THREAD:每次调用都会启用线程池中的独立线程,并发请求数目受线程池中线程数的限制。
    • SEMAPHORE: 调用会在原线程上执行,并发请求数目受信号量计数的限制,

线程隔离策略支持超时后的快速失败,即当原方法延迟过大时会直接失败或调用备用方法返回,而不需要等原方法全部执行完毕。当原方法中存在网络调用等容易引发超时的操作时,最好使用THREAD隔离策略。不过,相比于SEMAPHORE策略,其开销略大,因为SEMAPHORE不需要创建额外的线程。

  • execution.timeout.enabled 是否启用超时。如果设置为true,则会在原方法执行超时时启用备用方法。默认值为true。
  • execution.isolation.semaphore.maxConcurrentRequests 在使用SEMAPHORE策略时,该参数用来设置允许请求的最大数值。超过该数值的调用将被拒绝。默认值为10。
  • execution.isolation.thread.timeoutInMilliseconds 在启用超时的情况下,该参数用来设置超时时间的毫秒数。若原方法执行超过该时间,将执行备用方法。默认值1000。该参数对于THREAD和SEMAPHORE两种隔离策略均有效。THREAD策略因为使用独立的线程,可以实现在超时后快速失败或者调用备用方法返回。而SEMAPHORE策略因为使用的是调用方的线程,必须要等原方法执行完成,无法做到快速失败。
  • execution.isolation.thread.interruptOnTimeout 在使用THREAD策略时,设置当原方法执行超时后,是中断原方法还是继续执行原方法中的后续内容。默认为true,即中断原方法。
  • execution.isolation.thread.interruptOnCancel 在使用THREAD策略时,设置当原方法被取消后,是中断原方法还是继续执行原方法中的后续内容。默认为false,即继续执行原方法中的后续内容。

OK,以上就是Hystrix实现请求隔离的示例。

可以访问个人知乎阅读更多文章:易哥(https://www.zhihu.com/people/yeecode),欢迎关注。

作者书籍推荐

作者书籍推荐