簡介: 最近在作數據庫相關的事情,碰到了不少TCP相關的問題,新的場景新的挑戰,有不少以前並無掌握透徹的點,大大開了一把眼界,選了幾個案例分享一下。linux
![](http://static.javashuo.com/static/loading.gif)
做者 | 韓述
來源 | 阿里技術公衆號數據庫
最近在作數據庫相關的事情,碰到了不少TCP相關的問題,新的場景新的挑戰,有不少以前並無掌握透徹的點,大大開了一把眼界,選了幾個案例分享一下。後端
案例一:TCP中並非全部的RST都有效
背景知識
在TCP協議中,包含RST標識位的包,用來異常的關閉鏈接。在TCP的設計中它是不可或缺的,發送RST段關閉鏈接時,沒必要等緩衝區的數據都發送出去,直接丟棄緩衝區中的數據。而接收端收到RST段後,也沒必要發送ACK來確認。安全
問題現象
某客戶鏈接數據庫常常出現鏈接中斷,可是通過反覆排查,後端數據庫實例排查沒有執行異常或者Crash等問題,客戶端Connection reset的堆棧以下圖:網絡
![](http://static.javashuo.com/static/loading.gif)
通過復現及雙端抓包的初步定位,找到了一個可疑點,TCP交互的過程當中客戶端發了一個RST(後經查明是客戶端本地的一些安全相關iptables規則致使),可是神奇的是,這個RST並無影響TCP數據的交互,雙方很愉快的無視了這個RST,很開心的繼續數據交互,然而10s鍾以後,鏈接忽然中斷,參看以下抓包:session
![](http://static.javashuo.com/static/loading.gif)
關鍵點分析
從抓包現象看,在客戶端發了一個RST以後,雙方的TCP數據交互彷佛沒有受到任何影響,不管是數據傳輸仍是ACK都很正常,在本輪數據交互結束後,TCP鏈接又正常的空閒了一會,10s以後鏈接忽然被RST掉,這裏就有兩個有意思的問題了:併發
- TCP數據交互過程當中,在一方發了RST之後,鏈接必定會終止麼
- 鏈接會當即終止麼,仍是會等10s
查看一下RFC的官方解釋:負載均衡
![](http://static.javashuo.com/static/loading.gif)
簡單來講,就是RST包並非必定有效的,除了在TCP握手階段,其餘狀況下,RST包的Seq號,都必須in the window,這個in the window其實很難從字面理解,通過對Linux內核代碼的輔助分析,肯定了其含義實際就是指TCP的 —— 滑動窗口,準確說是滑動窗口中的接收窗口。tcp
咱們直接檢查Linux內核源碼,內核在收到一個TCP報文後進入以下處理邏輯:分佈式
![](http://static.javashuo.com/static/loading.gif)
下面是內核中關於如何肯定Seq合法性的部分:
![](http://static.javashuo.com/static/loading.gif)
總結
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」:
![](http://static.javashuo.com/static/loading.gif)
其實看到這裏,不少同窗已經在猜想問題的關鍵點了,經典的TCP time_wait 問題唄,關於TCP的 time_wait 的背景介紹以及應對方法不是本文的重點就不贅述了,能夠自行了解。乍一看,系統中有50W的 time_wait 鏈接,才65535的端口號,必然不可用:
![](http://static.javashuo.com/static/loading.gif)
可是這個猜想是錯誤的!由於系統參數 net.ipv4.tcp_tw_reuse 早就已經被打開了,因此不會因爲 time_wait 問題致使上述現象發生,理論上說在開啓 net.ipv4.cp_tw_reuse 的狀況下,只要對端IP + Port 不重複,可用的端口是不少的,由於每個對端IP + Port都有65535個可用端口:
![](http://static.javashuo.com/static/loading.gif)
問題分析
- 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的端口:
![](http://static.javashuo.com/static/loading.gif)
爲何在 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 狀態鏈接的端口:
![](http://static.javashuo.com/static/loading.gif)
而 connect() 系統調用在進行隨機端口的選擇時,是走 __inet_check_established 判斷可用性的,其中不但容許複用存在 TIME_WAIT 鏈接的端口,還針對存在TIME_WAIT的鏈接的端口進行了以下判斷比較,以肯定是否能夠複用:
![](http://static.javashuo.com/static/loading.gif)
一張圖總結一下:
![](http://static.javashuo.com/static/loading.gif)
因而答案就明瞭了,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代碼層面,是如何和三次握手的過程對應的,恐怕就不是那麼瞭解了,能夠看一下以下圖,理解一下:
![](http://static.javashuo.com/static/loading.gif)
這個過程的關鍵點是,在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 :
![](http://static.javashuo.com/static/loading.gif)
server.c :
![](http://static.javashuo.com/static/loading.gif)
經過執行上述代碼,咱們觀察Linux 3.10版本內核在全鏈接隊列滿的狀況下的現象。神奇的事情發生了,服務端全鏈接隊列已滿,該鏈接被丟掉,可是客戶端 connect() 系統調用卻已經返回成功,客戶端覺得這個TCP鏈接握手成功了,可是服務端殊不知道,這個鏈接猶如幽靈通常存在了一瞬又消失了:
![](http://static.javashuo.com/static/loading.gif)
這個問題對應的抓包以下:
![](http://static.javashuo.com/static/loading.gif)
正如問題中所述的現象,在一個320個節點的集羣中,總會有個別節點,明明 connect() 返回成功了,可是對端卻沒有成功,由於3.10內核在全鏈接隊列滿的狀況下,會先回復SYN-ACK,而後移進全鏈接隊列時才發現滿了因而丟棄鏈接,這樣從客戶端看來TCP鏈接成功了,可是服務端卻什麼都不知道。
Linux 4.9版本內核在全鏈接隊列滿時的行爲
在4.9內核中,對於全鏈接隊列滿的處理,就不同,connect() 系統調用不會成功,一直阻塞,也就是說可以避免幽靈鏈接的產生:
![](http://static.javashuo.com/static/loading.gif)
抓包報文交互以下,能夠看到Server端沒有回覆SYN-ACK,客戶端一直在重傳SYN:
![](http://static.javashuo.com/static/loading.gif)
事實上,在剛遇到這個問題的時候,我第一時間就懷疑到了全鏈接隊列滿的狀況,可是悲劇的是看的源碼是Linux 3.10的,而隨手找的一個本地平常測試的ECS卻恰好是Linux 4.9內核的,致使寫了個demo測試例子卻死活沒有復現問題。排除了全部其餘緣由,再次繞回來的時候已是一週以後了(這是一個悲傷的故事)。
總結
Q:當全鏈接隊列滿時,connect() 和 accept() 側是什麼表現行爲?
A:Linux 3.10內核和新版本內核行爲不一致,若是在Linux 3.10內核,會出現客戶端假鏈接成功的問題,Linux 4.9內核就不會出現問題。
這個案例告訴咱們,實踐是檢驗真理的最好方式,可是實踐的時候也必定要睜大眼睛看清楚環境差別,如Linux內核這般穩定的東西,也不是一成不變的。惟一不變的是變化,也許你也是能夠來數據庫內核玩玩底層技術的。
https://developer.aliyun.com/article/783421?utm_content=g_1000262070
本文爲阿里雲原創內容,未經容許不得轉載。