4. RTO 计算:从 RTT 采样到 Linux 内核实现
RTO(Retransmission Timeout)是 TCP 重传的核心参数。设得太小,网络一抖动就疯狂重传;设得太大,丢包了半天才发现。说白了,RTO 就是 TCP 的「耐心值」—— 怎么算才合适?
我刚开始调优时,总觉得 RTO 就是个固定值。后来在数据中心项目里,一台机器因为 RTO 设置不当,导致大量虚假重传,把整个集群的吞吐都拖垮了。嗯,从那以后,我老老实实把 RTO 计算的每个细节都啃了一遍。
4.1 RTT 采样:原始数据的获取
RTO 计算的第一步,是拿到 RTT(Round-Trip Time)的原始样本。TCP 怎么测量 RTT?
经典做法是:发送一个数据段时,记录下当前时间 t1;收到这个段的 ACK 时,记录时间 t2。RTT = t2 - t1。
但这里有个坑 —— 重传后的 ACK 怎么算?
Karn 算法很简单:重传的数据包不参与 RTT 采样。直到收到一个非重传段的 ACK,才恢复采样。我曾在某款网卡驱动里见过没处理好这个逻辑的 bug,导致 RTO 忽大忽小,排查了两天才定位到。
Linux 内核中,RTT 采样在 tcp_ack() 函数里触发。核心代码如下:
/* 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 采样值,单位微秒 */
/* 第一次采样,直接初始化 */
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, rto_min_us(sk));
return;
}
/* 后续采样,平滑更新 */
tp->srtt_us >>= 3; /* 先右移 3 位,准备做加权平均 */
tp->srtt_us += m; /* srtt = 7/8 * srtt + 1/8 * m */
}
注意这里的单位是微秒(us),不是毫秒。内核里所有 RTT 相关计算都用微秒,精度更高。
4.2 SRTT 计算:平滑的加权平均
单次 RTT 采样抖动太大,不能直接用。我们需要一个平滑后的值 —— SRTT(Smoothed RTT)。
公式很简单:
SRTT = (1 - α) × SRTT + α × RTT_sample
α 是平滑因子,标准推荐值是 1/8(0.125)。为什么是 1/8?
我个人理解:1/8 在二进制里就是右移 3 位,CPU 算起来极快。内核里确实也是这么实现的 —— 用移位代替浮点运算。
| 参数 | 标准值 | 内核实现 | 说明 |
|---|---|---|---|
| α(平滑因子) | 1/8 | 右移 3 位 | SRTT 更新权重 |
| β(偏差因子) | 1/4 | 右移 2 位 | RTTVAR 更新权重 |
| K(RTO 系数) | 4 | 4 | RTO = SRTT + K × RTTVAR |
你想想看,如果 α 太大,SRTT 对瞬时抖动太敏感;α 太小,又跟不上网络变化。1/8 是个折中,经过几十年验证,确实好用。
4.3 RTTVAR 计算:偏差的度量
光有平均值不够。网络延迟可能忽高忽低,我们需要知道它的「抖动程度」。这就是 RTTVAR(RTT Variation)。
计算公式:
RTTVAR = (1 - β) × RTTVAR + β × |SRTT - RTT_sample|
β 是 1/4。注意这里用的是绝对偏差,不是方差。为什么不用方差?
因为方差需要平方运算,CPU 开销大。绝对偏差用移位就能算,效率高得多。内核里是这么实现的:
/* 计算偏差 */
long delta = tp->srtt_us - m;
if (delta < 0)
delta = -delta;
/* 更新 RTTVAR */
tp->mdev_us += delta; /* mdev = 3/4 * mdev + 1/4 * |delta| */
tp->mdev_us >>= 1; /* 这里实际是右移 2 位,等效于 1/4 权重 */
tp->mdev_max_us = max(tp->mdev_us, tp->mdev_max_us);
这里有个细节:mdev_max_us 记录的是 RTTVAR 的最大值。为什么需要最大值?
因为 RTO 要「保守」一点。用最大值而不是当前值,可以避免 RTO 在抖动剧烈时缩得太小。我曾经在无线网络环境下遇到过这个问题 —— 信号波动大,RTTVAR 忽大忽小,RTO 也跟着乱跳。后来发现内核默认就用了 mdev_max_us,这个设计确实老道。
4.4 RTO 公式演进:从 RFC 793 到 RFC 6298
RTO 公式不是一天定型的。它经历了几个阶段:
- RFC 793(1981):RTO = SRTT × 2。简单粗暴,但网络抖动时完全不够用。
- RFC 1122(1989):引入 RTTVAR,RTO = SRTT + 2 × RTTVAR。开始考虑抖动。
- RFC 2988(2000):RTO = SRTT + 4 × RTTVAR。系数从 2 改成 4,更保守。
- RFC 6298(2011):沿用 RFC 2988 公式,但明确了 RTO 下限为 1 秒(后来 Linux 改成了 200ms)。
当前 Linux 内核使用的公式:
RTO = SRTT + max(4 × RTTVAR, 2 × RTTVAR + 1 个时钟滴答)
为什么是 4 倍?我个人的理解是:4 倍偏差可以覆盖 99% 以上的正常波动。如果网络延迟符合正态分布,4 倍标准差能覆盖 99.99% 的情况。当然,实际网络不是正态分布,但 4 倍确实是个经验好值。
4.5 Linux 内核实现:代码级解析
我们来看完整的 RTO 计算流程。内核里,每次收到 ACK 都会调用 tcp_rtt_estimator(),然后调用 tcp_set_rto() 更新 RTO。
/* net/ipv4/tcp_input.c */
static void tcp_set_rto(struct sock *sk)
{
const struct tcp_sock *tp = tcp_sk(sk);
/* 计算 RTO = SRTT + 4 × RTTVAR */
inet_csk(sk)->icsk_rto = tp->srtt_us >> 3; /* srtt_us 是 8 倍放大,右移 3 还原 */
inet_csk(sk)->icsk_rto += tp->rttvar_us; /* 加上 4 × RTTVAR */
/* 确保 RTO 在合法范围内 */
inet_csk(sk)->icsk_rto = min(inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
inet_csk(sk)->icsk_rto = max(inet_csk(sk)->icsk_rto, TCP_RTO_MIN);
}
注意 srtt_us 是 8 倍放大的值,所以右移 3 位才是真实 SRTT。rttvar_us 已经是 4 倍 RTTVAR 了,直接加就行。
整个计算流程可以用下图概括:
最后,RTO 还有个「退避」机制。如果重传后还没收到 ACK,RTO 会指数增长:
/* net/ipv4/tcp_timer.c */
void tcp_retransmit_timer(struct sock *sk)
{
struct tcp_sock *tp = tcp_sk(sk);
/* RTO 退避:每次重传,RTO 翻倍 */
inet_csk(sk)->icsk_rto = min(inet_csk(sk)->icsk_rto << 1, TCP_RTO_MAX);
/* 启动下一次重传定时器 */
sk_reset_timer(sk, &sk->sk_timer, jiffies + inet_csk(sk)->icsk_rto);
}
退避到最大值后,TCP 会一直重传直到超时(RTO 达到 120 秒)。这个机制叫「指数退避」,目的是避免网络拥塞时雪上加霜。
sysctl -w net.ipv4.tcp_rto_min=10ms。但注意,广域网场景不要动这个值,否则会引发大量虚假重传。
嗯,RTO 计算这块内容就这些。从采样到平滑,从偏差到退避,每一步都有它的道理。理解这些细节,你就能在性能调优时做出正确的判断 —— 而不是盲目改参数。