前言
本文希望解析清楚,當我們在代碼中寫下 socket.setSendBufferSize 和 sysctl 看到的rmem/wmem系統參數以及最終我們在TCP常常談到的接收發送視窗的關系,以及他們怎樣影響TCP傳輸的性能。
先明确一下:文章标題中所說的Buffer指的是sysctl中的 rmem或者wmem,如果是代碼中指定的話對應着SO_SNDBUF或者SO_RCVBUF,從TCP的概念來看對應着發送視窗或者接收視窗
TCP性能和發送接收Buffer的關系
相關參數:
$sudo sysctl -a | egrep "rmem|wmem|adv_win|moderate"
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.wmem_default = 212992
net.core.wmem_max = 212992
net.ipv4.tcp_adv_win_scale = 1
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304
net.ipv4.udp_rmem_min = 4096
net.ipv4.udp_wmem_min = 4096
vm.lowmem_reserve_ratio = 256 256 32
先從碰到的一個問題看起:
應用通過專線從公司通路阿裡雲上的服務,專線100M,時延20ms,一個SQL查詢了22M資料,結果花了大概25秒,這太慢了,不正常。如果通過雲上client通路雲上服務那麼1-2秒就傳回了(說明不跨網絡服務是正常的)。如果通過http或者scp從公司向雲上傳輸這22M的資料大概兩秒鐘也傳送完畢了(說明網絡帶寬不是瓶頸),是以這裡問題的原因基本上是我們的服務在這種網絡條件下有性能問題,需要找出為什麼。
抓包 tcpdump+wireshark
這個查詢結果22M的需要25秒,如下圖(wireshark 時序圖),橫軸是時間,縱軸是sequence number:

粗一看沒啥問題,因為時間太長掩蓋了問題。把這個圖形放大,就看中間50ms内的傳輸情況(橫軸是時間,縱軸是sequence number,一個點代表一個包)
換個角度,看看視窗尺寸圖形:
從bytes in flight也大緻能算出來總的傳輸時間 16K*1000/20=800Kb/秒
我們的應用會預設設定 socketSendBuffer 為16K:
socket.setSendBufferSize(16*1024) //16K send buffer
來看一下tcp包發送流程:
(圖檔
來自)
如果sendbuffer不夠就會卡在上圖中的第一步 sk_stream_wait_memory, 通過systemtap腳本可以驗證:
#!/usr/bin/stap
# Simple probe to detect when a process is waiting for more socket send
# buffer memory. Usually means the process is doing writes larger than the
# socket send buffer size or there is a slow receiver at the other side.
# Increasing the socket's send buffer size might help decrease application
# latencies, but it might also make it worse, so buyer beware.
# Typical output: timestamp in microseconds: procname(pid) event
#
# 1218230114875167: python(17631) blocked on full send buffer
# 1218230114876196: python(17631) recovered from full send buffer
# 1218230114876271: python(17631) blocked on full send buffer
# 1218230114876479: python(17631) recovered from full send buffer
probe kernel.function("sk_stream_wait_memory")
{
printf("%u: %s(%d) blocked on full send buffern",
gettimeofday_us(), execname(), pid())
}
probe kernel.function("sk_stream_wait_memory").return
{
printf("%u: %s(%d) recovered from full send buffern",
gettimeofday_us(), execname(), pid())
}
原了解析
如果tcp發送buffer也就是SO_SNDBUF隻有16K的話,這些包很快都發出去了,但是這16K不能立即釋放出來填新的内容進去,因為tcp要保證可靠,萬一中間丢包了呢。隻有等到這16K中的某些包ack了,才會填充一些新包進來然後繼續發出去。由于這裡rt基本是20ms,也就是16K發送完畢後,等了20ms才收到一些ack,這20ms應用、核心什麼都不能做,是以就是如第二個圖中的大概20ms的等待平台。這塊請參考
這篇文章sendbuffer相當于發送倉庫的大小,倉庫的貨物都發走後,不能立即騰出來發新的貨物,而是要等對方确認收到了(ack)才能騰出來發新的貨物。 傳輸速度取決于發送倉庫(sendbuffer)、接收倉庫(recvbuffer)、路寬(帶寬)的大小,如果發送倉庫(sendbuffer)足夠大了之後接下來的瓶頸就是高速公路了(帶寬、擁塞視窗)
如果是UDP,就沒有可靠的概念,有資料統統發出去,根本不關心對方是否收到,也就不需要ack和這個發送buffer了。
幾個發送buffer相關的核心參數
vm.lowmem_reserve_ratio = 256 256 32
net.core.wmem_max = 1048576
net.core.wmem_default = 124928
net.ipv4.tcp_wmem = 4096 16384 4194304
net.ipv4.udp_wmem_min = 4096
net.ipv4.tcp_wmem 預設就是16K,而且是能夠動态調整的,隻不過我們代碼中這塊的參數是很多年前從Cobra中繼承過來的,初始指定了sendbuffer的大小。代碼中設定了這個參數後就關閉了核心的動态調整功能,但是能看到http或者scp都很快,因為他們的send buffer是動态調整的,是以很快。
接收buffer是有開關可以動态控制的,發送buffer沒有開關預設就是開啟,關閉隻能在代碼層面來控制
net.ipv4.tcp_moderate_rcvbuf
優化
調整 socketSendBuffer 到256K,查詢時間從25秒下降到了4秒多,但是比理論帶寬所需要的時間略高
繼續檢視系統 net.core.wmem_max 參數預設最大是130K,是以即使我們代碼中設定256K實際使用的也是130K,調大這個系統參數後整個網絡傳輸時間大概2秒(跟100M帶寬比對了,scp傳輸22M資料也要2秒),整體查詢時間2.8秒。測試用的mysql client短連接配接,如果代碼中的是長連接配接的話會塊300-400ms(消掉了慢啟動階段),這基本上是理論上最快速度了
如果指定了tcp_wmem,則net.core.wmem_default被tcp_wmem的覆寫。send Buffer在tcp_wmem的最小值和最大值之間自動調整。如果調用setsockopt()設定了socket選項SO_SNDBUF,将關閉發送端緩沖的自動調節機制,tcp_wmem将被忽略,SO_SNDBUF的最大值由net.core.wmem_max限制。
BDP 帶寬時延積
BDP=rtt*(帶寬/8)
這個 buffer 調到1M測試沒有幫助,從理論計算BDP(帶寬時延積) 0.02秒*(100MB/8)=250Kb 是以SO_SNDBUF為256Kb的時候基本能跑滿帶寬了,再大實際意義也不大了。也就是前面所說的倉庫足夠後瓶頸在帶寬上了。
因為BDP是250K,也就是擁塞視窗(帶寬、接收視窗和rt決定的)即将成為新的瓶頸,是以調大buffer沒意義了。
用tc構造延時和帶寬限制的模拟重制環境
sudo tc qdisc del dev eth0 root netem delay 20ms
sudo tc qdisc add dev eth0 root tbf rate 500kbit latency 50ms burst 15kb
這個案例關于wmem的結論
預設情況下Linux系統會自動調整這個buffer(net.ipv4.tcp_wmem), 也就是不推薦程式中主動去設定SO_SNDBUF,除非明确知道設定的值是最優的。
從這裡我們可以看到,有些理論知識點雖然我們知道,但是在實踐中很難聯系起來,也就是常說的無法學以緻用,最開始看到抓包結果的時候比較懷疑發送、接收視窗之類的,沒有直接想到send buffer上,理論跟實踐的鴻溝。
說完發送Buffer(wmem)接下來我們接着一看看接收buffer(rmem)和接收視窗的情況
用這樣一個案例下來驗證接收視窗的作用:
有一個batch insert語句,整個一次要插入5532條記錄,所有記錄大小總共是376K。
SO_RCVBUF很小的時候并且rtt很大對性能的影響
如果rtt是40ms,總共需要5-6秒鐘:
基本可以看到server一旦空出來點視窗,client馬上就發送資料,由于這點視窗太小,rtt是40ms,也就是一個rtt才能傳3456位元組的資料,整個帶寬才80-90K,完全沒跑滿。
比較明顯間隔 40ms 一個等待台階,台階之間兩個包大概3K資料,總的傳輸效率如下:
斜線越陡表示速度越快,從上圖看整體SQL上傳花了5.5秒,執行0.5秒。
此時對應的視窗尺寸:
視窗由最開始28K(20個1448)很快降到了不到4K的樣子,然後基本遊走在即将滿的邊緣,雖然讀取慢,幸好rtt也大,導緻最終也沒有滿。(這個是3.1的Linux,應用SO_RCVBUF設定的是8K,用一半來做接收視窗)
SO_RCVBUF很小的時候并且rtt很小時對性能的影響
如果同樣的語句在 rtt 是0.1ms的話
雖然明顯看到接收視窗經常跑滿,但是因為rtt很小,一旦視窗空出來很快就通知到對方了,是以整個過小的接收視窗也沒怎麼影響到整體性能
如上圖11.4秒整個SQL開始,到11.41秒SQL上傳完畢,11.89秒執行完畢(執行花了0.5秒),上傳隻花了0.01秒
接收視窗情況:
如圖,接收視窗由最開始的28K降下來,然後一直在5880和滿了之間跳動
從這裡可以得出結論,接收視窗的大小對性能的影響,rtt越大影響越明顯,當然這裡還需要應用程式配合,如果應用程式一直不讀走資料即使接收視窗再大也會堆滿的。
SO_RCVBUF和tcp window full的壞case
上圖中紅色平台部分,停頓了大概6秒鐘沒有發任何有内容的資料包,這6秒鐘具體在做什麼如下圖所示,可以看到這個時候接收方的TCP Window Full,同時也能看到接收方(3306端口)的TCP Window Size是8192(8K),發送方(27545端口)是20480.
這個狀況跟前面描述的recv buffer太小不一樣,8K是很小,但是因為rtt也很小,是以server總是能很快就ack收到了,接收視窗也一直不容易達到full狀态,但是一旦接收視窗達到了full狀态,居然需要驚人的6秒鐘才能恢複,這等待的時間有點太長了。這裡應該是應用讀取資料太慢導緻了耗時6秒才恢複,是以最終這個請求執行會非常非常慢(時間主要耗在了上傳SQL而不是執行SQL).
實際原因不知道,從讀取TCP資料的邏輯來看這裡沒有明顯的block,可能的原因:
- request的SQL太大,Server(3306端口上的服務)從TCP讀取SQL需要放到一塊配置設定好的記憶體,記憶體不夠的時候需要擴容,擴容有可能觸發fgc,從圖形來看,第一次滿就卡頓了,而且每次滿都卡頓,不像是這個原因
- request請求一次發過來的是多個SQL,應用讀取SQL後,将SQL分成多個,然後先執行第一個,第一個執行完後傳回response,再讀取第二個。圖形中卡頓前沒有response傳回,是以也不是這個原因
- ……其它未知原因
接收方不讀取資料導緻的接收視窗滿同時有丢包發生
服務端傳回資料到client端,TCP協定棧ack這些包,但是應用層沒讀走包,這個時候 SO_RCVBUF 堆積滿,client的TCP協定棧發送 ZeroWindow 标志給服務端。也就是接收端的 buffer 堆滿了(但是服務端這個時候看到的bytes in fly是0,因為都ack了),這時服務端不能繼續發資料,要等 ZeroWindow 恢複。
那麼接收端上層應用不讀走包可能的原因:
- 應用代碼卡頓、GC等等
- 應用代碼邏輯上在做其它事情(比如DRDS将SQL分片到多個DB上,DRDS先讀取第一個分片,如果第一個分片資料很大很大,處理也慢,那麼第二個分片資料都傳回到了TCP buffer,也沒去讀取其它分片的結果集,直到第一個分片讀取完畢。如果SQL帶排序,那麼DRDS會輪詢讀取多個分片,造成這種卡頓的機率小了很多)
上圖這個流因為應用層不讀取TCP資料,導緻TCP接收Buffer滿,進而接收視窗為0,server端不能再發送資料而卡住,但是ZeroWindow的探測包,client都有正常回複,是以1903秒之後接收方視窗不為0後(window update)傳輸恢複。
這個截圖和前一個類似,是在Server上(3003端口)抓到的包,不同的是接收視窗為0後,server端多次探測(Server上抓包能看到),但是client端沒有回複 ZeroWindow(也有可能是回複了,但是中間環節把ack包丢了,或者這個探測包client沒收到),造成server端認為client死了、不可達之類,進而反複重傳,重傳超過15次之後,server端認為這個連接配接死了,粗暴單方面斷開(沒有reset和fin,因為沒必要,server認為網絡連通性出了問題)。
等到1800秒後,client的接收視窗恢複了,發個window update給server,這個時候server認為這個連接配接已經斷開了,隻能回複reset
網絡不通,重傳超過一定的時間(tcp_retries2)然後斷開這個連接配接是正常的,這裡的問題是:
- 為什麼這種場景下丢包了,而且是針對某個stream一直丢包
可能是因為這種場景下觸發了中間環節的流量管控,故意丢包了(比如proxy、slb、交換機都有可能做這種選擇性的丢包)
這裡server認為連接配接斷開,沒有發reset和fin,因為沒必要,server認為網絡連通性出了問題。client還不知道server上這個連接配接清理掉了,等client回複了一個window update,server早就認為這個連接配接早斷了,突然收到一個update,莫名其妙,隻能reset
接收視窗和SO_RCVBUF的關系
初始接收視窗一般是 mss乘以初始cwnd(為了和慢啟動邏輯相容,不想一下子沖擊到網絡),如果沒有設定SO_RCVBUF,那麼會根據 net.ipv4.tcp_rmem 動态變化,如果設定了SO_RCVBUF,那麼接收視窗要向下面描述的值靠攏。
初始cwnd可以大緻通過檢視到:
ss -itmpn dst "10.81.212.8"
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 10.xx.xx.xxx:22 10.yy.yy.yyy:12345 users:(("sshd",pid=1442,fd=3))
skmem:(r0,rb369280,t0,tb87040,f4096,w0,o0,bl0,d92)
Here we can see this socket has Receive Buffer 369280 bytes, and Transmit Buffer 87040 bytes.
Keep in mind the kernel will double any socket buffer allocation for overhead.
So a process asks for 256 KiB buffer with setsockopt(SO_RCVBUF) then it will get 512 KiB buffer
space. This is described on man 7 tcp.
初始視窗計算的代碼邏輯,重點在18行:
/* TCP initial congestion window as per rfc6928 */
#define TCP_INIT_CWND 10
/* 3. Try to fixup all. It is made immediately after connection enters
* established state.
*/
void tcp_init_buffer_space(struct sock *sk)
{
int tcp_app_win = sock_net(sk)->ipv4.sysctl_tcp_app_win;
struct tcp_sock *tp = tcp_sk(sk);
int maxwin;
if (!(sk->sk_userlocks & SOCK_SNDBUF_LOCK))
tcp_sndbuf_expand(sk);
//初始最大接收視窗計算過程
tp->rcvq_space.space = min_t(u32, tp->rcv_wnd, TCP_INIT_CWND * tp->advmss);
tcp_mstamp_refresh(tp);
tp->rcvq_space.time = tp->tcp_mstamp;
tp->rcvq_space.seq = tp->copied_seq;
maxwin = tcp_full_space(sk);
if (tp->window_clamp >= maxwin) {
tp->window_clamp = maxwin;
if (tcp_app_win && maxwin > 4 * tp->advmss)
tp->window_clamp = max(maxwin -
(maxwin >> tcp_app_win),
4 * tp->advmss);
}
/* Force reservation of one segment. */
if (tcp_app_win &&
tp->window_clamp > 2 * tp->advmss &&
tp->window_clamp + tp->advmss > maxwin)
tp->window_clamp = max(2 * tp->advmss, maxwin - tp->advmss);
tp->rcv_ssthresh = min(tp->rcv_ssthresh, tp->window_clamp);
tp->snd_cwnd_stamp = tcp_jiffies32;
}
傳輸過程中,最大接收視窗會動态調整,當指定了SO_RCVBUF後,實際buffer是兩倍SO_RCVBUF,但是要分出一部分(2^net.ipv4.tcp_adv_win_scale)來作為亂序封包緩存。
- net.ipv4.tcp_adv_win_scale = 2 //2.6核心,3.1中這個值預設是1
如果SO_RCVBUF是8K,總共就是16K,然後分出2^2分之一,也就是4分之一,還剩12K當做接收視窗;如果設定的32K,那麼接收視窗是48K
static inline int tcp_win_from_space(const struct sock *sk, int space)
{//space 傳入的時候就已經是 2*SO_RCVBUF了
int tcp_adv_win_scale = sock_net(sk)->ipv4.sysctl_tcp_adv_win_scale;
return tcp_adv_win_scale <= 0 ?
(space>>(-tcp_adv_win_scale)) :
space - (space>>tcp_adv_win_scale); //sysctl參數tcp_adv_win_scale
}
接收視窗有最大接收視窗和目前可用接收視窗。
一般來說一次中斷基本都會将 buffer 中的包都取走。
綠線是最大接收視窗動态調整的過程,最開始是1460*10,握手完畢後略微調整到1472*10(可利用body增加了12),随着資料的傳輸開始跳漲
上圖是四個batch insert語句,可以看到綠色接收視窗随着資料的傳輸越來越大,圖中藍色豎直部分基本表示SQL上傳,兩個藍色豎直條的間隔代表這個insert在伺服器上真正的執行時間。這圖非常陡峭,表示上傳沒有任何瓶頸.
設定 SO_RCVBUF 後通過wireshark觀察到的接收視窗基本
下圖是設定了 SO_RCVBUF 為8192的實際情況:
從最開始的14720,執行第一個create table語句後降到14330,到真正執行batch insert就降到了8192*1.5. 然後一直保持在這個值
If you set a "receive buffer size" on a TCP socket, what does it actually mean?
The naive answer would go something along the lines of: the TCP receive buffer setting indicates the maximum number of bytes aread()
syscall could retrieve without blocking. Note that if the buffer size is set with
setsockopt()
, the value returned with
getsockopt()
is always double the size requested to allow for overhead. This is described in
man 7 socket
.
總結
- 一般來說絕對不要在程式中手工設定SO_SNDBUF和SO_RCVBUF,核心自動調整比你做的要好;
- SO_SNDBUF一般會比發送滑動視窗要大,因為發送出去并且ack了的才能從SO_SNDBUF中釋放;
- TCP接收視窗跟SO_RCVBUF關系很複雜;
- SO_RCVBUF太小并且rtt很大的時候會嚴重影響性能;
- 接收視窗比發送視窗複雜多了。
總之記住一句話:不要設定socket的SO_SNDBUF和SO_RCVBUF