问题背景
前一阵遇到一个很有意思的问题,在开发微服务的时候,通过feign来实现服务间的调用。一开始用的默认配置,经过压测以后性能不太好,于是配置了线程池希望能优化,结果发现只要并发量一上来,性能立即急剧降低,甚至远远不如单线程时的性能。而尝试了各种配置方法都不起任何作用,请求一定会一直等到超时并失败。 经过排查以后,原因是一个一开始不起眼的问题,因此把问题的原因记录一下以提醒大家不要犯类似的错误。
问题复现
FeignClient接口
用过feignClient的就知道它只需要你声明一个interface,在方法上用注解指定HTTP请求的路由、方法和参数就可以了,下面是这个interface的示意实现。
@FeignClient(name = "demoClient", url = "http://localhost:8080")
public interface DemoClient {
@PostMapping("/test")
Response<String> request(@RequestBody Request body);
default String easyRequest(int id, String param, double threshold, boolean showAll) {
Response<String> response = this.request(new Request(id, param, threshold, showAll));
if (response.isSuccess()) {
return response.getResult();
}
return null;
}
}
细心的同学已经注意到,除了用于请求的request方法以外,我还额外定义了一个default方法,进行了简单的装箱、拆箱逻辑,来对接口进行方便调用。
服务端代码
Controller层就是正常的业务逻辑。这里我们模拟了两个接口,逻辑都非常简单,一个是响应feignClient请求的内部业务逻辑,这里我们直接返回ok。另一个则响应外部请求,随后使用feignClient请求接口并返回。
@RestController
@RequestMapping
public class DemoController {
@Resource
private DemoClient demoClient;
@PostMapping("/test")
public Response<String> ResponseFeignClient(@RequestBody Request body) {
return Response.success("ok");
}
@GetMapping("/test")
public Response<String> test() {
String result = demoClient.easyRequest(1,"2",3.0, true);
return Response.success(result);
}
}
线程池配置
在配置文件中添加以下配置以启用线程池。
#并发执行的最大线程数,默认为10
hystrix.threadpool.default.coreSize=100
#BlockingQueue的最大队列数,默认值-1(代表无上限)
hystrix.threadpool.default.maxQueueSize=1000
#队列达到queueSizeRejectionThreshold的值后,请求会被拒绝,默认值5
hystrix.threadpool.default.queueSizeRejectionThreshold=800
#超时配置,默认为1000ms
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=4000
发送请求
在外部使用多线程进行并发请求,很快就会发现在请求量较少时代码可以正常工作,但是并发量稍大时,立刻就服务卡死,大量请求失败,几乎没法正常响应。
#Python
import requests
import _thread
for i in range(0, 1000):
_thread.start_new_thread(requests.get, ('http://localhost:8080/test',))
问题解决过程
一开始的时候,面对这个问题,几乎完全摸不着头脑,试图增加超时时间、增加线程数量、队列长度等等,都没有任何作用。通过搜集各种资料,发现hystrix可以通过下列配置指定配置隔离策略为信号量模式,在使用了这个配置以后,就不会产生这个问题了。
#指定隔离策略为信号量模式
hystrix.command.default.execution.isolation.strategy=SEMAPHORE
#配置信号量模式下的并发数量
hystrix.command.default.execution.isolation.semaphore.maxConcurrentRequests=1000
这样虽然可以部分解决问题,但是对于问题的原因还是不清楚,一度怀疑hystrix中线程池的实现是否有BUG。 并且按照官方的说法,信号量模式下请求会阻塞当前的线程,因此除非是访问内存这种本地请求以外,涉及到网络,I/O的操作的还是建议用线程模型,于是我又将配置改了回来,继续寻找其他的原因。
在尝试寻找hystrix线程池是否有Bug的过程中,发现网上并没有类似的反馈声音。我想着在这么流行的组件中,如果存在有这么严重的BUG,不至于没有任何人反馈。那很可能是用法不太对。和网上的各种推荐的实例相比,我这里最大不一样的就是我为了偷懒,多了一个很简单的装箱拆箱的default方法,在尝试移除这个方法直接调用request方法后,果然就没有任何问题了。再顺着这个思路把整个过程捋一捋,一切都水落石出。
问题原因
要透彻分析这个问题,就得从feign或者hystrix的原理开始。在我们将声明了接口之后,hystrix会为该接口生成一个代理对象,然后针对其中的每个method生成一个代理方法,根据在method上的注解来决定最终要如何执行该方法。比如说对于声明了@PostMapping的方法,就会对注解中的url进行Post请求。而对于default方法则直接调用其原本的实现。
下面贴出一段hystrix中HystrixInvocationHandler的部分代码,是将任务提交到线程池的主要逻辑。
if (Util.isDefault(method)) {
return hystrixCommand.execute();
} else if (isReturnsHystrixCommand(method)) {
return hystrixCommand;
} else if (isReturnsObservable(method)) {
// Create a cold Observable
return hystrixCommand.toObservable();
} else if (isReturnsSingle(method)) {
// Create a cold Observable as a Single
return hystrixCommand.toObservable().toSingle();
} else if (isReturnsCompletable(method)) {
return hystrixCommand.toObservable().toCompletable();
} else if (isReturnsCompletableFuture(method)) {
return new ObservableCompletableFuture<>(hystrixCommand);
}
return hystrixCommand.execute();
这里的关键就在头两行上,如果是default方法,直接提交线程池运行。
注意到重点,也就是说,对于我们上面那种使用default装箱的写法,hystrix并不是只给了我们一个线程去执行请求,而是两个线程!1号线程只是很简单装箱以后,进行this.request调用。而this本身已经是一个代理对象了,因此又会重复走上面的逻辑,这次才执行真正的请求。 整个过程如下图所示
这个逻辑咋一看,似乎也是正确的,虽然多了一次提交任务到线程池的步骤,但是也不至于效率低到完全无法接受吧。其实不然,仔细想一下线程池的情况,就全明白了。
这里用方框代表线程池中的工作线程和队列,圆形代表需要执行的任务,1和2分别代指上面提到的1号线程和2号线程。下图是正常情况下,线程池的状态。
外部的请求会促使feignClient将1号线程提交到线程池,随后1号线程会将2号提交,得到结果以后再返回。
然而在并发量上来的情况下,线程池的状态很容易变成这样的。
此时,线程池中所有的线程都是1号线程,而由1号线程所提交的2号线程还在排队,池中没有任何空余线程可供调度,但是池中的1号线程拿到返回结果,也不会释放线程,双方竞争僵持不下产生了死锁,一直到hystrix检测到超时,强行终止了1号线程,2号线程才得以运行,但是即使它运行结束得到结果,返回值也不会被读取,只能白白空耗资源。
教训&总结
总结出原因以后,改进了feignClient的方式,将default接口从原本的interface中迁移到外层,调用interface时一定只调用最终发送请求的那个接口就没问题了,示例代码如下。
@Component
public class DemoUtil {
@Resource
private DemoClient demoClient;
String easyRequest(int id, String param, double threshold, boolean showAll) {
Response<String> response = demoClient.request(new Request(id, param, threshold, showAll));
if (response.isSuccess()) {
return response.getResult();
}
return null;
}
}
把这个记录下来,主要是因为一开始的时候怎么也没想到,图偷懒写一个方法省事(并且由于Java不支持默认参数,多写几个重载方法相互调用也是很常见的做法),最后竟产生了死锁,所以在涉及到系统关键操作时,还是要对原理多一些理解。