Skip to content

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 Port16源端口号
Destination Port16目标端口号
Sequence Number32序号:本报文段数据的第一个字节的序号。初始序号(ISN)随机生成
Acknowledgment Number32确认号:期望收到对方下一个报文段的序号
Data Offset4TCP 头部长度,以 4 字节为单位(最小 5 = 20 字节)
Reserved6保留(TCP 头中尚有 3 位用于 ECN)
Flags (6 bit)6URG/ACK/PSH/RST/SYN/FIN
Window Size16接收窗口大小,用于流量控制
Checksum16校验和(含伪首部)
Urgent Pointer16紧急指针(仅当 URG=1 时有效)
Options0-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 → ESTABLISHED

2.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
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:#fff

核心问题: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 32Cookie 有效期最多 32 个时间单位;过期后即使 Cookie 算对了也拒绝
MSS 编码 3 bit在 ISN 中编码 8 种常见 MSS 值(如 1460, 536, 1400 等),避免额外存储
四元组参与哈希每个连接的 Cookie 唯一,无法跨连接复用
计数器每分钟递增旧 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 后 → CLOSED

3.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_WAITLocal=proxy_ip:ephemeral Peer=mysql_ip:3306MySQL/下游已关闭,Proxy 没 close
Client 到 Proxy 大量 CLOSE_WAITLocal=proxy_ip:listen_port Peer=client_ip:ephemeralClient 已关闭,Proxy 没 close 上游连接
Proxy 大量 FIN_WAIT2Proxy 发了 FIN,对端迟迟不发 FIN对端应用不关闭或网络异常
Proxy 大量 TIME_WAITProxy 主动关闭很多短连接短连接/连接池复用不足

排查闭环:

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:#fff

5. 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 65535

5.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 中协商各自的 wscale

Timestamps — 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 --> SS

cwnd 变化曲线图(TCP Reno):

cwnd
^
|    /\              /\        ← 指数增长 (慢启动)
|   /  \            /  \
|  /    \----______/    \----_  ← 线性增长 (拥塞避免)
| /              \            \
|/                \            \____  ← 丢包后减半 (AIMD)
+--------------------------------------> time
         RTO超时          3 dup ACK

主流拥塞控制算法对比:

算法策略适用场景特点
Reno丢包减半 (AIMD)低带宽经典,但丢包后恢复慢
CUBIC三次函数探测高带宽通用Linux 默认,比 Reno 更激进
BBR带宽+ RTT 建模有一定丢包的网络Google 设计,不依赖丢包信号,微丢包不影响吞吐
VegasRTT 变化检测稳定网络在拥塞发生前降低速率,但太保守
bash
# 查看/设置拥塞控制算法
sysctl net.ipv4.tcp_congestion_control
echo "bbr" | sudo tee /proc/sys/net/ipv4/tcp_congestion_control

# 查看可用算法
sysctl net.ipv4.tcp_available_congestion_control

6.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)  // 禁用 Nagle

6.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=4096

6.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 -20

TIME_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.somaxconn1284096+高并发服务(Nginx/Go)
net.ipv4.tcp_max_syn_backlog1288192SYN Flood 防护
net.ipv4.tcp_syncookies11半连接队列满时启用
net.ipv4.tcp_tw_reuse01客户端 TIME_WAIT 复用
net.ipv4.tcp_fin_timeout6030加速释放 FIN_WAIT_2
net.ipv4.tcp_keepalive_time7200600提前检测死连接
net.ipv4.tcp_fastopen13TFO (客户端+服务端)
net.ipv4.tcp_slow_start_after_idle10长连接避免重复慢启动
选项作用典型场景
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
    → 强制清空所有队列 → 确保测到最新的 RTprop
mermaid
flowchart LR
    subgraph "BBR 的目标点"
        A["发送速率 = BtlBw"] --> B["inflight = BDP"]
        B --> C["缓冲区占用 ≈ 0"]
        C --> D["延迟最低 + 吞吐最高"]
    end

BBR 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 论文):

指标CUBICBBR提升
吞吐量(跨洲际链路)~100 Mbps~2 Gbps20×
RTT(有排队时)~100ms~40ms60% 降低
丢包率对吞吐影响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 timeoutSYN/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: 收到 ACK

8.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 -5

CLOSE_WAIT 根因定位:

语言/场景常见原因排查方法
GoHTTP Response Body 未 Closedefer resp.Body.Close() 缺了
Gogoroutine 阻塞在等待下游返回,没走到 closepprof goroutine dump
Java连接池泄漏(get 了没 release)连接池监控
Nginxproxy_read_timeout 超时后没正确关闭 upstream 连接error log
Pythonrequests.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),导致应用层持着连接不放。


批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记