第 16 章解决"怎么建立和关闭连接",本章解决"建好之后怎么保证不丢不乱、又不把网络压垮"——TCP 的两大核心机制都在这。

学习目标

读完本章,你应当能够:

  1. 用 RFC 6298 的递推式手算 SRTT、RTTVAR、RTO,解释 4 × RTTVAR 的作用与 Karn 算法要解决的采样歧义;
  2. 证明"每 RTT 加 1 MSS"与"每 ACK 加 MSS × MSS / cwnd“等价,画出 cwnd 从建连到稳态的完整折线并标出所有拐点;
  3. 判断零窗口、糊涂窗口综合征(SWS)、Nagle 与延迟 ACK 互等这三类"看起来卡住"的现象并给出解法;
  4. 辨析 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
首次测量(还没有 SRTT 时):SRTT = R ;  RTTVAR = R / 2 ;  RTO = SRTT + max(G, 4 × RTTVAR)
后续每次得到新采样 R:
    RTTVAR = (1 − β) × RTTVAR + β × |SRTT − R|      β = 1/4
    SRTT   = (1 − α) × SRTT   + α × R             α = 1/8
    RTO    = SRTT + max(G, 4 × RTTVAR)
求值顺序:先用【旧的】SRTT 更新 RTTVAR,再更新 SRTT;反过来会让 RTTVAR 用上已被本次
采样污染的 SRTT,从而低估偏差。α = 1/8 展开成整数更直观:SRTT = (7 × SRTT + R) / 8
(新采样只占 1/8 权重,等效记忆约 8 次采样);RTTVAR = (3 × RTTVAR + |SRTT − R|) / 4。
算例:第 1 次 R = 100 ms ⟹ SRTT = 100,RTTVAR = 50,RTO = 100 + 200 = 300 ms
第 2 次 R = 120 ms:偏差 20 ⟹ RTTVAR = 0.75 × 50 + 0.25 × 20 = 42.5 ms;
    SRTT = 0.875 × 100 + 0.125 × 120 = 102.5 ms ⟹ RTO = 102.5 + 170 = 272.5 ms
第 3 次 R = 300 ms(队列堆积,RTT 暴涨到 3 倍):偏差 |102.5 − 300| = 197.5
    RTTVAR = 0.75 × 42.5 + 0.25 × 197.5 = 81.25 ms;SRTT = 0.875 × 102.5 + 0.125 × 300 =
    127.1875 ms ⟹ RTO = 127.1875 + 325 = 452.1875 ms。RTT 涨 3 倍而 RTO 只从 272.5 涨到
    452.2 ms(约 1.7 倍)——指数加权移动平均的固有滞后,它故意"迟钝",否则一次抖动就会
    引发重传风暴。

警告

最常见的错误回答是"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 6298Linux 默认(man 7 tcp)为什么
初始 RTO1 sTCP_TIMEOUT_INIT = 1 s握手前无测量数据,只能保守
最小 RTO1 sTCP_RTO_MIN = 200 ms1 s 对低时延环境太粗;200 ms 远大于同机房 RTT 的数百倍
最大 RTO≥ 60 sTCP_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
2
3
4
5
6
7
8
9
发送方                  网络                接收方
  seg1 ─────────────────────────────────▶ 连续,回 ack=2
  seg2 ─────────────────────────────────▶ 连续,回 ack=3
  seg3 ──────✗ 丢失
  seg4 ─────────────────────────────────▶ 乱序,回 ack=3  ← 第 1 个重复 ACK
  seg5 ─────────────────────────────────▶ 乱序,回 ack=3  ← 第 2 个重复 ACK
  seg6 ─────────────────────────────────▶ 乱序,回 ack=3  ← 第 3 个重复 ACK
  ┌─ 满 3 个 ⟹ 判定 seg3 丢失,立即重传(不等超时)─┐
  seg3' ────────────────────────────────▶ 空洞补齐,回 ack=7(累积覆盖 3~6)

为什么不是 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):

1
2
3
4
5
6
接收方已收到:1 2 [3 丢失] 4 5 6 7 [8 丢失] 9
    累积 ACK:ack = 3        SACK 选项:(4, 8) (9, 10)
发送方据此得到的精确图像:
    [1..3)  已累积确认                  [3..4)  空洞 ⟹ 必须重传
    [4..8)  已在接收方缓冲区,绝不重传    [8..9)  空洞 ⟹ 必须重传
    [9..10) 已在接收方缓冲区

价值在高丢包大窗口场景:一个窗口丢多段时,纯累积确认下每丢一段都要经历一轮"重传 → 空洞前移 → 再等 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 下能装的段数完全不同。

1
2
3
4
5
6
7
8
9
发送方字节流视图(SND.UNA = 最早未确认,SND.NXT = 下一个要发,WND = 通告窗口)
  ├──────────────┼──────────────┼──────────────┼──────────────┤
  │ ① 已发送已确认 │ ② 已发送未确认 │ ③ 可发送未发送 │ ④ 不可发送     │
  │ (< SND.UNA)  │ [UNA, NXT)   │ [NXT, 右界)   │ (≥ 右界)      │
  └──────────────┴──────────────┴──────────────┴──────────────┘
                                └──── 发送窗口 WND ────┘
                       WND = min(接收方通告窗口 rwnd, 拥塞窗口 cwnd)
  窗口右界 = SND.UNA + WND 而非 SND.NXT + WND:ACK 前进时右界同幅前进、窗口尺寸不变;
  窗口"缩小"(右界左移)协议不允许,否则已发数据会被推翻。

rwnd = 接收缓冲区空闲字节数,由应用层读取速度决定——应用不 read,缓冲区填满,rwnd 收缩到 0(17.4.2)。发送方实际能发的上限是 min(rwnd, cwnd),这是流控与拥塞控制的交汇点。

17.4.2 零窗口与坚持定时器

接收缓冲区满了,接收方通告 window = 0,发送方必须停发。此时出现一个微妙问题:窗口恢复通知(窗口更新报文)是不含数据的纯 ACK,它可能丢失,且协议不重传它。若发送方傻等,而死等的前提正是"对方会通知我",通知一丢就永久死锁。零窗口探测(zero window probe)就是为这个缺口存在的:发送方启动坚持定时器(persist timer),超时后发 1 字节探测段,强制接收方回应当前窗口值。

1
2
3
4
5
发送方                             接收方(缓冲区满)
   │ ◀── window = 0 ────────────────── 通告零窗口;发送方启动 persist timer
   │ ──── 探测(1 字节)─────────────▶ 仍满,回 ack=原值, window=0
   │ 加倍间隔再探测 ──── 探测(1 字节)▶ 应用 read 了,有空闲
   │ ◀── ack=X, window=65535 ──────── 窗口更新,关闭 persist timer,恢复发送

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)上远远不够:

1
2
3
4
跨大洲 RTT ≈ 150 ms,带宽 1 Gbps:
    BDP = 带宽 × RTT = 1×10⁹ bit/s × 0.15 s = 1.5×10⁸ bit = 18.75 MB
    需要的最小窗口 18.75 MB,而 16 位字段只给 64 KB —— 差 300 倍
    不解决时的吞吐上限 = 64 KB / 150 ms ≈ 437 KB/s ≈ 3.5 Mbps

窗口扩大选项(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, 接收缓冲区大小的一半) 时才通告非零窗口。

1
2
3
4
5
接收缓冲区 64 KB,MSS 1460:
    空闲 100 字节  ⟹ 100 < min(1460, 32768) = 1460 ⟹ 通告 window = 0
    空闲 2000 字节 ⟹ 2000 ≥ 1460                  ⟹ 通告 window = 2000
为什么取"缓冲区一半":缓冲区很小时(如 4 KB)MSS 可能大于缓冲区,
    用一半做门槛可避免"阈值比缓冲区还大、永远通告 0"的死局。

发送方解法——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 撞在一起就形成互等:

1
2
3
4
5
6
7
场景:请求体分两次 write 发出(如 HTTP 首部一次、body 一次),MSS 远大于请求长度
客户端(Nagle 开)                  服务端(延迟 ACK 开)
  write 首部 ── 在途为空,立刻发 ──▶ 收到;凑不满两段、也无数据要回
                                      ⟹ 压住 ACK,等 40 ms 或第二个段
  write body ── 有未确认的在途数据 ⟹ Nagle 压住不发,等 ACK
                                    (服务端也在等第二个段才回 ACK)
  双方互等 ⟹ body 被压到延迟 ACK 定时器超时后才发出,每次交互白多花约 40 ms

注意这不是"永久卡死",而是每次交互都被拖约 40 ms——在需要多次往返的协议(某些 RPC、数据库协议)上会累积成几十到几百毫秒的尾延迟。三种解法:

解法做法代价 / 适用面
TCP_NODELAY关闭 Nagle,小段立即发可能增加小包数量与首部开销;交互式场景(RPC、Redis 客户端)必开
TCP_QUICKACK接收方关闭延迟 ACK,立即回Linux 特有且非粘性:内核会在后续某时刻自行恢复延迟 ACK,需在每次 recv 后重设
writev / 缓冲合并应用层把首部与 body 一次写出不改变内核行为,从源头消除"两个小段";最彻底
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/* 为什么用 writev 而不是两次 write:两次 write 会产生两个段,在 Nagle + 延迟 ACK
   组合下被拖约 40 ms;writev 一次性交给内核,内核按 MSS 切分时能立即填满一个段,
   不触发 Nagle 的攒包条件。NODELAY 是"让内核别替我攒",writev 是"我自己攒好了",
   两者择一即可;批量传大文件反而不该开 NODELAY。 */
struct iovec iov[2];
iov[0].iov_base = header;  iov[0].iov_len = header_len;
iov[1].iov_base = body;    iov[1].iov_len = body_len;
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
ssize_t n = writev(fd, iov, 2);

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 初值取任意大(实践中取通告窗口大小),因为此时并不知道网络容量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
cwnd
 ▲                                              ╱ ③ 快速恢复退出后回到拥塞避免
 │      ← ② 拥塞避免:线性,每 RTT +1 MSS →    ╱
 │ssthresh ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─●─ ─ ─ ─ ─●─ ─ ─ ─ ─ ─   (B) 折半后的新 ssthresh
 │ (1)                          ╱│  ╲
 │ ────────────────●──────────╱  │   ╲      B:快速重传
 │ (2)           ╱  ① 慢启动   │   │   ╲      ssthresh = cwnd / 2
 │             ╱    指数增长   │   │    ●── cwnd = ssthresh + 3 MSS
 │ IW10 ───●──╱                │   │    │    每收一个重复 ACK +1 MSS
 │  超时 ─●────────────────────●───┴────┘
 │  cwnd = 1 MSS,ssthresh = cwnd / 2,重新慢启动
 └────────────────────────────────────────────────────────▶ 时间
   (1) RFC 5681 的初始 ssthresh   (2) 超时后的新 ssthresh
   ① 慢启动:cwnd < ssthresh,每 RTT 翻倍;② 拥塞避免:cwnd ≥ ssthresh,每 RTT +1 MSS
   ③ 退出快速恢复:收到新数据的 ACK ⟹ cwnd = 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 的两种写法必须能证明等价:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
写法 A(按 RTT 计):每个 RTT 把 cwnd 增加 1 个 MSS
写法 B(按 ACK 计,实现用):每收到一个 ACK,cwnd += MSS × MSS / cwnd
等价性证明:一个 RTT 内发出的段数 = 窗口容纳的段数 = cwnd / MSS,收到的 ACK 个数也
    约等于 cwnd / MSS ⟹ 增量总和 = (cwnd / MSS) × (MSS × MSS / cwnd) = MSS
    ⟹ 每 RTT 恰好增加 1 个 MSS,与写法 A 一致。
为什么实现必须用写法 B:TCP 是"每收到一个 ACK 才算一次账",无法攒够一个 RTT 再统一
    计算。整数运算下写 cwnd += max(1, MSS × MSS / cwnd):+1 是必需的——cwnd 很大时
    MSS × MSS / cwnd 会截断成 0,窗口将永远停止增长。
快速重传(fast retransmit)的触发条件见 17.3.1;它在拥塞控制里的意义是不必等超时就
能检测丢包,因此不必把 cwnd 打回起点。快速恢复(fast recovery,Reno):
  触发时(第 3 个重复 ACK):ssthresh = max(cwnd / 2, 2 × MSS)(把当前速率砍半),
      cwnd = ssthresh + 3 × MSS。为什么加 3?3 个重复 ACK 说明有 3 个段已离开网络、
      到了接收方缓冲区,即"网络的在途数据"少了 3 个段;上调 3 个 MSS 补偿,使"本来
      可以立即发送"的量不因重传而缩水:28 MSS → ssthresh = 14,cwnd = 17(而非 14)。
  期间每再收到一个重复 ACK:cwnd += 1 × MSS(又有一个段离开网络;窗口足够时可发送
      新数据,这就是 Reno 的"窗口膨胀")
  收到确认【新数据】的 ACK:cwnd = ssthresh,退出快速恢复,回到拥塞避免
事件ssthreshcwnd之后阶段
三次握手完成取任意大(如通告窗口)10 × MSS(IW10)慢启动
cwnd 达到 ssthresh不变停止翻倍,改每 RTT +1 MSS拥塞避免
3 个重复 ACKmax(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 并重新慢启动,从头探测容量。

1
2
3
4
5
6
                    超时(RTO)              快速重传(3 dup ACK)
检测延迟             至少 1 个 RTO           约 3 个后续段的到达时间
cwnd 变化            = 1 × MSS               = ssthresh + 3 × MSS
ssthresh             max(cwnd/2, 2 × MSS)    max(cwnd/2, 2 × 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

算法丢包后的反应检测机制核心改进主要局限
Tahoecwnd = 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 计数"改成"按真实时间":

1
2
3
4
5
6
7
8
9
    W(t) = C × (t − K)³ + W_max
    W_max:本次拥塞事件前的窗口(丢包时的 cwnd);C = 0.4:控制增长陡峭度的常数
    K:从丢包点增长回 W_max 所需时间,K = (W_max × (1 − β) / C)^(1/3)
    β = 0.7:乘性减因子(Reno 用 0.5,所以 CUBIC 单次惩罚更轻)
三次函数的形状给出三个性质:
  ① t 远小于 K:曲线"下凹",增长最快 —— 丢包刚发生,正需快速抢回带宽;
  ② t 接近 K:曲线变平,增长最慢 —— 谨慎逼近上次丢包点,避免再次撞墙;
  ③ t 远大于 K:再次加快 —— 去探测新的容量。
  横轴是时间而非 RTT,因此无论 RTT 多大,恢复所需【墙钟时间】相同,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 处发生一次丢包):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
时间轴  事件                                cwnd(段) 在途 实际发出  阶段
  ├─ 握手:SYN ⇄ SYN-ACK ⇄ ACK;协商 MSS / wscale / SACK / timestamps,初始 RTO = 1 s;
  │  此后每个 ACK 提供一次 RTT 采样,SRTT 与 RTTVAR 开始收敛
  ├─ RTT 1 发 10 段(约 14.6 KB)⟹ 收 10 个 ACK ⟹ cwnd = 20      10  10  10  慢启动
  ├─ RTT 2 发 20 段 ⟹ 收 20 个 ACK ⟹ cwnd = 40(设 ssthresh = 32)20  20  20  慢启动(翻倍)
  ├─ RTT 3 cwnd 超过 ssthresh ⟹ 转入拥塞避免;发 40 段,第 3 段丢失,
  │  接收方回空洞前的 ACK + 连续 3 个重复 ACK
  │  ┌ 快速重传:ssthresh = 40/2 = 20,cwnd = 20 + 3 = 23 ┐
  │  ◀ 收到覆盖空洞的 ACK ⟹ cwnd = ssthresh = 20,回到拥塞避免
  ├─ RTT 4+ 每 RTT +1 段(线性)               20→21→22→…         拥塞避免
  │  发送窗口始终 = min(rwnd, cwnd) 并受在途字节数约束:接收方应用读得慢 ⟹ rwnd 收缩成
  │  为瓶颈(与 cwnd 无关);rwnd = 0 ⟹ 停发并启动 persist timer,周期性发 1 字节探测
  ▼  稳态:cwnd 在 ssthresh 附近小幅波动,吞吐 ≈ min(rwnd, cwnd) / RTT
几个必须能当场算出来的量:
  无丢包、无接收方限制时传完 100 KB:慢启动按 10 → 20 → 40 → 80 段走,累计
      (10+20+40+80) × 1460 ≈ 219 KB > 100 KB ⟹ 约 4 个 RTT = 4 × 40 ms = 160 ms;
      若每 RTT 只发 1 段则需 70 个 RTT = 2.8 s ⟹ 指数增长在短流中带来 17 倍以上差距
  稳态吞吐上界:吞吐 ≤ min(rwnd, cwnd) / RTT,cwnd = 20 MSS = 29200 字节、RTT = 40 ms
      ⟹ 29200 / 0.04 ≈ 730 KB/s ≈ 5.8 Mbps;提到 100 Mbps 需窗口
      ≥ (100 × 10⁶ / 8) × 0.04 = 500 KB ⟹ 约 343 个 MSS

重要

把这段时序和三件事对上号就能回答绝大多数追问: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"就停住,不知道时间戳已解歧义
快速重传阈值 31 ~ 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。

**Q2**(推导)证明"每 RTT 把拥塞窗口增加 1 个 MSS"与"每收到一个 ACK 就把 `cwnd` 增加 `MSS × MSS / cwnd`"等价,并解释实现里为什么必须写成 `cwnd += max(1, ...)`。
答案与解析

稳态下一个 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"当成拥塞避免的规则——那是慢启动的规则,混用会让拥塞避免变成指数增长。

**Q3**(推导)一条连接当前 `cwnd = 32 MSS`、`ssthresh = 64 MSS`,因 3 个重复 ACK 触发快速重传。写出 `ssthresh` 与 `cwnd` 的新值并说明 `+3 MSS` 的依据;随后又收到 2 个重复 ACK,再收到一个确认新数据的 ACK,分别给出 `cwnd` 与所处阶段。
答案与解析

触发时: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。

**Q4**(判断)判断下列六个说法的正误并给理由:① 接收方通告 0 窗口后,只要它恢复空间并发出窗口更新,发送方就一定能恢复;② `wscale` 的移位因子可在连接运行期间动态调整;③ Nagle 会延迟所有小于 MSS 的段,所以交互式应用必须关掉它;④ 只要 `SO_SNDBUF` 足够大,长肥管道的吞吐就能接近带宽上限;⑤ 延迟 ACK 与 Nagle 同时开启时,可能出现每次交互被拖长约 40 ms 的现象;⑥ 时间戳选项只让 RTT 更准,与序号回绕无关。
答案与解析

① 错:窗口更新报文可能丢失且协议不重传它;发送方已停发,不会再产生新 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 只影响效率,可靠性由累积确认加重传保证)。

**Q5**(设计)某内网 RPC 服务 P99 比 P50 高约 40 ms,抓包显示请求体被拆成两个小段、第二段在 40 ms 后才发出;另一条大文件上传连接吞吐只有带宽上限的 30%。分别给出诊断结论与修改方案。
答案与解析

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 时代该阈值退化为保底计数,尾丢包要靠 TLP17.3.1
TCP 窗口字段只有 16 位、最大 64 KB,怎么撑起长肥管道的带宽?wscale 与 BDP 的关系陷阱:答"内核会自动调大窗口"或"倍数可以动态调整"。加分:算出 BDP(1 Gbps × 150 ms = 18.75 MB),说明 wscale 只在握手协商且 S ≤ 1417.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 的经典图示。