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。一旦超时,就触发指数退避。每一步都环环相扣,少一个环节,RTO的准确性就会大打折扣。