如何使用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的主要参数如下:

其中,执行器参数如下;

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

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

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

作者书籍推荐