# 第 17 章 TCP 可靠传输与拥塞控制


> 第 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）。

> [!IMPORTANT]
> 四个支柱一句话串起来：**序号管顺序、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`）：

```text
首次测量（还没有 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 倍）——指数加权移动平均的固有滞后，它故意"迟钝"，否则一次抖动就会
    引发重传风暴。
```

> [!WARNING]
> 最常见的错误回答是"`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` 之间的量级，也是应用层必须自设超时的原因。

> [!NOTE]
> 追问的下一层：**为什么 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`：

```text
发送方                  网络                接收方
  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 信息统计"空洞之后已被确认的段数"作为等价计数。

> [!TIP]
> 记忆锚点：**3 个重复 ACK = "一次乱序不足以定罪，三次才算证据"**。加分说法："SACK 时代这个 3 已退化为保底计数——有 SACK 时发送方看的是 SACK 块里的空洞，而不是数重复 ACK 个数。"

### 17.3.2 SACK 与 DSACK

累积确认让发送方**不知道空洞之后收到了什么**，于是只有两个坏选择：只重传空洞那一个段（可能重传了已到达的段），或重传空洞之后的全部段（浪费更多）。**SACK（selective acknowledgment, RFC 2018）**通过选项回报"已收到的非连续块"，每块是一对 `(left edge, right edge)`：

```text
接收方已收到：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` 下能装的段数完全不同。

```text
发送方字节流视图（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 字节探测段，强制接收方回应当前窗口值。

```text
发送方                             接收方（缓冲区满）
   │ ◀── 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 | 乱序段到达 | 只影响重传效率 | 不影响正确性 |

> [!CAUTION]
> 把"窗口更新丢失"与"ACK 丢失"混为一谈是最会把面试答崩的错误。**ACK 丢失无害**（累积确认下后续 ACK 覆盖），**窗口更新丢失有害**（它是唤醒发送方的唯一信号，协议又不重传），所以 persist timer 不是"冗余保险"而是**协议正确性的一部分**。追问"接收方窗口恢复后为什么不能只靠下一次 ACK 顺带通知？"——因为发送方已停发，不会再有新 ACK 产生。

### 17.4.3 窗口扩大选项（wscale）与 BDP

TCP 首部 `window` 是 16 位，硬上限 `65535` 字节 ≈ 64 KB，这在长肥管道（long fat network, LFN）上远远不够：

```text
跨大洲 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 采样歧义 + 序号回绕保护 | —— | 握手协商 |

> [!WARNING]
> 三个高频错误：① 说 `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, 接收缓冲区大小的一半)` 时才通告非零窗口。

```text
接收缓冲区 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 撞在一起就形成互等：

```text
场景：请求体分两次 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 一次写出 | 不改变内核行为，从源头消除"两个小段"；最彻底 |

```c
/* 为什么用 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` 初值取任意大（实践中取通告窗口大小），因为此时并不知道网络容量。

```text
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 的两种写法必须能证明等价：

```text
写法 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，退出快速恢复，回到拥塞避免
```

| 事件 | `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` 并重新慢启动，从头探测容量。

```text
                    超时（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 ⟹ 瓶颈在对端应用（读得慢），此时调拥塞参数也无效。

> [!TIP]
> 现场区分瓶颈的话术：**发送端看 `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 计数"改成"按真实时间"：

```text
    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 都会干扰）。

> [!NOTE]
> 追问的下一层：**"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 处发生一次丢包）：

```text
时间轴  事件                                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
```

> [!IMPORTANT]
> 把这段时序和三件事对上号就能回答绝大多数追问：**`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%"而大幅上调。
<details><summary>答案与解析</summary>

第 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`。
</details>
**Q2**（推导）证明"每 RTT 把拥塞窗口增加 1 个 MSS"与"每收到一个 ACK 就把 `cwnd` 增加 `MSS × MSS / cwnd`"等价，并解释实现里为什么必须写成 `cwnd += max(1, ...)`。
<details><summary>答案与解析</summary>

稳态下一个 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"当成拥塞避免的规则——那是**慢启动**的规则，混用会让拥塞避免变成指数增长。
</details>
**Q3**（推导）一条连接当前 `cwnd = 32 MSS`、`ssthresh = 64 MSS`，因 3 个重复 ACK 触发快速重传。写出 `ssthresh` 与 `cwnd` 的新值并说明 `+3 MSS` 的依据；随后又收到 2 个重复 ACK，再收到一个确认新数据的 ACK，分别给出 `cwnd` 与所处阶段。
<details><summary>答案与解析</summary>

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

① **错**：窗口更新报文可能丢失且协议不重传它；发送方已停发，不会再产生新 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 只影响效率，可靠性由累积确认加重传保证）。
</details>
**Q5**（设计）某内网 RPC 服务 P99 比 P50 高约 40 ms，抓包显示请求体被拆成两个小段、第二段在 40 ms 后才发出；另一条大文件上传连接吞吐只有带宽上限的 30%。分别给出诊断结论与修改方案。
<details><summary>答案与解析</summary>

**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 就以为能提速（真正瓶颈是窗口）。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应小节 |
|---|---|---|---|
| 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 的经典图示。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch17/  

