背景
我們線上有一個 dubbo 的服務,出現大量的 CLOSE_WAIT 狀态的連接配接,這些 CLOSE_WAIT 的連接配接出現以後不會消失,這就有點意思了,于是做了一下分析記錄如下。
首先從 TCP 的角度看一下 CLOSE_WAIT
CLOSE_WAIT 狀态出現在被動關閉方,當收到對端 FIN 以後回複 ACK,但是自身沒有發送 FIN 包之前。
是以這裡的原因就很清楚了,出現永遠存在的 CLOSE_WAIT 的連接配接是因為,收到了對端的 FIN 包,但是自己一直沒有回複 FIN。通過抓包确實驗證了這個的想法。
問題就落在了為什麼沒有回複 FIN,這是一個健康檢查探測的請求,三次握手成功以後,探測服務會馬上發送 FIN,理論上 dubbo 服務也會立刻回複 FIN,但是沒有任何反應。
對于 dubbo 底層使用的 netty 來說,它就是一個普通的 tcp 服務端,無非就這幾步:
- bind、listen
- 注冊 accept 事件到 epoll
- epoll_wait 等待連接配接到來
- 連接配接到來時,調用 accept 接收連接配接
- 注冊新連接配接的 EPOLLIN、EPOLLERR、EPOLLHUP 等事件到 epoll
- epoll_wait 等待事件發生
如果是沒有發送 fin,有幾個比較明顯的可能原因。
- 第 2 步沒有做,壓根沒有注冊 accept 事件(可以排除,肯定有注冊)
- 第 4 步沒有做,連接配接到來時,netty 「忘了」調用 accept 把連接配接從核心的全連接配接隊列裡取走。這裡的「忘」可能是因為邏輯 bug 或者 netty 忙于其他事情沒有時間取走,這個待會驗證
- 第 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,全連接配接隊列沒有積壓。
至此最大的嫌疑在第 3 個原因,netty 确實調用了 accept 取走了連接配接,但是沒有注冊此連接配接的任何事件,導緻後面收到了 fin 包以後無動于衷。
為什麼 netty 沒有能注冊事件?
到這裡暫時陷入了僵局,但是有一個跟此次問題強相關的現象浮出了水面,就是業務執行個體在淩晨 1 點有個定時任務,一開始就 load 了大量的資料到記憶體中,導緻堆記憶體占滿,持續進行 fullgc
netty 線程也有列印 oom 異常。
這裡的 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 的源碼,采用了此方法更快。
重新建構項目,然後用 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 的代碼不夠健壯,一個 try-catch 包裹的邏輯太多,在 OOM throwable 異常處理時,沒能成功注冊事件也沒有 close 已建立的連接配接,導緻連接配接存在但是沒有人監聽事件處理。
可能有人會的一些疑問,為什麼沒有人監聽事件了,收到 fin 包,還是會回複 ACK?
因為回複 ACK 是核心協定棧的行為,不需要應用參與,也不需要關心是否有人感興趣。
如何修改
修改就很簡單了,在 catch 的 throwable 邏輯裡關閉一下就可以了,這裡就不貼代碼了。
最新版本的 netty 代碼這部分代碼看起來應該是完善了(沒有去做實驗),它把 accept 和注冊事件拆分開了,感興趣的同學可以試試。
後記
學好 TCP、網絡程式設計是解決這些類似問題的利器,隔離在家一起學起來。