第 17 章 TCP 可靠传输与拥塞控制
第 16 章解决"怎么建立和关闭连接",本章解决"建好之后怎么保证不丢不乱、又不把网络压垮"——TCP 的两大核心机制都在这。
学习目标
读完本章,你应当能够:
- 用 RFC 6298 的递推式手算
SRTT、RTTVAR、RTO,解释4 × RTTVAR的作用与 Karn 算法要解决的采样歧义; - 证明"每 RTT 加 1 MSS"与"每 ACK 加
MSS × MSS / cwnd“等价,画出cwnd从建连到稳态的完整折线并标出所有拐点; - 判断零窗口、糊涂窗口综合征(SWS)、Nagle 与延迟 ACK 互等这三类"看起来卡住"的现象并给出解法;
- 辨析 Tahoe / Reno / NewReno / SACK / CUBIC / BBR / DCTCP 的动机差异,说明"丢包当拥塞"在什么链路上失效。
前置知识检查
| 你需要知道 | 在哪一节补齐 |
|---|---|
TCP 首部字段、三次握手与四次挥手、TIME_WAIT;MTU/MSS 的层次归属(分段在传输层、分片在网络层)与 BDP、RTT 数量级 | 见第 16 章 16.1 ~ 16.4 节、第 15 章 15.4 / 15.7 节;本章只看 seq/ack/window/选项字段,窗口单位一律换算成字节 |
| 路由器队列与排队时延;epoll 与 HTTP 服务端并发模型 | 见第 3 章 3.4 节(本章 17.6 用"队列填满 ⟹ 丢包"作拥塞信号前提)、第 7 章 7.4 节(本章 17.5 只用"一次 write 可能被拆成多段"这一事实) |
17.1 可靠传输的四个支柱与它们的边界
TCP 不靠"重发一切"实现可靠,而是靠四个互相补位的机制。它们的边界比机制本身更值得记:
| 支柱 | 解决什么 | 留下的缺口 | 谁补上 |
|---|---|---|---|
| 序号 + 累积确认 | 乱序、重复、丢失的定位 | 只报告"连续收到了多少”,不说"之后收到了哪些" | SACK 选项(17.3.2) |
超时重传(RTO) | 段或 ACK 全丢时的兜底 | 反应慢(至少等一个 RTO),重传后 RTT 采样有歧义 | Karn + 时间戳(17.2.3) |
| 校验和 | 比特错误(链路误码、内存翻转) | 16 位反码求和检错能力有限,且只覆盖 TCP 段不含 IP 首部 | 逐跳链路层 CRC |
| 流量控制(滑动窗口) | 接收方来不及处理导致缓冲溢出 | 只解决端到端,看不到中间链路 | 拥塞控制(17.6) |
累积确认(cumulative acknowledgment)要说精确:ack = N 表示"N 之前的每个字节都已收到",它对多个段同时生效。好处是丢一个 ACK 不致命(后续 ACK 顺带确认);坏处是发送方无法区分两种处境——是"N 这个段丢了",还是"N 收到了但 N 之前那段丢了"。这正是快速重传只能靠"重复 ACK 的个数"来猜的原因(17.3.1)。
重要
四个支柱一句话串起来:序号管顺序、ACK 管反馈、RTO 管兜底、窗口管节奏。被问"TCP 怎么保证可靠"只答前三个就漏了流量控制,而流量控制正是拥塞控制的前身——两者差别只是"谁知道瓶颈"(见 17.6.1)。
17.2 超时重传与 RTO 的计算
17.2.1 为什么不能只用平均 RTT
RTO(retransmission timeout,重传超时)太小 ⟹ 没丢也重传,浪费带宽还加剧拥塞;太大 ⟹ 真丢了迟迟不补,吞吐塌方。难点在于 RTT 的方差随网络状态剧烈变化:同一对主机空闲时 RTT 可能 20 ms,队列堆积后可能 200 ms。只用平滑平均 SRTT 的问题是平均值掩盖波动:若 SRTT = 100 ms 而实际采样在 40 ~ 200 ms 间抖动,用 RTO = SRTT 会让近一半的段被误判丢失。RFC 6298 的解法是把偏差也做平滑,再用若干倍偏差做安全边界。
17.2.2 完整公式与算例
RFC 6298 的定义(α = 1/8、β = 1/4、G 为时钟粒度、K = 4):
| |
警告
最常见的错误回答是"RTO 取最近几次 RTT 的平均再乘 2":漏掉 RTTVAR 就无法适应抖动;系数也并非固定倍数(稳定链路上 RTO 会趋近 SRTT,如 SRTT = 100 ms、RTTVAR = 5 ms 时 RTO = 120 ms);还常把"先 RTTVAR 后 SRTT“的顺序写反。追问"为什么是 4 倍"时正确答:4 × RTTVAR 是工程安全裕度,在 RTT 近似正态假设下约覆盖 4 个标准差,RFC 6298 说它是经验值而非推导结果。
17.2.3 重传后的采样歧义:Karn 算法与时间戳
发送段 A、超时后重传 A、随后收到 A 的 ACK——这个 ACK 确认的是原始发送还是重传? 无法区分:若原发其实没丢只是 ACK 慢,R_s ≈ 1 个 RTT;若原发真丢了,R_s 也是 1 个 RTT,但起点晚了一个 RTO。用错那一版会污染 SRTT 并抬高 RTTVAR,使 RTO 越滚越大。Karn 算法的规则:① 重传过的段不采样 RTT;② 与指数退避配合,每次重传把 RTO 翻倍,但退避值不参与平滑公式,一旦收到新数据的 ACK 就用最新公式重算。代价是丢失重传期间的采样机会,持续高丢包时 RTO 会长期停在退避值上无法收敛。**时间戳选项(RFC 7323)**解决了这个歧义:TSval 是发送方填入的单调时间戳,TSecr 是接收方回显的"我确认的最后一个段的时间戳”,发送方读 TSecr 就知道该 ACK 对应哪一次发送,重传过的段也能安全采样。
| 机制 | 重传后能否采样 | 代价 | 启用条件 |
|---|---|---|---|
| 朴素实现 | 能,但采样值可能错 | SRTT 被污染,RTO 虚高 | —— |
| Karn 算法 | 不能 | 高丢包下 RTO 长期不收敛 | 默认总是生效 |
| Karn + 时间戳 | 能,且准确 | 每段多 12 字节(含 NOP 对齐) | 握手协商 Timestamps |
17.2.4 RTO 的上下界与退避
| 参数 | RFC 6298 | Linux 默认(man 7 tcp) | 为什么 |
|---|---|---|---|
初始 RTO | 1 s | TCP_TIMEOUT_INIT = 1 s | 握手前无测量数据,只能保守 |
最小 RTO | 1 s | TCP_RTO_MIN = 200 ms | 1 s 对低时延环境太粗;200 ms 远大于同机房 RTT 的数百倍 |
最大 RTO | ≥ 60 s | TCP_RTO_MAX = 120 s | 防止退避到无限长、连接"假死" |
| 退避规则 | 每次重传 RTO × 2 | 同 | 快速削减无效重传的二次冲击;退避值不参与平滑公式 |
退避后果要算得出来:初始 RTO = 1 s,连续重传后变成 1, 2, 4, 8, 16, 32 s,累计等待 63 s 才判定连接不可达。这就是"网络断了"到应用收到 ETIMEDOUT 之间的量级,也是应用层必须自设超时的原因。
注意
追问的下一层:为什么 RFC 6298 的最小 RTO 是 1 s,而 Linux 敢用 200 ms? 因为 RFC 的 1 s 是"任何实现都必须满足"的下界(要照顾卫星与慢速链路时代),而 Linux 面向数据中心,200 ms 已远大于同机房 RTT(约 0.1 ~ 0.5 ms)。另一个常被追问的点是时间戳为什么默认开启:Linux net.ipv4.tcp_timestamps 默认为 1,既为 RTT 采样消歧,也用于 PAWS(防止序号回绕后旧报文被误收)。
17.3 快速重传、SACK 与选择性重传
17.3.1 为什么阈值是 3 个重复 ACK
发送方连续发段 1 ~ 6,段 3 丢失,接收方收到 4、5、6 时都回 ack = 3,于是发送方收到 3 个重复 ACK,据此立即重传而不等 RTO:
| |
为什么不是 1:单个重复 ACK 在真实网络里几乎必然出现——路径改变、多径转发、以及最常见的乱序(reordering),只要段 4 比段 3 早到一点就产生一个重复 ACK。阈值取 1 会把"只是乱序"当"丢包",在链路已经拥塞时再加一份重复流量;取 2 仍偏激进;取 3 是误判率与发现延迟之间的经验折中——阈值越高,要等越多后续段到达才能发现。Linux 并不严格数重复 ACK:TLP(Tail Loss Probe,尾丢包探测)处理"丢的是窗口最后一个段、后面没有更多段来凑重复 ACK"的场景,用探针段提前逼出重复 ACK;FACK 用 SACK 信息统计"空洞之后已被确认的段数"作为等价计数。
提示
记忆锚点:3 个重复 ACK = “一次乱序不足以定罪,三次才算证据”。加分说法:“SACK 时代这个 3 已退化为保底计数——有 SACK 时发送方看的是 SACK 块里的空洞,而不是数重复 ACK 个数。”
17.3.2 SACK 与 DSACK
累积确认让发送方不知道空洞之后收到了什么,于是只有两个坏选择:只重传空洞那一个段(可能重传了已到达的段),或重传空洞之后的全部段(浪费更多)。**SACK(selective acknowledgment, RFC 2018)**通过选项回报"已收到的非连续块",每块是一对 (left edge, right edge):
| |
价值在高丢包大窗口场景:一个窗口丢多段时,纯累积确认下每丢一段都要经历一轮"重传 → 空洞前移 → 再等 3 个重复 ACK",SACK 让发送方在一个 RTT 内重传所有空洞。**DSACK(RFC 2883)**用同一选项回报"我收到了重复数据"(块的 left edge 小于累积 ACK),用途:告知发送方发生了误重传;检测虚假重传并撤销拥塞惩罚(F-RTO / Eifel 类算法:重传后发现原段其实到了,说明丢包判断错了,应恢复 cwnd/ssthresh);辅助判断链路乱序程度。**选择性重传(selective repeat)**在 TCP 里不是独立机制,而是"SACK + 重传队列"的组合:发送方为每个未确认段维护状态,收到 SACK 后只对空洞重传,并优先重传"之后被确认最多"的空洞(它最可能真丢了)。与 RTO 的配合要点:SACK 缩短的只是"检测"时间,不改变"兜底"逻辑——一旦 SACK 选项全丢或对端不支持,就退回纯累积确认 + RTO。工程上两条边界:接收方必须真能容纳乱序段(SO_RCVBUF 太小会直接丢弃乱序段,SACK 就无信息可报);SACK 选项受 40 字节选项区限制,一般只能报 3 ~ 4 个块,超限时只报最新块。
17.4 滑动窗口、流量控制与窗口扩大
17.4.1 发送窗口的四个区间(单位是字节)
最容易被讲错的点是单位:window 字段与 cwnd 都是字节数,不是报文段个数。同样的窗口在 MSS = 1460 与 MSS = 536 下能装的段数完全不同。
| |
rwnd = 接收缓冲区空闲字节数,由应用层读取速度决定——应用不 read,缓冲区填满,rwnd 收缩到 0(17.4.2)。发送方实际能发的上限是 min(rwnd, cwnd),这是流控与拥塞控制的交汇点。
17.4.2 零窗口与坚持定时器
接收缓冲区满了,接收方通告 window = 0,发送方必须停发。此时出现一个微妙问题:窗口恢复通知(窗口更新报文)是不含数据的纯 ACK,它可能丢失,且协议不重传它。若发送方傻等,而死等的前提正是"对方会通知我",通知一丢就永久死锁。零窗口探测(zero window probe)就是为这个缺口存在的:发送方启动坚持定时器(persist timer),超时后发 1 字节探测段,强制接收方回应当前窗口值。
| |
Linux 的探测间隔从 RTO 起算并指数退避,受 TCP_RTO_MIN(200 ms)约束、最长间隔约 2 分钟;次数上限由 net.ipv4.tcp_retries2 一类重传预算约束(约 15 次),超出后连接被判定失败。因此抓包里"周期性的 1 字节段"是零窗口探测最有辨识度的特征。
| 上报方式 | 触发场景 | 丢失后果 | 备注 |
|---|---|---|---|
| 纯 ACK(无数据、窗口不变) | 常规累积确认 | 无害,后续 ACK 会覆盖 | 无信息量,丢了不影响正确性 |
窗口更新(ACK 且 window 变大) | 接收方应用腾出空间 | 有害,且不重发 | 这是 persist timer 存在的唯一理由 |
| 带 SACK 块的 ACK | 乱序段到达 | 只影响重传效率 | 不影响正确性 |
小心
把"窗口更新丢失"与"ACK 丢失"混为一谈是最会把面试答崩的错误。ACK 丢失无害(累积确认下后续 ACK 覆盖),窗口更新丢失有害(它是唤醒发送方的唯一信号,协议又不重传),所以 persist timer 不是"冗余保险"而是协议正确性的一部分。追问"接收方窗口恢复后为什么不能只靠下一次 ACK 顺带通知?"——因为发送方已停发,不会再有新 ACK 产生。
17.4.3 窗口扩大选项(wscale)与 BDP
TCP 首部 window 是 16 位,硬上限 65535 字节 ≈ 64 KB,这在长肥管道(long fat network, LFN)上远远不够:
| |
窗口扩大选项(window scale option, RFC 7323)在握手中协商移位因子 S(0 ≤ S ≤ 14),之后双方通告的窗口都左移 S 位:实际窗口 = window × 2^S。上限取 14 而非 16,是因为要与时间戳选项共存在 40 字节选项区内,而 14 位已给出 65535 × 2¹⁴ ≈ 1 GB,足够覆盖当前任何 BDP。
| 机制 | 解决的问题 | 上限 | 协商时机 |
|---|---|---|---|
16 位 window 字段 | 接收方缓冲区溢出 | 64 KB | 每个 ACK 都带 |
wscale 选项 | 64 KB 不够覆盖 BDP | 约 1 GB(S ≤ 14) | 仅握手(SYN / SYN-ACK),建立后不可改 |
SACK 选项 | 累积确认无法表达部分确认 | 3 ~ 4 个块 | 握手协商 |
| 时间戳选项 | RTT 采样歧义 + 序号回绕保护 | —— | 握手协商 |
警告
三个高频错误:① 说 wscale “每个连接动态调整”——它只在握手协商一次;② 说窗口字段单位是"报文段个数"——是字节数;③ 把 wscale 与 SO_RCVBUF 混为一谈——前者决定协议层能表示多大窗口,后者是本地缓冲区的实际大小。
17.5 糊涂窗口综合征与 Nagle 算法
17.5.1 症状与两侧成因
**糊涂窗口综合征(silly window syndrome, SWS)**指 TCP 退化成"每次只传很少字节 + 恒定 40 字节首部"的低效状态。开销占比算起来很刺眼:只发 1 字节数据要配 20 字节 TCP 首部 + 20 字节 IP 首部,有效载荷利用率 1/41 ≈ 2.4%,等于浪费 97% 的链路带宽。它有两个独立成因,必须分开答:接收方一侧是缓冲区只腾出很少空间就立刻通告小窗口(应用每次只 read 几字节,接收方就通告一次小窗口,诱使对方发小段);发送方一侧是应用每次 write 极少字节就立刻发出(逐字节/逐字段写、未经缓冲,每个 write 触发一个段)。
17.5.2 两侧的解法
接收方解法——Clark 算法:要么通告 0,要么通告一个像样的窗口,只在空闲空间达到 min(MSS, 接收缓冲区大小的一半) 时才通告非零窗口。
| |
发送方解法——Nagle 算法(RFC 896):当且仅当没有未确认的在途数据时,才允许发送小于 MSS 的段;否则把小数据攒进缓冲区,等 ACK 回来或攒满 MSS 再发。收益是"自时钟"式合并(节奏跟着 ACK 走),代价是交互式场景下请求尾部的几十字节要等前一段的 ACK,等于人为加了一个 RTT。
17.5.3 Nagle 与延迟 ACK 的经典死锁
**延迟 ACK(delayed ACK, RFC 1122)**让接收方不要每收一段就回 ACK,而是等一小会(Linux 上 TCP_DELACK_MIN 约 40 ms 到 TCP_DELACK_MAX 约 200 ms,并有"每两个段必须回一个 ACK"的规则)以图搭车捎带数据。它与 Nagle 撞在一起就形成互等:
| |
注意这不是"永久卡死",而是每次交互都被拖约 40 ms——在需要多次往返的协议(某些 RPC、数据库协议)上会累积成几十到几百毫秒的尾延迟。三种解法:
| 解法 | 做法 | 代价 / 适用面 |
|---|---|---|
TCP_NODELAY | 关闭 Nagle,小段立即发 | 可能增加小包数量与首部开销;交互式场景(RPC、Redis 客户端)必开 |
TCP_QUICKACK | 接收方关闭延迟 ACK,立即回 | Linux 特有且非粘性:内核会在后续某时刻自行恢复延迟 ACK,需在每次 recv 后重设 |
writev / 缓冲合并 | 应用层把首部与 body 一次写出 | 不改变内核行为,从源头消除"两个小段";最彻底 |
| |
17.6 拥塞控制的四个阶段
17.6.1 为什么把丢包当作拥塞信号
TCP 是端到端协议,中间设备不参与端到端决策:路由器只在队列满时丢包,既不通知也不协商。发送方能观测到的信号只有两类:丢包(隐含)与时延增长(需主动测量)。Reno 时代只用丢包,理由很实际:丢包在协议里已"免费"可得(ACK 不前进就是丢包),且经典假设下"缓冲区 = 管道容量",队列填满正是链路利用率接近 100% 的时刻,丢包 ≈ “用满了”。这个假设的破绽在有损链路上:无线链路的随机误码(丢包率 1% 就足以让 Reno 类算法把 cwnd 砍半)与拥塞毫无关系,却被当成拥塞惩罚。更糟的是缓冲区膨胀(bufferbloat):深缓冲设备在严重排队时仍不丢包,只体现为 RTT 暴涨,Reno 会把 cwnd 一路推高直到队列极深、时延极高。这两点分别是 BBR 与 ECN/AQM 的动机。
17.6.2 四个阶段的完整演化
符号约定:cwnd 拥塞窗口(字节)、ssthresh 慢启动阈值、MSS 最大段长。RFC 5681 给的初始值是 cwnd = min(4 × MSS, max(2 × MSS, 4380 字节)),RFC 6928 改为 10 × MSS(IW10,Linux 3.0+ 采用);ssthresh 初值取任意大(实践中取通告窗口大小),因为此时并不知道网络容量。
| |
慢启动为什么叫"慢":名字来自"起始点低"而不是"增长慢"——初始 cwnd 只有 10 个 MSS(约 14 KB),相对 BDP 极小,从很小的窗口起步所以叫"慢启动"。它的增长是每收到一个 ACK 就把 cwnd 加 1 个 MSS,等效于每个 RTT 翻倍:第 1 个 RTT 发 10 段,收到 10 个 ACK 后 cwnd = 20;第 2 个 RTT 发 20 段 ⟹ 40;第 3 个 ⟹ 80。因此到达 ssthresh 只需 log₂(ssthresh / 10) 个 RTT。**拥塞避免(congestion avoidance)**改成线性增长,RFC 5681 的两种写法必须能证明等价:
| |
| 事件 | ssthresh | cwnd | 之后阶段 |
|---|---|---|---|
| 三次握手完成 | 取任意大(如通告窗口) | 10 × MSS(IW10) | 慢启动 |
cwnd 达到 ssthresh | 不变 | 停止翻倍,改每 RTT +1 MSS | 拥塞避免 |
| 3 个重复 ACK | max(cwnd / 2, 2 × MSS) | ssthresh + 3 × MSS | 快速恢复 |
| 快速恢复中每个重复 ACK | 不变 | + 1 × MSS | 快速恢复 |
| 收到新数据的 ACK | 不变 | = ssthresh | 拥塞避免 |
超时(RTO 触发) | max(cwnd / 2, 2 × MSS) | = 1 × MSS | 重新慢启动 |
17.6.3 超时与快速重传的两种反应
丢包本身没有丢与不丢的差别,差别在于"还能不能收到 ACK":3 个重复 ACK 意味着丢包之后的数据仍在陆续到达(否则不会有重复 ACK),即网络仍在工作、只是有一个空洞,砍半补洞是恰当的温和反应;RTO 超时意味着 RTO 时间内一个 ACK 都没回来——可能 ACK 全丢、可能大范围丢包、也可能对端挂了,这是"信号彻底中断",必须做最保守的假设:cwnd = 1 × MSS 并重新慢启动,从头探测容量。
| |
拥塞窗口与发送窗口取小值:每个 RTT 实际能发的字节数是 min(rwnd, cwnd) − 已发送未确认字节数,即流控约束与拥塞约束同时生效、取更紧的那个。常见现象:rwnd 很大但 cwnd 小 ⟹ 瓶颈在路径(拥塞),此时调大 SO_SNDBUF 完全无效;rwnd 为 0 ⟹ 瓶颈在对端应用(读得慢),此时调拥塞参数也无效。
提示
现场区分瓶颈的话术:发送端看 ss -tin 的 cwnd/rtt/retrans,接收端看 Recv-Q。若接收端 Recv-Q 持续非 0 而发送端 cwnd 未被打满,瓶颈在接收方应用;若发送端 cwnd 停在 ssthresh 附近且 retrans 持续增长,瓶颈在路径。
17.7 算法谱系:从 Tahoe 到 BBR
| 算法 | 丢包后的反应 | 检测机制 | 核心改进 | 主要局限 |
|---|---|---|---|---|
| Tahoe | cwnd = 1,ssthresh = cwnd/2,重新慢启动 | RTO + 3 dup ACK | 首次引入慢启动/拥塞避免/快重传 | 每次丢包都回到起点,恢复慢 |
| Reno | 快速恢复:cwnd = ssthresh + 3 MSS | 同 Tahoe | 不回到起点,砍半即可 | 一个窗口多个丢包时反复减半 |
| NewReno | 用"部分确认"判断空洞是否补全 | 同 Reno | 一轮恢复只减半一次 | 仍无精确选择性重传 |
| SACK(RFC 6675) | 按 SACK 块精确重传所有空洞 | SACK 选项 | 一个 RTT 补齐多空洞 | 需协商;选项空间有限 |
| CUBIC(RFC 9438) | cwnd = 0.7 × cwnd | 仍用丢包 | 窗口按时间的三次函数增长,与 RTT 解耦 | 仍是丢包驱动,有损链路表现差 |
| BBR | 不因丢包降窗(按模型限速) | 带宽 + 最小 RTT 采样 | 显式建模瓶颈带宽与传播 RTT | 公平性与 ACK 压缩场景有争议 |
17.7.1 CUBIC:为什么在高带宽长时延下更好
Reno/NewReno 的窗增长是"每 RTT +1 MSS",带来一个致命问题:窗口恢复到丢包前水平的时间与 RTT 成正比(RTT bias)。跨大洲 RTT ≈ 150 ms,若 cwnd 需从 5000 段恢复到 10000 段,需要 5000 个 RTT ≈ 750 s,期间带宽利用率极低——这正是 Reno 在高 BDP 链路上不可用的原因。CUBIC 把窗口增长从"按 RTT 计数"改成"按真实时间":
| |
17.7.2 BBR:不把丢包当拥塞
BBR(Bottleneck Bandwidth and Round-trip propagation time)改掉的是根假设:Reno/CUBIC 靠"把队列填满直到丢包"来测容量,代价是持续排队时延(bufferbloat);BBR 改为显式测量两个量——BtlBw(瓶颈带宽,用一段时间窗口内 ACK 到达速率估计出的最大交付速率)与 RTprop(最小往返传播时延,用时间戳选项测,窗口约 10 s 后重新探测),再用 pacing 限速并约束 inflight ≤ BDP = BtlBw × RTprop,在 Startup、Drain、ProbeBW、ProbeRTPROP 四个状态间循环。结果:随机丢包不再触发降窗,无线链路上吞吐显著优于 CUBIC;代价是对链路真实丢包率不敏感(在有丢包路径上与 CUBIC 竞争不公平),且依赖精确的 ACK 速率估计(ACK 压缩、接收方聚合 ACK 都会干扰)。
注意
追问的下一层:“BBR 不降窗为什么不会把别人挤死?” 因为它靠 pacing 控制发送节奏,并周期性主动降速(ProbeRTPROP 阶段)来探测最小 RTT,而不是等丢包才退让;但 BBRv1 与丢包驱动算法共存时的公平性确实是公开争议点,后续版本在调整。回答点到"公平性是公开争议"即可,不要编造具体测试数据。
17.7.3 ECN 与 DCTCP:把拥塞信号提前
ECN(explicit congestion notification, RFC 3168)让路由器在队列将满时标记而不丢包:IP 首部有 ECT/CE 两个比特,路由器把 ECT 置为 CE,接收方在 ACK 里用 ECE 回显,发送方据此降窗并用 CWR 确认。它把"丢包"这个二值、事后的信号变成"标记"这个可累积、事前的信号。DCTCP(RFC 8257)针对数据中心(浅缓冲交换机、RTT 约 100 μs 量级)改造反应方式:不按"有没有被标记"二值判断,而按被标记包的比例 α 平滑降窗(cwnd = cwnd × (1 − α/2)),并把 ECN 标记阈值设得很低(如 K ≈ 20 KB 而非整条队列)。效果是队列占用被压在很低的稳态水平,尾延迟显著改善——这正是"数据中心里排队时延比带宽更值得优化"的体现。
17.8 一次完整传输的时序复盘
把前面所有机制放到同一张时间轴(客户端发 100 KB、RTT 40 ms、MSS = 1460、第 3 个 RTT 处发生一次丢包):
| |
重要
把这段时序和三件事对上号就能回答绝大多数追问:cwnd 只由事件驱动(到 ssthresh、3 个重复 ACK、超时),不在这些事件上时它要么指数增长要么线性增长;rwnd 由对端应用决定,与网络无关;实际发送量 = min(cwnd, rwnd) − 在途。排障先把瓶颈定位到三者之一,否则调参一定是白调。
本章小结
| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 累积确认的边界 | ack = N 只说明 N 之前到齐,不说明 N 之后收到什么 | 以为 ACK 能表达"哪些收到了",忽略 SACK |
RTO 公式 | RTO = SRTT + max(G, 4 × RTTVAR),先更新 RTTVAR 再更新 SRTT | 只取平均 RTT 乘常数;或把两个更新顺序写反 |
| Karn 算法 | 重传过的段不采样 RTT;时间戳选项可解除该限制 | 说"重传后不能估 RTT"就停住,不知道时间戳已解歧义 |
| 快速重传阈值 3 | 1 ~ 2 个重复 ACK 在乱序下频繁出现,不足以定罪 | 答"3 是协议规定的",说不出误判与延迟的折中 |
| 窗口单位与上限 | window 是字节;16 位上限 64 KB,靠 wscale 扩到约 1 GB | 说单位是段数;说 wscale 能运行中动态调整 |
| 零窗口探测 | 窗口更新报文会丢且不重传,必须由 persist timer 兜底 | 把窗口更新丢失与 ACK 丢失等同看待 |
| SWS 的两侧解法 | 接收方用 Clark 算法(小于 min(MSS, 缓冲一半) 通告 0),发送方用 Nagle | 只答 Nagle,漏掉接收方一侧 |
| 两种丢包反应 | 超时 ⟹ cwnd = 1 MSS 重新慢启动;3 dup ACK ⟹ 砍半 + 快速恢复 | 说"两种都把 cwnd 减半" |
自测题
Q1(计算)某连接首次 RTT 采样为 60 ms,随后两次为 90 ms 与 70 ms。按 RFC 6298 逐步算出三次之后的 SRTT、RTTVAR、RTO(忽略 G),并说明第三次的 RTO 为什么没有因为"RTT 涨了 50%“而大幅上调。
答案与解析
第 1 次:SRTT = 60,RTTVAR = 30,RTO = 60 + 120 = 180 ms。第 2 次(R = 90):偏差 |60 − 90| = 30 ⟹ RTTVAR = 0.75 × 30 + 0.25 × 30 = 30,SRTT = 0.875 × 60 + 0.125 × 90 = 63.75,RTO = 183.75 ms。第 3 次(R = 70):偏差 |63.75 − 70| = 6.25 ⟹ RTTVAR = 0.75 × 30 + 0.25 × 6.25 = 24.0625,SRTT = 0.875 × 63.75 + 0.125 × 70 = 64.53125,RTO = 64.53125 + 96.25 = 160.78125 ms。RTO 反而下降的原因:第 3 次采样离 SRTT 很近,RTTVAR 从 30 收缩到 24 而 SRTT 几乎不动,4 × RTTVAR 的收缩主导了结果——这说明 RTO 由"平均 + 波动"共同决定,RTT 变大不一定让 RTO 变大,关键是它偏离平滑平均多少。
面试官会怎么追问:①"若 RTT 持续在 60 与 200 之间摆动,RTO 怎么变?"——RTTVAR 显著抬升,4 × RTTVAR 成为主导项。②”α 为什么取 1/8?"——它决定记忆长度(约 8 个采样),取值大则反应快但易被单次抖动带偏。③ 常见错答:把偏差算成 |90 − 70| = 20(拿上一次采样而非 SRTT 作基准),或先更新 SRTT 再算 RTTVAR。
答案与解析
稳态下一个 RTT 内发出的段数等于窗口能容纳的段数 cwnd / MSS;按"每段一个 ACK"计,收到的 ACK 个数也是 cwnd / MSS。把每个 ACK 的增量累加:(cwnd / MSS) × (MSS × MSS / cwnd) = MSS,即一个 RTT 总共增加 1 个 MSS,与写法 A 一致。必须写 max(1, ...) 的原因是整数运算与极小增量:cwnd 以字节为单位且截断为整数,当 cwnd > 1460² = 2131600 字节(约 1.4 MB)时 MSS × MSS / cwnd 会算成 0;若直接累加 0,窗口就永远不再增长、连接卡在拥塞避免里不动(Linux 的 tcp_cong_avoid_ai 就是 max(1U, ...) 这一类处理)。面试官会怎么追问:①"接收方做延迟 ACK(每两段回一个)时,推导哪一步变了?"——ACK 个数减半,若增量公式不变则每 RTT 只涨 MSS/2;RFC 5681 因此允许按被确认字节数计(cwnd += MSS × acked_bytes / cwnd)来抵消。② 常见错答是把"每 ACK +1 MSS"当成拥塞避免的规则——那是慢启动的规则,混用会让拥塞避免变成指数增长。
答案与解析
触发时:ssthresh = max(32/2, 2) = 16 MSS;cwnd = 16 + 3 = 19 MSS。+3 MSS 的依据:3 个重复 ACK 意味着除空洞之外的三个段已经离开网络、进入接收方缓冲区,在途数据少了 3 个 MSS;上调 3 个 MSS 是为了让"本来可以立即发送"的量不因重传而凭空缩水——净效果是从 32 降到 19,而不是直接降到 16。随后每多一个重复 ACK:cwnd += 1 MSS(又有一个段离开网络)。收到 2 个 ⟹ cwnd = 21 MSS,仍在快速恢复。再收到确认新数据的 ACK(累积 ACK 覆盖空洞及之后的段):cwnd = ssthresh = 16 MSS,退出快速恢复,进入拥塞避免,此后每 RTT 线性 +1 MSS。面试官会怎么追问:①"一个窗口里丢了 5 个段会怎样?"——纯累积确认下每补齐一个空洞都会收到"部分确认",Reno 会把它误当新拥塞事件而反复折半;NewReno 用部分确认判断"是否仍在同一轮恢复",SACK 直接用 SACK 块一次重传所有空洞。②"为什么入口写成 ssthresh + 3 MSS?"——因为 ssthresh 刚被设为 cwnd/2,两者一致,写法是为了与 max(..., 2 × MSS) 的下限逻辑一致。③ 常见错答:把 cwnd 直接设成 ssthresh(漏掉 +3 MSS),或在快速恢复期间再次折半 ssthresh。
答案与解析
① 错:窗口更新报文可能丢失且协议不重传它;发送方已停发,不会再产生新 ACK 来顺带通知,必须有 persist timer 周期性发零窗口探测。② 错:wscale 只在握手(SYN / SYN-ACK)阶段协商一次,双方各自通告移位因子,连接建立后不可更改。③ 错:Nagle 的条件是"存在未确认的在途数据时才攒小段",在途为空时小段立即发出;它的真实影响是小段要等前一段被确认,理由是与延迟 ACK 互等。④ 错:吞吐上限由 min(rwnd, cwnd) / RTT 决定,SO_SNDBUF 只决定本地能缓存多少未确认数据;未协商 wscale 或拥塞窗口受限时,它再大也无用。⑤ 对:客户端因有在途数据而压住小段,服务端因延迟 ACK 而压住 ACK,等待只能靠延迟 ACK 定时器(Linux 上约 40 ms)打破。⑥ 错:时间戳选项有两个作用——让 RTT 采样区分"原始发送"与"重传"(解除 Karn 歧义),以及用于 PAWS(序号回绕后拒绝旧报文),只说 RTT 漏掉一半。面试官会怎么追问:"wscale 协商失败会怎样?"——退回 64 KB 窗口上限,长肥管道上吞吐被硬性限制,可用 BDP / 64 KB 估算差多少倍;常见错答是认为"没有 SACK 就不可靠了"(SACK 只影响效率,可靠性由累积确认加重传保证)。
答案与解析
RPC 连接(40 ms 台阶):抓包特征"小段 + 固定约 40 ms 间隔"指向 Nagle 与延迟 ACK 互等——客户端第二次 write 的 body 被 Nagle 压住,等对端延迟 ACK 定时器超时才发出。修改方案按彻底程度:① 应用层用 writev 或先拼 buffer,把首部与 body 一次交给内核(不改内核行为,从源头消除两个小段);② 客户端设 TCP_NODELAY;③ 服务端设 TCP_QUICKACK(Linux 特有且非粘性,需在每次 recv 后重设)。上传连接(吞吐只有 30%):先按 吞吐 ≤ 窗口 / RTT 定位瓶颈。设带宽上限 1 Gbps、RTT 20 ms,则 BDP = 2.5 MB;若吞吐只有 300 Mbps,则有效窗口 ≈ (300 × 10⁶ / 8) × 0.02 = 750 KB,明显小于 BDP 所需。查三处:① 是否协商了 wscale(未协商则窗口硬上限 64 KB,吞吐被压到 64 KB / 20 ms = 3.2 MB/s ≈ 26 Mbps,与现象同量级);② SO_SNDBUF/SO_RCVBUF 与 net.ipv4.tcp_wmem、tcp_rmem 上限是否够;③ 是否有丢包把 cwnd 长期压在 ssthresh 附近(看 ss -tin 的 retrans 与 cwnd)。两条连接为什么需要相反的参数:RPC 是小请求、延迟敏感,段数少、每次写要立即发出,因此牺牲首部开销换延迟;上传是持续大流量、吞吐敏感,段几乎总是满 MSS,Nagle 根本不会触发,真正要解决的是窗口是否够大。面试官会怎么追问:①"为什么 TCP_QUICKACK 要在每次 recv 后重设?"——它不是持久状态,内核会在后续某时刻恢复延迟 ACK 行为。② 常见错答:把两个现象都归因为"网络慢",或在上传连接上关掉 Nagle 就以为能提速(真正瓶颈是窗口)。
面试高频问题与陷阱
| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| TCP 的 RTO 是怎么算出来的?为什么不能直接用平均 RTT? | RFC 6298 的递推式与误差裕度 | 陷阱:答"取最近几次 RTT 平均再乘 2",说不出 RTTVAR 与 4 × 的作用,或把两个更新的顺序写反。加分:写出两个平滑公式并给算例,说明 4 × RTTVAR 是工程裕度而非推导值 | 17.2.2 |
| 重传之后还能用这次 RTT 更新 RTO 吗? | Karn 算法与时间戳选项 | 陷阱:答"不能,所以重传期间不测量"就停住,不知道时间戳已解除该限制。加分:说出 TSecr 回显机制,并补充"退避值不参与平滑公式" | 17.2.3 |
| 为什么快速重传要等 3 个重复 ACK,收到 1 个就重传不行吗? | 乱序与误重传的权衡 | 陷阱:答"协议规定就是 3",讲不出 1 ~ 2 个重复 ACK 在乱序下频繁出现。加分:指出 SACK 时代该阈值退化为保底计数,尾丢包要靠 TLP | 17.3.1 |
| TCP 窗口字段只有 16 位、最大 64 KB,怎么撑起长肥管道的带宽? | wscale 与 BDP 的关系 | 陷阱:答"内核会自动调大窗口"或"倍数可以动态调整"。加分:算出 BDP(1 Gbps × 150 ms = 18.75 MB),说明 wscale 只在握手协商且 S ≤ 14 | 17.4.3 |
| 接收方通告了零窗口,发送方就一直等着吗? | 零窗口探测与通知的不可靠性 | 陷阱:答"等对端发窗口更新就行"——窗口更新会丢且不重传,形成死锁。加分:说出 persist timer 与 1 字节探测的抓包特征 | 17.4.2 |
| 为什么很多 RPC 框架默认设置 TCP_NODELAY? | Nagle 与延迟 ACK 的互等 | 陷阱:答"为了低延迟"就结束,说不出"客户端等在途数据的 ACK、服务端等第二个段才回 ACK"这条互等链。加分:给出三种解法并说明 QUICKACK 非粘性 | 17.5.3 |
| 超时重传和快速重传对拥塞窗口的影响有什么区别?为什么不一样? | 两种丢包信号的强度差异 | 陷阱:答"两种都把 cwnd 减半"。加分:超时意味着一个 RTO 内没有任何 ACK、信号彻底中断,故 cwnd = 1 MSS 重新慢启动;3 dup ACK 说明网络仍在工作,砍半即可 | 17.6.3 |
| CUBIC 比 Reno 强在哪?为什么高带宽长时延下差别这么大? | 增长函数与 RTT 偏差 | 陷阱:答"CUBIC 增长更快"——它接近 W_max 时反而更慢。加分:写出 W(t) = C × (t − K)³ + W_max,指出横轴是真实时间因而不受 RTT 影响,β = 0.7 比 Reno 的 0.5 温和 | 17.7.1 |
| 为什么无线链路上 TCP 表现差?BBR 怎么解决? | “丢包即拥塞"假设的失效 | 陷阱:答"无线带宽小”。加分:指出随机误码被误判为拥塞导致无谓减半,BBR 用 BtlBw 与 RTprop 建模、不因丢包降窗,并承认其公平性争议 | 17.7.2 |
延伸阅读
- RFC 6298《Computing TCP’s Retransmission Timer》:
SRTT/RTTVAR/RTO的递推式、α = 1/8、β = 1/4、K = 4、初始与最小 1 s 的权威定义,Karn 算法与指数退避亦在其中;RFC 5681《TCP Congestion Control》给出慢启动、拥塞避免、快速重传、快速恢复的规范描述与ssthresh更新规则;RFC 6928《Increasing TCP’s Initial Window》给出 IW10。 - RFC 2018《TCP Selective Acknowledgment Options》、RFC 2883《An Extension to the SACK Option for TCP》(DSACK)、RFC 6675《A Conservative Loss Recovery Algorithm Based on SACK》、RFC 7323《TCP Extensions for High Performance》:SACK 块格式、误重传检测、选择性重传算法,以及窗口扩大选项(
wscale,S ≤ 14)、时间戳选项(TSval/TSecr)与PAWS的完整定义。 - RFC 3168《The Addition of Explicit Congestion Notification (ECN) to IP》、RFC 8257《Data Center TCP (DCTCP)》、RFC 9438《CUBIC for Fast and Long-Distance Networks》:ECN 位语义、DCTCP 的按比例降窗与 CUBIC 三次函数的完整参数。
man 7 tcp:TCP_RTO_MIN(200 ms)、TCP_RTO_MAX(120 s)、TCP_TIMEOUT_INIT(1 s)、TCP_NODELAY、TCP_QUICKACK、tcp_congestion_control、tcp_timestamps等实现参数与默认值;W. Richard Stevens《TCP/IP Illustrated, Volume 1》第 17 ~ 24 章则给出超时重传、窗口与流量控制、拥塞控制、Nagle 与延迟 ACK 的经典图示。