3、RTO计算与动态调整:经典RTO算法(Jacobson算法)、RTT采样与平滑、RTO的退避策略、Linux内核RTO实现。

RTO,也就是重传超时时间,是TCP里最核心的参数之一。设短了,网络稍微抖动一下就触发重传,白白浪费带宽;设长了,丢包后要等很久才能恢复,用户感知就是卡顿。说白了,RTO就是TCP的“耐心值”,怎么算、怎么调,直接决定了传输效率。

我刚开始调优时,总觉得RTO是个固定值,后来被现实狠狠教育了一回。有一次线上服务延迟飙升,排查半天才发现是RTO计算过于激进,导致大量虚假重传。从那以后,我花了不少时间啃RTO的算法细节。今天咱们就把这块彻底讲透。

3.1 经典RTO算法:Jacobson算法

1988年,Van Jacobson提出了经典的RTO计算算法,一直沿用至今。它的核心思想很简单:RTO应该基于实测的RTT动态调整,而不是拍脑袋定死。

Jacobson算法的公式如下:

SRTT = (1 - α) × SRTT + α × RTT_sample
RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT_sample|
RTO = SRTT + 4 × RTTVAR

其中:

  • SRTT:平滑后的RTT,相当于RTT的移动平均
  • RTTVAR:RTT的偏差估计,衡量RTT的抖动程度
  • α:平滑因子,通常取1/8
  • β:偏差因子,通常取1/4

为什么是4倍RTTVAR?我个人的理解是:Jacobson通过大量实验发现,网络延迟的分布近似正态,4倍标准差能覆盖99.9%以上的情况。说白了,就是给RTO留足了安全余量。

关键点:RTO不是RTT的简单倍数,而是RTT加上4倍的抖动估计。抖动越大,RTO越保守。

3.2 RTT采样与平滑

RTT采样是RTO计算的基础。TCP通过测量数据包发送到ACK收到的时间差来获取RTT样本。但这里有个坑:重传包的RTT怎么算?

我记得早期TCP实现里,重传包的RTT采样经常出问题。你想想看,如果发了一个包没收到ACK,重传后才收到ACK,这个ACK是对原始包的确认还是对重传包的确认?搞不清楚的话,RTT样本就全乱了。

Linux内核的解决方案是:Karn算法——重传包的RTT样本直接丢弃,不参与SRTT计算。同时,RTO采用退避策略,避免因采样错误导致RTO过小。

RTT平滑的过程,说白了就是给RTT样本“降噪”。单次RTT可能受突发延迟影响,但平滑后的SRTT能反映网络的真实状态。我习惯把SRTT理解为RTT的“慢速指针”,它不会因为一次抖动就剧烈变化。

参数 含义 典型值
SRTT 平滑后的RTT 动态计算
RTTVAR RTT偏差估计 动态计算
α SRTT平滑因子 1/8
β RTTVAR平滑因子 1/4
RTO_min RTO下限 200ms(RFC 6298)
RTO_max RTO上限 120s(RFC 6298)

3.3 RTO的退避策略

当重传发生时,TCP会认为网络可能已经拥塞,此时如果还用原来的RTO,很可能再次超时。所以TCP引入了退避策略:每次重传后,RTO翻倍,直到收到ACK为止。

这个策略有个形象的叫法——指数退避。举个例子:

  • 第一次超时:RTO = 1s
  • 第二次超时:RTO = 2s
  • 第三次超时:RTO = 4s
  • 第四次超时:RTO = 8s
  • ……直到RTO_max(120s)

为什么要退避?说白了,如果网络已经拥塞,你还在拼命重传,只会让拥塞更严重。退避相当于给网络一个“喘息”的机会。我曾经在数据中心调优时遇到过一个问题:RTO退避太激进,导致短连接场景下重传次数过多,连接建立时间暴涨。后来调整了tcp_retries1和tcp_retries2参数,才把问题解决。

避坑指南:我曾经在无线网络环境下,发现RTO退避导致连接频繁断开。原因是无线网络的延迟抖动很大,退避后的RTO远大于实际RTT,但重传次数有限,超时后连接就被断开了。解决方案是适当增大tcp_retries2的值。

3.4 Linux内核RTO实现

Linux内核的RTO实现,基本遵循RFC 6298,但做了一些工程优化。咱们直接看代码:

// net/ipv4/tcp_input.c
static void tcp_rtt_estimator(struct sock *sk, long mrtt_us)
{
    struct tcp_sock *tp = tcp_sk(sk);
    long m = mrtt_us; // 实测RTT

    // 首次RTT采样
    if (tp->srtt_us == 0) {
        tp->srtt_us = m << 3;    // srtt = m * 8
        tp->mdev_us = m << 1;    // mdev = m * 2
        tp->mdev_max_us = tp->mdev_us;
        tp->rttvar_us = max(tp->mdev_us, rtt_min);
        return;
    }

    // 更新SRTT:srtt = 7/8 * srtt + 1/8 * m
    tp->srtt_us = tp->srtt_us - (tp->srtt_us >> 3) + (m << 3);

    // 更新偏差
    if (m < tp->srtt_us) {
        // RTT变小,偏差更新慢一些
        tp->mdev_us = tp->mdev_us - (tp->mdev_us >> 2) +
                      ((tp->srtt_us - m) >> 2);
    } else {
        // RTT变大,偏差更新快一些
        tp->mdev_us = tp->mdev_us - (tp->mdev_us >> 2) +
                      ((m - tp->srtt_us) >> 2);
    }

    // 更新RTO
    tp->rttvar_us = max(tp->mdev_us, rtt_min);
    tp->rto_us = tp->srtt_us + (tp->rttvar_us << 2); // RTO = SRTT + 4 * RTTVAR
}

这段代码有几个细节值得注意:

  • 内核用左移3位代替乘以8,用右移代替除法,全是整数运算,性能极高
  • RTT变小时偏差更新慢,变大时更新快——这是为了防止RTO过于激进
  • rtt_min是内核维护的最小RTT,用于防止RTO过小

嗯,这里还要提一下RTO计算的实际触发点。Linux不是在每个ACK都重新计算RTO,而是在收到有效的RTT样本后才触发。什么叫有效样本?就是非重传包的ACK,且时间戳选项可用。

调优建议:如果你在数据中心或内网环境,延迟很低且稳定,可以考虑调小RTO_min(默认200ms)。但千万别低于10ms,否则很容易触发虚假重传。我一般建议设置为实际RTT的2-3倍。

最后,咱们用一张图来总结RTO的整个计算和调整流程:

RTO计算与动态调整流程 RTT采样 Karn算法(丢弃重传样本) SRTT平滑计算 RTTVAR偏差估计 RTO = SRTT + 4 × RTTVAR 超时后指数退避 说明: RTT采样来自数据包与ACK的 时间差,重传包的样本被丢弃 (Karn算法)。 说明: SRTT和RTTVAR使用指数加权 移动平均(EWMA),α=1/8, β=1/4。 说明: RTO下限200ms,上限120s。 超时后RTO翻倍,直到收到ACK 或达到上限。

这张图把RTO的整个生命周期串起来了。从RTT采样开始,经过Karn算法过滤,再到SRTT和RTTVAR的计算,最后得出RTO。一旦超时,就触发指数退避。每一步都环环相扣,少一个环节,RTO的准确性就会大打折扣。