深蓝黑背景品牌封面:左侧大标题「流量控制 ≠ 拥塞控制」,副标题「TCP 的两套调速器」;右侧原创装饰——两个仪表盘,一个标注 rwnd 指向「接收方缓冲」,一个标注 cwnd 指向「网络路况」,下方一个 min 符号把两表合为实际发送速率;左上标注「经典题解析 · 网络」,右上标注「Day 04 · 10-02」 图 1:本文封面,点明两套调速器的分工。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「说说 TCP 的流量控制和拥塞控制」是网络面试的保留题——两个词都带「控制」,混为一谈的人一抓一把,面试官就靠这个筛人。

想象面试现场,面试官问:TCP 有流量控制也有拥塞控制,它们分别解决什么问题?先把一句话的答案钉在台面上:流量控制解决点对点的问题——别把接收方撑爆;拥塞控制解决全局的问题——别把网络压垮。一个看接收方的缓冲,一个看网络的承载,根本不是一回事。

第一套调速器:滑动窗口流量控制。

接收方每次回 ACK,都会捎上一个窗口通告:我的接收缓冲还剩多少地方,这个数就是 rwnd(接收窗口)。发送方立下规矩:已经发出去但还没被确认的数据,不能超过 rwnd——接收方说只能再接 8 段,你就最多压着 8 段在途。

所谓滑动窗口,是把发送方的数据流看成四段:已确认、已发未确认、可发、暂不可发。每收到一个 ACK,窗口左沿右移;接收方读走数据、缓冲腾出地方,下一次 ACK 的通告窗口随之变大,发送方才获准提速。这套机制在 TCP 规范(RFC 9293)里就是窗口机制的本职:让发送速率匹配接收方的消化能力。极端情况下缓冲满了会通告零窗口,发送方停发,靠后续窗口更新(必要时加零窗口探测)恢复。

第二套调速器:慢启动与拥塞控制。

接收方缓冲够大,不代表网络扛得住。路由器队列溢出就是丢包,丢包就要重传,全网都这么蛮干就是拥塞崩溃。所以发送方自己再维护一个拥塞窗口 cwnd,用探测的方式摸网络的脾气。RFC 5681 定义的经典算法里,和本题最相关的是两个:

慢启动:连接刚建立,cwnd 从很小的初始值出发,每收到一个新数据的 ACK 就加一个段——效果是每个 RTT 翻倍,指数爬坡,快速逼近网络的可用容量。

拥塞避免:cwnd 涨到阈值 ssthresh 后转入保守模式,每个 RTT 只加一个段,线性爬坡,小心试探。

一旦检测到丢包,就当作网络喊疼了:把 ssthresh 砍到当时在途数据量的一半,cwnd 重新起跳(超时这种严重丢包直接回到一个段,重新慢启动)。于是经典的 cwnd 曲线呈锯齿状:指数升、线性升、丢包回落、再升——永远在「快一点」和「退一步」之间找平衡。

两套调速器怎么配合:谁小听谁的。

答案简单到一句话:发送方实际能发的在途数据量,上限是 min(rwnd, cwnd)。rwnd 是接收方给的许可,cwnd 是自己对网络的估计,两个约束同时生效,谁更紧就听谁的。RFC 5681 的定义原文也是这么规定的:发送方不得发出序号超过「最高已确认序号加上 cwnd 与 rwnd 较小者」的数据。

用可运行代码把两套窗口演一遍。

下面这份代码按 RTT 模拟 cwnd 的慢启动、拥塞避免与丢包回落,并让 rwnd 全程在场,观察实际发送窗口由谁决定,用 Node.js 22.15.0 直接运行(node tcp-windows.js)即可:

'use strict';

// 每 RTT 模拟一次:慢启动翻倍、拥塞避免加一、丢包砍半重起;实际窗口 = min(rwnd, cwnd)
const RWND = 8;            // 接收方通告的剩余缓冲(段)
let cwnd = 1;              // 拥塞窗口(段)
let ssthresh = 16;

console.log('RTT | cwnd | rwnd | 实际可发 min(rwnd, cwnd) | 阶段');
for (let rtt = 1; rtt <= 20; rtt++) {
  const send = Math.min(RWND, cwnd);
  const phase = cwnd < ssthresh ? '慢启动' : '拥塞避免';
  console.log(`${String(rtt).padStart(3)} | ${String(cwnd).padStart(4)} | ${String(RWND).padStart(4)} | ${String(send).padStart(4)} | ${phase}${send === RWND && cwnd > RWND ? '(被接收方限速)' : ''}`);

  if (rtt === 14) { // 模拟丢包(超时):阈值砍半,窗口回到 1 重新慢启动
    ssthresh = Math.max(Math.floor(cwnd / 2), 2);
    cwnd = 1;
    console.log('--- 检测到丢包:ssthresh 降为 ' + ssthresh + ',cwnd 重起 ---');
    continue;
  }
  if (cwnd < ssthresh) cwnd *= 2;       // 慢启动:每 RTT 翻倍
  else cwnd += 1;                       // 拥塞避免:每 RTT 加一段
}

实测输出如下(第 6 至 13 轮与第 5 轮同形态,省略):

RTT | cwnd | rwnd | 实际可发 min(rwnd, cwnd) | 阶段
  1 |    1 |    8 |    1 | 慢启动
  2 |    2 |    8 |    2 | 慢启动
  3 |    4 |    8 |    4 | 慢启动
  4 |    8 |    8 |    8 | 慢启动
  5 |   16 |    8 |    8 | 拥塞避免(被接收方限速)
  …(第 6~13 轮:cwnd 线性爬升到 25,实际可发始终被 rwnd 按在 8)…
--- 检测到丢包:ssthresh 降为 12,cwnd 重起 ---
 15 |    1 |    8 |    1 | 慢启动
 16 |    2 |    8 |    2 | 慢启动
 17 |    4 |    8 |    4 | 慢启动
 18 |    8 |    8 |    8 | 慢启动
 19 |   16 |    8 |    8 | 拥塞避免(被接收方限速)
 20 |   17 |    8 |    8 | 拥塞避免(被接收方限速)

两个阶段一目了然:前 4 个 RTT,cwnd 比 rwnd 小,拥塞控制说了算,窗口指数爬;第 5 个 RTT 起 cwnd 超过 8,实际发送被 rwnd 按在 8——接收方成了瓶颈,流量控制接管。第 14 轮丢包后阈值砍半、窗口重起,锯齿曲线成型。

双机制对照图:左侧流量控制——接收方在 ACK 里通告自己的剩余缓冲 rwnd,发送方保证「已发未确认」不超过它,滑动窗口分四段:已确认、已发未确认、可发、暂不可发;它解决的是点对点问题:别把接收方撑爆。右侧拥塞控制——发送方自己维护 cwnd 试探网络:慢启动阶段每轮翻倍(指数增长),到阈值 ssthresh 后转入拥塞避免(每轮加一,线性增长),一旦丢包就把阈值砍半、窗口重起;它解决的是全局问题:别把网络压垮。底部汇合:实际可发送窗口 = min(rwnd, cwnd),两套机制同时生效,谁小听谁的;曲线图展示 cwnd 指数、线性、丢包回落的锯齿形态 图 2:两套调速器各自的目标、机制与汇合规则。

可能的追问。

「丢包一定是拥塞吗?」不一定,无线链路会随机丢包——这正是后来 BBR 等新算法改用带宽与 RTT 实测代替丢包信号的动机,一句带过即可。「rwnd 为零会怎样?」零窗口,发送方停发并周期性探测,等接收方腾地方。「三次握手里窗口怎么定?」初始 rwnd 由接收方通告,cwnd 有规范给定的初始上限,都从保守起步。

易错点清单。

一,把两者混成一个「窗口机制」——rwnd 是接收方通告的,cwnd 是发送方自己估的,来源都不同。二,以为慢启动很慢——它是指数增长,是爬坡最快的阶段,「慢」只相对一开始的保守而言。三,忘记 min 规则,声称「窗口大就能猛发」。四,把流量控制说成拥塞控制的子集——它们目标不同、缺一不可。

回到面试现场,建议的答题节奏:先用「点对点 vs 全局」一句话把两者劈开,再分别讲 rwnd 通告与 cwnd 的慢启动、拥塞避免、丢包回落,最后用 min(rwnd, cwnd) 收口。大模型推理是典型的长耗时连接,网关和客户端的缓冲、超时配置都要懂这套速率调节;拥塞控制「探测式提速、受挫即退」的思路,和服务端限流异曲同工。

你排查过「带宽够但传输就是快不起来」的问题吗?最后发现卡在哪一层?欢迎分享。