问题 在给我司旧系统解决性能问题的时候,有一个让我印象深刻的事件。我们有个历史项目,是用 Netty 做的 Http 服务器,这个项目在运行一段时间之后,几乎所有的接口都会变得很慢。通过一系列的观察,可以发现的是,每次重启服务器,接口就会变得很快,然后只要过了一周,即使在并发量及低的情况下,接口依然很慢。我最终定位到了瓶颈出现在了一个点上:通过 lsof -i
这个命令,可以看到项目所绑定的 8080 端口上,有着大量的 CLOSE_WAIT 的 tcp socket fd。具体的表征就是
#本地复现
1 2 3 4 5 6 7 8 9 66387 springchan 435u IPv6 0x978a4f8b9aa9a147 0t0 TCP localhost:http-alt->localhost:56126 (CLOSE_WAIT) java 66387 springchan 436u IPv6 0x978a4f8b9aa9b847 0t0 TCP localhost:http-alt->localhost:56127 (CLOSE_WAIT) java 66387 springchan 437u IPv6 0x978a4f8bacc17987 0t0 TCP localhost:http-alt->localhost:56319 (CLOSE_WAIT) java 66387 springchan 438u IPv6 0x978a4f8b9a8d4287 0t0 TCP localhost:http-alt->localhost:56129 (CLOSE_WAIT) java 66387 springchan 439u IPv6 0x978a4f8b9a8d53c7 0t0 TCP localhost:http-alt->localhost:56130 (CLOSE_WAIT) java 66387 springchan 440u IPv6 0x978a4f8b9a8d3707 0t0 TCP localhost:http-alt->localhost:56131 (CLOSE_WAIT) java 66387 springchan 441u IPv6 0x978a4f8b9a8d3147 0t0 TCP localhost:http-alt->localhost:56132 (CLOSE_WAIT) java 66387 springchan 442u IPv6 0x978a4f8bae270707 0t0 TCP localhost:http-alt->localhost:56350 (CLOSE_WAIT) java 66387 springchan 443u IPv6 0x978a4f8b9a8d4e07 0t0 TCP localhost:http-alt->localhost:56134 (CLOSE_WAIT)
也就是说,有着大量的 tcp socket 资源在并发量很低的情况下依旧处于 CLOSE_WAIT 的状态。可以肯定的是,这种现象一定是不健康的。CLOSE_WAIT 处于 TCP 四次分手过程中,客户端首先给服务端发送了 fin 报,服务端接受之后就会回应一个 ack 报给对方,同时状态切换为 CLOSE_WAIT。接下来呢,服务端真正需要考虑的事情是察看是否还有数据发送给对方 ,如果没有的话,那么就可以 close 这个 SOCKET,发送 FIN 报文给对方,也即关闭连接。所以你在 CLOSE_WAIT 状态下,需要完成的事情是等待你去关闭连接。这个时候,这两个过程是可以控制的,第一种,服务端不再发送报文给客户端,直接 close。第二种,服务端发送 fin 报给客户端,同时自己处于 LAST_ACK 状态。所以我比较确定的是,服务端在管理 TCP 连接的阶段出了问题。
复现 第一步,我用 ab test 复现这个情况,这个还是比较容易的。在本地,我对任何一个接口并发请求,这个时候可以看到服务端有大量的 ESTABLISHED 状态的 socket。第二步,我强行让请求端发送 fin 报,其实很简单:ctr + c
。这个时候如我所料,这些 socket 都变成 CLOSE_WAIT 状态。
分析 & 解决 这个老项目本身就是个单体应用,所以可以很确认问题是出在了项目代码这一块。可以猜测的是,是使用 Netty 的方式不对。所以我第一步,去研究了 Netty 的基本原理。
直接发现问题出现在了 channelRead()
这个方法里面。按照官方的说明,使用者的服务里面不应该有阻塞调用。这会严重耗费系统资源。如果有阻塞调用,希望放在线程池里。所以我将项目中的阻塞调用层,统一用了线程池来管理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class HttpServerInboundHandler extends ChannelInboundHandlerAdapter { @Override public void channelRead (ChannelHandlerContext ctx, Object msg) { new Route(ctx, msg).init(); } @Override public void channelReadComplete (ChannelHandlerContext ctx) throws Exception { ctx.flush(); } @Override public void exceptionCaught (ChannelHandlerContext ctx, Throwable cause) { ctx.flush(); ctx.channel().close(); ctx.close(); } }
修改之后
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 public class HttpServerInboundHandler extends ChannelInboundHandlerAdapter { ExecutorService executor =Executors.newCachedThreadPool(); @Override public void channelRead (ChannelHandlerContext ctx, Object msg) { executor.execute(new Route(ctx, msg)); } @Override public void channelReadComplete (ChannelHandlerContext ctx) throws Exception { ctx.flush(); } @Override public void exceptionCaught (ChannelHandlerContext ctx, Throwable cause) { ctx.flush(); ctx.channel().close(); ctx.close(); } }
这里让线程池来处理阻塞调用层的业务。经过 ab 测试发现不会再有类似的问题出现。
Netty 本质上是一个纯异步的框架,用来做一些并发高,实时性强的系统,比如通信,游戏等。但对于普通的互联网服务,比如由 tomcat,jetty 构建的一些服务而言,netty 可能并不适合。Netty 设计之初意在高性能通信,用有限的资源产出最大的并发量级。所以使用者不得不很清楚 socket 编程,select,poll 模型等。这对于单纯的非实时高并发应用开发者来说,增加了不少复杂度。如果是 Java 生态的,并且需要自己实现一些服务治理功能的,基于 Netty 开发是个明智的选择。