布景

我们线上有一个 dubbo 的服务,呈现很多的 CLOSE_WAIT 状况的衔接,这些 CLOSE_WAIT 的衔接呈现今后不会消失,这就有点意思了,所以做了一下分析记录如下。

首先从 TCP 的角度看一下 CLOSE_WAIT

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

CLOSE_WAIT 状况呈现在被迫封闭方,当收到对端 FIN 今后回复 ACK,可是本身没有发送 FIN 包之前。

所以这儿的原因就很清楚了,呈现永久存在的 CLOSE_WAIT 的衔接是由于,收到了对端的 FIN 包,可是自己一直没有回复 FIN。经过抓包的确验证了这个的想法。

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

问题就落在了为什么没有回复 FIN,这是一个健康检查勘探的恳求,三次握手成功今后,勘探服务会马上发送 FIN,理论上 dubbo 服务也会马上回复 FIN,可是没有任何反响。

关于 dubbo 底层运用的 netty 来说,它便是一个普通的 tcp 服务端,无非就这几步:

  1. bind、listen
  2. 注册 accept 事情到 epoll
  3. epoll_wait 等候衔接到来
  4. 衔接到来时,调用 accept 接收衔接
  5. 注册新衔接的 EPOLLIN、EPOLLERR、EPOLLHUP 等事情到 epoll
  6. epoll_wait 等候事情发生

如果是没有发送 fin,有几个比较明显的或许原因。

  1. 第 2 步没有做,压根没有注册 accept 事情(能够排除,肯定有注册)
  2. 第 4 步没有做,衔接到来时,netty 「忘了」调用 accept 把衔接从内核的全衔接行列里取走。这儿的「忘」或许是由于逻辑 bug 或许 netty 忙于其他事情没有时间取走,这个待会验证
  3. 第 5 步没有做,取走了衔接,三次握手真实完结,可是没有注册新衔接的后续事情

第 2 个原因能够经过半衔接行列、全衔接行列的积压来承认。ss 命令能够检查全衔接行列的大小和当时等候 accept 的衔接个数。

ss -lnt | grep :9090
State      Recv-Q Send-Q Local Address:Port               Peer Address:Port
LISTEN     51     50           *:9090                     *:*
  • 处于 LISTEN 状况的 socket,Recv-Q 表明当时 socket 的完结三次握手等候用户进程 accept 的衔接个数,Send-Q 表明当时 socket 全衔接行列能最大容纳的衔接数
  • 关于非 LISTEN 状况的 socket,Recv-Q 表明 receive queue 的字节大小,Send-Q 表明 send queue 的字节大小

经过 ss 命令承认过 Recv-Q 为 0,全衔接行列没有积压。

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

至此最大的嫌疑在第 3 个原因,netty 的确调用了 accept 取走了衔接,可是没有注册此衔接的任何事情,导致后边收到了 fin 包今后无动于衷。

为什么 netty 没有能注册事情?

到这儿暂时陷入了僵局,可是有一个跟此次问题强相关的现象浮出了水面,便是业务实例在清晨 1 点有个定时任务,一开始就 load 了很多的数据到内存中,导致堆内存占满,持续进行 fullgc

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

netty 线程也有打印 oom 反常。

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

这儿的 OOM 反常上面的一个 warning 引起了搭档斌哥的主见,去 netty 源码中一查找,发现呈现在 org.jboss.netty.channel.socket.nio.NioServerBoss#process 办法中(netty 版别很古老 3.7.0.final)

  1 @Override
  2 protected void process(Selector selector) {
  3 Set<SelectionKey> selectedKeys = selector.selectedKeys();
  4 if (selectedKeys.isEmpty()) {
  5     return;
  6 }
  7 for (Iterator<SelectionKey> i = selectedKeys.iterator(); i.hasNext();) {
  8     SelectionKey k = i.next();
  9     i.remove();
 10     NioServerSocketChannel channel = (NioServerSocketChannel) k.attachment();
 11
 12     try {
 13         // accept connections in a for loop until no new connection is ready
 14         for (;;) {
 15             SocketChannel acceptedSocket = channel.socket.accept(); // 调用 accept 从全衔接行列取走衔接
 16             if (acceptedSocket == null) {
 17                 break;
 18             }
 19             registerAcceptedChannel(channel, acceptedSocket, thread); // 为新衔接注册事情
 20         }
 21     } catch (CancelledKeyException e) {
 22         // Raised by accept() when the server socket was closed.
 23         k.cancel();
 24         channel.close();
 25     } catch (SocketTimeoutException e) {
 26         // Thrown every second to get ClosedChannelException
 27         // raised.
 28     } catch (ClosedChannelException e) {
 29         // Closed as requested.
 30     } catch (Throwable t) {
 31         if (logger.isWarnEnabled()) {
 32             logger.warn(
 33                     "Failed to accept a connection.", t);
 34         }
 35
 36         try {
 37             Thread.sleep(1000);
 38         } catch (InterruptedException e1) {
 39             // Ignore
 40         }
 41     }
 42 }
 43 }

第 15 行 netty 调用 accept 从全衔接行列取走衔接,第 19 行调用 registerAcceptedChannel,将当时 fd 设置为非堵塞一同为新衔接 fd 注册事情,具体的逻辑是在 org.jboss.netty.channel.socket.nio.NioWorker.RegisterTask#run中。

从过错日志中能够知道,这个办法的确抛出了 java.lang.OutOfMemoryError 反常。

因此这儿的原因就很清楚了,netty 这儿的处理的确不强健,一个 try-catch 包裹了 accept 衔接和注册事情这两个逻辑,当第 15 行 accept 成功,但在 19 行 registerAcceptedChannel 内部尝试注册事情时由于线程 OOM 排除反常时就凉凉了,没有close 这个新衔接,就导致了后边收到 fin 今后底子不会回复任何包(epoll 里压根没有这个 fd 的感兴趣事情)。

模仿复现

有几种办法,直接字节码注入一下,抛出反常或许直接改 netty 源码从头构建一下。由于本地有 netty 的源码,采用了此办法更快。

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

从头构建项目,然后用 nc 模仿健康检查握手然后 ctrl-c 断开衔接。

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

这个 CLOSE_WAIT 就一直存在了直到 netty 进程退出。再来一次 nc 然后断开就又多了一个 CLOSE_WAIT

由于我们线上的服务的健康检查一直在进行,导致 OOM 期间 CLOSE_WAIT 持续增加。写一个最简略的 go 程序模仿持续的健康检查

func main() {
	for i := 0; i < 200; i++ {
		println(i)
		conn, err := net.Dial("tcp", "192.168.31.197:20880")
		if err != nil {
			println(err)
			time.Sleep(time.Millisecond * 1500)
			continue
		}
		conn.Close()
		time.Sleep(time.Millisecond * 1500)
	}
	time.Sleep(time.Minute * 20000000)
}

的确会呈现很多 CLOSE_WAIT

一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析

到这儿的问题就很清楚了,总结便是 netty 的代码不行强健,一个 try-catch 包裹的逻辑太多,在 OOM throwable 反常处理时,没能成功注册事情也没有 close 已创立的衔接,导致衔接存在可是没有人监听事情处理。

或许有人会的一些疑问,为什么没有人监听事情了,收到 fin 包,仍是会回复 ACK?

由于回复 ACK 是内核协议栈的行为,不需要应用参加,也不需要关怀是否有人感兴趣。

怎么修改

修改就很简略了,在 catch 的 throwable 逻辑里封闭一下就能够了,这儿就不贴代码了。

最新版别的 netty 代码这部分代码看起来应该是完善了(没有去做试验),它把 accept 和注册事情拆分开了,感兴趣的同学能够试试。

跋文

学好 TCP、网络编程是解决这些类似问题的利器,隔离在家一同学起来。