那些你不知道的 TCP 冷門知識

最近在做數據庫相關的事情,碰到了很多TCP相關的問題,新的場景新的挑戰,有很多之前並沒有掌握透徹的點,大大開了一把眼界,選了幾個案例分享一下。

案例一:TCP中並不是所有的RST都有效

背景知識:在TCP協議中,包含RST標識位的包,用來異常的關閉連接。在TCP的設計中它是不可或缺的,發送RST段關閉連接時,不必等緩衝區的數據都發送出去,直接丟棄緩衝區中的數據。而接收端收到RST段後,也不必發送ACK來確認。

問題現象:某客戶連接數據庫經常出現連接中斷,但是經過反覆排查,後端數據庫實例排查沒有執行異常或者Crash等問題,客戶端Connection reset的堆棧如下圖

經過復現及雙端抓包的初步定位,找到了一個可疑點,TCP交互的過程中客戶端發了一個RST(後經查明是客戶端本地的一些安全相關iptables規則導致),但是神奇的是,這個RST並沒有影響TCP數據的交互,雙方很愉快的無視了這個RST,很開心的繼續數據交互,然而10s鍾之後,連接突然中斷,參看如下抓包:

關鍵點分析

從抓包現象看,在客戶端發了一個RST之後,雙方的TCP數據交互似乎沒有受到任何影響,無論是數據傳輸還是ACK都很正常,在本輪數據交互結束後,TCP連接又正常的空閒了一會,10s之後連接突然被RST掉,這裏就有兩個有意思的問題了:

  1. TCP數據交互過程中,在一方發了RST以後,連接一定會終止麼
  2. 連接會立即終止麼,還是會等10s

查看一下RFC的官方解釋:

簡單來說,就是RST包並不是一定有效的,除了在TCP握手階段,其他情況下,RST包的Seq號,都必須in the window,這個in the window其實很難從字面理解,經過對Linux內核代碼的輔助分析,確定了其含義實際就是指TCP的 —— 滑動窗口,準確說是滑動窗口中的接收窗口。

我們直接檢查Linux內核源碼,內核在收到一個TCP報文後進入如下處理邏輯:

下面是內核中關於如何確定Seq合法性的部分:

總結

Q:TCP數據交互過程中,在一方發了RST以後,連接一定會終止麼?
A:不一定會終止,需要看這個RST的Seq是否在接收方的接收窗口之內,如上例中就因爲Seq號較小,所以不是一個合法的RST被Linux內核無視了。

Q:連接會立即終止麼,還是會等10s?A:連接會立即終止,上面的例子中過了10s終止,正是因爲,linux內核對RFC嚴格實現,無視了RST報文,但是客戶端和數據庫之間經過的SLB(雲負載均衡設備),卻處理了RST報文,導致10s(SLB 10s 後清理session)之後關閉了TCP連接

這個案例告訴我們,透徹的掌握底層知識,其實是很有用的,否則一旦遇到問題,(自證清白並指向root cause)都不知道往哪個方向排查。

案例二:Linux內核究竟有多少TCP端口可用

背景知識:我們平時有一個常識,Linux內核一共只有65535個端口號可用,也就意味着一臺機器在不考慮多網卡的情況下最多隻能開放65535個TCP端口。

但是經常看到有單機百萬TCP連接,是如何做到的呢,這是因爲,TCP是採用四元組(Client端IP + Client端Port + Server端IP + Server端Port)作爲TCP連接的唯一標識的。如果作爲TCP的Server端,無論有多少Client端連接過來,本地只需要佔用同一個端口號。而如果作爲TCP的Client端,當連接的對端是同一個IP + Port,那確實每一個連接需要佔用一個本地端口,但如果連接的對端不是同一個IP + Port,那麼其實本地是可以複用端口的,所以實際上Linux中有效可用的端口是很多的(只要四元組不重複即可)。

問題現象:作爲一個分佈式數據庫,其中每個節點都是需要和其他每一個節點都建立一個TCP連接,用於數據的交換,那麼假設有100個數據庫節點,在每一個節點上就會需要100個TCP連接。當然由於是多進程模型,所以實際上是每個併發需要100個TCP連接。假如有100個併發,那就需要1W個TCP連接。但事實上1W個TCP連接也不算多,由之前介紹的背景知識我們可以得知,這遠遠不會達到Linux內核的瓶頸。但是我們卻經常遇到端口不夠用的情況, 也就是“bind:Address already in use”:

其實看到這裏,很多同學已經在猜測問題的關鍵點了,經典的TCP time_wait 問題唄,關於TCP的 time_wait 的背景介紹以及應對方法不是本文的重點就不贅述了,可以自行了解。乍一看,系統中有50W的 time_wait 連接,才65535的端口號,必然不可用:

但是這個猜測是錯誤的!因爲系統參數 net.ipv4.tcp_tw_reuse 早就已經被打開了,所以不會由於 time_wait 問題導致上述現象發生,理論上說在開啓 net.ipv4.cp_tw_reuse 的情況下,只要對端IP + Port 不重複,可用的端口是很多的,因爲每一個對端IP + Port都有65535個可用端口:

問題分析

  1. Linux中究竟有多少個端口是可以被使用
  2. 爲什麼在 tcp_tw_reuse 情況下,端口依然不夠用

Linux有多少端口可以被有效使用

理論來說,端口號是16位整型,一共有65535個端口可以被使用,但是Linux操作系統有一個系統參數,用來控制端口號的分配:

net.ipv4.ip_local_port_range

我們知道,在寫網絡應用程序的時候,有兩種使用端口的方式:

  • 方式一:顯式指定端口號 —— 通過 bind() 系統調用,顯式的指定bind一個端口號,比如 bind(8080) 然後再執行 listen() 或者 connect() 等系統調用時,會使用應用程序在 bind()中指定的端口號。
  • 方式二:系統自動分配 —— bind() 系統調用參數傳0即 bind(0) 然後執行 listen()。或者不調用 bind(),直接 connect(),此時是由Linux內核隨機分配一個端口號,Linux內核會在 net.ipv4.ip_local_port_range 系統參數指定的範圍內,隨機分配一個沒有被佔用的端口。

例如如下情況,相當於 1-20000 是系統保留端口號(除非按方法一顯式指定端口號),自動分配的時候,只會從 20000 - 65535 之間隨機選擇一個端口,而不會使用小於20000的端口:

爲什麼在 tcp_tw_reuse=1 情況下,端口依然不夠用

細心的同學可能已經發現了,報錯信息全部都是 bind() 這個系統調用失敗,而沒有一個是 connect() 失敗。在我們的數據庫分佈式節點中,所有 connect() 調用(即作爲TCP client端)都成功了,但是作爲TCP server的 bind(0) + listen() 操作卻有很多沒成功,報錯信息是端口不足。

由於我們在源碼中,使用了 bind(0) + listen() 的方式(而不是bind某一個固定端口),即由操作系統隨機選擇監聽端口號,問題的根因,正是這裏。connect() 調用依然能從
net.ipv4.ip_local_port_range 池子裏撈出端口來,但是 bind(0) 卻不行了。爲什麼,因爲兩個看似行爲相似的系統調用,底層的實現行爲卻是不一樣的。

源碼之前,了無祕密:bind() 系統調用在進行隨機端口選擇時,判斷是否可用是走的 inet_csk_bind_conflict ,其中排除了存在 time_wait 狀態連接的端口:

而 connect() 系統調用在進行隨機端口的選擇時,是走 __inet_check_established 判斷可用性的,其中不但允許複用存在 TIME_WAIT 連接的端口,還針對存在TIME_WAIT的連接的端口進行了如下判斷比較,以確定是否可以複用:

一張圖總結一下:

於是答案就明瞭了,bind(0) 和 connect()衝突了,ip_local_port_range 的池子裏被 50W 個 connect() 遺留的 time_wait 佔滿了,導致 bind(0) 失敗。知道了原因,修復方案就比較簡單了,將 bind(0) 改爲bind指定port,然後在應用層自己維護一個池子,每次從池子中隨機地分配即可。

總結

Q:Linux中究竟有多少個端口是可以被有效使用的?
A:Linux一共有65535個端口可用,其中 ip_local_port_range 範圍內的可以被系統隨機分配,其他需要指定綁定使用,同一個端口只要TCP連接四元組不完全相同可以無限複用。

Q:什麼在 tcp_tw_reuse=1 情況下,端口依然不夠用?
A:connect() 系統調用和 bind(0) 系統調用在隨機綁定端口的時候選擇限制不同,bind(0) 會忽略存在 time_wait 連接的端口。

這個案例告訴我們,如果對某一個知識點比如 time_wait,比如Linux究竟有多少Port可用知道一點,但是隻是一知半解,就很容易陷入思維陷阱,忽略真正的Root Case,要掌握就要透徹。

案例三:詭異的幽靈連接

背景知識:TCP三次握手,SYN、SYN-ACK、ACK是所有人耳熟能詳的常識,但是具體到Socket代碼層面,是如何和三次握手的過程對應的,恐怕就不是那麼瞭解了,可以看一下如下圖,理解一下(圖源:小林coding):

這個過程的關鍵點是,在Linux中,一般情況下都是內核代理三次握手的,也就是說,當你client端調用 connect() 之後內核負責發送SYN,接收SYN-ACK,發送ACK。然後 connect() 系統調用纔會返回,客戶端側握手成功。

而服務端的Linux內核會在收到SYN之後負責回覆SYN-ACK再等待ACK之後纔會讓 accept() 返回,從而完成服務端側握手。於是Linux內核就需要引入半連接隊列(用於存放收到SYN,但還沒收到ACK的連接)和全連接隊列(用於存放已經完成3次握手,但是應用層代碼還沒有完成 accept() 的連接)兩個概念,用於存放在握手中的連接。

問題現象:我們的分佈式數據庫在初始化階段,每兩個節點之間兩兩建立TCP連接,爲後續數據傳輸做準備。但是在節點數比較多時,比如320節點的情況下,很容易出現初始化階段卡死,經過代碼追蹤,卡死的原因是,發起TCP握手側已經成功完成的了 connect() 動作,認爲TCP已建立成功,但是TCP對端卻沒有握手成功,還在等待對方建立TCP連接,從而整個集羣一直沒有完成初始化。

關鍵點分析:看過之前的背景介紹,聰明的小夥伴一定會好奇,假如我們上層的 accpet() 調用沒有那麼及時(應用層壓力大,上層代碼在幹別的),那麼全連接隊列是有可能會滿的,滿的情況會是如何效果,我們下面就重點看一下全連接隊列滿的時候會發生什麼。當全連接隊列滿時,connect() 和 accept() 側是什麼表現行爲?實踐是檢驗真理的最好途徑我們直接上測試程序。

client.c :

server.c :

通過執行上述代碼,我們觀察Linux 3.10版本內核在全連接隊列滿的情況下的現象。神奇的事情發生了,服務端全連接隊列已滿,該連接被丟掉,但是客戶端 connect() 系統調用卻已經返回成功,客戶端以爲這個TCP連接握手成功了,但是服務端卻不知道,這個連接猶如幽靈一般存在了一瞬又消失了:

這個問題對應的抓包如下:

正如問題中所述的現象,在一個320個節點的集羣中,總會有個別節點,明明 connect() 返回成功了,但是對端卻沒有成功,因爲3.10內核在全連接隊列滿的情況下,會先回復SYN-ACK,然後移進全連接隊列時才發現滿了於是丟棄連接,這樣從客戶端看來TCP連接成功了,但是服務端卻什麼都不知道。

Linux 4.9版本內核在全連接隊列滿時的行爲在4.9內核中,對於全連接隊列滿的處理,就不一樣,connect() 系統調用不會成功,一直阻塞,也就是說能夠避免幽靈連接的產生:

抓包報文交互如下,可以看到Server端沒有回覆SYN-ACK,客戶端一直在重傳SYN:

事實上,在剛遇到這個問題的時候,我第一時間就懷疑到了全連接隊列滿的情況,但是悲劇的是看的源碼是Linux 3.10的,而隨手找的一個本地日常測試的ECS卻剛好是Linux 4.9內核的,導致寫了個demo測試例子卻死活沒有復現問題。排除了所有其他原因,再次繞回來的時候已經是一週之後了(這是一個悲傷的故事)。

總結

Q:當全連接隊列滿時,connect() 和 accept() 側是什麼表現行爲?
A:Linux 3.10內核和新版本內核行爲不一致,如果在Linux 3.10內核,會出現客戶端假連接成功的問題,Linux 4.9內核就不會出現問題。

這個案例告訴我們,實踐是檢驗真理的最好方式,但是實踐的時候也一定要睜大眼睛看清楚環境差異,如Linux內核這般穩定的東西,也不是一成不變的。唯一不變的是變化,也許你也是可以來數據庫內核玩玩底層技術的。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章