背景
https://leeweir.github.io/posts/linux-packet-loss/
最近一直在排查一些網絡的問題,比如 connect timeout 、read timeout 以及一些丟包的問題,剛好想整理一些東西,方便和團隊內及開發分享。
我們先看下 Linux 系統接收數據包的過程:

- 網卡收到數據包。
- 將數據包從網卡硬件緩存轉移到服務器內存中。
- 通知內核處理。
- 經過 TCP/IP 協議逐層處理。
- 應用程序通過 read() 從 socket buffer 讀取數據。
網卡丟包
我們先看下ifconfig的輸出:
# ifconfig eth0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 10.5.224.27 netmask 255.255.255.0 broadcast 10.5.224.255
inet6 fe80::5054:ff:fea4:44ae prefixlen 64 scopeid 0x20<link>
ether 52:54:00:a4:44:ae txqueuelen 1000 (Ethernet)
RX packets 9525661556 bytes 10963926751740 (9.9 TiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 8801210220 bytes 12331600148587 (11.2 TiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
RX(receive) 代表接收報文, TX(transmit) 表示發送報文。
- RX errors: 表示總的收包的錯誤數量,這包括 too-long-frames 錯誤,Ring Buffer 溢出錯誤,crc 校驗錯誤,幀同步錯誤,fifo overruns 以及 missed pkg 等等。
- RX dropped: 表示數據包已經進入了 Ring Buffer,但是由於內存不夠等系統原因,導致在拷貝到內存的過程中被丟棄。
- RX overruns: 表示了 fifo 的 overruns,這是由於 Ring Buffer(aka Driver Queue) 傳輸的 IO 大於 kernel 能夠處理的 IO 導致的,而 Ring Buffer 則是指在發起 IRQ 請求之前的那塊 buffer。很明顯,overruns 的增大意味着數據包沒到 Ring Buffer 就被網卡物理層給丟棄了,而 CPU 無法及時的處理中斷是造成 Ring Buffer 滿的原因之一,上面那台有問題的機器就是因為 interruprs 分布的不均勻(都壓在 core0),沒有做 affinity 而造成的丟包。
- RX frame: 表示 misaligned 的 frames。
對於 TX 的來說,出現上述 counter 增大的原因主要包括 aborted transmission, errors due to carrirer, fifo error, heartbeat erros 以及 windown error,而 collisions 則表示由於 CSMA/CD 造成的傳輸中斷。
dropped 與 overruns 的區別: dropped,表示這個數據包已經進入到網卡的接收緩存 fifo 隊列,並且開始被系統中斷處理准備進行數據包拷貝(從網卡緩存 fifo 隊列拷貝到系統內存),但由於此時的系統原因(比如內存不夠等)導致這個數據包被丟掉,即這個數據包被 Linux 系統丟掉。 overruns,表示這個數據包還沒有被進入到網卡的接收緩存 fifo 隊列就被丟掉,因此此時網卡的 fifo 是滿的。為什么 fifo 會是滿的?因為系統繁忙,來不及響應網卡中斷,導致網卡里的數據包沒有及時的拷貝到系統內存, fifo 是滿的就導致后面的數據包進不來,即這個數據包被網卡硬件丟掉。所以,個人覺得遇到 overruns 非0,需要檢測cpu負載與cpu中斷情況。
netstat -i也會提供每個網卡的接發報文以及丟包的情況:
# netstat -i
Kernel Interface table
Iface MTU RX-OK RX-ERR RX-DRP RX-OVR TX-OK TX-ERR TX-DRP TX-OVR Flg
eth0 1500 9528312730 0 0 0 8803615650 0 0 0 BMRU
Ring Buffer 溢出
如果硬件或者驅動沒有問題,一般網卡丟包是因為設置的緩存區(ring buffer)太小。當網絡數據包到達(生產)的速率快於內核處理(消費)的速率時, Ring Buffer 很快會被填滿,新來的數據包將被丟棄。

通過 ethtool 或 /proc/net/dev 可以查看因Ring Buffer滿而丟棄的包統計,在統計項中以fifo標識:
# ethtool -S eth0|grep rx_fifo
rx_fifo_errors: 0
# cat /proc/net/dev
Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
eth0: 10967216557060 9528860597 0 0 0 0 0 0 12336087749362 8804108661 0 0 0 0 0 0
如果發現服務器上某個網卡的 fifo 數持續增大,可以去確認 CPU 中斷是否分配均勻,也可以嘗試增加 Ring Buffer 的大小,通過 ethtool 可以查看網卡設備 Ring Buffer 最大值,修改 Ring Buffer 當前設置:
# 查看eth0網卡Ring Buffer最大值和當前設置
$ ethtool -g eth0
Ring parameters for eth0:
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 1024
RX Mini: 0
RX Jumbo: 0
TX: 1024
# 修改網卡eth0接收與發送硬件緩存區大小
$ ethtool -G eth0 rx 4096 tx 4096
Pre-set maximums:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
Current hardware settings:
RX: 4096
RX Mini: 0
RX Jumbo: 0
TX: 4096
netdev_max_backlog 溢出
netdev_max_backlog 是內核從 NIC 收到包后,交由協議棧(如 IP、TCP )處理之前的緩沖隊列。每個 CPU 核都有一個 backlog 隊列,與 Ring Buffer 同理,當接收包的速率大於內核協議棧處理的速率時, CPU 的 backlog 隊列不斷增長,當達到設定的 netdev_max_backlog 值時,數據包將被丟棄。
# cat /proc/net/softnet_stat
2e8f1058 00000000 000000ef 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
0db6297e 00000000 00000035 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
09d4a634 00000000 00000010 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
0773e4f1 00000000 00000005 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
其中: 每一行代表每個 CPU 核的狀態統計,從 CPU0 依次往下; 每一列代表一個 CPU 核的各項統計:第一列代表中斷處理程序收到的包總數;第二列即代表由於 netdev_max_backlog 隊列溢出而被丟棄的包總數。 從上面的輸出可以看出,這台服務器統計中,確實有因為 netdev_max_backlog 導致的丟包。
netdev_max_backlog 的默認值是 1000,在高速鏈路上,可能會出現上述第二列統計不為 0 的情況,可以通過修改內核參數 net.core.netdev_max_backlog 來解決:
sysctl -w net.core.netdev_max_backlog=2000
Socket Buffer 溢出
Socket 可以屏蔽 linux 內核不同協議的差異,為應用程序提供統一的訪問接口。每個 Socket 都有一個讀寫緩存區。
-
讀緩沖區,緩存遠端發來的數據。如果讀緩存區已滿,就不能再接收新的數據。
-
寫緩沖區,緩存了要發出去的數據。如果寫緩沖區已滿,應用程序的寫操作就會阻塞。

半連接隊列和全連接隊列溢出
之前有個 connect timeout 的 case ,這篇博客里,我也詳細介紹了如何去查看半連接隊列和全連接隊列,包括如何去優化,這里我不展開寫。
但是補充一點,在半連接滿的情況下,若啟用syncookie機制,並不會直接丟棄 SYN 包,而是回復帶有 syncookie 的 SYN+ACK 包,設計的目的是防范 SYN Flood 造成正常請求服務不可用。 syncookie 之前也有一篇博客分享,參考 談談 syn-cookie 的問題.
PAWS
PAWS 全名 Protect Againest Wrapped Sequence numbers ,目的是解決在高帶寬下, TCP 序列號在一次會話中可能被重復使用而帶來的問題。

如上圖所示,客戶端發送的序列號為 A 的數據包 A1 因某些原因在網絡中“迷路”,在一定時間沒有到達服務端,客戶端超時重傳序列號為 A 的數據包 A2 ,接下來假設帶寬足夠,傳輸用盡序列號空間,重新使用 A ,此時服務端等待的是序列號為 A 的數據包 A3 ,而恰巧此時前面“迷路”的 A1 到達服務端,如果服務端僅靠序列號A就判斷數據包合法,就會將錯誤的數據傳遞到用戶態程序,造成程序異常。
PAWS 要解決的就是上述問題,它依賴於 timestamp 機制,理論依據是:在一條正常的 TCP 流中,按序接收到的所有 TCP 數據包中的 timestamp 都應該是單調非遞減的,這樣就能判斷那些 timestamp 小於當前 TCP 流已處理的最大 timestamp 值的報文是延遲到達的重復報文,可以予以丟棄。在上文的例子中,服務器已經處理數據包 Z,而后到來的 A1 包的 timestamp 必然小於 Z 包的 timestamp ,因此服務端會丟棄遲到的 A1 包,等待正確的報文到來。
PAWS 機制的實現關鍵是內核保存了 Per-Connection 的最近接收時間戳,如果加以改進,就可以用來優化服務器TIME_WAIT狀態的快速回收。
TIME_WAIT 狀態是TCP四次揮手中主動關閉連接的一方需要進入的最后一個狀態,並且通常需要在該狀態保持 2*MSL (報文最大生存時間),它存在的意義有兩個:
-
可靠地實現 TCP 全雙工連接的關閉:關閉連接的四次揮手過程中,最終的 ACK 由主動關閉連接的一方(稱為 A )發出,如果這個 ACK 丟失,對端(稱為 B )將重發 FIN ,如果 A 不維持連接的 TIME_WAIT 狀態,而是直接進入 CLOSED ,則無法重傳 ACK , B 端的連接因此不能及時可靠釋放。
-
等待“迷路”的重復數據包在網絡中因生存時間到期消失:通信雙方 A 與 B , A 的數據包因“迷路”沒有及時到達 B , A 會重發數據包,當 A 與 B 完成傳輸並斷開連接后,如果 A 不維持 TIME_WAIT 狀態 2MSL 時間,便有可能與 B 再次建立相同源端口和目的端口的“新連接”,而前一次連接中“迷路”的報文有可能在這時到達,並被 B 接收處理,造成異常,維持 2MSL 的目的就是等待前一次連接的數據包在網絡中消失。
TIME_WAIT 狀態的連接需要占用服務器內存資源維持, Linux 內核提供了一個參數來控制 TIME_WAIT 狀態的快速回收:tcp_tw_recycle,它的理論依據是:
在 PAWS 的理論基礎上,如果內核保存 Per-Host 的最近接收時間戳,接收數據包時進行時間戳比對,就能避免 TIME_WAIT 意圖解決的第二個問題:前一個連接的數據包在新連接中被當做有效數據包處理的情況。這樣就沒有必要維持 TIME_WAIT 狀態 2*MSL 的時間來等待數據包消失,僅需要等待足夠的 RTO (超時重傳),解決 ACK 丟失需要重傳的情況,來達到快速回收 TIME_WAIT 狀態連接的目的。
但上述理論在多個客戶端使用 NAT 訪問服務器時會產生新的問題:同一個 NAT 背后的多個客戶端時間戳是很難保持一致的( timestamp 機制使用的是系統啟動相對時間),對於服務器來說,兩台客戶端主機各自建立的 TCP 連接表現為同一個對端IP的兩個連接,按照 Per-Host 記錄的最近接收時間戳會更新為兩台客戶端主機中時間戳較大的那個,而時間戳相對較小的客戶端發出的所有數據包對服務器來說都是這台主機已過期的重復數據,因此會直接丟棄。
通過netstat可以得到因PAWS機制timestamp驗證被丟棄的數據包統計:
# netstat -s |grep -e "passive connections rejected because of time stamp" -e "packets rejects in established connections because of timestamp”
387158 passive connections rejected because of time stamp
825313 packets rejects in established connections because of timestamp
通過sysctl查看是否啟用了 tcp_tw_recycle 及 tcp_timestamp :
$ sysctl net.ipv4.tcp_tw_recycle
net.ipv4.tcp_tw_recycle = 1
$ sysctl net.ipv4.tcp_timestamps
net.ipv4.tcp_timestamps = 1
如果服務器作為服務端提供服務,且明確客戶端會通過 NAT 網絡訪問,或服務器之前有7層轉發設備會替換客戶端源IP時,是不應該開啟 tcp_tw_recycle 的,而 timestamps 除了支持 tcp_tw_recycle 外還被其他機制依賴,推薦繼續開啟:
sysctl -w net.ipv4.tcp_tw_recycle=0
sysctl -w net.ipv4.tcp_timestamps=1
包丟在哪里了
第一個是 dropwatch ,之前 霸爺博客 也做過分享。
# dropwatch -l kas
Initalizing kallsyms db
dropwatch> start
Enabling monitoring...
Kernel monitoring activated.
Issue Ctrl-C to stop monitoring
1 drops at sk_stream_kill_queues+50 (0xffffffff81687860)
1 drops at tcp_v4_rcv+147 (0xffffffff8170b737)
1 drops at __brk_limit+1de1308c (0xffffffffa052308c)
1 drops at ip_rcv_finish+1b8 (0xffffffff816e3348)
1 drops at skb_queue_purge+17 (0xffffffff816809e7)
3 drops at sk_stream_kill_queues+50 (0xffffffff81687860)
2 drops at unix_stream_connect+2bc (0xffffffff8175a05c)
2 drops at sk_stream_kill_queues+50 (0xffffffff81687860)
1 drops at tcp_v4_rcv+147 (0xffffffff8170b737)
2 drops at sk_stream_kill_queues+50 (0xffffffff81687860)
第二個是 perf 監視 kfree_skb 事件。
# perf record -g -a -e skb:kfree_skb
^C[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 1.212 MB perf.data (388 samples) ]
# perf script
containerd 93829 [031] 951470.340275: skb:kfree_skb: skbaddr=0xffff8827bfced700 protocol=0 location=0xffffffff8175a05c
7fff8168279b kfree_skb ([kernel.kallsyms])
7fff8175c05c unix_stream_connect ([kernel.kallsyms])
7fff8167650f SYSC_connect ([kernel.kallsyms])
7fff8167818e sys_connect ([kernel.kallsyms])
7fff81005959 do_syscall_64 ([kernel.kallsyms])
7fff81802081 entry_SYSCALL_64_after_hwframe ([kernel.kallsyms])
f908d __GI___libc_connect (/usr/lib64/libc-2.17.so)
13077d __nscd_get_mapping (/usr/lib64/libc-2.17.so)
130c7c __nscd_get_map_ref (/usr/lib64/libc-2.17.so)
0 [unknown] ([unknown])
containerd 93829 [031] 951470.340306: skb:kfree_skb: skbaddr=0xffff8827bfcec500 protocol=0 location=0xffffffff8175a05c
7fff8168279b kfree_skb ([kernel.kallsyms])
7fff8175c05c unix_stream_connect ([kernel.kallsyms])
7fff8167650f SYSC_connect ([kernel.kallsyms])
7fff8167818e sys_connect ([kernel.kallsyms])
7fff81005959 do_syscall_64 ([kernel.kallsyms])
7fff81802081 entry_SYSCALL_64_after_hwframe ([kernel.kallsyms])
f908d __GI___libc_connect (/usr/lib64/libc-2.17.so)
130ebe __nscd_open_socket (/usr/lib64/libc-2.17.so)
第三個是tcpdrop,之前我也有一篇 博客介紹, 它顯示了源包和目標包的詳細信息,以及 TCP 會話狀態(來自內核)、TCP 標志(來自包 TCP 報頭)和導致這次丟包的內核堆棧跟蹤。
TIME PID IP SADDR:SPORT > DADDR:DPORT STATE (FLAGS)
05:46:07 82093 4 10.74.40.245:50010 > 10.74.40.245:58484 ESTABLISHED (ACK)
tcp_drop+0x1
tcp_rcv_established+0x1d5
tcp_v4_do_rcv+0x141
tcp_v4_rcv+0x9b8
ip_local_deliver_finish+0x9b
ip_local_deliver+0x6f
ip_rcv_finish+0x124
ip_rcv+0x291
__netif_receive_skb_core+0x554
__netif_receive_skb+0x18
process_backlog+0xba
net_rx_action+0x265
__softirqentry_text_start+0xf2
irq_exit+0xb6
xen_evtchn_do_upcall+0x30
xen_hvm_callback_vector+0x1af
05:46:07 85153 4 10.74.40.245:50010 > 10.74.40.245:58446 ESTABLISHED (ACK)
tcp_drop+0x1
tcp_rcv_established+0x1d5
tcp_v4_do_rcv+0x141
tcp_v4_rcv+0x9b8
ip_local_deliver_finish+0x9b
ip_local_deliver+0x6f
ip_rcv_finish+0x124
ip_rcv+0x291
__netif_receive_skb_core+0x554
__netif_receive_skb+0x18
process_backlog+0xba
net_rx_action+0x265
__softirqentry_text_start+0xf2
irq_exit+0xb6
xen_evtchn_do_upcall+0x30
xen_hvm_callback_vector+0x1af
總結

linux 網絡協議棧太深,每一層都有可能出現各種各樣的問題,我們需要了解這些原理,同時利用好工具去排查這些問題。同時我們在優化的時候,不要盲目的看別人的優化結果,更重要的是體系化的去了解 linux 協議棧的實現,只有知其所以然,才能結合實際業務特點,得出最合理的優化配置。
最后我按照倪鵬飛之前的優化,整理了一個表格,方便參考(數值僅供參考,具體配置還需要結合實際場景來調整):



