TCP 协议深度解析
#网络 · #TCP · #三次握手 · #拥塞控制 · #TIME_WAIT · #滑动窗口 · #BBR
TCP 是互联网最核心的协议之一。本文从 TCP 头部格式出发,深入三次握手与四次挥手的状态转换、TIME_WAIT 与 CLOSE_WAIT 的工程排查、tcpdump 抓包实战,以及拥塞控制算法的演进。
1. TCP 头部格式(20-60 字节)
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Port | Destination Port |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Acknowledgment Number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Data | |U|A|P|R|S|F| |
| Offset| Reserved |R|C|S|S|Y|I| Window |
| | |G|K|H|T|N|N| |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Checksum | Urgent Pointer |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Options (variable, up to 40 bytes) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+| 字段 | 位数 | 说明 |
|---|---|---|
| Source Port | 16 | 源端口号 |
| Destination Port | 16 | 目标端口号 |
| Sequence Number | 32 | 序号:本报文段数据的第一个字节的序号。初始序号(ISN)随机生成 |
| Acknowledgment Number | 32 | 确认号:期望收到对方下一个报文段的序号 |
| Data Offset | 4 | TCP 头部长度,以 4 字节为单位(最小 5 = 20 字节) |
| Reserved | 6 | 保留(TCP 头中尚有 3 位用于 ECN) |
| Flags (6 bit) | 6 | URG/ACK/PSH/RST/SYN/FIN |
| Window Size | 16 | 接收窗口大小,用于流量控制 |
| Checksum | 16 | 校验和(含伪首部) |
| Urgent Pointer | 16 | 紧急指针(仅当 URG=1 时有效) |
| Options | 0-320 | 可选字段,最大 40 字节 |
6 个标志位 (Flags):
| 标志 | 含义 | 典型场景 |
|---|---|---|
| SYN | 同步序号,建立连接 | 三次握手 |
| ACK | 确认序号有效 | 几乎所有数据包 |
| FIN | 发送方数据发送完毕 | 四次挥手 |
| RST | 重置连接 | 拒绝连接 / 异常断开 |
| PSH | 尽快推送数据到应用层 | TCP_NODELAY |
| URG | 紧急指针有效 | 带外数据 |
1.1 TCP 伪首部(用于校验和计算)
0 7 8 15 16 23 24 31
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Source Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Destination Address |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| zero | PTCL | TCP Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+校验和覆盖:伪首部 + TCP 头部 + TCP 数据。伪首部仅在计算校验和时构造,不实际传输。包含源/目标 IP 地址使得即使 IP 层没有校验数据正确性,TCP 也能检测出被错误路由的包。
1.2 TCP 协议开销的完整量化分析
包头开销——有效载荷比例
每个 TCP 数据包的协议开销:
以太网帧头: 14B (目标MAC 6B + 源MAC 6B + 类型 2B)
IP 头: 20B (无选项)
TCP 头: 20B (无选项) ~ 32B (带时间戳选项)
以太网 FCS: 4B
─────────────────────────────────
总协议开销: 58B ~ 70B
有效载荷比例 (不同数据大小):
| 应用数据大小 | 总包大小 | 有效载荷比例 | 场景 |
|-------------|---------|:-----------:|------|
| 1B (ACK) | 54B | 1.9% | 纯 ACK、心跳 |
| 64B | 118B | 54% | Redis GET 响应 |
| 200B | 254B | 79% | 小 HTTP 响应 |
| 1460B (MSS) | 1514B | 96% | 满载数据包 ✅ |
| 64KB (GSO) | ~65KB | 99.9% | TSO/GSO 大包 |
结论: 小包场景下 TCP 的协议开销非常显著
→ 这就是为什么 Redis pipeline、HTTP/2 多路复用如此重要
→ 合并小包 (Nagle 算法) vs 低延迟 (TCP_NODELAY) 的权衡
三次握手的延迟代价:
┌──────────────────────────────────────────────────────────┐
│ 客户端 网络 (RTT) 服务端 │
│ │
│ SYN ──────────→ (0.5 RTT) ──────────→ 收到 SYN │
│ 发送 SYN+ACK │
│ 收到 SYN+ACK ←── (0.5 RTT) ←────────── SYN+ACK │
│ 发送 ACK+数据 │
│ ACK+数据 ─────→ (0.5 RTT) ──────────→ 收到数据 │
│ │
│ 总延迟: 1.5 RTT (客户端视角: 1 RTT 后可发数据) │
└──────────────────────────────────────────────────────────┘
不同网络环境的握手延迟:
同机房 (RTT ~0.5ms): 握手 ~0.75ms
同城 (RTT ~2ms): 握手 ~3ms
跨城 (RTT ~30ms): 握手 ~45ms
跨国 (RTT ~200ms): 握手 ~300ms
加上 TLS 握手 (额外 1-2 RTT):
同机房: 0.75ms + 1ms = ~1.75ms
跨城: 45ms + 60ms = ~105ms
跨国: 300ms + 400ms = ~700ms !
→ 这就是为什么连接复用 (keep-alive) 和连接池如此关键
→ 每次新建连接的"首包延迟"可能比业务处理本身还长
小包问题 (Small Packet Problem):
场景: 交互式应用(SSH、Telnet)每次只发 1 个字符
1 字节数据 + 20B TCP + 20B IP + 14B 以太网 = 55B
→ 有效载荷率 = 1/55 = 1.8% → 98.2% 是协议开销!
→ 如果每秒发 100 个字符: 100 × 55B = 5.5KB 带宽
但有效数据只有 100B → 带宽利用率 1.8%
Nagle 算法的解决:
等待一小段时间,将多个小数据合并成一个大包发送
→ 减少包数量 → 提高带宽利用率
→ 但增加了延迟 (~200ms 等待)
TCP_NODELAY 的选择:
禁用 Nagle → 每个 write 立即发送 → 低延迟但带宽利用率低
适用: 游戏、实时通信、Redis 等延迟敏感场景2. 三次握手(Three-Way Handshake)
mermaid
sequenceDiagram
participant C as 客户端(CLOSED)
participant S as 服务端(LISTEN)
Note over C: 随机生成 ISN = x
C->>S: SYN=1, Seq=x, Ack=0<br/>状态: SYN_SENT
Note over S: 收到 SYN → SYN_RCVD<br/>随机生成 ISN = y
S->>C: SYN=1, ACK=1, Seq=y, Ack=x+1<br/>状态: SYN_RCVD
Note over C: 收到 SYN+ACK → ESTABLISHED
C->>S: ACK=1, Seq=x+1, Ack=y+1<br/>状态: ESTABLISHED
Note over S: 收到 ACK → ESTABLISHED2.1 为什么是三次而不是两次?
两次握手的致命问题:
1. 客户端发送 SYN (ISN=x),但在网络中延迟了
2. 客户端超时重发 SYN (ISN=x')
3. 服务端收到 x' → 建立连接 → 传输数据 → 关闭
4. 此时网络中的延迟 SYN (ISN=x) 到达服务端
5. 如果是两次握手,服务端以为客户端又要建连接 → 分配资源等待数据
6. 客户端早已关闭 → 服务端资源泄漏
三次握手:服务端收到延迟 SYN 回复 SYN+ACK,客户端发现不对回复 RST → 服务端释放资源2.2 ISN(初始序列号)为什么随机?
- 防止历史连接中的数据包被新连接误接收(相同四元组)
- 防止序列号预测攻击(攻击者伪造 RST 或注入数据)
- Linux 使用基于时钟的随机算法生成 ISN
2.3 SYN Cookie — 防 SYN Flood
mermaid
flowchart TD
A["客户端 SYN 到达"] --> B{"SYN 队列已满?"}
B -->|"否"| NORMAL["正常三次握手"]
B -->|"是"| COOKIE["启用 SYN Cookie"]
COOKIE --> C["不分配连接资源<br/>用(源IP, 端口, 时间戳, MSS)哈希<br/>生成 Seq=y 作为 Cookie"]
C --> D["回复 SYN+ACK (Seq=Cookie)"]
D --> E{"收到客户端 ACK"}
E -->|"Ack=Cookie+1"| F["验证 Cookie 正确<br/>分配连接资源 ✅"]
E -->|"错误"| G["丢弃 ❌"]
style COOKIE fill:#FF9800,color:#fff
style F fill:#4CAF50,color:#fff
style G fill:#F44336,color:#fffSYN Cookie 的底层编码原理
核心问题:SYN Flood 攻击的致命之处在于——攻击者只发 SYN 不回复 ACK,服务端为每个 SYN 都分配一个连接控制块(TCB),瞬间填满内存。SYN Cookie 的思路是:把连接信息编码到 TCP 序列号里,不分配任何内存,只有当收到合法的 ACK 时才真正分配资源。
正常三次握手分配的资源:
每个 SYN_RECV 连接 ≈ 2KB (tcp_sock + sk_buff + 时间戳等)
SYN Flood (10万 pps):
→ 每秒消耗 200MB 内存
→ 数秒内 OOM
SYN Cookie 开启后:
收到 SYN: 计算 Cookie → 填入 Seq 字段 → 回复 SYN+ACK → 不分配内存!
收到 ACK: 验证 Cookie → 合法才分配内存
攻击者的 SYN: Cookie 被回填到 Seq,但攻击者不会发 ACK
→ 零内存消耗!Cookie 的编码公式(Linux 内核实现):
c
// Linux net/ipv4/syncookies.c 简化
static __u32 cookie_hash(__be32 saddr, __be32 daddr,
__be16 sport, __be16 dport,
__u32 count, int data)
{
// 输入: 四元组 + 时间戳计数器 + MSS 编码值
// 输出: 24 位 Cookie
// round 1: SHA1(源IP, 目标IP, 源端口, 目标端口, 计数器, 密钥1)
// round 2: SHA1(源IP, 目标IP, 源端口, 目标端口, 计数器, 密钥2)
// 取低 24 位作为 Cookie
// 计数器每分钟递增一次,防止重放攻击
}
// 最终 SYN Cookie 的 ISN (初始序列号) 构成为:
// ┌───┬────────────────────┬──────────────────────────┐
// │ 3 │ 5 │ 24 │
// │MSS│ 时间戳 (mod 32) │ Cookie (SHA1 low) │
// └───┴────────────────────┴──────────────────────────┘
// 高位 低位
//
// MSS 编码 (3 bit): 编码客户端通告的 MSS 值
// 时间戳 (5 bit): 当前时间戳 mod 32,用于防止重放
// Cookie (24 bit): SHA1 哈希的低 24 位mermaid
flowchart TB
subgraph Encode["编码 — 服务端收到 SYN"]
E1["提取: (saddr, daddr, sport, dport, MSS)"] --> E2["SHA1(saddr, daddr, sport, dport,<br/>timestamp, secret_key_1)"]
E2 --> E3["取低 24 位作为 Cookie"]
E3 --> E4["组装 ISN:<br/>[MSS_enc:3bit][t mod 32:5bit][Cookie:24bit]"]
E4 --> E5["SYN+ACK, Seq = ISN"]
end
subgraph Decode["解码 — 服务端收到 ACK"]
D1["提取 ACK number = ISN+1"] --> D2["反推 ISN = Ack-1"]
D2 --> D3["取出 Cookie:24bit"]
D2 --> D4["取出 MSS_enc:3bit → MSS"]
D3 --> D5["重新计算 SHA1(同样的输入)"]
D5 --> D6{"Cookie == 重新算出的值?"}
D6 -->|"✅ 匹配"| D7["合法客户端 → 分配 TCB<br/>→ 还原 MSS → 建立连接"]
D6 -->|"❌ 不匹配"| D8["伪造 ACK → 丢弃"]
end
E5 -.->|"客户端回复<br/>ACK=ISN+1"| D1
style E4 fill:#FF9800,color:#fff
style D7 fill:#4CAF50,color:#fff
style D8 fill:#F44336,color:#fff关键设计细节:
| 设计 | 原因 |
|---|---|
| 密钥秘密保存 | 只有服务端知道 secret_key,攻击者无法伪造 Cookie |
| 时间戳 mod 32 | Cookie 有效期最多 32 个时间单位;过期后即使 Cookie 算对了也拒绝 |
| MSS 编码 3 bit | 在 ISN 中编码 8 种常见 MSS 值(如 1460, 536, 1400 等),避免额外存储 |
| 四元组参与哈希 | 每个连接的 Cookie 唯一,无法跨连接复用 |
| 计数器每分钟递增 | 旧 Cookie 过期,防止重放攻击 |
SYN Cookie 的副作用
| 副作用 | 说明 |
|---|---|
| 不支持 TCP Options | 不分配 TCB → 无法协商 Window Scale、SACK、Timestamp → 性能下降 |
| MSS 受限 | 只有 3 bit 编码 MSS → 不能表达所有值(仅 8 种预设) |
| 序列号空间损失 | ISN 的低 24 位被 Cookie 占用 → 随机性降低 |
| 大窗口不可用 | 没有 Window Scale option → 窗口最大 64KB → 高带宽延迟积链路性能受限 |
| 连接恢复后仍有影响 | 通过 Cookie 建立的连接无法享受 Window Scale / SACK 的好处 |
工程实践:因此 SYN Cookie 是应急机制而非日常配置。正常情况下由半连接队列处理,只有队列满时才自动切换到 Cookie 模式。如果生产环境经常触发 SYN Cookie,说明需要增大 backlog 或增加抗 DDoS 措施,而非依赖 Cookie。
3. 四次挥手(Four-Way Wave)
mermaid
sequenceDiagram
participant A as 主动关闭方
participant B as 被动关闭方
Note over A: 应用调用 close()
A->>B: FIN=1, Seq=u<br/>状态: FIN_WAIT_1
Note over B: 收到 FIN → CLOSE_WAIT
B->>A: ACK=1, Seq=v, Ack=u+1<br/>状态: CLOSE_WAIT
Note over A: 收到 ACK → FIN_WAIT_2
Note over B: 应用调用 close()
B->>A: FIN=1, ACK=1, Seq=w, Ack=u+1<br/>状态: LAST_ACK
Note over A: 收到 FIN → TIME_WAIT
A->>B: ACK=1, Seq=u+1, Ack=w+1<br/>进入 TIME_WAIT (2MSL)
Note over B: 收到 ACK → CLOSED
Note over A: 等待 2MSL 后 → CLOSED3.1 为什么是四次而不是三次?
- TCP 是全双工通信,每个方向都需要独立关闭
- FIN 只表示"我不再发数据了",不代表"我不收数据了"
- 被动方收到 FIN 后,可能还有数据要发 → ACK 和 FIN 分开
3.2 TIME_WAIT — 2MSL 等待
2MSL (Maximum Segment Lifetime) ≈ 2 × 60s = 120s(RFC 793),Linux 默认 60s。
两个原因:
| 原因 | 说明 |
|---|---|
| 1. 确保最后一个 ACK 能到达 | 如果最后 ACK 丢失,被动方会重传 FIN,需要 TIME_WAIT 来重发 ACK |
| 2. 消化网络中残留的数据包 | 2MSL 内,该连接四元组产生的所有包都会在网络中消失,不会干扰新连接 |
bash
# 查看 TIME_WAIT 数量
netstat -an | grep TIME_WAIT | wc -l
# 按端口分组统计
ss -tan state time-wait | awk '{print $4}' | awk -F: '{print $NF}' | sort | uniq -c | sort -rn为什么主动关闭方进入 TIME_WAIT?
主动关闭方发送最后一个 ACK → 这个 ACK 可能丢失
如果主动关闭方直接 CLOSED:
被动方超时重传 FIN → 主动方以 RST 回应 → 被动方以为连接异常断开
TIME_WAIT 确保能重发 ACK,完成优雅关闭TIME_WAIT 过多怎么办?
| 方案 | 适用场景 | 注意 |
|---|---|---|
SO_REUSEADDR | 服务端重启 | 允许绑定 TIME_WAIT 状态的端口 |
tcp_tw_reuse | 客户端主动连接 | 仅对客户端有效,对 LISTEN 端口无效 |
调整 tcp_tw_recycle | 已废弃 (Linux 4.12+) | NAT 环境下严重丢包 |
| 减少 MSL | 调 net.ipv4.tcp_fin_timeout | 这个参数主要影响 FIN_WAIT_2,不要误以为能安全缩短所有 TIME_WAIT |
| 长连接 / 连接池 | 客户端、代理、数据库访问 | 最优先,从源头减少短连接 |
| 扩大临时端口范围 | 高并发客户端连接固定后端 | 治标不治本,仍要配合连接复用 |
**TIME_WAIT 多一定有问题吗?**不一定。关键看它是否造成资源瓶颈:
| 观察点 | 说明 | 判断方式 |
|---|---|---|
| 临时端口是否耗尽 | 客户端频繁连接同一个 ip:port 时最危险 | cat /proc/sys/net/ipv4/ip_local_port_range + ss -tan state time-wait |
| NAT 网关是否耗尽 | 多个客户端经过同一个 NAT 出口访问同一目标,四元组空间被压缩 | 看 NAT 设备连接跟踪表、conntrack -S |
| 是否短连接过多 | QPS 高但没有连接复用,主动关闭方持续积累 TIME_WAIT | 按目标地址聚合 ss -tan state time-wait |
| 是否影响新建连接 | 端口耗尽时常见 cannot assign requested address | 应用日志 + dmesg + ss -s |
为什么代理层最容易遇到 TIME_WAIT?
text
client → proxy → upstream
如果 proxy 每个请求都重新连接 upstream:
proxy 是主动连接方,也是主动关闭方
→ proxy 本机积累大量 TIME_WAIT
→ 同一个 upstream 的临时端口复用受限
→ 新建 upstream 连接失败
正确方向:
proxy → upstream 使用连接池 / keepalive
client → proxy 也尽量复用长连接3.3 CLOSE_WAIT — 被忽略的危险信号
主动方发送 FIN → 被动方收到 → 被动方进入 CLOSE_WAIT
CLOSE_WAIT 的含义:对端已经关闭了,等着我(被动方)调用 close()
如果应用层没有调用 close(),连接一直处于 CLOSE_WAIT!CLOSE_WAIT 过多的根因:应用 bug,不是系统配置问题。
bash
# 排查 CLOSE_WAIT 堆积
ss -tan state close-wait | awk '{print $4}' | awk -F: '{print $NF}' | sort | uniq -c | sort -rn
# 查看 CLOSE_WAIT 连接对应的进程
ss -tanp state close-wait| 原因 | 排查 |
|---|---|
代码未调用 Close() | 检查请求超时/错误分支是否漏了 Close |
defer resp.Body.Close() 缺失 | HTTP Client 最常见问题 |
| 死锁/阻塞 | 业务逻辑卡住,连接来不及关闭 |
| 连接未移交线程池 / goroutine | 连接被遗忘 |
CLOSE_WAIT 的本质判断:内核已经帮应用回复了对端的 FIN,但应用还没有调用 close()。所以它通常不是靠调内核参数解决,而是要修应用层连接生命周期。
3.4 代理链路中的 CLOSE_WAIT:client → proxy → mysql
线上代理、网关、数据库中间件最怕的不是单个连接异常,而是异常连接持续占用 fd:
mermaid
sequenceDiagram
participant C as Client
participant P as Proxy
participant M as MySQL
C->>P: 1. 请求进入
P->>M: 2. 转发 SQL / 请求
Note over M: 下游慢查询、锁等待、线程池满<br/>或 MySQL 主动关闭连接
M->>P: 3. FIN
Note over P: 内核回 ACK<br/>P 侧连接进入 CLOSE_WAIT
P--xP: 4. 应用 goroutine/thread 卡住<br/>未及时 close 下游 fd
C->>P: 5. 新请求持续进入
Note over P: CLOSE_WAIT + ESTABLISHED 增长<br/>fd 被耗尽
P--xC: 6. accept/connect/open file 失败典型故障链路:
text
MySQL 慢 / 连接池满 / 网络半断
→ proxy 读写下游阻塞或错误分支漏 close
→ 下游 fd 停留在 CLOSE_WAIT
→ 每个连接仍占用一个 fd、socket buffer、业务对象
→ 进程达到 ulimit -n
→ 新连接 accept 失败,或者连接 MySQL 失败
→ client 侧看到 502/504/connection reset/timeout如何区分是哪一侧出问题?
| 位置 | 现象 | 含义 |
|---|---|---|
Proxy 到 MySQL 大量 CLOSE_WAIT | Local=proxy_ip:ephemeral Peer=mysql_ip:3306 | MySQL/下游已关闭,Proxy 没 close |
Client 到 Proxy 大量 CLOSE_WAIT | Local=proxy_ip:listen_port Peer=client_ip:ephemeral | Client 已关闭,Proxy 没 close 上游连接 |
Proxy 大量 FIN_WAIT2 | Proxy 发了 FIN,对端迟迟不发 FIN | 对端应用不关闭或网络异常 |
Proxy 大量 TIME_WAIT | Proxy 主动关闭很多短连接 | 短连接/连接池复用不足 |
排查闭环:
bash
# 1. 看状态分布
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
# 2. 找 CLOSE_WAIT 连接对应进程
ss -tanp state close-wait
# 3. 看进程 fd 是否逼近上限
pid=<pid>
ls /proc/$pid/fd | wc -l
cat /proc/$pid/limits | grep 'open files'
# 4. 按对端聚合,判断是 client 侧还是 mysql 侧
ss -tanp state close-wait | awk '{print $4, $5}' | sort | uniq -c | sort -rn | head
# 5. 追踪是否有 close 调用,或线程/goroutine 是否卡住
strace -f -e trace=close,shutdown,read,write -p $pid修复原则:
| 层面 | 修复动作 |
|---|---|
| 代码 | 所有成功/失败/超时/取消路径都必须 close(),Go HTTP 必须关闭并读完 resp.Body 才能复用连接 |
| 超时 | 给 connect/read/write/query 设置明确 timeout,避免无限阻塞 |
| 连接池 | 下游连接池设置 max open、max idle、max lifetime、idle timeout |
| 背压 | 下游慢时拒绝/排队/熔断,不能无限接收上游请求 |
| 观测 | 监控 fd 数、各 TCP 状态、连接池等待数、下游错误率和 P99 延迟 |
| 止血 | 临时重启只能释放 fd,根因仍是连接生命周期或下游阻塞 |
4. TCP 状态机完整图
mermaid
flowchart TD
CLOSED(["CLOSED"]) -->|"被动打开"| LISTEN(["LISTEN"])
CLOSED -->|"主动打开: SYN"| SYN_SENT(["SYN_SENT"])
LISTEN -->|"收 SYN, 发 SYN+ACK"| SYN_RCVD(["SYN_RCVD"])
SYN_SENT -->|"收 SYN+ACK, 发 ACK"| ESTAB(["ESTABLISHED"])
SYN_RCVD -->|"收 ACK"| ESTAB
ESTAB -->|"主动关闭: FIN"| FIN_WAIT1(["FIN_WAIT_1"])
FIN_WAIT1 -->|"收 ACK"| FIN_WAIT2(["FIN_WAIT_2"])
FIN_WAIT1 -->|"收 FIN+ACK"| TIME_WAIT(["TIME_WAIT"])
FIN_WAIT2 -->|"收 FIN, 发 ACK"| TIME_WAIT
ESTAB -->|"收 FIN, 发 ACK"| CLOSE_WAIT(["CLOSE_WAIT"])
CLOSE_WAIT -->|"应用 close: FIN"| LAST_ACK(["LAST_ACK"])
LAST_ACK -->|"收 ACK"| CLOSED
TIME_WAIT -->|"2MSL 超时"| CLOSED
style ESTAB fill:#4CAF50,color:#fff
style TIME_WAIT fill:#FF9800,color:#fff
style CLOSE_WAIT fill:#F44336,color:#fff
style LISTEN fill:#2196F3,color:#fff
style CLOSED fill:#607D8B,color:#fff5. tcpdump 实战
5.1 三次握手抓包
bash
# 抓取与 10.0.0.1:80 之间的 TCP 握手
tcpdump -i eth0 -nn 'host 10.0.0.1 and port 80' -c 20
# 只抓 SYN 包(握手包)
tcpdump -i eth0 'tcp[tcpflags] & (tcp-syn) != 0 and tcp[tcpflags] & (tcp-ack) == 0'
# 输出示例(三次握手)
# 14:23:01.123456 IP 192.168.1.5.54321 > 10.0.0.1.80: Flags [S], seq 123456789, win 65535
# 14:23:01.124000 IP 10.0.0.1.80 > 192.168.1.5.54321: Flags [S.], seq 987654321, ack 123456790, win 28960
# 14:23:01.124100 IP 192.168.1.5.54321 > 10.0.0.1.80: Flags [.], ack 1, win 655355.2 常用 tcpdump 过滤
bash
# 按标志位过滤
tcpdump 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0' # SYN 或 FIN
tcpdump 'tcp[tcpflags] & (tcp-rst) != 0' # RST 包
# 按状态过滤 (需要状态跟踪)
tcpdump 'tcp[tcpflags] == tcp-syn' # SYN
tcpdump 'tcp[tcpflags] == tcp-ack' # 纯 ACK
tcpdump 'tcp[tcpflags] == tcp-fin' # FIN
# 按连接状态查看
ss -tan state time-wait # TIME_WAIT 连接
ss -tan state close-wait # CLOSE_WAIT 连接
ss -tan state established # 已建立连接
ss -tan state syn-recv # SYN_RECV (可能被 SYN Flood)5.3 典型问题诊断
| 现象 | tcpdump 特征 | 诊断 |
|---|---|---|
| 连接超时 | 只有 SYN 没有 SYN+ACK | 防火墙/服务未监听 |
| 连接被拒 | SYN → RST | 端口未监听或 backlog 满 |
| 半开连接 | SYN → 无响应 | 防火墙丢包 |
| CLOSE_WAIT 堆积 | FIN → ACK → 很长时间没有 FIN | 应用未调用 close() |
| 大量重传 | 相同 Seq 多次出现 | 丢包/拥塞 |
bash
# 观察 RTT 和重传
tcpdump -i eth0 -nn -tttt 'host 10.0.0.1' | grep -E 'seq|ack'
# 计算三次握手 RTT(从第一行到第二行的时间差)6. TCP 核心机制
6.1 可靠传输 — 确认与重传
发送方 接收方
| |
|-- Seq=1000, Len=100 ------------->| (收到 1000-1099)
|<-------- Ack=1100 ----------------| (期望 1100)
| |
|-- Seq=1100, Len=100 --[丢失]---> |
| |
| RTO 超时 (~200ms) |
|-- Seq=1100, Len=100 ------------->| (重传)
|<-------- Ack=1200 ----------------|RTO (Retransmission Timeout) = SRTT + 4 × RTTVAR(基于 RTT 动态计算)。
6.2 流量控制 — 滑动窗口
接收方窗口 = 0 → 发送方停止发送
接收方窗口 = 65535 → 发送方最多发 65535 字节无需等待 ACK
零窗口探测 (Zero Window Probe):
发送方周期性发送 1 字节探测包,检测接收方窗口是否恢复滑动窗口的完整推导:
发送窗口(SND.WND)的实际大小由两个因素共同决定:
SND.WND = min(cwnd, rwnd)
其中:
cwnd (Congestion Window) = 发送方根据网络拥塞程度自控的发送上限
rwnd (Receiver Window) = 接收方通告的接收缓冲区剩余大小rwnd 通告机制的完整流程:
┌──────────────────────────────────────────────────────────┐
│ 接收方 │
│ ┌─────────────────────────┐ │
│ │ 接收缓冲区 (64KB-数MB) │ │
│ │ [已收未读][ 空闲空间 ] │ │
│ │ ↑ ↑ │ │
│ │ RCV.NXT RCV.WND │ │
│ └─────────────────────────┘ │
│ │
│ rwnd = 接收缓冲区总大小 - (RCV.NXT - 最后交付给应用的位置) │
│ │
│ 每发一个 ACK 就通告当前 rwnd │
│ rwnd = 0 时,发送方停止,但发送方会周期性发"零窗口探测报文" │
└──────────────────────────────────────────────────────────┘零窗口探测(Zero Window Probe):当发送方收到 rwnd=0 的通告后,启动"坚持定时器(Persist Timer)",周期性发送 1 字节的探测报文。如果接收方回复的 ACK 中 rwnd 仍然为 0,则重启定时器继续等待;如果 rwnd > 0,则可恢复发送。Linux 默认探测间隔从 5 秒起步,指数退避到最大 60 秒。
糊涂窗口综合征(Silly Window Syndrome):当接收方每次只从缓冲区取少量数据(如 1 字节),通告给发送方的 rwnd 始终很小,导致发送方每次只发很小的包,TCP 头部开销占比巨大(40 字节头 + 1 字节数据 = 97.5% 开销浪费)。
问题场景:
接收方每次读 1 字节 → rwnd 从 0 变到 1
发送方立即发 1 字节 → 接收方又满了 → rwnd=0
接收方再读 1 字节 → rwnd=1 → 发送方再发 1 字节 → ...
两个方向都是非正常的碎片化传输双重防御:
| 方向 | 防御机制 | 规则 |
|---|---|---|
| 接收方 | Clark 解法 | 不通告很小的窗口,等窗口 >= min(MSS, 缓冲区/2) 才通告 |
| 发送方 | Nagle 算法 | 在已发送数据未确认前,不发小包;等待攒满 MSS 或收到 ACK |
6.3 拥塞控制
| 算法 | 说明 |
|---|---|
| 慢启动 | cwnd=1 → 每收到 ACK cwnd++ → 指数增长 → 到 ssthresh 切换 |
| 拥塞避免 | cwnd += 1/cwnd → 线性增长 |
| 快速重传 | 收到 3 个重复 ACK 立即重传(不等 RTO) |
| 快速恢复 | 重传后 ssthresh = cwnd/2, cwnd = ssthresh + 3 |
| BBR | 基于带宽和 RTT 建模,不依赖丢包信号 |
慢启动为什么是指数增长?—— 完整推导:
慢启动规则: 每收到一个 ACK,cwnd = cwnd + 1
设当前 cwnd = W(单位:MSS),RTT 为一个往返时间
第 1 个 RTT:
发送方一口气发出 W 个包
接收方逐个回复 ACK,共 W 个 ACK
发送方每收到 1 个 ACK → cwnd++
收到 W 个 ACK → cwnd = W + W = 2W
因此: 每经过 1 个 RTT,cwnd 翻倍 → 指数增长:1→2→4→8→16→...
数学表达: cwnd(t) = 2^(t/RTT) × initial_cwnd
从 cwnd=1 到达 ssthresh=65535 需要的 RTT 数: log₂(65535) ≈ 16 个 RTT
在 100ms RTT 网络上 = 1.6 秒
在 10ms RTT 网络上 = 0.16 秒为什么到 ssthresh 后切成线性增长?—— AIMD 的稳定性原理:
AIMD (Additive Increase Multiplicative Decrease) 是拥塞控制的数学基础:
增: 每 RTT 线性加 1 (Additive Increase) → 缓慢探测可用带宽
减: 丢包时 cwnd = cwnd/2 (Multiplicative Decrease) → 快速退避
为什么这个组合是"公平且稳定"的?
如果所有流都遵循 AIMD,它们会自动收敛到公平共享带宽。
考虑两条流共享一条瓶颈链路:
cwnd₁ + cwnd₂ = B (链路容量)
AIMD 的迭代: (cwnd₁, cwnd₂) → 丢包 → (cwnd₁/2, cwnd₂/2)
→ 线性增 → (cwnd₁/2+k, cwnd₂/2+k) → ...
重复多次后:cwnd₁ ≈ cwnd₂ ≈ B/2 → 公平!
且整个过程不会发散——减半 + 线性增的组合像"阻尼振荡"一样收敛。
这是 Chiu-Jain 矢量图证明的经典结论:只有 AIMD 能同时满足收敛性和公平性。RTO 计算公式——Jacobson/Karn 算法推导:
RTO (Retransmission Timeout) 不能简单取固定值,必须根据网络动态计算:
Step 1 — 测量 RTT:
每发送一个带 TSopt 时间戳的包,收到 ACK 时计算:
SampleRTT = 当前时间 - 包中 TSval
Step 2 — 平滑 RTT (SRTT):
SRTT = (1 - α) × SRTT_old + α × SampleRTT
其中 α = 1/8 (RFC 6298)
等效递推: SRTT = 7/8 × SRTT_old + 1/8 × SampleRTT
含义: 给历史 RTT 7/8 权重,新采样 1/8 权重 → 平滑滤除短期抖动
Step 3 — RTT 变化量 (RTTVAR):
RTTVAR = (1 - β) × RTTVAR_old + β × |SRTT - SampleRTT|
其中 β = 1/4 (RFC 6298)
含义: 追踪 RTT 的波动幅度(类似标准差),但不是方差而是平均绝对偏差
Step 4 — 最终 RTO:
RTO = SRTT + 4 × RTTVAR
为什么是 4 倍?
RTT 近似服从正态分布,SRTT ± 4×RTTVAR 覆盖了约 99.99% 的样本
→ 几乎不会因为正常 RTT 抖动而误判超时
→ 如果 4×RTTVAR 后还没收到 ACK,大概率是真的丢包了
Karn 算法——重传包的 RTT 采样问题:
问题: 发送一个包,超时重传,收到了 ACK
→ 这个 ACK 对应的是原始包还是重传包?
→ 如果误把它当作原始包的 ACK,RTT 计算会偏小
→ 如果误把它当作重传包的 ACK,RTT 计算会偏大
Karn 解法: 超时重传的包不参与 RTT 采样,等收到后续正常包的 ACK 再更新 SRTT。
(这是"宁可慢一点,也别算错"的保守策略)
注: 启用 Timestamps Option (TSopt) 后,可以在 ACK 里精确区分原始包和重传包,
所以 Linux 内核在 4.x+ 后已不完全依赖 Karn 算法,而是用 TSopt 精确采样。6.4 TCP Options — 头部可扩展字段
Options 字段最大 40 字节,承载了现代 TCP 的大部分性能增强功能。
Option 通用格式:
+--------+--------+-------------------+
| Kind | Length | Option Data |
| 1B | 1B | (Length-2)B |
+--------+--------+-------------------+MSS (Maximum Segment Size) — Kind 2
MSS = MTU - IP头(20) - TCP头(20) = 1500 - 40 = 1460
作用: 告诉对方"我能接收的最大 TCP 段是 n 字节"
避免: IP 分片 → 一个分片丢失,整个 IP 包作废
MSS 协商: 双方各发自己的 MSS,取 min(对端MSS, 本地MTU-40)SACK (Selective ACK) — Kind 4/5
传统 TCP: 丢包后必须从丢包点开始重传所有后续数据
SACK: 告诉发送方"我收到了 1-1000 和 2000-3000,只缺 1001-1999"
需要双方在 SYN 中协商 sackOK
大大减少不必要的重传,在高延迟高带宽网络(长肥管道)中效果显著Window Scale — Kind 3
问题: TCP Window Size 字段只有 16 位 → 最大 65535 字节
在 100ms RTT 下: 最大吞吐 = 65535 / 0.1 = 5.2 Mbps ← 根本不够!
Window Scale (wscale): 将窗口值左移 n 位
wscale=7 → 窗口范围: 65535 × 128 = 8.3 MB
100ms RTT 下吞吐: 8.3MB / 0.1 = 664 Mbps ✅
双方在 SYN 中协商各自的 wscaleTimestamps — Kind 8
两个 4 字节时间戳: TSval (发送时间) + TSecr (回显对方的时间戳)
用途:
1. RTTM (RTT Measurement): 精确计算 RTT
2. PAWS (Protection Against Wrapped Sequences):
高速网络(10Gbps+)中,32位序列号可能在短时间内回绕
→ Timestamps 提供额外的时间维度来区分新旧包TCP Fast Open (TFO) — Kind 34
mermaid
sequenceDiagram
participant C as 客户端
participant S as 服务端
Note over C,S: === 首次连接 (正常三次握手) ===
C->>S: SYN + TFO Cookie Request
S->>C: SYN-ACK + TFO Cookie (加密生成)
C->>S: ACK
C->>S: HTTP GET → 响应
Note over C,S: === 后续连接 (TFO) ===
C->>S: SYN + TFO Cookie + HTTP GET (数据随SYN发出!)
S->>C: SYN-ACK + HTTP Response
Note over C,S: 节省 1 个 RTT!bash
# 开启 TFO (Linux 4.11+)
sysctl -w net.ipv4.tcp_fastopen=3 # 1=客户端, 2=服务端, 3=两端6.5 拥塞控制深度
mermaid
flowchart TD
START["开始: cwnd=1 MSS<br/>ssthresh=65535"] --> SS["慢启动 Slow Start<br/>每收到 ACK: cwnd++<br/>= 指数增长: 1→2→4→8→16→..."]
SS --> CHECK1{"cwnd >= ssthresh?"}
CHECK1 -->|"否"| SS
CHECK1 -->|"是"| CA["拥塞避免 Congestion Avoidance<br/>每 RTT: cwnd += 1<br/>= 线性增长"]
CA --> LOSS{"丢包?"}
LOSS -->|"RTO 超时"| TIMEOUT["超时重传<br/>ssthresh = cwnd/2<br/>cwnd = 1 → 慢启动"]
LOSS -->|"3 个重复 ACK"| FR["快速重传 Fast Retransmit<br/>不等 RTO, 立即重传"]
FR --> RECOV["快速恢复 Fast Recovery<br/>ssthresh = cwnd/2<br/>cwnd = ssthresh + 3"]
RECOV --> CA
TIMEOUT --> SScwnd 变化曲线图(TCP Reno):
cwnd
^
| /\ /\ ← 指数增长 (慢启动)
| / \ / \
| / \----______/ \----_ ← 线性增长 (拥塞避免)
| / \ \
|/ \ \____ ← 丢包后减半 (AIMD)
+--------------------------------------> time
RTO超时 3 dup ACK主流拥塞控制算法对比:
| 算法 | 策略 | 适用场景 | 特点 |
|---|---|---|---|
| Reno | 丢包减半 (AIMD) | 低带宽 | 经典,但丢包后恢复慢 |
| CUBIC | 三次函数探测 | 高带宽通用 | Linux 默认,比 Reno 更激进 |
| BBR | 带宽+ RTT 建模 | 有一定丢包的网络 | Google 设计,不依赖丢包信号,微丢包不影响吞吐 |
| Vegas | RTT 变化检测 | 稳定网络 | 在拥塞发生前降低速率,但太保守 |
bash
# 查看/设置拥塞控制算法
sysctl net.ipv4.tcp_congestion_control
echo "bbr" | sudo tee /proc/sys/net/ipv4/tcp_congestion_control
# 查看可用算法
sysctl net.ipv4.tcp_available_congestion_control6.6 Nagle 算法 vs TCP_NODELAY
Nagle 算法(默认开启):
规则: 如果还有未确认的小包 → 等,攒满 MSS 或收到 ACK 再发
问题: 搭配 Delayed ACK 时,两个机制相互等待 → 最多 200ms 延迟!
Nagle 在等 ACK,Delayed ACK 在等数据 → 死锁
TCP_NODELAY (禁用 Nagle):
有小包就立刻发,不等待
何时必须关闭 Nagle (开启 NODELAY):
- SSH 交互(按键回显不能等)
- 在线游戏(延迟敏感)
- HTTP request(小请求不能等)
- Redis/RPC(小命令小响应)
何时保留 Nagle:
- 大文件传输(telnet 风格,一次发很多,可以等)
- 吞吐优先于延迟的场景go
// Go 中设置 TCP_NODELAY
conn, _ := net.Dial("tcp", "host:port")
tcpConn := conn.(*net.TCPConn)
tcpConn.SetNoDelay(true) // 禁用 Nagle6.7 TCP Keep-Alive
用途: 检测对端是否还活着(应用层无数据时)
默认: 空闲 7200 秒(2小时) → 每 75 秒探测一次 → 9 次无响应 → 断开
保活报文: 一个字节为 0 的数据, Seq=下一个应发送的字节-1
对方回复: ACK (ack=Seq+1)go
// Go 中设置 Keep-Alive
tcpConn.SetKeepAlive(true)
tcpConn.SetKeepAlivePeriod(60 * time.Second)Keep-Alive 是传输层机制,不是应用层心跳。应用层心跳(如 WebSocket Ping/Pong)能更精确地探测业务层的健康状态。
6.8 半连接队列与全连接队列
mermaid
flowchart LR
C["客户端 SYN"] --> SYNRECV["半连接队列<br/>(SYN Queue)<br/>SYN_RECV 状态的连接"]
SYNRECV -->|"收到 ACK"| ACCEPT["全连接队列<br/>(Accept Queue)<br/>ESTABLISHED 等 accept()"]
ACCEPT -->|"应用 accept()"| APP["应用处理"]
SYNRECV -.- N1["net.ipv4.tcp_max_syn_backlog<br/>控制此队列大小"]
ACCEPT -.- N2["net.core.somaxconn<br/>+ listen(fd, backlog)<br/>取两者最小值"]队列溢出现象:
| 现象 | 原因 | 排查 |
|---|---|---|
| SYN 被丢弃 | 半连接队列满 | ss -s 看 SYN-RECV 数量 |
| ESTABLISHED 被丢弃 | 全连接队列满 | ss -lnt, Recv-Q > Send-Q |
| 客户端 SYN 无响应 | SYN Cookie 关闭 + 队列满 | 开启 tcp_syncookies |
bash
# 查看全连接队列溢出
netstat -s | grep -i "listen queue"
# 查看当前 backlog
ss -lnt
# Send-Q: 当前 backlog (listen 时设置)
# Recv-Q: 当前堆积的连接数 (Recv-Q > 0 → 队列满了!)
# 增大队列
sysctl -w net.core.somaxconn=40966.9 TIME_WAIT 排查实战
TIME_WAIT 是 TCP 状态机中最常见的线上问题之一——高并发服务看到大量 TIME_WAIT 连接,担心"端口耗尽":
bash
# 查看当前 TIME_WAIT 数量
ss -tan state time-wait | wc -l
# 按数量排序
netstat -an | grep TIME_WAIT | awk '{print $5}' | sort | uniq -c | sort -rn | head -20TIME_WAIT 的真相:
TIME_WAIT 存在的原因(RFC 793):
1. 确保最后一个 ACK 能到达对端(对端可能重传 FIN)
2. 让旧连接的"迷路"包在网络上消失(2MSL = 2×最大报文生存时间)
TIME_WAIT 持续: 2MSL ≈ 60s (Linux)
到底多少才算多?
单客户端端口范围: 32768-60999 ≈ 28231 个
每个连接占用: (src_ip, src_port, dst_ip, dst_port)
如果服务端连接到不同目标 → 四元组不同 → 不冲突!
只有连接"同一个目标"时才会端口耗尽
真正危险的场景:
- Nginx 反向代理到"固定的后端服务"
- 代理的 QPS > 28231/60s ≈ 470 QPS (复用连接的情况下)降低 TIME_WAIT 影响的措施:
| 方案 | 配置 | 风险 |
|---|---|---|
| 连接复用 (Keep-Alive) | Nginx: proxy_set_header Connection "" | 最佳方案!从源头减少连接 |
| 内核快速回收 | net.ipv4.tcp_tw_reuse=1 | 仅对客户端有效,安全 |
| 减小 TIME_WAIT 时间 | 不推荐修改内核 | 可能违反 TCP 规范 |
| 扩大端口范围 | net.ipv4.ip_local_port_range=1024-65535 | 治标不治本 |
6.10 TCP 内核参数调优速查表
| 参数 | 默认值 | 建议 | 场景 |
|---|---|---|---|
net.core.somaxconn | 128 | 4096+ | 高并发服务(Nginx/Go) |
net.ipv4.tcp_max_syn_backlog | 128 | 8192 | SYN Flood 防护 |
net.ipv4.tcp_syncookies | 1 | 1 | 半连接队列满时启用 |
net.ipv4.tcp_tw_reuse | 0 | 1 | 客户端 TIME_WAIT 复用 |
net.ipv4.tcp_fin_timeout | 60 | 30 | 加速释放 FIN_WAIT_2 |
net.ipv4.tcp_keepalive_time | 7200 | 600 | 提前检测死连接 |
net.ipv4.tcp_fastopen | 1 | 3 | TFO (客户端+服务端) |
net.ipv4.tcp_slow_start_after_idle | 1 | 0 | 长连接避免重复慢启动 |
| 选项 | 作用 | 典型场景 |
|---|---|---|
SO_REUSEADDR | 允许绑定 TIME_WAIT 的端口 | 服务端重启(必备) |
SO_REUSEPORT | 多个进程绑定同一端口(内核负载均衡) | Nginx worker, Go net.Listen |
SO_LINGER | 控制 close() 时是否等待未发送数据 | l_onoff=1, l_linger=0 → 直接 RST |
SO_KEEPALIVE | 开启 TCP Keep-Alive | 长连接检测 |
TCP_NODELAY | 禁用 Nagle 算法 | 低延迟交互 |
TCP_QUICKACK | 禁用 Delayed ACK | 减少延迟 |
TCP_DEFER_ACCEPT | 等第一个数据包到达再唤醒 accept | 减少无数据连接 |
6.11 BBR 拥塞控制 — 现代网络的核心算法
为什么需要 BBR?
传统拥塞控制(Reno/CUBIC)的核心假设是:丢包 = 拥塞。但在现代网络中,这个假设越来越不成立:
| 场景 | 传统算法的问题 | BBR 的优势 |
|---|---|---|
| 无线网络(WiFi/4G/5G) | 随机丢包被误判为拥塞 → 降速 | 不依赖丢包信号,不误降 |
| 长肥管道(高带宽高延迟) | 慢启动太慢,拥塞避免太保守 | 快速探测到真实带宽 |
| 浅缓冲交换机 | 缓冲区小 → 容易丢包 → 频繁降速 | 目标是不填满缓冲区 |
| 与 CUBIC 共存 | CUBIC 填满缓冲区 → BBR 被挤压 | BBR v2 改善了公平性 |
BBR 的核心模型
BBR 不看丢包,而是持续测量两个关键参数:
text
BtlBw (Bottleneck Bandwidth) = 瓶颈链路的最大带宽
RTprop (Round-Trip Propagation time) = 最小 RTT(无排队时的纯传播延迟)
最优发送速率 = BtlBw
最优 inflight = BtlBw × RTprop (BDP = 带宽延迟积)BtlBw 如何测量?—— delivery rate 与 max filter:
BBR 在每个 ACK 到达时计算"交付速率" (delivery rate):
delivery_rate = Δdelivered / Δtime
Δdelivered: 自上次采样以来成功交付的字节数
Δtime: 自上次采样以来的时间间隔
为什么用 max filter 而非平均值?
物理链路的瓶颈带宽在短时间内是恒定的(如 100Mbps 或 1Gbps)。
当 inflight < BDP 时,网络未充分利用,delivery_rate < BtlBw。
当 inflight ≥ BDP 时,瓶颈链路满载,delivery_rate ≈ BtlBw。
因此:BtlBw = max(delivery_rate) over 最近的 10 个 RTT 窗口
max filter 的作用:
- 在 inflight 不足时,delivery_rate 偏低 → max 不会被错误拉低
- 随着 inflight 增加,delivery_rate 趋近真实 BtlBw → max 会跟踪到真实值
- 窗口长度 10 RTT 确保陈旧数据过期(如网络切换后旧 max 会过期)RTprop 如何测量?—— min filter:
RTprop 是"无排队时的纯传播延迟",等于物理延迟(光速限制 + 交换延迟)。
测量: RTprop = min(RTT) over 最近的 10 秒窗口
为什么用 min filter 而非平均值?
当 inflight ≈ BDP 时,缓冲区为空,RTT = RTprop(最小值)
当 inflight > BDP 时,缓冲区开始排队,RTT = RTprop + queue_delay
因此: 观测到的最小 RTT 就是 RTprop(当刚好没有排队时)
min filter 的作用:
- 正常运行时,inflight 围绕 BDP 波动,sum 有一半时间在 BDP 以下
→ 没有排队 → RTT 触达 RTprop → min filter 能捕捉到
- 10 秒窗口确保即使路由变更导致 RTprop 变化也能被感知
注意: ProbeRTT 状态会刻意降低 inflight 到 4 个包,持续 200ms
→ 强制清空所有队列 → 确保测到最新的 RTpropmermaid
flowchart LR
subgraph "BBR 的目标点"
A["发送速率 = BtlBw"] --> B["inflight = BDP"]
B --> C["缓冲区占用 ≈ 0"]
C --> D["延迟最低 + 吞吐最高"]
endBBR vs CUBIC 的根本差异:
CUBIC: 不断增加发送量 → 填满缓冲区 → 丢包 → 降速 → 再增加
= "填满再退" (buffer-filling)
BBR: 测量带宽和延迟 → 发送量 = BDP → 不填缓冲区 → 不丢包
= "刚好够用" (model-based)BBR 的四个状态
mermaid
stateDiagram-v2
[*] --> Startup: 连接建立
Startup --> Drain: BtlBw 不再增长
Drain --> ProbeBW: inflight 降到 BDP
ProbeBW --> ProbeBW: 大部分时间在这里
ProbeBW --> ProbeRTT: 200ms 未更新 RTprop
ProbeRTT --> ProbeBW: 测量完成| 状态 | 目的 | 行为 | 持续时间 |
|---|---|---|---|
| Startup | 快速探测带宽 | 指数增长(类似慢启动),pacing_gain=2.89 | 直到 BtlBw 连续 3 轮不增长 |
| Drain | 排空启动阶段积累的队列 | pacing_gain=0.35(发送速率降低) | 直到 inflight ≤ BDP |
| ProbeBW | 稳态运行 + 周期性探测带宽变化 | 8 轮周期:[1.25, 0.75, 1, 1, 1, 1, 1, 1] | 绝大部分时间 |
| ProbeRTT | 测量最新的 RTprop | 将 inflight 降到 4 个包,持续 200ms | 每 10 秒最多一次 |
BBR vs CUBIC 真实网络数值对比
Google 内部测试数据(2016 论文):
| 指标 | CUBIC | BBR | 提升 |
|---|---|---|---|
| 吞吐量(跨洲际链路) | ~100 Mbps | ~2 Gbps | 20× |
| RTT(有排队时) | ~100ms | ~40ms | 60% 降低 |
| 丢包率对吞吐影响 | 1% 丢包 → 吞吐降 30% | 1% 丢包 → 吞吐几乎不变 | |
| YouTube 缓冲率 | 基线 | 降低 4% | |
| Google.com 延迟 | 基线 | P50 降低 8%, P95 降低 53% |
不同网络条件下的对比:
| 网络条件 | CUBIC 表现 | BBR 表现 |
|---|---|---|
| 低延迟低丢包(数据中心) | 优秀 | 优秀(差异不大) |
| 高延迟低丢包(跨洲际) | 慢启动太慢,利用率低 | 快速达到满带宽 |
| 低延迟高丢包(WiFi) | 频繁降速,吞吐抖动 | 稳定,不受随机丢包影响 |
| 高延迟高丢包(卫星/移动) | 几乎不可用 | 仍能维持合理吞吐 |
| 浅缓冲区交换机 | 频繁丢包降速 | 不填满缓冲区,延迟低 |
BBR 的已知问题
| 问题 | 说明 | BBR v2 是否解决 |
|---|---|---|
| 与 CUBIC 不公平 | BBR 在共享瓶颈时可能抢占 CUBIC 的带宽 | ✅ 部分解决 |
| 高重传率 | Startup 阶段过于激进,可能导致大量丢包 | ✅ 改善 |
| ProbeRTT 吞吐下降 | 每 10 秒降到 4 个包测 RTT,短暂吞吐骤降 | ✅ 改善 |
| 多流竞争 | 多个 BBR 流共享瓶颈时可能振荡 | 部分改善 |
| ACK 聚合 | 移动网络 ACK 延迟/聚合导致带宽估计偏高 | ✅ 改善 |
BBR v2 的改进
text
BBR v1 (2016):
- 不看丢包,纯模型驱动
- 问题: 太激进,与 CUBIC 不公平
BBR v2 (2019-2022, 仍在演进):
- 引入 "loss_round" 概念: 如果持续丢包,降低 inflight
- 引入 "ecn_round": 支持 ECN (Explicit Congestion Notification)
- Startup 更保守: 检测到丢包时提前退出
- ProbeRTT 改进: 不再骤降到 4 个包
- 公平性: 与 CUBIC 共存时更公平什么场景该用 BBR?
| 场景 | 推荐 | 原因 |
|---|---|---|
| CDN / 边缘节点 | ✅ 强烈推荐 | 跨地域高延迟,BBR 优势明显 |
| 跨机房通信 | ✅ 推荐 | 长肥管道,CUBIC 利用率低 |
| 移动端服务 | ✅ 推荐 | 无线网络随机丢包多 |
| 数据中心内部 | ⚠️ 谨慎 | 延迟低、丢包少,CUBIC 够用;BBR 可能不公平 |
| 与大量 CUBIC 流共存 | ⚠️ 谨慎 | BBR v1 可能抢带宽,建议用 v2 |
| 实时音视频 | ✅ 推荐 | 延迟敏感,BBR 不填缓冲区 |
配置与验证
bash
# 查看当前拥塞控制算法
sysctl net.ipv4.tcp_congestion_control
# 输出: net.ipv4.tcp_congestion_control = cubic
# 切换到 BBR
modprobe tcp_bbr
echo "bbr" > /proc/sys/net/ipv4/tcp_congestion_control
# 或永久生效:
echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf
sysctl -p
# 验证
sysctl net.ipv4.tcp_congestion_control
# 输出: net.ipv4.tcp_congestion_control = bbr
# 注意: BBR 需要 fq (Fair Queuing) 作为 qdisc
# 因为 BBR 依赖 pacing(匀速发送),fq 提供精确的包间隔控制
tc qdisc show dev eth0
# 应该看到: qdisc fq ...BBR 需要 fq 的原因:
text
传统拥塞控制: 突发发送一窗口的数据 → 交换机缓冲区瞬间被填满
BBR + fq: 将一窗口的数据均匀分散在一个 RTT 内发送 (pacing)
→ 缓冲区占用平滑 → 延迟更低一个实战案例:跨地域服务切换 BBR 的效果
text
场景: 北京 → 新加坡,RTT ~80ms,带宽 1Gbps,偶发 0.1% 丢包
CUBIC:
- 慢启动需要 ~15 个 RTT 才能达到满带宽 (1.2s)
- 0.1% 丢包时吞吐降到 ~600Mbps
- 平均 RTT: ~120ms (缓冲区被填满)
BBR:
- 3 个 RTT 内达到满带宽 (~240ms)
- 0.1% 丢包时吞吐仍维持 ~950Mbps
- 平均 RTT: ~85ms (接近 RTprop)7. 工程实践:TCP 真正影响服务稳定性的,是排队、重传和尾延迟
7.0 一眼看懂:TCP 慢通常慢在哪
mermaid
flowchart LR
A["DNS / connect"] --> B["SYN 队列 / accept 队列"]
B --> C["连接收发"]
C --> D["重传 / 拥塞 / 窗口收缩"]
D --> E["P99 / timeout / 雪崩放大"]| 现象 | 优先怀疑 | 常见误判 |
|---|---|---|
| connect timeout | 队列满、丢包、对端忙 | 只怪 DNS 或“网络差” |
| RT 抖动大 | 重传、窗口收缩、排队 | 只看平均 RT |
| TIME_WAIT 多 | 短连接、复用差 | 先调内核参数 |
| 某实例特别慢 | 单机 accept 慢、NIC 异常、热点连接 | 只怪机器配置低 |
7.1 一次连接变慢,通常不是慢在“TCP 协议”,而是慢在链路某处开始排队
客户端一次请求从 TCP 视角看,通常至少经历:
- DNS 拿到目标 IP
connect()发起三次握手- 服务端进入 SYN 队列 / accept 队列
- 建连成功后进入请求收发
- 任一方向如果消费变慢,就开始出现窗口收缩、重传、排队、超时
所以线上看到“建连慢”“请求 RT 高”,不应只盯着 TCP 头部或状态机,而要看 哪一段开始积压。
7.2 三次握手慢,常见不是协议复杂,而是队列、丢包或对端压力
典型原因包括:
- SYN 包被丢弃或延迟
- 服务端半连接队列满
- 服务端全连接队列满,
accept()不及时 - 机器过载导致握手响应变慢
- 中间网络抖动导致重传
这类问题用户通常只会看到:
- connect timeout
- 建连偶发几十毫秒到几秒抖动
- 某些实例特别慢
7.3 backlog 问题的本质,是应用消费连接的速度跟不上进入速度
mermaid
flowchart LR
A["客户端 SYN / ACK 持续进入"] --> B["半连接队列 / 全连接队列堆积"]
B --> C["应用 accept 不及时"]
C --> D["新连接建立变慢或失败"]
D --> E["上游看到 connect timeout / connection refused"]所以 backlog 不只是一个内核参数问题,它经常说明:
- 应用线程/Goroutine 消费连接不够快
- 事件循环被阻塞
- 下游慢导致上层请求释放不及时
- 短连接太多,建连压力被放大
7.4 重传带来的伤害,常常首先体现在 P99 / P999,而不是平均值
TCP 一旦进入重传、快速重传、RTO,最先被拉高的通常是尾延迟:
- 少量丢包就能显著拉高单次请求耗时
- 同一连接上的后续请求可能一起受影响
- HTTP/2 / gRPC 场景中,一个连接上的多条流会一起被拖慢
所以“平均 RT 还行”并不代表 TCP 层没有问题。
7.5 拥塞控制不是纯理论,它决定了高峰期系统是平稳降速还是剧烈抖动
真实系统里,当链路接近瓶颈时:
- 有些算法会更激进抢带宽
- 有些算法会更保守地退让
- 丢包、RTT、带宽估计误差都会改变吞吐与时延
这意味着你在高峰期看到的现象可能是:
- 吞吐还在,但 RT 上升很明显
- CPU 不高,但连接上的等待时间越来越长
- 某些跨地域、跨机房链路抖动比本地请求严重得多
7.6 小包与交互式请求里,Nagle / Delayed ACK 影响的不是吞吐,而是体感延迟
对于:
- RPC 小报文
- Redis 命令
- HTTP 小请求
- 交互式长连接
如果小包发送策略不合理,用户感知往往是:
- 请求偶发多出几十到几百毫秒
- P50 看不明显,P95/P99 有尖刺
- 网络并没断,但就是“肉眼感觉慢”
7.7 一个典型故障链:client -> proxy -> app -> db
mermaid
sequenceDiagram
participant C as Client
participant P as Proxy
participant A as App
participant D as DB
C->>P: connect + request
P->>A: 转发请求
A->>D: 查询数据库
Note over D: 慢 SQL / 锁等待 / I/O 抖动
D-->>A: 返回变慢
Note over A: 响应写回延迟变长
A-->>P: 返回变慢
Note over P: 连接占用变久 / accept速度下降
P-->>C: connect抖动、RT升高、超时7.8 常见误判
| 现象 | 容易误判为 | 实际可能是 |
|---|---|---|
| connect 慢 | DNS 慢 | backlog 满、SYN 丢包、对端压力大 |
| RT 高 | 应用代码慢 | TCP 重传、窗口收缩、队列积压 |
| TIME_WAIT 多 | 内核参数不行 | 短连接太多、连接复用不足 |
| 某实例慢 | 机器性能差 | 丢包、队列积压、热点连接 |
| 平均 RT 正常 | 网络没问题 | 尾延迟已被重传和拥塞放大 |
7.9 排障顺序
| 现象 | 优先看什么 |
|---|---|
| connect timeout | SYN/ACK 抓包、SYN-RECV、listen 队列 |
| RT 抖动 | 重传、RTT、丢包、Recv-Q/Send-Q |
| 大量短连接 | keep-alive、连接池、TIME_WAIT 分布 |
| 某实例慢 | NIC 错误、丢包、单机 accept/负载 |
| 高峰期抖动 | 拥塞控制、链路带宽、下游慢导致连接占用时间变长 |
7.10 一个实战原则
text
先判断连接是慢在"建连""排队""重传"还是"收发阶段",
再决定调 backlog、调连接池、改短连接策略,还是排查下游慢链路;
不要把所有问题都归因于 TCP 参数。8. TIME_WAIT 与 CLOSE_WAIT 深度实战专题
8.1 完整故障链:client → proxy → mysql 通路的 CLOSE_WAIT 堆积
这是一个线上极其高频的故障模式:
mermaid
sequenceDiagram
participant C as Client
participant P as Proxy/Nginx
participant DB as MySQL
C->>P: HTTP 请求
P->>DB: TCP 连接 + SQL 查询
Note over DB: 慢 SQL / 锁等待 / 磁盘抖动
DB-->>P: 长时间无响应
C->>C: 客户端超时, 主动 FIN
C->>P: FIN → 连接关闭
Note over P: Proxy 还在等 DB 返回<br/>没有 close fd → CLOSE_WAIT!
DB-->>P: 终于返回结果
Note over P: worker 释放, 但已经晚了
P->>P: 新请求继续进入, CLOSE_WAIT 累积
Note over P: fd 耗尽 → 无法 accept → 502/504关键误区:
text
❌ "CLOSE_WAIT 说明对端没有及时关闭连接"
✅ "CLOSE_WAIT 说明本端应用没有调用 close() 释放 fd"CLOSE_WAIT 是被关闭方的状态——对端已经发送 FIN 了,本端应用还没有 close。这说明:
- 不是网络问题
- 不是内核问题
- 是应用层 bug——持有连接但忘记关闭
8.2 两个状态的本质区别
| 状态 | 触发方 | 含义 | 持续时间 | 典型根因 |
|---|---|---|---|---|
| TIME_WAIT | 主动关闭方 | 等待 2MSL 确保 FIN ACK 被对端收到 | 2MSL(Linux 默认 60s) | 短连接过多 |
| CLOSE_WAIT | 被动关闭方 | 对端已 FIN,但本端应用还没 close | 无限(直到应用 close 或进程退出) | 应用 bug |
mermaid
stateDiagram-v2
direction LR
ESTABLISHED --> FIN_WAIT_1: 主动 close()
FIN_WAIT_1 --> FIN_WAIT_2: 收到 ACK
FIN_WAIT_2 --> TIME_WAIT: 收到 FIN
TIME_WAIT --> CLOSED: 2MSL 超时
ESTABLISHED --> CLOSE_WAIT: 收到 FIN
CLOSE_WAIT --> LAST_ACK: 应用 close()
LAST_ACK --> CLOSED: 收到 ACK8.3 TIME_WAIT 过多的问题与解决
TIME_WAIT 为什么是 2MSL(60s)?
text
MSL (Maximum Segment Lifetime) = 30s
TIME_WAIT = 2MSL = 60s 的原因:
1. 保证最后一个 ACK 能到达对端(如果 ACK 丢了,对端重传 FIN)
2. 让旧连接的所有包在网络上消失(防止新连接收到旧数据)TIME_WAIT 多的影响:
| 影响 | 量化 |
|---|---|
| 占用端口 | 每个 TIME_WAIT 连接占用一个本地端口(四元组) |
| 端口耗尽 | 短连接场景 connect() 被分配端口 → TIME_WAIT 60s → 端口不够 |
| 内存 | 每个 TIME_WAIT 约 280B(tcp_timewait_sock) |
解决方案:
bash
# 方案1: 启用 tcp_tw_reuse(客户端)
sysctl -w net.ipv4.tcp_tw_reuse=1 # 允许复用 TIME_WAIT 端口做新连接
# 方案2: 缩短 TIME_WAIT(不推荐,违反 RFC)
# 不推荐修改 tcp_fin_timeout
# 方案3: 连接复用(最优雅)
# HTTP 层面: Keep-Alive, HTTP/2 多路复用
# 应用层面: 连接池
# 架构层面: 长连接 + 多路复用代替短连接
# 方案4: 增加可用端口范围
sysctl -w net.ipv4.ip_local_port_range="1024 65535"8.4 CLOSE_WAIT 堆积的排查
bash
# 1. 看连接状态分布
ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
# 输出示例:
# 5000 CLOSE-WAIT ← 严重故障!
# 1200 ESTAB
# 100 TIME-WAIT
# 2. 找 CLOSE_WAIT 最多的进程
ss -antp | grep CLOSE-WAIT | awk '{print $NF}' | sort | uniq -c | sort -rn
# 3. 看进程 fd 数量
ls /proc/<PID>/fd | wc -l
# 4. 看具体 fd 的类型
ss -antp | grep CLOSE-WAIT | head -5CLOSE_WAIT 根因定位:
| 语言/场景 | 常见原因 | 排查方法 |
|---|---|---|
| Go | HTTP Response Body 未 Close | defer resp.Body.Close() 缺了 |
| Go | goroutine 阻塞在等待下游返回,没走到 close | pprof goroutine dump |
| Java | 连接池泄漏(get 了没 release) | 连接池监控 |
| Nginx | proxy_read_timeout 超时后没正确关闭 upstream 连接 | error log |
| Python | requests.get() 没用 with 或没 close response | 代码审计 |
8.5 线上排障 Runbook
mermaid
flowchart TD
A["用户报障: 服务不可用/502"] --> B{"ss -ant 看连接状态"}
B -->|"大量 CLOSE_WAIT"| C["找哪个进程: ss -antp"]
C --> D{"什么语言的程序?"}
D -->|"Go"| E["pprof goroutine 看阻塞在哪里"]
D -->|"Java"| F["连接池监控 + thread dump"]
D -->|"Nginx"| G["error log + upstream 状态"]
E --> H["修复: 加 Body.Close / context 超时"]
B -->|"大量 TIME_WAIT"| I["看是否短连接高频请求"]
I --> J["方案: 连接池 / Keep-Alive / tcp_tw_reuse"]
B -->|"大量 ESTAB + Recv-Q 堆积"| K["应用读不动, 查 pprof"]8.6 一个实战案例
text
场景: Go 微服务 → Nginx → Go 后端,偶发 502
排查:
1. ss -ant 发现 Nginx 的 upstream 侧有 3000+ CLOSE_WAIT
2. 后端 Go 服务 pprof goroutine 发现大量 goroutine 阻塞在 slow SQL
3. 请求超时后 Nginx 关闭了连接,后端收到 FIN → CLOSE_WAIT
4. 但后端 goroutine 还在等 SQL,没有 close fd
5. CLOSE_WAIT 累积 → Nginx 连接池耗尽 → 502
根因: 后端慢 SQL 导致 CLOSE_WAIT,不是 Nginx 的问题
修复:
1. 优化慢 SQL(根因)
2. 后端加 http.Server ReadTimeout / WriteTimeout
3. Nginx 侧 proxy_read_timeout 合理设置核心结论:CLOSE_WAIT 是果不是因——说明本端应用有 bug,没有及时 close。真正的根因通常在下游(慢 SQL、慢 Redis、慢 RPC),导致应用层持着连接不放。
登录后即可发表评论 👇