Linux 網絡性能的 15 個優化建議
建議1:盡量減少不必要的網絡 IO
我要給出的第一個建議就是不必要用網絡 IO 的盡量不用。
是的,網絡在現代的互聯網世界里承載了很重要的角色。用戶通過網絡請求線上服務、服務器通過網絡讀取數據庫中數據,通過網絡構建能力無比強大分布式系統。網絡很好,能降低模塊的開發難度,也能用它搭建出更強大的系統。但是這不是你濫用它的理由!
原因是即使是本機網絡 IO 開銷仍然是很大的。先說發送一個網絡包,首先得從用戶態切換到內核態,花費一次系統調用的開銷。進入到內核以后,又得經過冗長的協議棧,這會花費不少的 CPU 周期,最后進入環回設備的“驅動程序”。接收端呢,軟中斷花費不少的 CPU 周期又得經過接收協議棧的處理,最后喚醒或者通知用戶進程來處理。當服務端處理完以后,還得把結果再發過來。又得來這么一遍,最后你的進程才能收到結果。你說麻煩不麻煩。另外還有個問題就是多個進程協作來完成一項工作就必然會引入更多的進程上下文切換開銷,這些開銷從開發視角來看,做的其實都是無用功。
上面我們還分析的只是本機網絡 IO,如果是跨機器的還得會有雙方網卡的 DMA 拷貝過程,以及兩端之間的網絡 RTT 耗時延遲。所以,網絡雖好,但也不能隨意濫用!
建議2:盡量合并網絡請求
在可能的情況下,盡可能地把多次的網絡請求合并到一次,這樣既節約了雙端的 CPU 開銷,也能降低多次 RTT 導致的耗時。
我們舉個實踐中的例子可能更好理解。假如有一個 redis,里面存了每一個 App 的信息(應用名、包名、版本、截圖等等)。你現在需要根據用戶安裝應用列表來查詢數據庫中有哪些應用比用戶的版本更新,如果有則提醒用戶更新。
那么最好不要寫出如下的代碼:
<?php
for(安裝列表 as 包名){
redis->get(包名)
...
}
上面這段代碼功能上實現上沒問題,問題在于性能。據我們統計現代用戶平均安裝 App 的數量在 60 個左右。那這段代碼在運行的時候,每當用戶來請求一次,你的服務器就需要和 redis 進行 60 次網絡請求。總耗時最少是 60 個 RTT 起。更好的方法是應該使用 redis 中提供的批量獲取命令,如 hmget、pipeline等,經過一次網絡 IO 就獲取到所有想要的數據,如圖。
建議3:調用者與被調用機器盡可能部署的近一些
在前面的章節中我們看到在握手一切正常的情況下, TCP 握手的時間基本取決于兩臺機器之間的 RTT 耗時。雖然我們沒辦法徹底去掉這個耗時,但是我們卻有辦法把 RTT 降低,那就是把客戶端和服務器放得足夠的近一些。盡量把每個機房內部的數據請求都在本地機房解決,減少跨地網絡傳輸。
舉例,假如你的服務是部署在北京機房的,你調用的 mysql、redis最好都位于北京機房內部。盡量不要跨過千里萬里跑到廣東機房去請求數據,即使你有專線,耗時也會大大增加!在機房內部的服務器之間的 RTT 延遲大概只有零點幾毫秒,同地區的不同機房之間大約是 1 ms 多一些。但如果從北京跨到廣東的話,延遲將是 30 - 40 ms 左右,幾十倍的上漲!
建議4:內網調用不要用外網域名
假如說你所在負責的服務需要調用兄弟部門的一個搜索接口,假設接口是:"http://www.sogou.com/wq?key=開發內功修煉"。
那既然是兄弟部門,那很可能這個接口和你的服務是部署在一個機房的。即使沒有部署在一個機房,一般也是有專線可達的。所以不要直接請求 www.sogou.com, 而是應該使用該服務在公司對應的內網域名。在我們公司內部,每一個外網服務都會配置一個對應的內網域名,我相信你們公司也有。
為什么要這么做,原因有以下幾點
1)外網接口慢。本來內網可能過個交換機就能達到兄弟部門的機器,非得上外網兜一圈再回來,時間上肯定會慢。
2)帶寬成本高。在互聯網服務里,除了機器以外,另外一塊很大的成本就是 IDC 機房的出入口帶寬成本。兩臺機器在內網不管如何通信都不涉及到帶寬的計算。但是一旦你去外網兜了一圈回來,行了,一進一出全部要繳帶寬費,你說虧不虧!!
3)NAT 單點瓶頸。一般的服務器都沒有外網 IP,所以要想請求外網的資源,必須要經過 NAT 服務器。但是一個公司的機房里幾千臺服務器中,承擔 NAT 角色的可能就那么幾臺。它很容易成為瓶頸。我們的業務就遇到過好幾次 NAT 故障導致外網請求失敗的情形。NAT 機器掛了,你的服務可能也就掛了,故障率大大增加。
建議5:調整網卡 RingBuffer 大小
在 Linux 的整個網絡棧中,RingBuffer 起到一個任務的收發中轉站的角色。對于接收過程來講,網卡負責往 RingBuffer 中寫入收到的數據幀,ksoftirqd 內核線程負責從中取走處理。只要 ksoftirqd 線程工作的足夠快,RingBuffer 這個中轉站就不會出現問題。
但是我們設想一下,假如某一時刻,瞬間來了特別多的包,而 ksoftirqd 處理不過來了,會發生什么?這時 RingBuffer 可能瞬間就被填滿了,后面再來的包網卡直接就會丟棄,不做任何處理!
通過 ethtool 就可以加大 RingBuffer 這個“中轉倉庫”的大小。。
# ethtool -G eth1 rx 4096 tx 4096
這樣網卡會被分配更大一點的”中轉站“,可以解決偶發的瞬時的丟包。不過這種方法有個小副作用,那就是排隊的包過多會增加處理網絡包的延時。所以應該讓內核處理網絡包的速度更快一些更好,而不是讓網絡包傻傻地在 RingBuffer 中排隊。我們后面會再介紹到 RSS ,它可以讓更多的核來參與網絡包接收。
建議6:減少內存拷貝
假如你要發送一個文件給另外一臺機器上,那么比較基礎的做法是先調用 read 把文件讀出來,再調用 send 把數據把數據發出去。這樣數據需要頻繁地在內核態內存和用戶態內存之間拷貝,如圖 9.6。
目前減少內存拷貝主要有兩種方法,分別是使用 mmap 和 sendfile 兩個系統調用。使用 mmap 系統調用的話,映射進來的這段地址空間的內存在用戶態和內核態都是可以使用的。如果你發送數據是發的是 mmap 映射進來的數據,則內核直接就可以從地址空間中讀取,這樣就節約了一次從內核態到用戶態的拷貝過程。
不過在 mmap 發送文件的方式里,系統調用的開銷并沒有減少,還是發生兩次內核態和用戶態的上下文切換。如果你只是想把一個文件發送出去,而不關心它的內容,則可以調用另外一個做的更極致的系統調用 - sendfile。在這個系統調用里,徹底把讀文件和發送文件給合并起來了,系統調用的開銷又省了一次。再配合絕大多數網卡都支持的"分散-收集"(Scatter-gather)DMA 功能。可以直接從 PageCache 緩存區中 DMA 拷貝到網卡中。這樣絕大部分的 CPU 拷貝操作就都省去了。
建議7:使用 eBPF 繞開協議棧的本機 IO
如果你的業務中涉及到大量的本機網絡 IO 可以考慮這個優化方案。本機網絡 IO 和跨機 IO 比較起來,確實是節約了驅動上的一些開銷。發送數據不需要進 RingBuffer 的驅動隊列,直接把 skb 傳給接收協議棧(經過軟中斷)。但是在內核其它組件上,可是一點都沒少,系統調用、協議棧(傳輸層、網絡層等)、設備子系統整個走 了一個遍。連“驅動”程序都走了(雖然對于回環設備來說這個驅動只是一個純軟件的虛擬出來的東東)。
如果想用本機網絡 IO,但是又不想頻繁地在協議棧中繞來繞去。那么你可以試試 eBPF。使用 eBPF 的 sockmap 和 sk redirect 可以繞過 TCP/IP 協議棧,而被直接發送給接收端的 socket,業界已經有公司在這么做了。
建議8:盡量少用 recvfrom 等進程阻塞的方式
在使用了 recvfrom 阻塞方式來接收 socket 上數據的時候。每次一個進程專?為了等一個 socket 上的數據就得被從 CPU 上拿下來。然后再換上另一個 進程。等到數據 ready 了,睡眠的進程又會被喚醒。總共兩次進程上下文切換開銷。如果我們服務器上需要有大量的用戶請求需要處理,那就需要有很多的進程存在,而且不停地切換來切換去。這樣的缺點有如下這么幾個:
- 因為每個進程只能同時等待一條連接,所以需要大量的進程。
- 進程之間互相切換的時候需要消耗很多 CPU 周期,一次切換大約是 3 - 5 us 左右。
- 頻繁的切換導致 L1、L2、L3 等高速緩存的效果大打折扣
大家可能以為這種網絡 IO 模型很少見了。但其實在很多傳統的客戶端 SDK 中,比如 mysql、redis 和 kafka 仍然是沿用了這種方式。
建議9:使用成熟的網絡庫
使用 epoll 可以高效地管理海量的 socket。在服務器端。我們有各種成熟的網絡庫進行使用。這些網絡庫都對 epoll 使用了不同程度的封裝。
首先第一個要給大家參考的是 Redis。老版本的 Redis 里單進程高效地使用 epoll 就能支持每秒數萬 QPS 的高性能。如果你的服務是單進程的,可以參考 Redis 在網絡 IO 這塊的源碼。
如果是多線程的,線程之間的分工有很多種模式。那么哪個線程負責等待讀 IO 事件,哪個線程負責處理用戶請求,哪個線程又負責給用戶寫返回。根據分工的不同,又衍生出單 Reactor、多 Reactor、以及 Proactor 等多種模式。大家也不必頭疼,只要理解了這些原理之后選擇一個性能不錯的網絡庫就可以了。比如 PHP 中的 Swoole、Golang 的 net 包、Java 中的 netty 、C++ 中的 Sogou Workflow 都封裝的非常的不錯。
建議10:使用 Kernel-ByPass 新技術
如果你的服務對網絡要求確實特別特特別的高,而且各種優化措施也都用過了,那么現在還有終極優化大招 -- Kernel-ByPass 技術。
內核在接收網絡包的時候要經過很?的收發路徑。在這期間牽涉到很多內核組件之間的協同、協議棧的處理、以及內核態和用戶態的拷貝和切換。Kernel-ByPass 這類的技術方案就是繞開內核協議棧,自己在用戶態來實現網絡包的收發。這樣不但避開了繁雜的內核協議棧處理,也減少了頻繁了內核態用戶態之間的拷貝和切換,性能將發揮到極致!
目前我所知道的方案有 SOLARFLARE 的軟硬件方案、DPDK 等等。如果大家感興趣,可以多去了解一下!
建議11:配置充足的端口范圍
客戶端在調用 connect 系統調用發起連接的時候,需要先選擇一個可用的端口。內核在選用端口的時候,是采用從可用端口范圍中某一個隨機位置開始遍歷的方式。如果端口不充足的話,內核可能需要循環撞很多次才能選上一個可用的。這也會導致花費更多的 CPU 周期在內部的哈希表查找以及可能的自旋鎖等待上。因此不要等到端口用盡報錯了才開始加大端口范圍,而且應該一開始的時候就保持一個比較充足的值。
# vi /etc/sysctl.conf
net.ipv4.ip_local_port_range = 5000 65000
# sysctl -p //使配置生效
如果端口加大了仍然不夠用,那么可以考慮開啟端口 reuse 和 recycle。這樣端口在連接斷開的時候就不需要等待 2MSL 的時間了,可以快速回收。開啟這個參數之前需要保證 tcp_timestamps 是開啟的。
# vi /etc/sysctl.conf
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tw_recycle = 1
# sysctl -p
建議12:小心連接隊列溢出
服務器端使用了兩個連接隊列來響應來自客戶端的握手請求。這兩個隊列的長度是在服務器 listen 的時候就確定好了的。如果發生溢出,很可能會丟包。所以如果你的業務使用的是短連接且流量比較大,那么一定得學會觀察這兩個隊列是否存在溢出的情況。因為一旦出現因為連接隊列導致的握手問題,那么 TCP 連接耗時都是秒級以上了。
對于半連接隊列, 有個簡單的辦法。那就是只要保證 tcp_syncookies 這個內核參數是 1 就能保證不會有因為半連接隊列滿而發生的丟包。
對于全連接隊列來說,可以通過 netstat -s 來觀察。netstat -s 可查看到當前系統全連接隊列滿導致的丟包統計。但該數字記錄的是總丟包數,所以你需要再借助 watch 命令動態監控。
# watch 'netstat -s | grep overflowed'
160 times the listen queue of a socket overflowed //全連接隊列滿導致的丟包
如果輸出的數字在你監控的過程中變了,那說明當前服務器有因為全連接隊列滿而產生的丟包。你就需要加大你的全連接隊列的?度了。全連接隊列是應用程序調用 listen時傳入的 backlog 以及內核參數 net.core.somaxconn 二者之中較小的那個。如果需要加大,可能兩個參數都需要改。
如果你手頭并沒有服務器的權限,只是發現自己的客戶端機連接某個 server 出現耗時長,想定位一下是否是因為握手隊列的問題。那也有間接的辦法,可以 tcpdump 抓包查看是否有 SYN 的 TCP Retransmission。如果有偶發的 TCP Retransmission, 那就說明對應的服務端連接隊列可能有問題了。
建議13:減少握手重試
在 6.5 節我們看到如果握手發生異常,客戶端或者服務端就會啟動超時重傳機制。這個超時重試的時間間隔是翻倍地增長的,1 秒、3 秒、7 秒、15 秒、31 秒、63 秒 ......。對于我們提供給用戶直接訪問的接口來說,重試第一次耗時 1 秒多已經是嚴重影響用戶體驗了。如果重試到第三次以后,很有可能某一個環節已經報錯返回 504 了。所以在這種應用場景下,維護這么多的超時次數其實沒有任何意義。倒不如把他們設置的小一些,盡早放棄。其中客戶端的 syn 重傳次數由 tcp_syn_retries 控制,服務器半連接隊列中的超時次數是由 tcp_synack_retries 來控制。把它們兩個調成你想要的值。
建議14:如果請求頻繁,請棄用短連接改用長連接
如果你的服務器頻繁請求某個 server,比如 redis 緩存。和建議 1 比起來,一個更好一點的方法是使用長連接。這樣的好處有
1)節約了握手開銷。短連接中每次請求都需要服務和緩存之間進行握手,這樣每次都得讓用戶多等一個握手的時間開銷。
2)規避了隊列滿的問題。前面我們看到當全連接或者半連接隊列溢出的時候,服務器直接丟包。而客戶端呢并不知情,所以傻傻地等 3 秒才會重試。要知道 tcp 本身并不是專門為互聯網服務設計的。這個 3 秒的超時對于互聯網用戶的體驗影響是致命的。
3)端口數不容易出問題。端連接中,在釋放連接的時候,客戶端使用的端口需要進入 TIME_WAIT 狀態,等待 2 MSL的時間才能釋放。所以如果連接頻繁,端口數量很容易不夠用。而長連接就固定使用那么幾十上百個端口就夠用了。
建議15:TIME_WAIT 的優化
很多線上服務如果使用了短連接的情況下,就會出現大量的 TIME_WAIT。
首先,我想說的是沒有必要見到兩三萬個 TIME_WAIT 就恐慌的不行。從內存的?度來考慮,一條 TIME_WAIT 狀態的連接僅僅是 0.5 KB 的內存而已。從端口占用的角度來說,確實是消耗掉了一個端口。但假如你下次再連接的是不同的 Server 的話,該端口仍然可以使用。只有在所有 TIME_WAIT 都聚集在和一個 Server 的連接上的時候才會有問題。
那怎么解決呢? 其實辦法有很多。第一個辦法是按上面建議開啟端口 reuse 和 recycle。 第二個辦法是限制 TIME_WAIT 狀態的連接的最大數量。
# vi /etc/sysctl.conf
net.ipv4.tcp_max_tw_buckets = 32768
# sysctl -p
如果再徹底一些,也可以干脆直接用?連接代替頻繁的短連接。連接頻率大大降低以后,自然也就沒有 TIME_WAIT 的問題了。?