在資料中心網路內,機器之間資料傳輸的往返時間(rtt)一般在10ms以內,為此調內部服務的超時時間一般會設定成50ms、200ms、500ms等,如果在傳輸過程中出現丟包,這樣的服務超時時間,tcp層有機會發現並重傳一次資料麼?如果設定成200ms以內,答案是沒有機會,原因是linux系統下第一次重傳時間等於傳輸的往返時間上至少加上200ms的預測偏差值,即如果rtt值是7ms,第一次重傳超時時間至少是207ms,這樣如果對某個介面的超時時間設定成200ms以內, 即便是rtt時間很小,仍然無法容忍一次丟包,因為在tcp發現丟包之前,該介面已經超時了。
本文針對linux系統tcp資料包第一次重傳時間的計算進行探究,結果會讓人大吃一驚。提出的優化方法,理論上能夠降低內部服務呼叫時延和出錯量。
tcp傳送資料包後,會設定一個定時器,到期後如果還沒有收到對方的回覆(ack),就會重傳資料包。從發出資料包到第一次重傳之間的間隔時間稱為retransmission timeout(RTO),rto由資料包的往返時間(rtt)加上rtt的預測偏差(波動值)計算出來。
即 rto = srtt + rttvar,其中srtt是rtt的平滑值,而rttvar是波動值,代表可能的預測偏差。
接下來我們做一個試驗。
先ping一下www.weibo.com,看一下資料包的往返時間,如下:
1 2 3 4 5 6 |
[xiaohong@localhost ~]$ ping www.weibo.com PING www.weibo.com (123.125.104.197) 56(84) bytes of data. 64 bytes from 123.125.104.197: icmp_seq=1 ttl=55 time=3.65 ms 64 bytes from 123.125.104.197: icmp_seq=2 ttl=55 time=3.38 ms 64 bytes from 123.125.104.197: icmp_seq=3 ttl=55 time=4.34 ms 64 bytes from 123.125.104.197: icmp_seq=4 ttl=55 time=7.82 ms |
再看一下tcp對到www.weibo.com的rtt相關資料,下面的命令是針對centos7(如果是以下的版本,執行的命令是ip route list tab cache)如下:
1 2 |
[xiaohong@localhost ~]$ sudo ip tcp_metrics 123.125.104.197 age 22.255sec rtt 7375us rttvar 7250us cwnd 10 |
由上面看出,平滑後的rtt值約為7ms,rttvar約為7ms,那按理說rto值應該是14ms左右,也就是等14ms後,如果沒有收到對方的響應,就會重傳資料。實際的情況會是這樣麼?
在一個命令視窗裡,執行下面的命令:
1 2 3 4 |
[xiaohong@localhost ~]$ nc www.weibo.com 80 GET / HTTP/1.1 Host: www.weibo.com Connection: |
同時再開一個命令列視窗裡,執行下面的命令:
1 2 3 |
[xiaohong@localhost iproute2-3.19.0]$ ss -eipn '( dport = :www )' tcp ESTAB 0 0 10.209.80.111:56486 123.125.104.197:80 users:(("nc",1713,3)) uid:1000 ino:14243 sk:ffff88002c992d00 <-> ts sack cubic wscale:0,7 rto:207 rtt:7.375/7.25 mss:1448 cwnd:10 send 15.7Mbps rcv_space:14600 |
從上面的結果可以看出,實際的rto值是207ms,相當於rtt值加上200ms,為什麼呢?
下面從核心tcp原始碼中分析原因。
設定超時時間的函式是tcp_set_rto,在net/ipv4/tcp_input.c中,如下:
1 2 3 4 5 6 7 8 |
static inline void tcp_set_rto(struct sock *sk) { const struct tcp_sock *tp = tcp_sk(sk); inet_csk(sk)->icsk_rto = __tcp_set_rto(tp); tcp_bound_rto(sk); } |
可以看出,重傳的定時值isck_rto實際上是呼叫 __tcp_set_rto,接著看它的原始碼,這個在檔案include/tcp/net/tcp.h中,如下:
1 2 3 4 |
static inline u32 __tcp_set_rto(const struct tcp_sock *tp) { return (tp->srtt >> 3) + tp->rttvar; } |
為了避免浮點數運算,rtt乘以8儲存在socket資料結構中,從程式碼可以確認:
1 |
icsk_rto = srtt + rttvar |
而計算和影響srtt和rttvar的函式是tcp_rtt_estimator,在檔案net/ipv4/tcp_input.c中,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
static void tcp_rtt_estimator(struct sock *sk, const __u32 mrtt) { struct tcp_sock *tp = tcp_sk(sk); long m = mrtt; /* RTT */ /* The following amusing code comes from Jacobson's * article in SIGCOMM '88. Note that rtt and mdev * are scaled versions of rtt and mean deviation. * This is designed to be as fast as possible * m stands for "measurement". * * On a 1990 paper the rto value is changed to: * RTO = rtt + 4 * mdev * * Funny. This algorithm seems to be very broken. * These formulae increase RTO, when it should be decreased, increase * too slowly, when it should be increased quickly, decrease too quickly * etc. I guess in BSD RTO takes ONE value, so that it is absolutely * does not matter how to _calculate_ it. Seems, it was trap * that VJ failed to avoid. 8) */ if (m == 0) m = 1; if (tp->srtt != 0) { m -= (tp->srtt >> 3); /* m is now error in rtt est */ tp->srtt += m; /* rtt = 7/8 rtt + 1/8 new */ if (m < 0) { m = -m; /* m is now abs(error) */ m -= (tp->mdev >> 2); /* similar update on mdev */ /* This is similar to one of Eifel findings. * Eifel blocks mdev updates when rtt decreases. * This solution is a bit different: we use finer gain * for mdev in this case (alpha*beta). * Like Eifel it also prevents growth of rto, * but also it limits too fast rto decreases, * happening in pure Eifel. */ if (m > 0) m >>= 3; } else { m -= (tp->mdev >> 2); /* similar update on mdev */ } tp->mdev += m; /* mdev = 3/4 mdev + 1/4 new */ if (tp->mdev > tp->mdev_max) { tp->mdev_max = tp->mdev; if (tp->mdev_max > tp->rttvar) tp->rttvar = tp->mdev_max; } if (after(tp->snd_una, tp->rtt_seq)) { if (tp->mdev_max < tp->rttvar) tp->rttvar -= (tp->rttvar - tp->mdev_max) >> 2; tp->rtt_seq = tp->snd_nxt; tp->mdev_max = tcp_rto_min(sk); } } else { /* no previous measure. */ tp->srtt = m << 3; /* take the measured time to be rtt */ tp->mdev = m << 1; /* make sure rto = 3*rtt */ tp->mdev_max = tp->rttvar = max(tp->mdev, tcp_rto_min(sk)); tp->rtt_seq = tp->snd_nxt; } } |
從上面的程式碼可以看出,srtt = 7/8 old srtt + 1/8 new rtt,這個跟RFC一致,沒有啥可以說的。
獲得第一個往返時間資料時(一般是建立連線完成時,對於客戶端就是發出sync請求,收到服務端的回應時,而對於伺服器端就是發出syc+ack後,收到客戶端的ack時)的計算分析如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
} else { /* no previous measure. */ /* 以前沒有rtt的資料,這是收到第一個rtt的樣本資料的程式碼邏輯 */ /* m是本次的rtt值,乘以8儲存到 srtt中 */ tp->srtt = m << 3; /* take the measured time to be rtt */ /* rtt的初始偏差值mdev是 2倍rtt值 */ tp->mdev = m << 1; /* make sure rto = 3*rtt */ /* 設定rttvar和rtt偏差的最大值mdev_max這兩者的初始值 */ /* 2倍的rtt值,tcp_rto_min之間,那個大,就選那個 */ tp->mdev_max = tp->rttvar = max(tp->mdev, tcp_rto_min(sk)); tp->rtt_seq = tp->snd_nxt; } |
再看tcp_rto_min的程式碼,在檔案include/net/tcp.h中:
1 2 3 4 5 6 7 8 9 |
static inline u32 tcp_rto_min(struct sock *sk) { struct dst_entry *dst = __sk_dst_get(sk); u32 rto_min = TCP_RTO_MIN; /* 200ms */ if (dst && dst_metric_locked(dst, RTAX_RTO_MIN)) rto_min = dst_metric_rtt(dst, RTAX_RTO_MIN); return rto_min; } |
結合起來看,如果第一個資料包往返時間在100ms以內,rtt預測初始的偏差值就固定為200ms,當資料包往返時間超過100ms,rtt預測偏差的初始值是2倍的rtt值,也就是說rttvar最小值是200ms。
接著分析計算和影響srtt和rttvar的函式是tcp_rtt_estimator的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
if (tp->mdev > tp->mdev_max) { /* 跟蹤rtt的偏差,記錄偏差最大值mdev_max */ tp->mdev_max = tp->mdev; if (tp->mdev_max > tp->rttvar) /* 偏差最大值大於 rttvar時,rttvar跟著變大 */ tp->rttvar = tp->mdev_max; } if (after(tp->snd_una, tp->rtt_seq)) { /* 偏差最大值小於 rttvar時,rttvar也會相應減少 */ if (tp->mdev_max < tp->rttvar) tp->rttvar -= (tp->rttvar - tp->mdev_max) >> 2; tp->rtt_seq = tp->snd_nxt; /* 每個傳送週期結束,重置mdev_max為tcp_rto_min */ tp->mdev_max = tcp_rto_min(sk); } |
也就是說,rtt預測偏差值rttvar會跟著實際的rtt預測偏差值變化,如果波動變大,則跟著變大,反之,如果波動變小,也會跟著變小。但因為每個傳送週期內,偏差的最大值會重置為tcp_rto_min,所以,rtt預測偏差值rttvar不會小於200ms。
那這200ms的限制,有啥簡單的方法調整麼?繼續看tcp_rto_min的程式碼,前面也貼過,如下:
1 2 3 4 5 6 7 8 9 |
static inline u32 tcp_rto_min(struct sock *sk) { struct dst_entry *dst = __sk_dst_get(sk); u32 rto_min = TCP_RTO_MIN; /* 200ms */ if (dst && dst_metric_locked(dst, RTAX_RTO_MIN)) rto_min = dst_metric_rtt(dst, RTAX_RTO_MIN); return rto_min; } |
從上面的程式碼可以看出,如果對應的目標的路由表項中設定了rto_min值,則以設定的值為準。這可以通過netlink機制來修改,具體可以通過ip route命令,增加rto_min選項來完成。
分析完原始碼,接著試驗一下。
執行下面的命令修改成20ms:
1 |
sudo ip route add 123.125.104.197/32 via 10.209.83.254 rto_min 20 |
看以下修改後的結果:
1 2 3 4 |
[xiaohong@localhost ~]$ ip route list default via 10.209.83.254 dev enp0s3 proto static metric 1024 10.209.80.0/22 dev enp0s3 proto kernel scope link src 10.209.80.111 123.125.104.197 via 10.209.83.254 dev enp0s3 rto_min lock 20ms |
清除以下路由表的快取,這樣可以立即檢視效果:
1 |
sudo ip tcp_metrics flush |
再測試訪問weibo.com:
1 2 |
[xiaohong@localhost ~]$ nc www.weibo.com 80 GET / |
在另外的終端中確認一下結果:
1 2 3 |
[xiaohong@localhost iproute2-3.19.0]$ ss -eipn '( dport = :www )' tcp ESTAB 0 0 10.209.80.111:56487 123.125.104.197:80 users:(("nc",1786,3)) uid:1000 ino:14606 sk:ffff88002c992d00 <-> ts sack cubic wscale:0,7 rto:22 rtt:2/1 mss:1448 cwnd:10 send 57.9Mbps rcv_space:14600 |
可以看出,本次的rtt值是2ms,rto為22ms,即已經生效。
歡迎一起討論,拍磚也可以。呵呵。