Categories
程式開發

由STGW下載慢問題引發的網絡傳輸學習之旅


導語 本文分享了筆者在STGW現網遇到的一個文件下載慢的問題。最開始嘗試過很多辦法,包括域名解析,網絡鏈路分析,AB環境測試,網絡抓包等,但依然找不到原因。然後利用網絡命令和報文得到的蛛絲馬跡,結合內核網絡協議棧的實現代碼,找到了一個內核隱藏很久但在最近版本解決了的BUG。如果你也想了解如何分析和解決詭異的網絡問題,如果你也想溫習一下課堂上曾經學習過的慢啟動、擁塞避免、快速重傳、AIMD等老掉牙的知識,如果你也渴望學習課本上完全沒介紹過的TCP的一系列優化比如混合慢啟動、尾包探測甚至BBR等,那麼本文或許可以給你一些經驗和啟發。

問題背景

線上用戶經過STGW(Secure Tencent Gateway,騰訊安全網關-七層轉發代理)下載一個50M左右的文件,與直連用戶自己的服務器相比,下載速度明顯變慢,需要定位原因。在了解到用戶的問題之後,相關的同事在線下做瞭如下嘗試:

  1. 從廣州和上海直接訪問用戶的回源VIP(Virtual IP,提供服務的公網IP地址)下載,都耗時4s+,正常

由STGW下載慢問題引發的網絡傳輸學習之旅 1

  1. 只經過TGW(Tencent Gateway,騰訊網關-四層負載均衡系統),不經過STGW訪問,從廣州和上海訪問上海的TGW,耗時都是4s+,正常

由STGW下載慢問題引發的網絡傳輸學習之旅 2

  1. 經過STGW,從上海訪問上海的STGW VIP,耗時4s+,正常

由STGW下載慢問題引發的網絡傳輸學習之旅 3

  1. 經過STGW,從廣州訪問上海的STGW VIP,耗時12s+,異常

由STGW下載慢問題引發的網絡傳輸學習之旅 4

前面的三種情況都是符合預期,而第四種情況是不符合預期的,這個也是本文要討論的問題。

前期定位排查

發現下載慢的問題後,我們分析了整體的鏈路情況,按照鏈路經過的節點順序有瞭如下的排查思路:

(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。

由STGW下載慢問題引發的網絡傳輸學習之旅 5

這裡有一個很奇怪的地方就是為什麼第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

由STGW下載慢問題引發的網絡傳輸學習之旅 6

復現得到的cwnd.log如上圖,找到對應的連接,grep出來後對照來看。果然發現在前面幾個包中,擁塞窗口就直接被置為7,並且ssthresh也等於7,並且可以看到後面窗口增加的很慢,直接進入了擁塞避免,這麼小的發送窗口,增長又很緩慢,自然發送數據就會很慢了。

那麼到底是什麼原因導致這裡直接在前幾個包就進入擁塞避免呢?從現有的信息來看,沒辦法直接確定原因,只能去啃代碼了,但tcp擁塞控制相關的代碼這麼多,如何能快速定位呢

觀察上面異常數據包的cwnd信息,可以看到一個很明顯的特徵,最開始ssthresh是沒有顯示出來的,經過了幾個數據包之後,ssthresh與cwnd是相等的,所以嘗試按照”snd_ssthresh =”和”snd_cwnd =”的關鍵字來搜索,按照snd_cwnd = snd_ssthresh的原則來找,排除掉一些不太可能的函數之後,最後找到了tcp_end_cwnd_reduction這個函數。

由STGW下載慢問題引發的網絡傳輸學習之旅 7

再查找這個函數引用的地方,有兩處:tcp_fastretrans_alert和tcp_process_tlp_ack這兩個函數。

由STGW下載慢問題引發的網絡傳輸學習之旅 8

tcp_fastretrans_alert看名字就知道是跟快速重傳相關的函數,我們知道快速重傳觸發的條件是收到了三個重複的ack包。但根據前面的抓包及分析來看,並不滿足快速重傳的條件,所以疑點就落在了這個tcp_process_tlp_ack函數上面。那麼到底什麼是TLP呢?

什麼是TLP(Tail Loss Probe)

在講TLP之前,我們先來回顧下大學課本里學到的擁塞控制算法,祭出這張經典的擁塞控製圖。

由STGW下載慢問題引發的網絡傳輸學習之旅 9

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,然後再從此處加法增加慢慢的達到理想窗口,不能增長過快,因為要“避免擁塞” 。

由STGW下載慢問題引發的網絡傳輸學習之旅 10

由STGW下載慢問題引發的網絡傳輸學習之旅 11

實際上,各個擁塞控制算法都有自己的實現,初始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 train
        if ((s32)(now - ca->last_ack) last_ack = now;
            //如果ack_train的总长度大于1/2 * min_rtt,则退出慢启动,ca->delay_min = 8*min_rtt
            if ((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 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 - 1
    elif (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 rtt
    return 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 ER
    if (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 ER
    1 enables ER
    2 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 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) + 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次數,內核都有相應的計數器跟踪。

由STGW下載慢問題引發的網絡傳輸學習之旅 12

由STGW下載慢問題引發的網絡傳輸學習之旅 13

由STGW下載慢問題引發的網絡傳輸學習之旅 14

既然有計數器就好辦了,復現的時候netstat -s就可以查看是否命中TLP了。寫了個腳本將結果寫入到文件裡。

while true; do date +"%T.%6N" >> loss.log; netstat -s | grep Loss >> loss.log; done

由STGW下載慢問題引發的網絡傳輸學習之旅 15

由STGW下載慢問題引發的網絡傳輸學習之旅 16

查看計數器增長的情況,結合抓包文件來看,基本確認肯定是命中TLP了。知道原因那就好辦了,關掉TLP驗證一下應該就可以解決了。

如上面介紹ER算法時提到,內核提供了一個開關,tcp_early_retrans可用於開啟和關閉ER和TLP,默認是3(enable TLP and delayed ER),sysctl -w net.ipv4.tcp_early_retrans=2 關掉TLP,再次重新測試,發現問題解決了:

由STGW下載慢問題引發的網絡傳輸學習之旅 17

窗口增加的很快,最終的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的這一部分代碼來看。

由STGW下載慢問題引發的網絡傳輸學習之旅 18

只有當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包含了哪一個呢?如何繼續排查呢?

調試內核信息,最常用的工具就是ftracesystemtap

這里首先嘗試了ftrace,發現它並不能滿足我的需求。 ftrace最主要的功能是可以跟踪函數的調用信息,並且可以知道各個函數的執行時間,在有些場景下非常好用,但原生的ftrace命令用起來很不方便,ftrace團隊也意識到了這個問題,因此提供了另外一個工具trace-cmd,使用起來非常簡單。

trace-cmd record -p function_graph -P 3252 //跟踪pid 3252的函数调用情况
trace-cmd report > report.log //以可视化的方式展示ftrace的结果并重定向到文件里

下圖是使用trace-cmd跟踪的一個例子部分截圖,可以看到完整打印了內核函數的調用信息及對應的執行時間。

由STGW下載慢問題引發的網絡傳輸學習之旅 19

但在當前的這個問題裡,主要是想確認flag這個變量的值,ftrace沒有辦法打印出變量的值,因此考慮下一個強大的工具:systemtap

systemtap是一個很強大的動態追踪工具,利用它可以很方便的調試內核信息,跟踪內核函數,打印變量信息等,很顯然它是符合我們的需求的。 systemptap的使用需要安裝內核調試信息包(kernel-debuginfo),但由於復現的那台機器上的內核版本較老,沒有debug包,無法使用stap工具,因此這條路也走不通。

最後,聯繫了h_tlinux_Helper尋求幫助,他幫忙找到了復現機器內核版本的dev包,並在tcp_process_tlp_ack函數里打印了一些變量,並輸出堆棧信息。重新安裝了調試的內核,復現後打印瞭如下的堆棧及變量信息:

由STGW下載慢問題引發的網絡傳輸學習之旅 20

綠色標記處的那一行,就是收到的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函數。

由STGW下載慢問題引發的網絡傳輸學習之旅 21

看到這裡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內核代碼:

由STGW下載慢問題引發的網絡傳輸學習之旅 22

發現從內核版本linux 4.0開始,BUG就已經被修復了,去掉了flag的一些不合理的判斷條件,這才是真正的符合TLP的設計原理。

如果要避免這個問題,可以升級內核版本到linux 4.0及以上,或者sysctl -w net.ipv4.tcp_early_retrans=2 關閉TLP特性。

到此,整個問題的所有疑點才都得到了解釋。

總結

本文從一個下載慢的線上問題入手,首先介紹了一些常規的排查思路和手段,發現仍然不能定位到原因。然後分享了一個可以查詢每條連接的擁塞窗口命令,結合內核代碼分析了TCP擁塞控制ssthresh的設計理念及混合慢啟動,ER和尾包探測(TLP)等優化算法,並介紹了兩個常用的內核調試工具:ftrace和systemtap,最終定位到是內核TLP實現BUG導致的下載慢的問題,從linux 4.0版本之後已經修復了這個問題。