🌈个人主页:一条泥憨鱼(欢迎各位大佬莅临)
❄️《数据结构》 ❄️《AI与Agent那些事》
❄️《从0开始学计算机网络》 ❄️《后端开发》
前言:
你一定遇到过这种情况:点开一个网页,浏览器标签页上的小圆圈一直转啊转,转了十几秒,最后啪一下给你一个「无法访问此网站」。
这十几秒里到底发生了什么?为什么不是立刻报错,而是非要等这么久?
要回答这个问题,就得聊到一个听起来挺唬人、但其实很朴素的东西:TCP 超时重传时间。英文缩写叫RTO(Retransmission Timeout)。
搞懂三件事:数据包、确认和超时
在聊 RTO 之前,得先建立三个最基本的概念。别急,我们一个一个来。
第一件:数据包。
你在网上传的每一样东西——一张图片、一段文字、一个视频的碎片——都不是囫囵个儿发过去的。计算机发送数据的方式,更像寄快递:把大件东西拆成一个个小包裹,每个包裹上写好「收件人地址」「寄件人地址」「这是第几个包裹」,然后一个个发出去。这个小包裹就叫数据包(packet)。
第二件:ACK 确认。
寄快递最怕什么?最怕东西寄丢了还不知道。所以快递公司有个「签收回执」的服务——收件人签收后,快递员把回执寄回来,你看到回执就知道东西送到了。
TCP 也是这么干的。收件方(接收方)每收到一个数据包,就要回一个小纸条给发送方,说「我收到了」。这个小纸条叫ACK(Acknowledgment,确认)。ACK 本身也是一个小数据包,只不过它里面不装你真正要传的内容,只装一句「第 N 号包裹我收到了」。
第三件:超时。
现在问题来了。发送方把数据包发出去,然后开始等 ACK。如果 ACK 一直没回来,有两种可能:
1. 数据包在路上丢了,对方根本没收到;
2. 对方收到了,但 ACK 回程的路上丢了。
不管是哪种,对发送方来说结果都一样:没等到回执。那怎么办?只能重发一遍。
但「等多久才重发」是个技术活。等太短,ACK 可能还在路上,你就急吼吼地重发,结果对方收到两份重复数据,白白浪费带宽;等太长,真丢包了,你还在那儿傻等,用户体验就是浏览器转圈转半天。
这个「等多久」的时间,就是 RTO——超时重传时间。
打个比方:你在网上买了个东西,商家说「三天到」。你等到第三天还没到,就开始怀疑是不是丢件了,决定联系客服补发。这个「三天」就是 RTO。设成一天,快递可能还在路上你就催了;设成三十天,那你这一个月都干等着。
所以 RTO 的核心问题只有一个:这个值到底该设多少?
固定时间的坑:为什么不能写死一个值
早期的 TCP 实现很朴素,直接写死一个值。比如规定 RTO 就是 1 秒:发出去等 1 秒,没收到 ACK 就重发。
听起来挺简单,但马上就会撞墙。
想象一下寄快递:同城快递可能当天就到,跨洋快递得等一两周。如果你用同一个「超时时间」去判断这两种情况,必然出问题。
- 用同城的标准(比如一天)去等跨洋快递,那跨洋的包裹天天「超时」,你天天补发,纯属浪费。
- 用跨洋的标准(比如两周)去等同城快递,那同城真丢件了你也得等两周才发现,黄花菜都凉了。
网络世界一模一样。同一个机房里两台服务器之间,一来一回可能只要 0.1 毫秒;你从家里访问美国西海岸的服务器,一来一回可能要 200 毫秒,差了 2000 倍。
固定 RTO 根本应付不了这种差异。唯一的出路,就是让 RTO 跟着实际网络状况动态调整。
那怎么知道「实际网络状况」呢?只有一个办法:亲自测。
RTT 是唯一的线索:先测出「一来一回要多久」
我们刚才说,发送方发出数据包,要等 ACK。那「发出去」到「收到 ACK」之间这段时间,是可以测出来的。
这个时间有个名字,叫RTT(Round-Trip Time,往返时间)。字面意思就是「一来一回」花了多久。
测法非常朴素:
1. 发送数据包的那一刻,记下时间 t1;
2. 收到对应 ACK 的那一刻,记下时间 t2;
3. `RTT = t2 - t1`。
用伪代码写出来大概是这样:
# 简化版:测一次 RTT t1 = 当前时间() 发送数据包(序号=1) 等待 ACK(序号=1) # 阻塞在这里,直到收到 t2 = 当前时间() rtt = t2 - t1 print("这次往返花了", rtt, "毫秒") ```那是不是直接把测出来的 RTT 当成 RTO 就行了?比如测得 RTT 是 100 毫秒,那 RTO 就设 100 毫秒?
不行.
因为网络是会抖动的。你连续测 5 次,可能得到 100、105、98、300、102 毫秒——那个 300 就是个异常值,可能是那一瞬间网络堵了一下。如果你把 RTO 设成刚刚测到的 100 毫秒,下一次网络稍微抖一下,ACK 稍微晚回来一点点,你就误判成丢包,开始重传。重传又会加重网络负担,越堵越重传,越重传越堵,最后整个连接崩掉。
所以直接拿单次 RTT 当 RTO,是个坑。我们需要一个更聪明的办法:既参考 RTT,又不被个别抖动带偏。
平滑与加权:RTO 公式的进化史
接下来的内容稍微有点数学,但每一步我都先讲「为什么需要它」,再给公式。跟着走就行。
第一步:把抖动的 RTT 抹平——SRTT
既然单次 RTT 会抖,那就多测几次,取个平均。但不是简单地把所有历史值加起来除以个数,而是用一种叫移动平均的方法:
> 新的平均值 = (1 - α) × 旧的平均值 + α × 这次测到的 RTT
这个 α是个 0 到 1 之间的小数字,比如 0.125。它的作用是:新测到的值只占一小部分权重,大部分还是沿用之前的平均值。这样个别异常值就不会把平均值带跑偏。
这个算出来的平均值,叫SRTT(Smoothed Round-Trip Time,平滑往返时间)。「平滑」两个字,说的就是这个抹平抖动的过程。
# 模拟连续测量 RTT,并计算 SRTT alpha = 0.125 srtt = None rtt_samples = [100, 105, 98, 300, 102, 101, 99, 103] # 单位毫秒 for rtt in rtt_samples: if srtt is None: srtt = rtt # 第一次没有历史值,直接采用 else: srtt = (1 - alpha) * srtt + alpha * rtt print(f"这次 RTT={rtt}ms,SRTT={srtt:.2f}ms")你会发现,那个 300 毫秒的异常值进来后,SRTT 只是稍微往上抬了一点点,很快又被后面的正常值拉回来。这就是「平滑」的威力。
第二步:光有平均值不够——RTTVAR
但只有平均值还是不行。
假设 SRTT 是 100 毫秒。可如果网络波动很大,实际 RTT 在 50 到 200 之间乱跳,那 100 这个平均值根本代表不了什么。你要是把 RTO 设成 100,一半的包都得误判超时。
所以除了「平均有多快」,我们还得知道「波动有多大」。这个波动的度量,叫RTTVAR(Round-Trip Time Variation,往返时间偏差)。
它的算法跟 SRTT 很像,也是移动平均,只不过它平均的是「这次测到的 RTT 和当前 SRTT 差了多少」:
> 新的偏差 = (1 - β) × 旧的偏差 + β × |SRTT - 这次测到的 RTT|
β(贝塔)一般取 0.25。这个公式的意思就是:实时盯着每次测量和平均值偏离多远,把偏离程度也做个平滑。
第三步:合起来就是经典公式
有了平均值 SRTT,又有了波动范围 RTTVAR,RTO 就顺理成章了:
> RTO = SRTT + 4 × RTTVAR
翻译一下:超时时间 = 平均往返时间 + 4 倍的波动范围。
为什么乘 4?因为在实际网络里,绝大多数 RTT 都落在「平均值 ± 4 倍偏差」这个范围里。乘 4 相当于给了一个足够宽的缓冲区,既不会因为一点小抖动就误判超时,也不会宽到真丢包了还傻等。
这个公式在 1988 年由 Jacobson 和 Karels 提出,是 TCP 沿用至今的经典算法。完整代码长这样:
class RTOEstimator: def __init__(self): self.srtt = None self.rttvar = None self.rto = 1.0 # 初始 RTO 给个保守值,比如 1 秒 def update(self, rtt): """每测到一次 RTT,就更新一次 RTO""" if self.srtt is None: # 第一次测量,直接初始化 self.srtt = rtt self.rttvar = rtt / 2 else: alpha, beta = 0.125, 0.25 # 先更新波动范围 self.rttvar = (1 - beta) * self.rttvar + beta * abs(self.srtt - rtt) # 再更新平均值 self.srtt = (1 - alpha) * self.srtt + alpha * rtt # 经典公式 self.rto = self.srtt + 4 * self.rttvar # 实际实现里 RTO 通常有下限(比如 200ms)和上限(比如 60s) self.rto = max(0.2, min(self.rto, 60.0)) return self.rto # 用一组模拟数据跑一遍 est = RTOEstimator() for rtt in [100, 105, 98, 300, 102, 101, 99, 103]: print(f"RTT={rtt}ms -> RTO={est.update(rtt):.1f}ms") ```跑一遍你会看到,那个 300 毫秒的异常值进来后,RTO 会适度抬高(因为波动变大了),但不会失控。这就是我们想要的:既能跟得上网络变化,又不至于一惊一乍。
别忘了退避:连续丢包时 RTO 要翻倍
讲到这里,RTO 的计算看起来已经完备了。但还有个场景没考虑:如果重传之后,ACK 还是没回来怎么办?
网络真堵死了,或者链路真断了,那重传一次没用,重传两次也没用。这时候如果每次都按同一个 RTO 去等,就会一直傻等下去,效率极低。
解决办法叫指数退避(exponential backoff):
- 第一次重传,等 RTO;
- 第二次重传,等 2 × RTO;
- 第三次重传,等 4 × RTO;
- 第四次,8 × RTO……
为什么要翻倍?因为如果网络真的堵了,你越频繁重传,堵得越厉害。拉长等待时间,相当于给网络一个喘息的机会。而且这也是在赌:如果对方真的只是暂时收不到,那么等久一点,说不定就通了。
这个策略在 TCP 里是硬性规定。Linux 默认最多重传 15 次,累计等待时间会拉长到十几分钟——这也是为什么有时候你感觉网页「卡了很久很久才报错」。
一个容易被忽略的坑:Karn 算法
这里有个坑,第一次接触的人基本都会踩。
刚才我们说,RTT 是靠「发出去」和「收到 ACK」两个时间点相减测出来的。那问题来了:如果这个数据包被重传过,你测到的 RTT 到底算哪一次的?
假设你发了数据包,等了一会儿没等到 ACK,于是重传。重传之后收到了 ACK——这个 ACK 是对第一次发的响应,还是对第二次重传的响应?
你没法确定。如果是第一次的,那真实 RTT 很短;如果是对重传的响应,那 RTT 要算上超时等待的时间,会大得多。用这种模糊的测量值去更新 RTO,会把平均值带跑偏。
解决办法叫Karn 算法,规则很简单:
> 被重传过的数据包,它的 ACK 不拿来测 RTT。
宁可少测几次,也不能测错。这个规则在实现里就是加个标记:重传过的包,收到 ACK 时只做「确认收到」的处理,不更新 SRTT 和 RTTVAR。
顺带一提,退避之后什么时候把 RTO 重置回正常值?答案是:当收到一个新数据的 ACK 时。因为这说明网络已经恢复正常,之前的超时是偶发事件。
收尾:一张全景图
把整条链路串起来,RTO 的计算大概是这样的:
1. 发出数据包,记时间 t1;
2. 收到 ACK,记时间 t2,算出这次 RTT;
3. 用移动平均更新 SRTT(平均往返时间);
4. 用移动平均更新 RTTVAR(波动范围);
5. 算出 `RTO = SRTT + 4 × RTTVAR`;
6. 如果没等到 ACK,超时后重传,并且 RTO 翻倍;
7. 重传过的包不参与 RTT 测量(Karn 算法);
8. 收到新数据的 ACK 时,RTO 重置。
作为开发者,你不需要自己手算 RTO——操作系统内核全帮你搞定了。但理解这套机制,能帮你在排查问题时读懂现象:
- 用 `ss -ti` 看连接的 RTO、RTT、重传次数,一眼就能看出是不是网络在抖;
- 用 Wireshark 抓包,看到「TCP Retransmission」标记,你就知道是超时重传触发了;
- 遇到「接口偶尔慢一下」,先看看是不是 RTO 被拉长了,而不是急着去优化代码。
网络这东西,很多「玄学」问题,拆开看其实都是很朴素的机制在起作用。RTO 就是其中一个。下次再看到浏览器转圈十几秒,你大概能猜到它背后经历了什么了。