随着微服务的流行,单体应用被拆分成一个个独立的微进程,可能一个简单的请求,需要多个微服务共同处理,这样其实是增加了出错的概率,所以如何保证在单个微服务出现问题的时候,对整个系统的负面影响降到最低,这就需要用到我们今天要介绍的线程隔离。
在介绍线程隔离之前,我们先了解一下主流容器,框架的线程模型,因为微服务是一个个独立的进程,之间的调用其实就是走网络io,网络io的处理容器如Tomcat,通信框架如netty,微服务框架如dubbo,都很好的帮我们处理了底层的网络io流,让我们可以更加的关注于业务处理;
Netty是基于JAVA nio的高性能通信框架,使用了主从多线程模型,借鉴Netty系列之 Netty线程模型的一张图片如下所示:
主线程负责认证,连接,成功之后交由从线程负责连接的读写操作,大致如下代码:
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup);
主线程是一个单线程,从线程是一个默认为cpu*2个数的线程池,可以在我们的业务handler中做一个简单测试:
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("thread name=" + Thread.currentThread().getName() + " server receive msg=" + msg);
}
服务端在读取数据的时候打印一下当前的线程:
thread name=nioEventLoopGroup-3-1 server receive msg="..."
可以发现这里使用的线程其实和处理io线程是同一个;
Dubbo的底层通信框架其实使用的就是Netty,但是Dubbo并没有直接使用Netty的io线程来处理业务,可以简单在生产者端输出当前线程名称:
thread name=DubboServerHandler-192.168.1.115:20880-thread-2,...
可以发现业务逻辑使用并不是nioEventLoopGroup线程,这是因为Dubbo有自己的线程模型,可以看看官网提供的模型图:
其中的Dispatcher调度器可以配置消息的处理线程:
Dubbo默认使用FixedThreadPool,线程数默认为200;
Tomcat可以配置四种线程模型:BIO,NIO,APR,AIO;Tomcat8开始默认配置NIO,此模型和Netty的线程模型很像,可以理解为都是Reactor模式,在此不过多介绍;其中maxThreads参数配置专门处理IO的Worker数,默认是200;可以在业务Controller中输出当前线程名称:
ThreadName=http-nio-8888-exec-1...
可以发现处理业务的线程就是Tomcat的io线程;
从上面的介绍的线程模型可以知道,处理业务的时候还是使用的io线程比如Tomcat和netty,这样会有什么问题那,比如当前服务进程需要同步调用另外三个微服务,但是由于某个服务出现问题,导致线程阻塞,然后阻塞越积越多,占满所有的io线程,最终当前服务无法接受数据,直至奔溃;Dubbo本身做了IO线程和业务线程的隔离,出现问题不至于影响IO线程,但是如果同样有以上的问题,业务线程也会被占满;做线程隔离的目的就是如果某个服务出现问题可以把它控制在一个小的范围,不至于影响到全局;
做线程隔离原理也很简单,给每个请求分配单独的线程池,每个请求做到互不影响,当然也可以使用一些成熟的框架比如Hystrix(已经不更新了),Sentinel等;
SpringBoot+Tomcat做一个简单的隔离测试,为了方便模拟配置MaxThreads=5,提供隔离Controller,大致如下所示:
@RequestMApping("/h1")
String home() throws Exception {
System.out.println("h1-->ThreadName=" + Thread.currentThread().getName());
Thread.sleep(200000);
return "h1";
}
@RequestMapping("/h3")
String home3() {
System.out.println("h3-->ThreadName=" + Thread.currentThread().getName());
return "h3";
}
请求5次/h1请求,再次请求/h3,观察日志:
h1-->ThreadName=http-nio-8888-exec-1
h1-->ThreadName=http-nio-8888-exec-2
h1-->ThreadName=http-nio-8888-exec-3
h1-->ThreadName=http-nio-8888-exec-4
h1-->ThreadName=http-nio-8888-exec-5
可以发现h1请求占满了5条线程,请求h3的时候Tomcat无法接受请求;改造一下h1请求使用使用线程池来处理:
ExecutorService executorService = Executors.newFixedThreadPool(2);
List<Future<String>> list = new CopyOnWriteArrayList<Future<String>>();
@RequestMapping("/h2")
String home2() throws Exception {
Future<String> result = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println("h2-->ThreadName=" + Thread.currentThread().getName());
Thread.sleep(200000);
return "h2";
}
});
list.add(result);
//降级处理
if (list.size() >= 3) {
return "h2-fallback";
}
String resultStr = result.get();
list.remove(result);
return resultStr;
}
如上部分伪代码,使用线程池异步执行,并且超出限制范围做降级处理,这样再次请求h3的时候,就不受影响了;当然上面代码比较简陋,我们可以使用成熟的隔离框架;
Hystrix 提供两种隔离策略:线程池隔离(Bulkhead Pattern)和信号量隔离,其中最推荐也是最常用的是线程池隔离。Hystrix的线程池隔离针对不同的资源分别创建不同的线程池,不同服务调用都发生在不同的线程池中,在线程池排队、超时等阻塞情况时可以快速失败,并可以提供fallback机制;可以看一个简单的实例:
public class HelloCommand extends HystrixCommand<String> {
public HelloCommand(String name) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ThreadPoolTestGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("testCommandKey"))
.andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey(name))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter().withExecutionTimeoutInMilliseconds(20000))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter().withMaxQueueSize(5) // 配置队列大小
.withCoreSize(2) // 配置线程池里的线程数
));
}
@Override
protected String run() throws InterruptedException {
StringBuffer sb = new StringBuffer("Thread name=" + Thread.currentThread().getName() + ",");
Thread.sleep(2000);
return sb.append(System.currentTimeMillis()).toString();
}
@Override
protected String getFallback() {
return "Thread name=" + Thread.currentThread().getName() + ",fallback order";
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<Future<String>> list = new ArrayList<>();
System.out.println("Thread name=" + Thread.currentThread().getName());
for (int i = 0; i < 8; i++) {
Future<String> future = new HelloCommand("hystrix-order").queue();
list.add(future);
}
for (Future<String> future : list) {
System.out.println(future.get());
}
Thread.sleep(1000000);
}
}
如上配置了处理此业务的线程数为2,并且指定当线程满了之后可以放入队列的最大数量,运行此程序结果如下:
Thread name=main
Thread name=hystrix-hystrix-order-1,1589776137342
Thread name=hystrix-hystrix-order-2,1589776137342
Thread name=hystrix-hystrix-order-1,1589776139343
Thread name=hystrix-hystrix-order-2,1589776139343
Thread name=hystrix-hystrix-order-1,1589776141343
Thread name=hystrix-hystrix-order-2,1589776141343
Thread name=hystrix-hystrix-order-2,1589776143343
Thread name=main,fallback order
主线程执行可以理解为就是io线程,业务执行使用的是hystrix线程,线程数2+队列5可以同时处理7条并发请求,超过的部分直接fallback;
线程池隔离的好处是隔离度比较高,可以针对某个资源的线程池去进行处理而不影响其它资源,但是代价就是线程上下文切换的开销比较大,特别是对低延时的调用有比较大的影响;上面对线程模型的介绍,我们发现Tomcat默认提供了200个io线程,Dubbo默认提供了200个业务线程,线程数已经很多了,如果每个命令在使用一个线程池,线程数会非常多,对系统的影响其实也很大;有一种更轻量的隔离方式就是信号量隔离,仅限制对某个资源调用的并发数,而不是显式地去创建线程池,所以开销比较小;Hystrix和Sentinel都提供了信号量隔离方式,Hystrix已经停止更新,而Sentinel干脆就没有提供线程隔离,或者说线程隔离是没有必要的,完全可以用更轻量的信号量隔离代替;
本文从线程模型开始,讲到了IO线程,以及为什么要分开IO线程和业务线程,具体如何去实现,最后简单介绍了一下更加轻量的信号量隔离,为什么说更加轻量哪,其实业务还是在IO线程处理,只不过会限制某个资源的并发数,没有多余的线程产生;当然也不是说线程隔离就没有价值了,其实还是要根据实际情况来定,根据你使用的容器,框架本身的线程模型来决定。