Summary

上一篇讲清了 TCP 怎么保证"不丢不乱",但留了个关键的空:发送方一次能往外扔多少数据?

这个数字定小了,带宽白白浪费;定大了,轻则把接收方撑爆,重则把整个网络堵死。而且它还不能是个固定值——网络状况和对方的处理能力时时刻刻都在变。

TCP 的答案是同时受两个"刹车"约束:

  • 流量控制(flow control):别发太快,接收方处理不过来。
  • 拥塞控制(congestion control):别发太快,中间的网络扛不住。

这两件事经常被搞混,但它们防的是完全不同的东西。用送货来区分:流量控制是收货方的仓库满了,你得慢点送;拥塞控制是路上堵车了,你也得慢点送。仓库和马路是两个独立的瓶颈,任何一个卡住,你都得减速。

发送方实际能发多少 = min(流量控制允许的, 拥塞控制允许的)。这一篇把两个刹车分别讲透。


流量控制:别把接收方撑爆

问题:快的一方会淹死慢的一方

想象一个千兆网卡的服务器,给一个正在忙别的事、处理很慢的客户端发数据。服务器噼里啪啦发过去,客户端的接收缓冲区很快就满了——后面的数据只能被丢弃,然后触发重传,重传又被丢弃……带宽全浪费在做无用功上。

问题的根子是:发送方不知道接收方还剩多少空间。

解法很直接:让接收方主动告诉他。

接收窗口 rwnd:接收方的"我还能吃多少"

TCP三次握手与四次挥手.md 提过 TCP 头部有个 16 位的窗口大小(window)字段。它的含义就是:“我的接收缓冲区还剩这么多字节,你最多再发这么多。” 这个值叫接收窗口(rwnd, receive window)。

关键在于,这个字段捎带在每一个 ACK 里——接收方每确认一次数据,顺便就把最新的剩余空间通报一次。发送方据此实时调整。

接收方缓冲区(总共 8KB):

  已收到、应用还没读走      空闲
 ┌──────────────────────┬───────────────┐
 │  ████████████████    │               │
 └──────────────────────┴───────────────┘
                         └── rwnd = 3KB ─┘   ← 回 ACK 时告诉发送方

应用调用 Read() 读走数据后,空闲变大 → 下次 ACK 里的 rwnd (Receive Window 接收窗口) 变大
应用一直不读 → 缓冲区被塞满 → rwnd 一路降到 0

这个机制的精髓是"由消费者定速":不是生产者猜着发,而是消费者每次都明确说"我还能接多少"。餐厅上菜也是这个道理——服务员看你桌上还有多少空位再决定要不要上下一道,而不是一口气全端上来堆在地上。

注意一个常被忽略的点:rwnd 反映的是"缓冲区剩余空间",而不是"网络有多快"。如果你的应用 Read() 的比较慢(比如每读一条就去做一次耗时的处理),缓冲区就会积压,rwnd 就会缩小,发送方就被迫降速——你的应用处理速度,会一路反压到对方的发送速度上。

零窗口与窗口探测:一个必须防的死锁

如果接收方缓冲区被彻底塞满,它会通告 rwnd = 0,意思是"别发了,一个字节也别发"。发送方老老实实停下来。

问题来了:后来应用终于把数据读走了,缓冲区空出来,接收方发一个"窗口更新"的 ACK 说"可以发了,rwnd=4KB"。但如果这个 ACK 在网络里丢了呢?

死锁就形成了:发送方在等"可以发了"的通知,接收方在等数据——两边都在等对方,谁也不会先动。而这个 ACK 是纯确认包,不携带数据,TCP 的重传机制不管它(重传只负责有数据的段)。

TCP 的解法是持续计时器 + 零窗口探测(Zero Window Probe):发送方收到零窗口通告后启动一个计时器,超时就主动发一个只带 1 个字节的探测包过去,逼对方回一个 ACK,顺便把最新的 rwnd 带回来。窗口还是 0 就继续等、继续探(间隔指数增长);窗口开了就恢复发送。

这个设计值得记住的地方在于它的思路:与其信任一条"通知"不丢,不如让关心结果的一方主动去问。 这是分布式系统里"推(push)不可靠就改成拉(pull)“的经典手法,预言机的演变过程可以说类似这个设计。

糊涂窗口综合症:两头都可能犯的傻

有个很形象的名字叫糊涂窗口综合症(Silly Window Syndrome),说的是 TCP 陷入"每次只传几个字节"的低效状态——40 字节的头部(20 IP + 20 TCP)运 1 个字节的数据,有效载荷率 2.4%,把带宽全浪费在信封上了。

它有两个成因,分别在两头:

接收方犯傻:缓冲区满了(rwnd=0),应用每次只读走 1 个字节,接收方就傻乎乎地通告"rwnd=1”。发送方立刻发 1 字节过来,又满了。如此循环。

解法(David Clark 方案):接收方憋着不报——除非空出来的空间达到一个 MSS(最大段长)或者缓冲区的一半,否则一律继续通告 0。攒够了再一次性说"我空出 4KB 了"。

发送方犯傻:应用每次只 Write 一个字节(比如 Telnet 敲键盘、逐字符发送),TCP 老老实实每个字节封一个包发出去。

解法(Nagle 算法):发送方攒一攒再发。规则是:如果还有未被确认的数据在途,新的小数据先在缓冲区里等着;等到收到 ACK,或者攒够了一个满段(MSS),再一次性发出去。

Nagle 算法与延迟确认:一对会互相伤害的优化

Nagle 算法很有效,但它和另一个优化撞车了,这是 TCP 里最经典的"两个好心办坏事"的案例,值得单独讲。

延迟确认(Delayed ACK):接收方收到数据后不立刻回 ACK,而是等一下(通常最多 40~200ms)。为什么要等?两个好处:一是也许马上有反方向的数据要发,ACK 可以捎带过去省一个包;二是也许马上又收到一个段,可以一次 ACK 确认两段。

这两个优化单独看都很合理,凑在一起就出事了:

发送方(开 Nagle)                接收方(开延迟 ACK)
  发出小包 1  ──────────────────▶  收到,想:等等看有没有别的
  应用又写了小包 2                     (延迟 ACK 计时器开始跑…)
  Nagle:有未确认数据,先憋着
       ↓                                   ↓
    干等 ACK                          干等更多数据
       └──────── 双方僵持 40ms+ ────────┘
                (直到延迟 ACK 超时才打破)

一个在等 ACK 才肯发,一个在等数据才肯 ACK,活生生凑出一个几十毫秒的延迟。这在请求-响应型的小包交互里(RPC 心跳、游戏指令、交易所行情推送)是致命的——明明网络只要 1ms,实测却是 40ms。

解法是关掉 Nagle,也就是设置 TCP_NODELAY。Go 里更方便:

conn, _ := net.Dial("tcp", addr)
if tc, ok := conn.(*net.TCPConn); ok {
    tc.SetNoDelay(true) // 关闭 Nagle,小包立即发出
}

注意:Go 的 net 包默认就是 NoDelay = true(即默认关闭 Nagle),因为 Go 主要用于服务端和 RPC 场景,低延迟比省包更重要。这是个很贴心的默认值,但你要知道它的存在——如果你在写一个海量小包、带宽敏感的场景(比如日志上报),反而可能需要手动打开 Nagle。


拥塞控制:别把网络堵死

流量控制解决了"接收方"的瓶颈,但还有一个更大的瓶颈:中间那张网。

为什么这件事非做不可:拥塞崩溃

网络中间的路由器有缓冲队列,队列满了就丢包。丢包会怎样?TCP 会重传。

看出问题了吗?网络越堵 → 丢包越多 → 重传越多 → 网络更堵。这是一个正反馈的死亡螺旋,叫做拥塞崩溃(congestion collapse)。

这不是理论推演。1986 年,NSFNET 主干网真的发生过一次:一条 32 kbit/s 的链路,吞吐量崩塌到了 40 bit/s——只剩千分之一。整条链路上跑的几乎全是重传包,真正有效的数据几乎为零。正是这次事故催生了 Van Jacobson 的拥塞控制算法,也就是今天所有 TCP 的基础。

这里有个很深刻的点值得停下来体会:拥塞控制不是为了让你自己更快,恰恰相反,它要求你在网络繁忙时主动让步。 它是一套公地悲剧的解决方案——每个人都克制,大家才都有得用;每个人都疯狂发包,结果是所有人的吞吐都归零。TCP 之所以能撑起整个互联网,拥塞控制这套"自律机制"居功至伟。

最大难点:网络的容量没人告诉你

流量控制之所以简单,是因为接收方会明确告诉你 rwnd。但网络不会——没有任何一台路由器会给你发一条"我这儿快堵了"的消息。

所以拥塞控制只能靠猜,而且只有一个线索:丢包。

传统 TCP 的核心假设是:丢包 = 网络拥塞了。基于这个假设,它采用一种"试探"策略——慢慢加速,直到撞墙(丢包),然后退回来,再慢慢加速。像一个蒙着眼睛的人在陌生房间里走路:小步试探,碰到墙就后退,再换个速度试。

这个假设在有线网络里基本成立(有线链路的误码率极低,丢包几乎都是被路由器丢的)。但在无线网络里就不准了——WiFi/4G 的丢包很多是信号干扰造成的,和拥塞无关。TCP 却一律理解成"堵了"然后疯狂降速,这是移动网络下 TCP 表现差的重要原因,也是后来 BBR 要解决的问题之一。

拥塞窗口 cwnd

发送方自己维护一个变量叫拥塞窗口(cwnd, congestion window),表示"根据我对网络状况的判断,我觉得可以在途的数据量"。

它和 rwnd 一起决定实际发送量:

实际发送窗口 = min(rwnd, cwnd)
                 ↑      ↑
            接收方能力  网络能力
            (对方告诉你) (你自己猜的)

两个刹车,哪个先踩住听哪个。 剩下的问题就是:cwnd 怎么调?


四个经典算法

这四个算法配合起来,构成了经典 TCP(Reno)的拥塞控制。理解它们最好的方式是看 cwnd 随时间变化的那张图。

慢启动(Slow Start):其实一点都不慢

连接刚建立时,发送方对网络一无所知,不敢猛发,于是 cwnd 从 1 个 MSS 开始(现代实现通常是 10 个,RFC 6928),然后——

每收到一个 ACK,cwnd 增加 1 个 MSS。

这条规则的效果是每经过一个 RTT,cwnd 翻倍:

RTT 1:  cwnd = 1    发 1 个段,收到 1 个 ACK → +1
RTT 2:  cwnd = 2    发 2 个段,收到 2 个 ACK → +2
RTT 3:  cwnd = 4    发 4 个段,收到 4 个 ACK → +4
RTT 4:  cwnd = 8
RTT 5:  cwnd = 16   ...

所以"慢启动"这个名字很有误导性——它是指数增长,一点都不慢。 “慢"只是相对于"一上来就全速"而言:它的起点很低。用开车比喻,不是"慢慢开”,而是"从怠速起步,但油门越踩越深"。

指数增长显然不能一直持续下去(几个 RTT 就能撑爆任何网络),所以设了一道闸:慢启动阈值(ssthresh)。cwnd 涨到 ssthresh 就切换到下一个模式。

拥塞避免(Congestion Avoidance):切换成小心翼翼

cwnd >= ssthresh 后,进入拥塞避免阶段,增长规则改成:

每经过一个 RTT,cwnd 只增加 1 个 MSS。

从指数增长变成线性增长。含义是:“我已经接近上次出问题的水位了,不敢再猛冲,改成一点点试探。”

     cwnd
      │                              ╱ 线性(拥塞避免)
      │                          ╱
ssthresh ─ ─ ─ ─ ─ ─ ─ ─ ─╱─ ─ ─ ─ ─ ─ ─
      │                ╱
      │             ╱  指数(慢启动)
      │          ╱
      │       ╱
      │    ╱
      └──────────────────────────────▶ 时间(RTT)

一句话记住两个阶段:慢启动"翻倍冲",拥塞避免"加一试"。 分界线是 ssthresh。

撞墙之后:两种反应

跑着跑着丢包了,说明撞到网络容量的墙了。TCP 的反应取决于它是怎么发现丢包的——这个区分很关键。

情况一:超时(RTO)才发现丢包 —— 认为网络很糟

一整个超时周期都没等到 ACK,说明可能不只丢了一个包,网络状况严重恶化。反应非常激进:

ssthresh = cwnd / 2      (把阈值砍到当前的一半,记住这个水位)
cwnd = 1                 (窗口直接打回原形!)
重新进入慢启动

cwnd 直接归 1,等于从头再来。这个反应很惨烈,吞吐会断崖式下跌——所以现代 TCP 会尽量避免走到超时这一步。

情况二:收到 3 个重复 ACK —— 认为只是轻微丢包

TCP可靠传输 讲过快重传:收到 3 个重复 ACK 说明有一个段丢了,但后面的段还在陆续到达——这说明网络还是通的,只是偶尔丢一个。既然情况没那么糟,就不必打回原形:

ssthresh = cwnd / 2
cwnd = ssthresh + 3      (减半,不归 1) // +3说明网络中已经有三个段被成功接收,所以可以多发三个
直接进入拥塞避免(不走慢启动)

这就是快恢复(Fast Recovery)。它和快重传是一对搭档:快重传负责"赶紧把丢的那个补上",快恢复负责"别反应过度"。

完整的锯齿图

把四个算法串起来,cwnd 随时间的变化是一条经典的锯齿波:

   cwnd
    │        超时!                    3个重复ACK
    │         ↓                            ↓
 24 │        ╱│                          ╱│
    │      ╱  │                        ╱  │
 16 │    ╱    │                      ╱    ╲___ 快恢复:减半后继续爬
    │  ╱      │                    ╱          ╲
 12 │╱  线性  │                  ╱  线性        ╲╱
    │         │                ╱
  8 │─ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─ ╱─ ─ ─ ─ ssthresh(动态调整)
    │         │            ╱
  4 │         │         ╱ 指数
    │         │      ╱
  1 │         └───╱  cwnd 归 1,重新慢启动
    └────────────────────────────────────────▶ 时间
      慢启动→拥塞避免→超时→慢启动→拥塞避免→快恢复→…

这个"慢慢加,一撞就减半“的模式有个学名叫 AIMD(Additive Increase, Multiplicative Decrease,加性增、乘性减)。

为什么是"加着增、乘着减"而不是反过来? 这里有个漂亮的道理:

  • 减要快(乘性),因为网络已经出问题了,慢慢减等于继续伤害网络,必须立刻大幅让步。
  • 增要慢(加性),因为你不知道容量恢复了多少,猛加会立刻再次撞墙,来回震荡。

更妙的是,数学上可以证明:多条 AIMD 的连接共享一条链路时,会自动收敛到平均分配带宽。也就是说,AIMD 不只是"不把网络搞崩”,它还顺带实现了公平性——这是 TCP 能让全球几十亿条连接和平共处的根本原因。


现代拥塞控制:CUBIC 与 BBR

经典 Reno 是 1980 年代的产物,当时的网络是"低带宽、低延迟"。今天的网络是"高带宽、长距离"(比如跨洋的 10Gbps 链路),Reno 就不够用了。

Reno 的问题:高带宽下爬得太慢

拥塞避免每个 RTT 只加 1 个 MSS。假设一条 10Gbps、RTT 100ms 的跨洋链路,要把 cwnd 从减半后爬回满窗口,需要上万个 RTT,也就是十几分钟。一次偶然丢包,吞吐就要十几分钟才恢复——这在现代网络里完全不可接受。

CUBIC:Linux 的默认选择

CUBIC 用一个三次函数曲线代替线性增长,思路很聪明:

        cwnd
         │        ╱─────────  快速探测新容量
         │      ╱
  Wmax ──┼─────●──────────    ← 上次丢包时的窗口(记住这个水位)
         │   ╱   在这附近走得很慢,谨慎试探
         │ ╱
         │╱  丢包后快速爬回来
         └──────────────────▶ 时间

它的核心洞察是:上次出问题的水位(Wmax)附近,才是最需要小心的地方。

  • 离 Wmax 还远时:猛爬(离墙远,放心冲)。
  • 接近 Wmax 时:放慢(快到墙了,小心试探)。
  • 超过 Wmax 后:再次加速(说明网络容量确实变大了,大胆探索新领地)。

另一个关键改进:CUBIC 的增长只和"距离上次丢包过了多久"有关,与 RTT 无关。这解决了 Reno 的一个不公平问题——RTT 短的连接每个 RTT 加一次,加得更频繁,会抢走 RTT 长的连接的带宽。CUBIC 让长距离连接不再吃亏。

BBR:换一个思路,别再盯着丢包

Google 在 2016 年提出的 BBR(Bottleneck Bandwidth and Round-trip propagation time) 更激进——它根本不把丢包当作拥塞信号。

理由是前面提到的:丢包只是拥塞的滞后症状,而且在无线网络里还常常是误报。更要命的是**缓冲区膨胀(bufferbloat)**这个现象:

现代路由器的缓冲区做得非常大(因为内存便宜)。基于丢包的算法必须把缓冲区填满才会看到丢包,于是它们会一直加速直到塞满那些巨大的队列。结果是——丢包确实没发生,但你的包在队列里排了几百毫秒。延迟暴涨,而算法浑然不觉,还以为一切正常。这就是为什么家里有人下载大文件时,你打游戏会卡得离谱。

BBR 改为直接测量两个物理量:

  • 瓶颈带宽(BtlBw):这条路径最窄处能跑多快。
  • 最小往返延迟(RTprop):队列为空时的纯传播延迟。

然后把发送速率控制在恰好填满管道、但不产生排队的那个点上——也就是让在途数据量 ≈ 带宽时延积(BDP = 带宽 × RTT)。

        ↑ 延迟
        │                    ╱ 队列开始堆积,延迟暴涨
        │                  ╱      ← 基于丢包的算法工作在这一带(bufferbloat)
        │                ╱
  RTprop├──────────────●
        │              ↑
        │        BBR 想待在这个点:带宽跑满,队列为空
        └──────────────┴───────────▶ 在途数据量
                     BDP

BBR 在长肥管道和有随机丢包的链路上优势巨大(Google 用它把 YouTube 的吞吐提升了几个百分点、延迟降低了几十毫秒)。它已经进入 Linux 内核,可以直接切换:

# 查看当前可用与正在使用的拥塞控制算法
sysctl net.ipv4.tcp_available_congestion_control
sysctl net.ipv4.tcp_congestion_control

# 切到 BBR(需要内核 4.9+)
sysctl -w net.ipv4.tcp_congestion_control=bbr

这是一个真实可用的运维优化手段:如果你的节点或报价服务跨地域部署、跨洋拉数据,换 BBR 常常有立竿见影的效果。(需要注意 BBR 和传统算法共存时,在某些场景下会抢占更多带宽,公平性有争议。)

带宽时延积(BDP)

上面反复出现的 BDP = 带宽 × RTT,是理解网络性能的核心数字。它的含义是:要把这条链路"跑满",在途必须同时有多少数据。

一条 1 Gbps、RTT 100ms 的跨洋链路:
BDP = 1 Gbit/s × 0.1 s = 100 Mbit = 12.5 MB

意思是:要跑满这条链路,你必须允许 12.5 MB 的数据同时"在路上"。

这个数字有个非常实际的用途:如果你的 TCP 窗口小于 BDP,带宽就跑不满,而且这和你买了多大的带宽完全无关。

TCP 头部的窗口字段只有 16 位,最大 64KB——远小于 12.5MB。所以现代 TCP 必须启用窗口缩放选项(Window Scale,RFC 7323),用一个位移因子把窗口放大到最多 1GB。它在三次握手时协商,平时你感知不到,但如果某个中间设备把这个选项剥掉了,你会看到一个诡异的现象:带宽明明很大,传输速度却卡在几 MB/s 上不去。这是排查跨地域传输慢的经典套路之一。


从 Go 的视角看

这一整套机制都在操作系统内核里,Go 程序碰不到、也不该碰。但有几个你能控制、也确实该知道的点:

缓冲区大小

conn, _ := net.Dial("tcp", addr)
if tc, ok := conn.(*net.TCPConn); ok {
    tc.SetReadBuffer(4 << 20)  // 接收缓冲区 4MB
    tc.SetWriteBuffer(4 << 20) // 发送缓冲区 4MB
}

接收缓冲区直接决定了 rwnd 的上限。如果你在做跨洋的大数据传输(同步区块、拉历史事件),默认缓冲区(通常几百 KB)会成为瓶颈——它比 BDP 小,窗口开不大,带宽跑不满。这时候调大缓冲区是最直接的优化。

反过来,在有几万条连接的网关上,每条连接都开 4MB 缓冲区会直接吃光内存。Linux 默认开启了缓冲区自动调整(tcp_moderate_rcvbuf),大多数时候让内核自己调更好,只在明确诊断出"BDP 大于缓冲区"时才手动介入。

TCP_NODELAY

tc.SetNoDelay(true)  // Go 的默认值就是 true(关闭 Nagle)

一个必须建立的认知:Go 层面看不到窗口

conn.Write() 返回,只代表数据进了内核发送缓冲区。至于内核什么时候把它真正发出去、发多快、有没有被拥塞控制卡住,在 Go 层面完全看不到。

这带来一个实际后果:Write 很快返回,不代表数据传得快。如果网络拥塞,数据会积压在内核缓冲区里;缓冲区满了,Write 才会阻塞。所以用 Write 的耗时来衡量网络速度是错的。真要测,得测端到端的应用层确认。

想真正观察 TCP 的内部状态(cwnd、ssthresh、RTT 估计),得用系统工具:

ss -tin                    # 显示每条连接的 cwnd、rtt、重传次数等内部信息
# 输出里能看到:cwnd:10 ssthresh:7 rtt:0.5/0.2 retrans:0/3

netstat -s | grep -i retrans   # 全局重传统计,重传率高说明网络或对端有问题

ss -tin 是排查"为什么这条连接慢"的第一手段——如果看到 cwnd 很小而 retrans 在涨,说明网络在丢包,拥塞控制把你限住了,这时候优化应用代码是没用的。