本页目录

TCP

互联网最复杂的协议——三次握手建连接、滑动窗口做流控、CUBIC/BBR 管拥塞、SACK 减少重传。TCP 在不可靠的 IP 之上造了一个可靠的字节流抽象,每个机制都是几十年工程实践的沉淀。

概述

TCP 是互联网最基础的可靠传输协议,1981 年由 RFC 793 定义,承载了绝大多数互联网流量(HTTP、SSH、SMTP 等)。它在一个不可靠的 IP 层之上提供:有序字节流、丢包重传、流控和拥塞控制。

理解 TCP 有一条主线:⁠它要同时保护两个东西⁠。滑动窗口保护的是对端⁠(别把接收方的 buffer 灌爆),拥塞控制保护的是路上的网络⁠(别把中间路由器的队列灌爆)。前者有明确的信号(对端直接告诉你窗口多大),后者没有——网络不会主动汇报"我快满了",发送方只能靠猜。TCP 的核心创新不是"保证可靠",而正是这个猜的艺术:拥塞控制。过去 40 年拥塞算法从 Tahoe/Reno 演进到 CUBIC(Linux 默认)再到 BBR(Google 2016),反映了互联网从铜线到移动网络到数据中心的变迁。

TCP Header

 0                   1                   2                   3
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          Source Port          |       Destination Port        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                        Sequence Number                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Acknowledgment Number                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data  |       |U|A|P|R|S|F|                               |
|Offset | Rsrvd |R|C|S|S|Y|I|            Window             |
| (4)   | (3)  |G|K|H|T|N|N|           (16 bits)            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           Checksum            |         Urgent Pointer        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options (if Data Offset > 5)               |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

几个关键字段的语义:

  • Sequence Number:此 segment 第一个 data byte 的序号。初始序号 ISN 随机化(防序号预测攻击)。
  • Acknowledgment Number:期望收到的下一个 byte 的序号——这是累计 ACK:ack=1000 表示 999 及之前全部收到。
  • Data Offset:TCP header 长度,以 4 字节为单位,最小 5(20 字节无选项头)。
  • Flags:CWR、ECE(显式拥塞通知 ECN 用)、URG、ACK、PSH、RST、SYN、FIN。
  • Window:接收方当前剩余 buffer 大小。原始字段只有 16 位(最大 64KB),现代 TCP 靠 window scale 选项扩展(见下)。

关键 Options

Optionkind出现时机作用
MSS2仅 SYN声明本端能接收的最大 segment,通常 = MTU(1500) − IP(20) − TCP(20) = 1460
Window Scale3仅 SYN窗口值左移 N 位(最大 14),突破 64KB 上限
SACK Permitted4仅 SYN声明支持选择性确认
SACK5数据传输中报告收到的非连续区间,让重传只补洞
Timestamp8每个包RTT 测量(RTTM)+ 序号回绕保护(PAWS)

两个值得展开的:

Window Scale 为什么必要⁠——没有它,窗口最大 64KB。而发送方能"填在路上"的数据量上限是 min(窗口, cwnd),链路的容量是 BDP(带宽 × RTT)。一条 1Gbps、RTT 50ms 的链路 BDP ≈ 6.25MB,64KB 的窗口只能跑出 64KB / 50ms ≈ 10Mbps——带宽再大也跑不满。有了 scale(如 window=65535, scale=7 → 实际 8MB),窗口才追得上现代链路。

SACK 解决什么⁠——累计 ACK 只能表达"连续收到了多少"。丢了 byte 2001-2999 时,即使 3000-4000 已经收到,ACK 也只能停在 2001,发送方可能把后面已收到的数据也重发一遍。SACK 让接收方明说"我收到了 1000-2000 和 3000-4000",发送方只补 2001-2999 这一个洞。

连接建立:三次握手

sequenceDiagram
    participant C as Client
    participant S as Server
    Note over C: CLOSED → SYN_SENT
    Note over S: LISTEN
    C->>S: SYN, seq=x
    Note over S: SYN_RCVD
    S->>C: SYN+ACK, seq=y, ack=x+1
    C->>S: ACK, seq=x+1, ack=y+1
    Note over C,S: 双方 ESTABLISHED

为什么是三次,两次不行? 握手的目标是双方都确认"双向通道可用"。两次的话:Client 发 SYN、Server 回 SYN+ACK——此时 Client 确认了双向可达,但 Server 不知道自己的 SYN+ACK 有没有到达。第三次的 ACK 就是给 Server 的这个确认。少一次,就有一方对通道状态存疑。

为什么 SYN 要消耗一个序号? SYN 和 FIN 都必须"可被确认"——如果它们不占序号,丢了就没法用 ACK 机制发现并重发。SYN(seq=x) 收到 ACK(x+1),才证明"你的 SYN 我收到了"。这也是握手图里 ack 都是 x+1、y+1 的原因。

顺带一提:SYN_RCVD 状态要占服务端资源(半连接队列),这是 SYN flood 攻击的靶点——Linux 用 syncookies 把状态编码进 ISN 返还给客户端,做到无状态应答。

连接关闭:四次挥手

sequenceDiagram
    participant A as 主动关闭方
    participant P as 被动关闭方
    A->>P: FIN, seq=u
    Note over A: FIN_WAIT_1
    P->>A: ACK, ack=u+1
    Note over P: CLOSE_WAIT — 应用层还没 close
    Note over A: FIN_WAIT_2
    P->>A: FIN, seq=v
    Note over P: LAST_ACK
    A->>P: ACK, ack=v+1
    Note over A: TIME_WAIT — 等 2MSL 后 CLOSED
    Note over P: CLOSED

挥手是四次而不是三次,因为关闭是两个独立的半关闭⁠:收到 FIN 只说明"对方不再发了",本方可能还有数据要发(所以 ACK 和自己的 FIN 之间隔着一个 CLOSE_WAIT,长短取决于应用什么时候调 close)。

TIME_WAIT 为什么要等 2MSL(约 120s):

  1. 保证最后一个 ACK 送达⁠——如果它丢了,对端会重发 FIN;主动关闭方必须还"活着"才能重新应答,否则对端永远卡在 LAST_ACK。
  2. 让旧连接的迷路包在网络中过期⁠——防止上一条连接的延迟报文,被误认为属于紧接着复用同一 5 元组的新连接。

TIME_WAIT 大量堆积(常见于高并发短连接的客户端侧)的缓解手段:

手段作用范围说明
tcp_tw_reuse=1客户端新连接可复用 TIME_WAIT socket,依赖 timestamp 单调递增判定安全
SO_REUSEADDR服务端重启后立即 bind 端口,不等 TIME_WAIT 排空
tcp_fin_timeout孤儿连接只影响 orphan socket 的 FIN_WAIT_2 超时,常被误当 MSL 调
连接池架构层根本方案——复用长连接,从源头少产生 TIME_WAIT

滑动窗口与流控

流控回答的问题是:⁠发送方怎么知道对端还能接多少? 答案简单直接——接收方在每个 ACK 里通告自己剩余的 buffer(Window 字段),发送方保证"已发未确认"的数据量不超过它。

发送方视角的序号空间:

        已确认          已发送未确认        可以发送        窗口外
  ──────────────┬──────────────────┬────────────────┬─────────────
                SND.UNA            SND.NXT          SND.UNA+SND.WND

发送条件就是 SND.NXT < SND.UNA + SND.WND

两个经典的坑,都出在"窗口变小"上:

  • 零窗口死锁⁠:接收方 buffer 满,通告 window=0,发送方停发。之后接收方腾出空间发 window update——如果这个 update 丢了,双方互相等,死锁。所以 TCP 有 persist timer:发送方定期发 1 字节的 window probe,强迫接收方回 ACK 带上最新窗口。
  • Silly Window Syndrome:接收方每空出 1 字节就通告 window=1,发送方就发 1 字节——无尽的小包,头部开销远超载荷。两端各有缓解:发送方用 Nagle 算法(攒够 MSS 或收到 ACK 才发),接收方用 Clark 方案(窗口没腾出足够空间前不通告小窗口)。

拥塞控制:CUBIC → BBR

流控有对端明确报数,拥塞控制没有——⁠网络不会告诉你它快满了⁠,发送方只能从旁证推断:丢包了?延迟涨了?这个"用什么信号推断拥塞"的选择,就是各代算法的分野:CUBIC 把丢包当信号,BBR 把测得的带宽和 RTT 当信号。

发送速率由拥塞窗口 cwnd 控制,实际能发的量是 min(cwnd, 对端窗口)

CUBIC(Linux 默认)

CUBIC 的增长曲线是一个三次函数,以上次丢包时的窗口 W_max 为"记忆":

cwnd = C × (t − K)³ + W_max        K = ³√(W_max × β / C),  β = 0.7

直觉上:丢包后 cwnd 砍到 0.7 倍,然后先快速逼近 W_max,接近时放缓(曲线平台期),确认没事后再加速探测更高水位⁠——三次曲线天然给出这个"快-慢-快"的形状,且增长只和距上次丢包的时间 t 有关、和 RTT 无关(长肥管道上比 Reno 的每 RTT 线性加一快得多)。

flowchart LR
    SS["🚀 <b>Slow Start</b><br><small>cwnd 每 RTT 翻倍</small>"] -->|"到达 ssthresh"| CA["📈 <b>Congestion Avoidance</b><br><small>沿 CUBIC 曲线增长</small>"]
    SS -.->|"丢包"| MD["💥 <b>Decrease</b><br><small>cwnd ×0.7,记录 W_max</small>"]
    CA -.->|"丢包"| MD
    MD ==> CA
    classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
    classDef steady fill:#4493f81f,stroke:#4493f8,stroke-width:2px
    classDef loss fill:#f851491f,stroke:#f85149,stroke-width:2px
    class SS grow
    class CA steady
    class MD loss

CUBIC 的问题在它的信号本身:⁠丢包是一个太迟、且不总是对的信号⁠。

  • 在 bufferbloat 链路上(路由器 buffer 巨大),队列填满才丢包——此时延迟已经涨到几百毫秒,CUBIC 还在踩油门。
  • 在高带宽 × 长 RTT(大 BDP)链路上,一次丢包砍 30% 之后爬回来很慢。
  • 在有随机丢包的链路(WiFi、跨洋)上,把噪声误判成拥塞,白白降速。

BBR(Bottleneck Bandwidth and RTT,Google 2016)

BBR 换了信号:不等丢包,而是直接给链路建模⁠。把路径看成一根管道,测出两个参数——瓶颈带宽 BW(近期交付速率的最大值)和传播时延 min RTT(近期 RTT 的最小值),管道的容量就是 BDP = max(BW) × min(RTT)。把 in-flight 数据量钉在 BDP 附近:既跑满带宽,又不在路由器里排队。

flowchart LR
    S["🚀 <b>Startup</b><br><small>每轮翻倍<br>快速填满管道</small>"] -->|"交付速率<br>不再增长"| D["🫗 <b>Drain</b><br><small>排空超出 BDP<br>的排队数据</small>"]
    D -->|"inflight 降回 BDP"| P["⚖️ <b>ProbeBW</b><br><small>稳态:增益循环<br>轻探新带宽</small>"]
    P -->|"每 10s"| R["📏 <b>ProbeRTT</b><br><small>压到 4 包<br>重测 min RTT</small>"]
    R ==>|"拿到干净的 min RTT"| P
    classDef grow fill:#3fb9501f,stroke:#3fb950,stroke-width:2px
    classDef warn fill:#d299221f,stroke:#d29922,stroke-width:2px
    classDef steady fill:#4493f826,stroke:#4493f8,stroke-width:2.5px
    classDef neutral fill:#64748b1f,stroke:#64748b,stroke-width:2px
    class S grow
    class D warn
    class P steady
    class R neutral
  • Startup:类似慢启动,每轮翻倍,快速填管道。
  • Drain:填满后 inflight 会超出 BDP(多出的在排队),先排空。
  • ProbeBW:稳态。用增益循环 [5/4, 3/4, 1, 1, 1, 1, 1, 1] 周期性轻踩油门探测新带宽、再让出来。
  • ProbeRTT:每 10s 把 inflight 压到极低,腾空队列测真实传播时延——不然自己制造的队列会污染 min RTT 的测量。

实际效果(Google YouTube 部署数据):平均延迟降约 33%,重传降约 20%。局限也真实存在:BBR v1 对 CUBIC 流不公平(probe 太激进,会挤压共存的丢包型流量),v2(配合 RFC 9438 时代的改进)加入了对丢包和 ECN 的响应、改善了公平性。

一张表对比

CUBICBBR
拥塞信号丢包测量的 BW + RTT 模型
bufferbloat 链路填满队列,延迟飙升inflight ≈ BDP,低延迟
随机丢包链路误判拥塞,吞吐塌不因丢包自动降速,吞吐稳
公平性同类间收敛良好v1 对丢包型流不公平,v2 改善
部署Linux 默认sysctl net.ipv4.tcp_congestion_control=bbr

拥塞控制的这套思路并不专属 TCP——QUIC 在用户态重新实现了全套(见 UDP 与 QUIC 传输层),换算法不用改内核。调优实操见网络性能与调优

参考

  • RFC: 793, 7323 (timestamp/window scale), 2018 (SACK), 7413 (TFO), 5681, 8312 (CUBIC), 9438 (BBRv2)
  • 源码⁠: net/ipv4/tcp_input.c (tcp_rcv_established), net/ipv4/tcp_cubic.c, net/ipv4/tcp_bbr.c
  • 工具⁠: ss -tianp, nstat -a | grep Tcp, bpftrace -e 'k:tcp_retransmit_skb { @[comm] = count(); }'

Keywords: TCP, three-way handshake, TIME_WAIT, sliding window, CUBIC, BBR, SACK, TFO, Window Scale, congestion control, BDP, bufferbloat