最近在做數據庫相關的事情,碰到了很多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掉,這裡就有兩個有意思的問題了:
查看一下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内核這般穩定的東西,也不是一成不變的。唯一不變的是變化,也許你也是可以來數據庫内核玩玩底層技術的。
本文為阿裡雲原創内容,未經允許不得轉載。
,更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!