白話TCP/IP原理
TCP/IP(Transmission-Control-Protocol/Internet-Protocol),中文譯名為傳輸控制協議/網際網路互聯協議,又名網路通訊協議,是Internet最基本的協議、也是Internet國際互聯網路的基礎。
我上大學的時候就是一門必修課。工作後我還專門重新看了一遍,覺得比上學時理解的多了些。但是書本上東西畢竟不貼合工作。本文結合工作中常用的方面以及現實中出現過的線上問題來講解說明。
TCP/IP協議棧為什麼是「棧」?
大學的時候課本上講過:開放系統互聯參考模型OSI/RM分7層,從低到高分別是物理層,數據鏈路層,網路層,傳輸層,會話層,表示層和應用層。
先從7層模型入手,是因為下面講的TCP/IP四層模型里缺少咱們平時工作中常用的RPC遠程過程調用協議,它工作在會話層,基於下面圖中的socket來實現。
實際上互聯網通訊使用的是TCP/IP協議棧。這裡舉例來說明為什麼叫「棧」。
棧是一種先進後出的數據結構。拿一個HTTP報文來說,HTTP報文屬於應用層協議的報文,我們輸入網址,首先會調用到DNS協議(域名協議)。HTTP報文在傳輸層用的是TCP協議,我們把TCP壓入棧中,再將IP層也壓入棧中。鏈路層的話,就用最常見的乙太網。
好了,現在我們的棧裡面從頭至尾依次是:
HTTP協議-TCP協議-IP協議-以太幀頭
然後我們先忽略最底層的物理層,假設這個封裝好的棧一樣的報文漂洋過海,來到了它的目的地。對端收到這個報文以後,也就是我們封裝好的這個棧一樣的東西以後該怎麼辦呢?會不會也是先拿HTTP呢?因為這個報文是我們構造的一個棧,所以說它的順序肯定也是棧。
因此拿取的順序就是:
以太幀頭-IP協議-TCP協議-HTTP協議
在《接下來一段時間會對大家進行網路通訊的魔鬼訓練-理解socket》中我講過:傳輸層操作是在內核空間完成的,就是說不是靠咱們平時的應用編碼可以直接介入的。咱們平時直接用的就是應用層協議。想通過應用層操作傳輸層怎麼辦呢?這就用到了socket編程。因為HTTP協議的內容被封裝進入了socket。這裡稱為套接字層。總體示意圖如下:
發現沒,最先被封裝入的HTTP報文(套接字層)是最後才被拿走的。
TCP三次握手和四次揮手
三次握手
在《懂得三境界-使用dubbo時請求超過問題》這篇文章中,我講過三次握手。這裡用打比方的方式再講一遍:
我在《兩地書–K8s基礎知識》里講過司馬相如和卓文君的故事。其實在巜史記》和《漢書》里有記載說:他們的愛情是一場精心策劃的陰謀。
司馬相如從一開始就打算找個富婆。所以和當地縣令竄通,縣令經常登門拜訪司馬相如,司馬相如卻避而不見。但縣令還是天天來。很有三顧茅廬的意思。當地都在傳這個人得縣令如此垂青一定不簡單。
卓文君的爹是當地首富,聽說之後就請司馬相如去坐客。司馬相如在宴會上的目標是和卓文君建立通訊聯繫。他精心計算過:卓文君夫婿剛剛去世,被接回娘家。不像未出閣的姑娘那麼挑剔。自己又儀錶堂堂,文采不凡。應該搞得定。
但是想傍富婆的何只他一人?他精心準備了一曲《鳳求凰》,向卓文君發出第一次建立通訊的請求。
第一次握手,客戶端司馬相如發出請求。服務端卓文君只有一個進程專門處理所有的連接請求,處理能力有限。所以這個才子、那個才子的建立聯繫請求會先放入隊列,服務端會按照順序來處理申請。
服務端卓文君收到客戶端司馬相如的申請,會驗證收到的資訊是否有誤(實際TCP中檢驗的是通訊數據是否有錯誤)。無誤則對這個客戶端的狀態變成listen狀態,並向司馬相如發出一個回執:「你的琴彈的不錯,可以建立聯繫。」這就是第二次握手。
客戶端司馬相如收到服務端卓文君的回執,認為自己的目的已達到,把自己標記為established狀態,並進行第三次握手:「好的,仰慕已久。」這時候客戶端已經一廂情願的認為自己可以自由與服務端通訊了。
但是服務端卓文君那邊呢,目前還處在與客戶端司馬相如建立連接的過程中,請求在全連接隊列中排隊呢。畢竟要應付的才子多啊。客戶端司馬相如的回執還在排隊,卓文君還沒處理呢。所以這時候,客戶端司馬相如如果發了一封書信(傳輸的數據)。卓文君沒還沒跟自己丫鬟說跟客戶端司馬相如已經建立聯繫了呢。所以書信根本到不了卓文君手上。這封信發送就失敗了。
等服務端卓文君處理到客戶端司馬相如的請求,真正建立連接。司馬相如的書信才真正到達卓文君手上。達到了司馬相如的第一步。
後來司馬相如讓卓文君和他私奔。他們回到司馬相如的家鄉,卓文君才發現司馬相如家一貧如洗。卓文君問娘家要錢,娘家不給。司馬相如就帶著卓文君回到了卓文君家鄉,並讓卓文君拋頭露面當廬賣酒。為的就是套路文君爸,讓他爸覺得臉上無光,主動給錢。
司馬相如計劃得逞,文君爸給了他們萬貫家財,讓他們回司馬相如老家過日子。但是錢大手大腳花,也很快花完了。司馬相如就進京找錢去了。
四次揮手
這次司馬相如果然得到了漢武帝賞識。司馬相如發達了就想休妻再娶。終於某日,客戶端司馬相如給服務端卓文君送出了一封十三字的分手信:「一二三四五六七八九十百千萬。」 無億,就是說我就你已經無意啦。這就是第一次揮手。實際上第一次揮手會告訴服務端如果你還有數據沒有發送完成,則不必急著關閉Socket,可以繼續發送數據。
服務端卓文君收到這個消息,非常震驚。她還沒有準備好。於是,先回復了一個消息讓客戶端司馬相如等一等。這就是第二次揮手。(這個時候客戶端就進入FIN_WAIT狀態,繼續等待服務端的FIN報文。)
在這時候,他們還是夫妻關係。服務端卓文君可以繼續給客戶端司馬相如發送消息。服務端卓文君先回復了一首《怨郎詩》:
一別之後,
二地相思,
只道是三四月,
又誰知五六年。
七弦琴無心彈,
八行書無可傳,
九曲連環從中折斷,
十里長亭望眼欲穿。
百思想,
千繫念,
萬般無奈把君怨。
萬語千言說不完,
百無聊賴十倚欄。
重九登高看孤雁,
八月中秋月圓人不圓。
七月半,秉燭燒香問蒼天。
六月伏天人人搖扇我心寒。
五月石榴似火紅,偏遭陣陣冷雨澆花端。
四月枇杷未黃,我欲對鏡心意亂。
急匆匆,三月桃花隨水轉;
飄零零,二月風箏線兒斷。
噫,郎呀郎,恨不得下一世,你為女來我做男。
等服務端卓文君把自己想說的話說完了,就給客戶端司馬相如回復說:「聞君有兩意,故來相決絕。」(出自《白頭吟》)意思是我準備好了,既然你變心啦,咱們就算了吧。這就是第三次揮手。
這時候,如果客戶端司馬相如是一般人,得償所願了。就會回復一個:「祝你幸福。」這是韓劇里標準的分手時最後話術,是通知服務端徹底斷絕關係的。然後再等等確認服務端徹底沒有回饋就是分手成功了。這就是第四次揮手。(實際上客戶端收到FIN報文後,就知道可以關閉連接了,但是它還是不相信網路,怕服務端不知道要關閉,所以發送ACK後進入TIME_WAIT狀態,如果服務端沒有收到ACK則可以重傳。)
但是司馬相如是個藝人啊。藝人最怕脫粉啊。想想司馬相如掙錢靠的主要是《長門賦》這樣用來挽回感情的怨婦詩。自己這般鐵石心腸,那詩還有誰會買賬呢!還會得罪多少達官貴人。何況卓文君的這兩篇《怨郎詩》和《白頭吟》文采如此出眾!聰明如司馬相如馬上就知道如果不繼續維持這段才子佳人的佳話,那事業也沒了。所以他選擇了重新三次握手建立連接。
TCP長連接和短連接
TCP連接有三次握手和四次揮手的加持,被稱為可靠的連接。UDP這樣的數據報被稱為不可靠的連接。可靠就有代價。
長連接和短連接的本質區別如上圖。短連接每次通訊都伴隨著三次握手和四次揮手,而長連接復用了這兩個過程。為了讓收益最大化成本最小化,不同的應用場景會選擇採用長連接或者短連接。
長連接和短連接各自的優缺點
-
長連接可以省去較多的TCP建立和關閉的操作,減少浪費,節約時間,但是一直連接對於客戶端來說比較耗電。
-
對於頻繁請求資源的客戶端來說,較適用長連接。
-
客戶端與服務端之間的連接如果一直不關閉的話,會存在一個問題:隨著客戶端連接越來越多,服務端早晚有扛不住的時候,這時候服務端需要採取一些策略,如關閉一些長時間沒有讀寫事件發生的連接,這樣可以避免一些惡意連接導致服務端服務受損。
-
如果條件再允許就可以以客戶端機器為顆粒度,限制每個客戶端的最大長連接數,這樣可以完全避免某些客戶端出問題後連累服務端。
-
短連接對於伺服器來說管理較為簡單,存在的連接都是有用的連接,不需要額外的控制手段。
-
一次TCP連接和斷開需要7個來回,如果客戶端請求頻繁,將在TCP的建立和關閉操作上浪費大量時間和頻寬。
短連接應用場景
一般網站類的web服務都是短連接。試想一個普通網站,比如查看我這篇公眾號文章,總共有6000多個字。一旦打開,內容載入完之後,很長時間不用再次傳輸數據。那占著連接是不是很浪費?
而且服務端可承載的最大連接數是有限的,不然文件句柄不夠用啊。一個網站希望用幾十、幾百台4核8G就可以支撐日活幾百萬,那最好使用短連接。
長連接應用場景
公司內的各個系統之間使用RPC。大家使用的工具不太相同,有的公司自己基於thrift協議進行開發,有的使用開源的Dubbo。但是大家都頭腦清醒的使用了長連接。
因為內部場景下,上下游是固定的,接入的客戶端數量也相對固定。長連接節省連接建立開銷,請求量上來了也可以直接進行數據傳輸相對高效。
公司內部使用的中間件也大多使用長連接。例如:MQ、k8s、Redis、mysql。提到這些不得不提相關的兩個技術。
keepalive保活機制
KeepAlive並不是TCP協議規範的一部分,但在幾乎所有的TCP/IP協議棧(不管是Linux還是Windows)中,都實現了KeepAlive功能。
先看看使用場景:
我沒看過這個電影,也不知道是哪個電影里的場景。只是知道,張藝謀在一個姑娘樓下不斷的叫喊:「安紅我想你」 這個鏡頭有段時間特別火。
客戶端張藝謀發送了消息。服務端收到消息後一看,瞧給你牛的,然後沒理客戶端,傻狗客戶端一直在等待,但是不知道是不是伺服器掛掉了?
這時候TCP協議提出一個辦法,當客戶端端等待超過一定時間後自動給服務端發送一個空的報文,如果對方回復了這個報文證明連接還存活著,如果對方沒有報文返回且進行了多次嘗試都是一樣,那麼就認為連接已經丟失,客戶端就沒必要繼續保持連接了。如果沒有這種機制就會有很多空閑的連接佔用著系統資源。原理如下圖:
如何設置它?
在設置之前我們先來看看KeepAlive都支援哪些設置項
-
KeepAlive默認情況下是關閉的,上層應用可以通過開關來開啟和關閉
-
tcp_keepalive_time: KeepAlive的空閑時長,或者說每次正常發送心跳的周期,默認值為7200s(2小時)
-
tcp_keepalive_intvl: KeepAlive探測包的發送間隔,默認值為75s
-
tcp_keepalive_probes: 在tcp_keepalive_time之後,沒有接收到對方確認,繼續發送保活探測包次數,默認值為9(次)
我們講講在Linux作業系統和使用Java如何設置。
在Linux內核設置
KeepAlive默認不是開啟的,如果想使用KeepAlive,需要在你的應用中設置SO_KEEPALIVE才可以生效。
查看當前的配置:
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes
在Linux中我們可以通過修改 /etc/sysctl.conf 的全局配置:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
添加上面的配置後輸入 sysctl -p 使其生效,你可以使用 sysctl -a | grep keepalive 命令來查看當前的默認配置
如果應用中已經設置SO_KEEPALIVE,程式不用重啟,內核直接生效
使用Netty4設置
這裡我們使用常用的Java網路框架Netty來設置,只需要在服務端設置即可:
EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 100) .childOption(ChannelOption.SO_KEEPALIVE, true) .handler(new LoggingHandler(LogLevel.INFO)); // Start the server. ChannelFuture f = b.bind(8088).sync(); // Wait until the server socket is closed. f.channel().closeFuture().sync(); } finally { // Shut down all event loops to terminate all threads. bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }
這段程式碼來自經典的echo伺服器,我們在childOption中開啟了SO_KEEPALIVE。
Java程式只能做到設置SO_KEEPALIVE選項,其他配置項只能依賴於sysctl配置,系統進行讀取。
使用的場景
一般我們使用KeepAlive時會修改空閑時長,避免資源浪費,系統內核會為每一個TCP連接建立一個保護記錄,相對於應用層面效率更高。
常見的幾種使用場景:
-
檢測掛掉的連接(導致連接掛掉的原因很多,如服務停止、網路波動、宕機、應用重啟等)
-
防止因為網路不活動而斷連,如使用NAT代理或者防火牆的時候,經常會出現這種問題
-
HTTP協議的Keep-Alive意圖在於連接復用,同一個連接上串列方式傳遞請求-響應數據
-
TCP的KeepAlive機制意圖在於保活、心跳,檢測連接錯誤
KeepAlive通過定時發送探測包來探測連接的對端是否存活,但通常也會許多在業務層面處理,他們之間的特點是:
-
TCP自帶的KeepAlive使用簡單,發送的數據包相比應用層心跳檢測包更小,僅提供檢測連接功能
-
應用層心跳包不依賴於傳輸層協議,無論傳輸層協議是TCP還是UDP都可以用
-
應用層心跳包可以訂製,可以應對更複雜的情況或傳輸一些額外資訊
-
KeepAlive僅代表連接保持著,而心跳包往往還代表客戶端可正常工作
像Dubbo這種通訊中間件都使用到了TCP的保活機制。k8s客戶端和服務端是基於http協議的長連接,用到了http的保活復用連接。
連接池技術
上面四次揮手講了,如果伺服器總是在斷開連接,tcp會總是處於time wait狀態。很多連接沒有得到真正的釋放。像資料庫操作,是非常頻繁的。咱們一般都不會像下圖這麼每次都申請和關閉連接吧。
所以就希望儘可能的復用三次握手和四次揮手的過程,讓客戶端和服務端投入更多的資源在數據傳輸上。這就要用到連接池。
連接池是一種池式結構。其他的池式結構有:執行緒池、協程池、記憶體池和對象池。它們的實現都很接近。連接池中也有很多大家聽說過的場景,如:資料庫連接池、MQ的連接池、Redis的連接池。
提到MQ的連接池,之前的時候,有個同事排查我們使用的MQ並發吞吐太低。因為用的是標準的Java消息服務JMS客戶端,跟了程式碼發現裡面用到了connection.close。就懷疑用的短連接。其實close方法的實現一般不是銷毀連接,還是歸還到連接池。
連接池咱們平時使用最多、面試最多的是其中控制伸縮性的參數:最小連接數、最大連接數。最小連接數主要是要限定池的大小,最大連接數主要是限定能打開的最大連接數。使用連接池進行通訊的流程如下圖所示:
關於連接池更詳細的內容因為預計還要2千字以上,我會專門寫一篇文章來講。
細思極恐的Socket
在《接下來一段時間會對大家進行網路通訊的魔鬼訓練-理解socket》中我專門講過socket,它功能強大但是讓人細思極恐。之前發生過一個線上問題,某個時刻發生了幾筆請求超時,排查發現列印Dubbo調用的來源IP時,觸發了DNS反解析。網路閃斷,連接不到DNS伺服器,結果夯住10s直到超時。
熟悉Java語言的朋友可以了解一下當時線上問題的原因:
dubbo調用的來源IP使用的是dubbo的
RpcContext.getContext().getRemoteAddress().getHostName();
因為RpcContext.getContext()是基於上篇文章《ThreadLocal&MDC記憶體泄漏問題》中提到的ThreadLocal,就是保存在了執行緒里的一個固定值。所以也就相當於
new InetSocketAddress(固定的IP, 固定的埠).getHostName();
這個方法會進行DNS lookup!所以具體這個函數的執行時間受到網路狀況的影響。建議可以直接使用IP的地方使用getAddress()代替getHostName()。
這個問題可以這麼來理解:
相當於在程式里執行了一個linux命令:nslookup 某IP
為什麼說讓人細思極恐呢?想想看,使用Socket編程,只需一行程式碼,有時候就是隱式的,實際上卻可能發出了一個繞地球半圈的訊號,關鍵多數開發者還不知道。
總結
如大家所見,本篇算是《白話linux作業系統原理》的姊妹篇。和《網路通訊之Session的歷史血脈》、《深入理解MQ生產端的底層通訊過程-理解channel》、《接下來一段時間會對大家進行網路通訊的魔鬼訓練-理解socket》、《網路位元組序列-大端序和小端序》、《https引起的跨域問題-COE&casestudy》、《懂得三境界-使用dubbo時請求超過問題》、《一個http請求進來都經過了什麼(2021版)》、《架構師之路-https底層原理》是一個系列。
在之前的文章中說過,我盡量想辦法讓大家能把這些系列文章有興趣看下去。如果大家堅持下去,會對以後的工作有很大幫助。《白話linux作業系統原理》這篇文章發出之後,回饋特別好。我就在想是不是這裡存在著可以讓大家堅持下去的點。可是,我自己都沒搞清楚這個點在哪裡。
只要是用心寫了,對自己的知識能力也有自信,知道所寫的對大家知識能力是很有幫助的。那就堅持下去。相信可以幫助到大家,也為祖國的科技發展盡一份力。