那些你不知道的 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掉,這裡就有兩個有意思的問題了:

TCP資料互動過程中,在一方發了RST以後,連線一定會終止麼

連線會立即終止麼,還是會等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個可用埠:

問題分析

Linux中究竟有多少個埠是可以被使用

為什麼在 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核心這般穩定的東西,也不是一成不變的。唯一不變的是變化,也許你也是可以來資料庫核心玩玩底層技術的。

相關文章