由STGW下载慢问题引发的网络传输学习之旅
導語:本文分享了筆者現網遇到的一個文件下載慢的問題。最開始嘗試過很多辦法,包括域名解析,網絡鏈路分析,AB環境測試,網絡抓包等,但依然找不到原因。然后利用網絡命令和報文得到的蛛絲馬跡,結合內核網絡協議棧的實現代碼,找到了一個內核隱藏很久但在最近版本解決了的BUG。如果你也想了解如何分析和解決詭異的網絡問題,如果你也想溫習一下課堂上曾經學習過的慢啟動、擁塞避免、快速重傳、AIMD等老掉牙的知識,如果你也渴望學習課本上完全沒介紹過的TCP的一系列優化比如混合慢啟動、尾包探測甚至BBR等,那么本文或許可以給你一些經驗和啟發。
問題背景線上用戶經過STGW(Secure Tencent Gateway,騰訊安全網關-七層轉發代理)下載一個50M左右的文件,與直連用戶自己的服務器相比,下載速度明顯變慢,需要定位原因。在了解到用戶的問題之后,相關的同事在線下做了如下嘗試:
1. 從廣州和上海直接訪問用戶的回源VIP(Virtual IP,提供服務的公網IP地址)下載,都耗時4s+,正常;
2. 只經過TGW(Tencent Gateway,騰訊網關-四層負載均衡系統),不經過STGW訪問,從廣州和上海訪問上海的TGW,耗時都是4s+,正常;
3. 經過STGW,從上海訪問上海的STGW VIP,耗時4s+,正常;
4. 經過STGW,從廣州訪問上海的STGW VIP,耗時12s+,異常。
前面的三種情況都是符合預期,而第四種情況是不符合預期的,這個也是本文要討論的問題。
前期定位排查發現下載慢的問題后,我們分析了整體的鏈路情況,按照鏈路經過的節點順序有了如下的排查思路:
(1)從客戶端側來排查,DNS解析慢,客戶端讀取響應慢或者接受窗口小等;
(2)從鏈路側來排查,公網鏈路問題,中間交換機設備問題,丟包等;
(3)從業務服務側來排查,業務服務側發送響應較慢,發送窗口較小等;
(4)從自身轉發服務來排查,TGW或STGW轉發程序問題,STGW擁塞窗口緩存等;
按照上面的這些思路,我們分別做了如下的排查:
1.是否是由于異常客戶端的DNS服務器解析慢導致的?
用戶下載小文件沒有問題,并且直接訪問VIP,配置hosts訪問,發現問題依然復現,排除。
2.是否是由于客戶端讀取響應慢或者接收窗口較小導致的?
抓包分析客戶端的數據包處理情況,發現客戶端收包處理很快,并且接收窗口一直都是有很大空間。排除。
3.是否是廣州到上海的公網鏈路或者交換機等設備問題,導致訪問變慢?
從廣州的客戶端上ping上海的VIP,延時很低,并且測試不經過STGW,從該客戶端直接訪問TGW再到回源服務器,下載正常,排除。
4.是否是STGW到回源VIP這條鏈路上有問題?
在STGW上直接訪問用戶的回源VIP,耗時4s+,是正常的。并且打開了STGW LD(LoadBalance Director,負載均衡節點)與后端server之間的響應緩存,抓包可以看到,后端數據4s左右全部發送到STGW LD上,是STGW LD往客戶端回包比較慢,基本可以確認是Client->STGW這條鏈路上有問題。排除。
5.是否是由于TGW或STGW轉發程序有問題?
由于異地訪問必定會復現,同城訪問就是正常的。而TGW只做四層轉發,無法感知源IP的地域信息,并且抓包也確認TGW上并沒有出現大量丟包或者重傳的現象。STGW是一個應用層的反向代理轉發,也不會對于不同地域的cip有不同的處理邏輯。排除。
6.是否是由于TGW是fullnat影響了擁塞窗口緩存?
因為之前由于fullnat出現過一些類似于本例中下載慢的問題,當時定位的原因是由于STGW LD上開啟了擁塞窗口緩存,在fullnat的情況下,會影響擁塞窗口緩存的準確性,導致部分請求下載慢。但是這里將擁塞窗口緩存選項 sysctl -w net.ipv4.tcp_no_metrics_save=1 關閉之后測試,發現問題依然存在,并且線下用另外一個fullnat的vip測試,發現并沒有復現用戶的問題。排除。
根據一些以往的經驗和常規的定位手段都嘗試了以后,發現仍然還是沒有找到原因,那到底是什么導致的呢?
問題分析首先,在復現的STGW LD上抓包,抓到Client與STGW LD的包如下圖,從抓包的信息來看是STGW回包給客戶端很慢,每次都只發很少的一部分到Client。
這里有一個很奇怪的地方就是為什么第7號包發生了重傳?不過暫時可以先將這個疑問放到一邊,因為就算7號包發生了一個包的重傳,這中間也并沒有發生丟包,LD發送數據也并不應該這么慢。那既然LD發送數據這么慢,肯定要么是Client的接收窗口小,要么是LD的擁塞窗口比較小。
對端的接收窗口,抓包就可以看到,實際上Client的接收窗口并不小,而且有很大的空間。那是否有辦法可以看到LD的發送窗口呢?答案是肯定的:ss -it,這個指令可以看到每條連接的rtt,ssthresh,cwnd等信息。有了這些信息就好辦了,再次復現,并寫了個命令將cwnd等信息記錄到文件:
while true; do date +"%T.%6N" >> cwnd.log; ss -it >> cwnd.log; done復現得到的cwnd.log如上圖,找到對應的連接,grep出來后對照來看。果然發現在前面幾個包中,擁塞窗口就直接被置為7,并且ssthresh也等于7,并且可以看到后面窗口增加的很慢,直接進入了擁塞避免,這么小的發送窗口,增長又很緩慢,自然發送數據就會很慢了。
那么到底是什么原因導致這里直接在前幾個包就進入擁塞避免呢?從現有的信息來看,沒辦法直接確定原因,只能去啃代碼了,但tcp擁塞控制相關的代碼這么多,如何能快速定位呢?
觀察上面異常數據包的cwnd信息,可以看到一個很明顯的特征,最開始ssthresh是沒有顯示出來的,經過了幾個數據包之后,ssthresh與cwnd是相等的,所以嘗試按照"snd_ssthresh ="和"snd_cwnd ="的關鍵字來搜索,按照snd_cwnd = snd_ssthresh的原則來找,排除掉一些不太可能的函數之后,最后找到了tcp_end_cwnd_reduction這個函數。
再查找這個函數引用的地方,有兩處:tcp_fastretrans_alert和tcp_process_tlp_ack這兩個函數。
tcp_fastretrans_alert看名字就知道是跟快速重傳相關的函數,我們知道快速重傳觸發的條件是收到了三個重復的ack包。但根據前面的抓包及分析來看,并不滿足快速重傳的條件,所以疑點就落在了這個tcp_process_tlp_ack函數上面。那么到底什么是TLP呢?
什么是TLP(Tail Loss Probe)在講TLP之前,我們先來回顧下大學課本里學到的擁塞控制算法,祭出這張經典的擁塞控制圖。?
TCP的擁塞控制主要分為四個階段:慢啟動,擁塞避免,快重傳,快恢復。長久以來,我們聽到的說法都是,最開始擁塞窗口從1開始慢啟動,以指數級遞增,收到三個重復的ack后,將ssthresh設置為當前cwnd的一半,并且置cwnd=ssthresh,開始執行擁塞避免,cwnd加法遞增。
這里我們來思考一個問題,發生丟包時,為什么要將ssthresh設置為cwnd的一半?
想象一個場景,A與B之間發送數據,假設二者發包和收包頻率是一致的,由于A與B之間存在空間距離,中間要經過很多個路由器,交換機等,A在持續發包,當B收到第一個包時,這時A與B之間的鏈路里的包的個數為N,此時由于B一直在接收包,因此A還可以繼續發,直到第一個包的ack回到A,這時A發送的包的個數就是當前A與B之間最大的擁塞窗口,即為2N,因為如果這時A多發送,肯定就丟包了。
ssthresh代表的就是當前鏈路上可以發送的最大的擁塞窗口大小,理想情況下,ssthresh就是2N,但現實的環境很復雜,不可能剛好cwnd經過慢啟動就可以直接到達2N,發送丟包的時候,肯定是N<1/2*cwnd<2N,因此此時將ssthresh設置為1/2*cwnd,然后再從此處加法增加慢慢的達到理想窗口,不能增長過快,因為要“避免擁塞”。
實際上,各個擁塞控制算法都有自己的實現,初始cwnd的值也一直在優化,在linux 3.0版本以后,內核CUBIC的實現里,采用了Google在RFC6928的建議,將初始的cwnd的值設置為10。而在linux 3.0版本之前,采取的是RFC3390中的策略,根據不同的MSS,設置了不同的初始化cwnd。具體的策略為:
If (MSS <= 1095 bytes)? ? then cwnd=4;
If (1095 bytes < MSS < 2190 bytes)
? ? then cwnd=3;
If (2190 bytes <= MSS)
? ? then cwnd=2;
并且在執行擁塞避免時,當前CUBIC的實現里也不是將ssthresh設置為cwnd的一半,而是717/1024≈0.7左右,RFC8312也提到了這樣做的原因。
Principle 4: To balance between the scalability and convergence speed, CUBIC sets the multiplicative window decrease factor to 0.7 while Standard TCP uses 0.5. While this improves the scalability of CUBIC, a side effect of this decision is slower convergence, especially under low statistical multiplexing environments.從上面的描述可以看到,在TCP的擁塞控制算法里,最核心的點就是ssthresh的確定,如何能快速準確的確定ssthresh,就可以更加高效的傳輸。而現實的網絡環境很復雜,在有些情況下,沒有辦法滿足快速重傳的條件,如果每次都以丟包作為反饋,代價太大。比如,考慮如下的幾個場景:
是否可以探測到ssthresh的值,不依賴丟包來觸發進入擁塞避免,主動退出慢啟動?
如果沒有足夠的dup ack(大于0,小于3)來觸發快速重傳,如何處理?
如果沒有任何的dup ack(等于0),比如尾丟包的情況,如何處理?
是否可以主動探測網絡帶寬,基于反饋驅動來調整窗口,而不是丟包等事件驅動來執行擁塞控制?
針對上面的前三種情況,TCP協議棧分別都做了相應的優化,對應的優化算法分別為:hystart(Hybrid Slow Start),ER(Early Retransmit)和TLP(Tail Loss Probe)。對于第四種情況,Google給出了答案,創造了一種新的擁塞控制算法,它的名字叫BBR,從linux 4.19開始,內核已經將默認的擁塞控制算法從CUBIC改成了BBR。受限于本文的篇幅有限,無法對BBR算法做詳盡的介紹,下面僅結合內核CUBIC的代碼來分別介紹前面的這三種優化算法。
1. 慢啟動的hystart優化
混合慢啟動的思想是在論文《Hybrid Slow Start for High-Bandwidth and Long-Distance Networks》里首次提出的,前面我也說過,如果每次判斷擁塞都依賴丟包來作為反饋,代價太大,hystart也是在這個方向上做優化,它主要想解決的問題就是不依賴丟包作為反饋來退出慢啟動,它提出的退出條件有兩類:
判斷在同一批發出去的數據包收到的ack包(對應論文中的acks train length)的總時間大于min(rtt)/2;
判斷一批樣本中的最小rtt是否大于全局最小rtt加一個閾值的和;
內核CUBIC的實現里默認都是開啟了hystart,在bictcp_init函數里判斷是否開啟并做初始化
static inline void bictcp_hystart_reset(struct sock *sk) {struct tcp_sock *tp = tcp_sk(sk);struct bictcp *ca = inet_csk_ca(sk);ca->round_start = ca->last_ack = bictcp_clock();ca->end_seq = tp->snd_nxt;ca->curr_rtt = 0;ca->sample_cnt = 0; } static void bictcp_init(struct sock *sk) {struct bictcp *ca = inet_csk_ca(sk);bictcp_reset(ca);ca->loss_cwnd = 0;if (hystart)//如果開啟了hystart,那么做初始化bictcp_hystart_reset(sk);if (!hystart && initial_ssthresh)tcp_sk(sk)->snd_ssthresh = initial_ssthresh; }核心的判斷是否退出慢啟動的函數在hystart_update里
static void hystart_update(struct sock *sk, u32 delay) {struct tcp_sock *tp = tcp_sk(sk);struct bictcp *ca = inet_csk_ca(sk);if (!(ca->found & hystart_detect)) {u32 now = bictcp_clock();/* first detection parameter - ack-train detection *///判斷如果連續兩個ack的間隔小于hystart_ack_delta(2ms),則為一個acks trainif ((s32)(now - ca->last_ack) <= hystart_ack_delta) {ca->last_ack = now;//如果ack_train的總長度大于1/2 * min_rtt,則退出慢啟動,ca->delay_min = 8*min_rttif ((s32)(now - ca->round_start) > ca->delay_min >> 4)ca->found |= HYSTART_ACK_TRAIN;}/* obtain the minimum delay of more than sampling packets *///如果小于HYSTART_MIN_SAMPLES(8)個樣本則直接計數if (ca->sample_cnt < HYSTART_MIN_SAMPLES) {if (ca->curr_rtt == 0 || ca->curr_rtt > delay)ca->curr_rtt = delay;ca->sample_cnt++;} else {/** 否則,判斷這些樣本中的最小rtt是否要大于全局的最小rtt+有范圍變化的閾值,* 如果是,則說明發生了擁塞*/if (ca->curr_rtt > ca->delay_min +HYSTART_DELAY_THRESH(ca->delay_min>>4))ca->found |= HYSTART_DELAY;}/** Either one of two conditions are met,* we exit from slow start immediately.*///判斷ca->found如果為真,則退出慢啟動,進入擁塞避免if (ca->found & hystart_detect)tp->snd_ssthresh = tp->snd_cwnd;} }2. ER(Early?Retransmit)算法
我們知道,快重傳的條件是必須收到三個相同的dup ack,才會觸發,那如果在有些情況下,沒有足夠的dup ack,只能依賴rto超時,再進行重傳,并且開始執行慢啟動,這樣的代價太大,ER算法就是為了解決這樣的場景,RFC5827詳細介紹了這個算法。
算法的基本思想:
ER_ssthresh = 3 //ER_ssthresh代表觸發快速重傳的dup ack的個數 if (unacked segments < 4 && no new data send)if (sack is unable) // 如果SACK選項不支持,則使用還未ack包的個數減一作為閾值ER_ssthresh = unacked segments - 1elif (sacked packets == unacked segments - 1) // 否則,只有當還有一個包還未sack,才能啟用ER,并且置閾值為還未ack包的個數減一ER_ssthresh = unacked segments - 1對應到代碼里的函數為tcp_time_to_recover:
static bool tcp_time_to_recover(struct sock *sk, int flag) {.../* Trick#6: TCP early retransmit, per RFC5827. To avoid spurious* retransmissions due to small network reorderings, we implement* Mitigation A.3 in the RFC and delay the retransmission for a short* interval if appropriate.*/if (tp->do_early_retrans //開啟ER算法&& !tp->retrans_out //沒有重傳數據&& tp->sacked_out //當前收到了dupack包&& (tp->packets_out >= (tp->sacked_out + 1) && tp->packets_out < 4) //滿足ER的觸發條件&& !tcp_may_send_now(sk)) //沒有新的數據發送return !tcp_pause_early_retransmit(sk, flag);//判斷是立即進入ER還是需要delay 1/4 rttreturn false; } /** 這里內核的實現與rfc5827有一點不同,就是引入了delay ER的概念,主要是防止過多減小的dupack 閾值帶來的* 無效的重傳,所以默認加了一個1/4 RTT的delay,在ER的基礎上又做了一個折中,等一段時間再判斷是否要重傳。* 如果是false,則立即進入ER,如果是true,則delay max(RTT/4,2msec)再進入ER*/ static bool tcp_pause_early_retransmit(struct sock *sk, int flag) {struct tcp_sock *tp = tcp_sk(sk);unsigned long delay;/* Delay early retransmit and entering fast recovery for* max(RTT/4, 2msec) unless ack has ECE mark, no RTT samples* available, or RTO is scheduled to fire first.*///內核提供了一個參數tcp_early_retrans來控制ER和delay ER,等于2和3時,是打開了delay ERif (sysctl_tcp_early_retrans < 2 || sysctl_tcp_early_retrans > 3 ||(flag & FLAG_ECE) || !tp->srtt)return false;delay = max_t(unsigned long, (tp->srtt >> 5), msecs_to_jiffies(2));if (!time_after(inet_csk(sk)->icsk_timeout, (jiffies + delay)))return false;//設置delay ER的定時器inet_csk_reset_xmit_timer(sk, ICSK_TIME_EARLY_RETRANS, delay,TCP_RTO_MAX);return true; }delay ER的定時器超時的處理函數tcp_resume_early_retransmit。
void tcp_resume_early_retransmit(struct sock *sk) {struct tcp_sock *tp = tcp_sk(sk);tcp_rearm_rto(sk);/* Stop if ER is disabled after the delayed ER timer is scheduled */if (!tp->do_early_retrans)return;//執行快速重傳tcp_enter_recovery(sk, false);tcp_update_scoreboard(sk, 1);tcp_xmit_retransmit_queue(sk); }內核提供了一個開關,tcp_early_retrans用于開啟和關閉TLP和ER算法,默認是3,即打開了delay ER和TLP算法。
sysctl_tcp_early_retrans (defalut:3)0 disables ER1 enables ER2 enables ER but delays fast recovery and fast retransmit by a fourth of RTT.3 enables delayed ER and TLP.4 enables TLP only.到此,這就是內核設計ER算法的相關的代碼。ER算法在cwnd比較小的情況下,是可以有一些改善的,但個人認為,實際的效果可能一般。因為如果cwnd較小,執行慢啟動與執行快速重傳再進入擁塞避免相比,二者的實際傳輸效率可能相差并不大。
3.TLP(Tail Loss Probe)算法
TLP想解決的問題是:如果尾包發生了丟包,沒有新包可發送觸發多余的dup ack來實現快速重傳,如果完全依賴RTO超時來重傳,代價太大,那如何能優化解決這種尾丟包的情況。
TLP算法是2013年谷歌在論文《Tail Loss Probe (TLP): An Algorithm for Fast Recovery of Tail Losses》中提出來的,它提出的基本思想是:
在每個發送的數據包的時候,都更新一個定時器PTO(probe timeout),這個PTO是動態變化的,當發出的包中存在未ack的包,并且在PTO時間內都未收到一個ack,那么就會發送一個新包或者重傳最后的一個數據包,探測一下當前網絡是否真的擁塞發生丟包了。
如果收到了tail包的dup ack,則說明沒有發生丟包,繼續執行當前的流程;否則說明發生了丟包,需要執行減窗,并且進入擁塞避免。
這里其中一個比較重要的點是PTO如何設置,設置的策略如下:
if unacked packets == 0:no need set PTO else if unacked packets == 1:PTO=max(2rtt, 1.5*rtt+TCP_DELACK_MAX, 10ms) else:PTO=max(2rtt, 10ms) 注:TCP_DELACK_MAX = 200ms對應到代碼里的tcp_schedule_loss_probe函數:
bool tcp_schedule_loss_probe(struct sock *sk) {struct inet_connection_sock *icsk = inet_csk(sk);struct tcp_sock *tp = tcp_sk(sk);u32 timeout, tlp_time_stamp, rto_time_stamp;u32 rtt = tp->srtt >> 3;if (WARN_ON(icsk->icsk_pending == ICSK_TIME_EARLY_RETRANS))return false;/* No consecutive loss probes. */if (WARN_ON(icsk->icsk_pending == ICSK_TIME_LOSS_PROBE)) {tcp_rearm_rto(sk);return false;}/* Don't do any loss probe on a Fast Open connection before 3WHS* finishes.*/if (sk->sk_state == TCP_SYN_RECV)return false;/* TLP is only scheduled when next timer event is RTO. */if (icsk->icsk_pending != ICSK_TIME_RETRANS)return false;/* Schedule a loss probe in 2*RTT for SACK capable connections* in Open state, that are either limited by cwnd or application.*///判斷是否開啟了TLP及一些觸發條件if (sysctl_tcp_early_retrans < 3 || !rtt || !tp->packets_out ||!tcp_is_sack(tp) || inet_csk(sk)->icsk_ca_state != TCP_CA_Open)return false;if ((tp->snd_cwnd > tcp_packets_in_flight(tp)) &&tcp_send_head(sk))return false;/* Probe timeout is at least 1.5*rtt + TCP_DELACK_MAX to account* for delayed ack when there's one outstanding packet.*///這個與上面描述的策略是一致的timeout = rtt << 1;if (tp->packets_out == 1)timeout = max_t(u32, timeout,(rtt + (rtt >> 1) + TCP_DELACK_MAX));timeout = max_t(u32, timeout, msecs_to_jiffies(10));/* If RTO is shorter, just schedule TLP in its place. */tlp_time_stamp = tcp_time_stamp + timeout;rto_time_stamp = (u32)inet_csk(sk)->icsk_timeout;if ((s32)(tlp_time_stamp - rto_time_stamp) > 0) {s32 delta = rto_time_stamp - tcp_time_stamp;if (delta > 0)timeout = delta;}//設置PTO定時器inet_csk_reset_xmit_timer(sk, ICSK_TIME_LOSS_PROBE, timeout,TCP_RTO_MAX);return true; }?PTO超時之后,會觸發tcp_send_loss_probe發送TLP包:
/* When probe timeout (PTO) fires, send a new segment if one exists, else* retransmit the last segment.*/ void tcp_send_loss_probe(struct sock *sk) {struct tcp_sock *tp = tcp_sk(sk);struct sk_buff *skb;int pcount;int mss = tcp_current_mss(sk);int err = -1;//如果還可以發送新數據,那么就發送新數據if (tcp_send_head(sk) != NULL) {err = tcp_write_xmit(sk, mss, TCP_NAGLE_OFF, 2, GFP_ATOMIC);goto rearm_timer;}/* At most one outstanding TLP retransmission. *///一次最多只有一個TLP探測包if (tp->tlp_high_seq)goto rearm_timer;/* Retransmit last segment. *///如果沒有新數據可發送,就重新發送最后的一個數據包skb = tcp_write_queue_tail(sk);if (WARN_ON(!skb))goto rearm_timer;pcount = tcp_skb_pcount(skb);if (WARN_ON(!pcount))goto rearm_timer;if ((pcount > 1) && (skb->len > (pcount - 1) * mss)) {if (unlikely(tcp_fragment(sk, skb, (pcount - 1) * mss, mss)))goto rearm_timer;skb = tcp_write_queue_tail(sk);}if (WARN_ON(!skb || !tcp_skb_pcount(skb)))goto rearm_timer;err = __tcp_retransmit_skb(sk, skb);/* Record snd_nxt for loss detection. */if (likely(!err))tp->tlp_high_seq = tp->snd_nxt; rearm_timer:inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,inet_csk(sk)->icsk_rto,TCP_RTO_MAX);if (likely(!err))NET_INC_STATS_BH(sock_net(sk),LINUX_MIB_TCPLOSSPROBES);return; }發送TLP探測包后,在tcp_process_tlp_ack里判斷是否發生了丟包,做相應的處理:
/* This routine deals with acks during a TLP episode.* Ref: loss detection algorithm in draft-dukkipati-tcpm-tcp-loss-probe.*/ static void tcp_process_tlp_ack(struct sock *sk, u32 ack, int flag) {struct tcp_sock *tp = tcp_sk(sk);//判斷這個包是否是tlp包的dup ack包bool is_tlp_dupack = (ack == tp->tlp_high_seq) &&!(flag & (FLAG_SND_UNA_ADVANCED |FLAG_NOT_DUP | FLAG_DATA_SACKED));/* Mark the end of TLP episode on receiving TLP dupack or when* ack is after tlp_high_seq.*///如果是dup ack,說明沒有發生丟包,繼續當前的流程if (is_tlp_dupack) {tp->tlp_high_seq = 0;return;}//否則,減窗,并進入擁塞避免if (after(ack, tp->tlp_high_seq)) {tp->tlp_high_seq = 0;/* Don't reduce cwnd if DSACK arrives for TLP retrans. */if (!(flag & FLAG_DSACKING_ACK)) {tcp_init_cwnd_reduction(sk, true);tcp_set_ca_state(sk, TCP_CA_CWR);tcp_end_cwnd_reduction(sk);tcp_try_keep_open(sk);NET_INC_STATS_BH(sock_net(sk),LINUX_MIB_TCPLOSSPROBERECOVERY);}} }TLP算法的設計思路還是挺好的,主動提前發現網絡是否擁塞,而不是被動的去依賴丟包來作為反饋。在大多數情況下是可以提高網絡傳輸的效率的,但在某些情況下可能會"適得其反",而本文遇到的問題就是"適得其反"的一個例子。
問題的解決回到我們的這個問題上,如何確認確實是由于TLP引起的呢?
繼續查看代碼可以看到,TLP的loss probe和loss recovery次數,內核都有相應的計數器跟蹤。
既然有計數器就好辦了,復現的時候netstat -s就可以查看是否命中TLP了。寫了個腳本將結果寫入到文件里。
while true; do date +"%T.%6N" >> loss.log; netstat -s | grep Loss >> loss.log; done?
查看計數器增長的情況,結合抓包文件來看,基本確認肯定是命中TLP了。知道原因那就好辦了,關掉TLP驗證一下應該就可以解決了。
如上面介紹ER算法時提到,內核提供了一個開關,tcp_early_retrans可用于開啟和關閉ER和TLP,默認是3(enable TLP and delayed ER),sysctl -w net.ipv4.tcp_early_retrans=2 關掉TLP,再次重新測試,發現問題解決了:
窗口增加的很快,最終的ssthresh為941,下載速度4s+,也是符合預期,到此用戶的問題已經解決,但所有的疑問都得到了正確的解答了嗎?
真正的真相雖然用戶的問題已經得到了解決,但至少還有兩個問題沒有得到答案:
1. 為什么會每次都在握手完的前幾個包里就會觸發TLP?
2. 雖然觸發了TLP,但從抓包來看,已經收到了尾包的dup ack包,那說明沒有發生丟包,為什么還是進入了擁塞避免?
先回答第一個問題,根據文章最前面的網絡結構圖可以看到,STGW是掛在TGW的后面。在本場景中,用戶訪問的是TGW的高防VIP,高防VIP有一個默認開啟的功能就是SYN代理。
syn代理指的是client發起連接時,首先是由tgw代答syn ack包,client真正開始發送數據包時,tgw再發送三次握手的包到rs,并轉發數據包。
在本例中,tgw的rs就是stgw,也就是說,stgw的收到三次握手包的rtt是基于與tgw計算出來的,而后面的數據包才是真正與client之間的通信。前面背景描述中提到,用戶同城訪問(上海client訪問上海的vip)也是沒有問題的,跨城訪問就有問題。
這是因為同城訪問的情況下,tgw與stgw之間的rtt與client與stgw之間的rtt,相差并不大,并沒有滿足觸發tlp的條件。而跨城訪問后,三次握手的數據包的rtt是基于與tgw來計算的,比較小,后面收到數據包后,計算的是client到stgw之間的rtt,一下子增大了很多,并且滿足了tlp的觸發條件
PTO=max(2rtt, 10ms)設置的PTO定時器超時了,協議棧認為是不是由于網絡發生了擁塞,所以重傳了尾包探測一下查看是否真的發生了擁塞,這就是為什么每次都是在握手完隨后的幾個包里就會有重傳包,觸發了TLP的原因。
再回到第二個問題,從抓包來看,很明顯,網絡并沒有發生擁塞或丟包,stgw已經收到了尾包的dup ack包,按照TLP的原理來看,不應該進入擁塞避免的,到底是什么原因導致的。百思不得其解,只能再繼續啃代碼了,再回到tlp_ack的這一部分代碼來看。
只有當is_tlp_dupack為false時,才會進入到下面部分,進入擁塞避免,也就是說這里is_tlp_dupack肯定是為false的。ack == tp->tlp_high_seq這個條件是滿足的,那么問題就出在了幾個flag上面,看下幾個flag的定義:
#define FLAG_SND_UNA_ADVANCED 0x400 #define FLAG_NOT_DUP (FLAG_DATA|FLAG_WIN_UPDATE|FLAG_ACKED) #define FLAG_DATA_SACKED 0x20 /* New SACK.也就是說,只要flag包含了上面幾個中的任意一個,都會將is_tlp_dupack置為false,那到底flag包含了哪一個呢?如何繼續排查呢?
調試內核信息,最常用的工具就是ftrace及systemtap。
這里首先嘗試了ftrace,發現它并不能滿足我的需求。ftrace最主要的功能是可以跟蹤函數的調用信息,并且可以知道各個函數的執行時間,在有些場景下非常好用,但原生的ftrace命令用起來很不方便,ftrace團隊也意識到了這個問題,因此提供了另外一個工具trace-cmd,使用起來非常簡單。
trace-cmd record -p function_graph -P 3252 //跟蹤pid 3252的函數調用情況 trace-cmd report > report.log //以可視化的方式展示ftrace的結果并重定向到文件里下圖是使用trace-cmd跟蹤的一個例子部分截圖,可以看到完整打印了內核函數的調用信息及對應的執行時間。
但在當前的這個問題里,主要是想確認flag這個變量的值,ftrace沒有辦法打印出變量的值,因此考慮下一個強大的工具:systemtap。
systemtap是一個很強大的動態追蹤工具,利用它可以很方便的調試內核信息,跟蹤內核函數,打印變量信息等,很顯然它是符合我們的需求的。systemptap的使用需要安裝內核調試信息包(kernel-debuginfo),但由于復現的那臺機器上的內核版本較老,沒有debug包,無法使用stap工具,因此這條路也走不通。
最后,聯系了h_tlinux_Helper尋求幫助,他幫忙找到了復現機器內核版本的dev包,并在tcp_process_tlp_ack函數里打印了一些變量,并輸出堆棧信息。重新安裝了調試的內核,復現后打印了如下的堆棧及變量信息:
綠色標記處的那一行,就是收到的dup ack的那個包,可以看到flag的標記為0x4902,換算成宏定義為:
FLAG_UPDATE_TS_RECENT | FLAG_DSACKING_ACK | FLAG_SLOWPATH | FLAG_WIN_UPDATE再對照tcp_process_tlp_ack函數看一下,正是FLAG_WIN_UPDATE這個標記導致了is_tlp_dupack = false。那在什么情況下,flag會被置為FLAG_WIN_UPDATE呢?
繼續看代碼,對端回復的每個ack包基本會進入到tcp_ack_update_window函數。
看到這里flag被置為FLAG_WIN_UPDATE的條件是tcp_may_update_window返回true。
?
再看到tcp_may_update_window函數這里,after(ack_seq, tp->snd_wl1)?是基本都會命中的,因為不管窗口有沒有變化,ack_seq都會比snd_wl1 大的,ack_seq都是遞增的,snd_wl1在tcp_update_wl中又會被更新成上一次的ack_seq。因此絕大多數的包的flag都會被打上FLAG_WIN_UPDATE標記。
如果是這樣的話,那is_tlp_dupack不就是都為false了嗎?不管有沒有收到dup ack包,TLP都會進入擁塞避免,這個就不符合TLP的設計初衷了,這里是否是內核實現的Bug?
隨后我查看了linux 4.14內核代碼:
發現從內核版本linux 4.0開始,BUG就已經被修復了,去掉了flag的一些不合理的判斷條件,這才是真正的符合TLP的設計原理。
到此,整個問題的所有疑點才都得到了解釋。
總結本文從一個下載慢的線上問題入手,首先介紹了一些常規的排查思路和手段,發現仍然不能定位到原因。然后分享了一個可以查詢每條連接的擁塞窗口命令,結合內核代碼分析了TCP擁塞控制ssthresh的設計理念及混合慢啟動,ER和尾包探測(TLP)等優化算法,并介紹了兩個常用的內核調試工具:ftrace和systemtap,最終定位到是內核的TLP實現BUG導致的下載慢的問題,從內核4.0版本之后已經修復了這個問題。
總結
以上是生活随笔為你收集整理的由STGW下载慢问题引发的网络传输学习之旅的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 打造轻量级可视化数据爬取工具-菩提
- 下一篇: 腾讯在信息流内容理解技术上的解决方案