天天看點

一次 Netty 代碼不健壯導緻的大量 CLOSE_WAIT 連接配接原因分析

作者:馬士兵教育CTO
一次 Netty 代碼不健壯導緻的大量 CLOSE_WAIT 連接配接原因分析

背景

我們線上有一個 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 斷開連接配接。

這個 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、網絡程式設計是解決這些類似問題的利器,隔離在家一起學起來。

繼續閱讀