高并发下,Tomcat、HttpClient 让系统瘫痪
最近做了一个项目,需要通过http多次请求和外部系统数据交换,例如支付,地图等。但是交互过程通过http调用第三方接口响应时间慢会导致并发量下降,甚至堵死系统。
下面将从Tomcat底层原理上分析为什么http交互会导致Tomcat性能下降。
Tomcat和BIO
老版本的Tomcat底层使用BIO方式实现,就是java常用的Socket网络编程。
什么是BIO
BIO的实现在java.io包中。它是基于流模型实现的,交互的方式是同步、阻塞方式。也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里。
特点:
1.同步阻塞IO
2.一个请求对应一个线程
3.没有数据达到,也会阻塞
优点: 代码比较简单、直观
缺点: 同步执行导致阻塞,一个Socket使用一个线程,浪费资源,容易成为应用性能瓶颈。
正是因为BIO的特性,因此每一个客户端连接需要分配一个线程。虽然使用线程池可以让提升处理性能,但是线程分配也是有上限的不可能无限分配线程。这就导致如果系统内发起http请求返回数据等待时间较长时,并发数基本上就是分配的线程数上限。
当线程池分配的线程都在使用时,新accept的socket在调用executorService.execute时就会进入线程池的队列中等待。等到有可用线程时任务才开始执行,但是http请求的响应时间又很长,这就导致后续的socket等待的时间也开始变长,出现恶性循环。
ExecutorService executorService = Executors.newFixedThreadPool(200); ServerSocket serverSocket = new ServerSocket(8080); while (true) { Socket accept = serverSocket.accept(); executorService.execute(new Runnable() { @Override public void run() { try { // todo 调用servlet } catch (IOException e) { e.printStackTrace(); } } }); }
那有没有方法可以提高Tomcat的性能呢?其实新版本的Tomcat的底层有一套NIO的实现,通过配置Connector的protocol为Http11NioProtocol就可以实现NIO的方式。
Tomcat和NIO
什么是NIO
NIO的实现在java.nio包中,通过单个Selector监听多个Channel中的数据到达事件,俗称多路复用。这样的好处是一个线程就可以监听许多Channel的数据,相对于BIO有着显著的性能提成的。
特点:
1.同步非阻塞IO
2.利用IO多路复用技术+NIO,多个channel一个线程监听
优点: 事件监听线程只有一个主线程。数据发过来时启动另一个线程读取,主线程又可以继续监听其他Channel的事件。
同时读取线程使用线程池可以公用资源,用完还给线程池再给别的线程用。
缺点: 事件监听是异步的,在业务中数据都是通过接口回调的方式进行的。所以编程的思想和思路都要发生转变。
同时也增加了技术实现的难度。
// 创建一个selector Selector selector = Selector.open();
// 初始化TCP连接监听通道 ServerSocketChannel serverCh = ServerSocketChannel.open(); serverCh.bind(new InetSocketAddress(8080)); serverCh.configureBlocking(false); serverCh.register(selector, SelectionKey.OP_ACCEPT);
while (true) { int events = selector.select(); if (events > 0) { Iterator selectionKeys = selector.selectedKeys().iterator(); while (selectionKeys.hasNext()) { SelectionKey key = selectionKeys.next(); if (key.isAcceptable()) { SocketChannel sc = ((ServerSocketChannel) key.channel()).accept(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // todo 调用servlet } selectionKeys.remove(); } } }
问题: 这么说是不是使用Tomcat的Http11NioProtocol就万事大吉了呢?
我们以Spring Boot为例,Spring Boot底层集成了Embed Tomcat并且使用了Http11NioProtocol。
下面使用Spring Boot实现一个请求第三方接口返回数据的demo。
配置一个test接口延时1000毫秒后返回数据,来模拟第三方接口返回数据慢的情况。
@SpringBootApplication @RestController public class DemoApplication {
public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }
@Bean public OkHttpClient okHttpClient() { return new OkHttpClient(); }
@Autowired private OkHttpClient okHttpClient;
@RequestMapping("/bio") public byte[] bio(HttpServletRequest req) throws IOException { Request request = new Request.Builder().url("http://127.0.0.1:8080/test").build(); Response response = okHttpClient.newCall(request).execute(); byte[] bytes = response.body().bytes(); return bytes; }
@RequestMapping("/test") public String test() throws Exception { Thread.sleep(1000); return "ok"; } }
配置Tomcat线程数为500,最大排队数为0
server.port=8080 server.Tomcat.max-threads=200 server.Tomcat.accept-count=0
使用jmeter进行测试,配置jmeter并发线程数为800,每个线程循环100次,进行测试。
测试结束发现
当jmeter并发线程逐步升高到500以上时,性能开始下降直至整个系统崩溃
。似乎使用Tomcat NIO模型性能并没有提升。和BIO模型性能差不多,这是为什么呢。
所以我们再仔细回想一下,Tomcat使用了NIO监听数据事件,调用线程池异步执行。当代码执行到test方法时可以断点查看确实已经在线程池里了。
所以问题不是在Tomcat NIO上。那就是OKHttpClient的问题。
OkHttpClient号称是java界性能最好的HttpClient为什么性能不行呢。以下我们分析一下OkHttpClient的实现原理。
OkHttpClient底层使用BIO
如果你阅读过OkHttpClient的源代码你就会发现他的底层是BIO实现的。虽然使用NIO架构的Tomcat的工作线程有500个,但是当jmeter并发数到达500时,所有的线程都在阻塞等待OkHttpClient的数据返回。
如果这个时候再有新的请求上来,Tomcat就会因为线程数就不够而拒绝服务。
ReactorNetty
所以要想性能获得提升,就需要使用基于NIO的httpServer和httpClient。
下面我们httpServer继续使用NIO模型的Tomcat,OkHttpClient替换成reactor-netty的HttpClient。
首先,导入maven包
<dependencies> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-core</artifactId> <version>3.3.1.RELEASE</version> </dependency> <dependency> <groupId>io.projectreactor.netty</groupId> <artifactId>reactor-netty</artifactId> <version>0.9.2.RELEASE</version> </dependency> </dependencies>
代码实现如下
@SpringBootApplication @RestController public class DemoApplication implements WebMvcConfigurer {
public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); }
@RequestMapping("/nio") public DeferredResult<byte[]> nio(HttpServletRequest req) throws IOException { DeferredResult<byte[]> result = new DeferredResult(0L); HttpClient httpClient = HttpClient.create(); httpClient.request(HttpMethod.GET) .uri("http://127.0.0.1:8080/test") .responseSingle(new BiFunction<HttpClientResponse, ByteBufMono, Mono<byte[]>>() { @Override public Mono<byte[]> apply(HttpClientResponse httpClientResponse, ByteBufMono byteBufMono) { return byteBufMono.asByteArray(); } }) .doOnNext(e -> result.setResult(e)) .doOnError(e -> result.setErrorResult(e)) .subscribe(); return result; }
@RequestMapping("/test") public String test() throws Exception { Thread.sleep(1000); return "ok"; } }
为什么要使用DeferredResult呢。因为HttpClient数据发送和返回都是异步的。如果不使用DeferredResult,spring mvc默认你是同步调用test方法执行完成就默认返回200给客户端,并且释放HttpServletRequest和HttpServletResponse。
加了DeferredResult以后,spring mvc就知道你需要异步返回数据就会为保持和客户端的连接。
此时再次使用jmeter进行并发测试,配置参数不变。
对比图
BIO
NIO
总结
最后我们通过流程图再梳理一下整个的调用流程。
1.Tomcat监听到请求后启动线程处理业务,由于返回的是DeferredResult,所以与客户端连接保持。但是线程已经释放。
2.httpClient监听到,第三方接口返回数据时,启动线程处理数据返回客户端。结束这次http请求,释放线程。
通过流程图发生,实际上从httpClient收到数据时,后续的业务逻辑是在httpClient启动的线程上执行的,而不是在Tomcat的线程上执行的。这是和BIO模式最大的区别。
都2020年你在使用Tomcat和okhttp做业务?
微信关注我,下期带你了解最前沿的Spring WebFlux。