跳到主要内容

TCP-IP协议栈与抓包分析

1. 概念与原理

TCP/IP 是互联网的事实标准,前端的每个 fetch、每张图片、每次 WebSocket 都跑在它上面。理解它,你才能解释为什么 Connection reset 不是超时、为什么大文件下载偶尔卡 30 秒、为什么 keep-alive 能省一半建连开销。

1.1 OSI 七层 vs TCP/IP 四层

OSITCP/IP协议示例前端关心
应用层应用层HTTP、WebSocket、DNS★★★★★
表示层(并入应用层)TLS、JSON★★★★
会话层(并入应用层)TLS Session★★
传输层传输层TCP、UDP、QUIC★★★★
网络层网际层IP、ICMP★★★
数据链路层网络接口Ethernet、ARP
物理层网络接口网线、光纤-

每一层都给上层数据加 header,下层只看自己的 header。这叫 封装(encapsulation)

[Ethernet][IP][TCP][HTTP payload]

tcpdump -X 看到的就是这个完整的字节序列。

1.2 IP 协议核心要点

  • 无连接:每个包独立路由,可能走不同路径
  • 不可靠:丢包、乱序、重复都不保证。可靠交给 TCP
  • MTU:链路最大传输单元。以太网 1500 字节,包含 IP/TCP header(一般 40 字节),TCP 实际 payload(MSS)= 1460 字节
  • 分片:包大于 MTU 会在路由器分片,性能差。现代用 PMTUD 探测路径 MTU
# 看路径 MTU
tracepath example.com
# 不分片探测
ping -M do -s 1472 example.com # 1472 + 28(ICMP) = 1500

MTU 不一致是经典坑。隧道(VPN、PPPoE)会减小 MTU,云内网常见 9000(jumbo frame),混用会丢大包。

2. TCP 核心机制

2.1 三次握手

Client Server
| SYN, seq=x |
|─────────────────────────────> | ① Client → Server
| |
| SYN+ACK, seq=y, ack=x+1 |
| <─────────────────────────────| ② Server → Client
| |
| ACK, ack=y+1 |
|─────────────────────────────> | ③ Client → Server
| |
| 连接建立,开始数据传输 |

为什么三次:

  • 一次:服务端不知道客户端的接收能力
  • 两次:客户端不知道自己发的能不能到,且第三次才能确认服务端的初始序列号

序列号(seq)不是从 0 开始,是随机的,防止旧连接的包混入新连接(也防序列号预测攻击)。

握手抓包验证

sudo tcpdump -i any -nn -S 'tcp[tcpflags] & (tcp-syn|tcp-ack) != 0 and host example.com' -c 6
# 输出三次握手 + 后续 ACK

2.2 四次挥手

Client Server
| FIN, seq=u |
|───────────────────────────> | ① 主动方提出关闭
| |
| ACK, ack=u+1 |
| <───────────────────────────| ② 被动方确认
| |
| FIN, seq=v |
| <───────────────────────────| ③ 被动方关闭(可能延迟,因为可能还要发数据)
| |
| ACK, ack=v+1 |
|───────────────────────────> | ④ 主动方确认
| |
| TIME_WAIT 2*MSL |

为什么四次:TCP 是全双工,两个方向独立关闭。被动方收到 FIN 后可能还有数据要发,所以 ACK 和 FIN 分开。

2.3 TIME_WAIT 与 CLOSE_WAIT(必懂)

状态出现在含义危险信号
TIME_WAIT主动关闭方等 2*MSL(默认 60s)确保对方收到最后 ACK + 让旧包过期大量出现 = 应用主动关闭连接频繁,端口耗尽
CLOSE_WAIT被动关闭方收到对方 FIN 后还没调用 close()大量出现 = 应用代码 bug,没正确关连接
ss -tn state time-wait | wc -l
ss -tn state close-wait | wc -l

CLOSE_WAIT 堆积是 Node 应用最常见的连接泄漏,常见原因:

  • HTTP client 没正确处理 response 流,没读完也没 destroy
  • 数据库连接池配置不当,连接没回收
  • WebSocket 没监听 close 事件
  • axios 长连接 keepAlive agent 配置错误

排查:lsof -p &lt;pid> | grep CLOSE_WAIT 看是哪些远端 IP,再去看代码对应的请求逻辑。

2.4 TIME_WAIT 优化

高并发场景,本机作为客户端发起大量短连接(如 Nginx 反向代理到上游),TIME_WAIT 堆积会耗尽本地端口(默认范围 32768-60999)。

正确做法

  1. 复用连接(首选):HTTP keep-alive、连接池、长连接
  2. 调内核参数(生产慎用):
# /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1 # 允许 TIME_WAIT socket 给新连接(需双方支持时间戳)
net.ipv4.ip_local_port_range = 10000 65535 # 扩大端口范围

# net.ipv4.tcp_tw_recycle 在 4.12 内核已删除,不要再用(NAT 环境会丢包)

sysctl -p

tcp_tw_recycle 历史遗毒,老资料会推荐,绝对不要开,会导致 NAT 环境下连接随机失败。

2.5 重传与拥塞控制

重传

TCP 通过 ACK 确认接收,没收到 ACK 就重传。重传有两种触发:

  • 超时重传(RTO):定时器到期没 ACK,等 RTO 时间。RTO 动态计算,初始 1s,指数退避
  • 快速重传:收到 3 个重复 ACK 立即重传,不等 RTO

抓包看重传:

sudo tcpdump -i any -nn 'tcp' -w cap.pcap
# Wireshark 打开,过滤 tcp.analysis.retransmission

重传率 > 1% 表示网络质量差。

拥塞控制

经典四阶段:慢启动 → 拥塞避免 → 快速重传 → 快速恢复。

Linux 默认 CUBIC(高带宽适合)。Google 的 BBR 算法在弱网(高延迟、丢包)下显著好于 CUBIC:

# 看当前算法
sysctl net.ipv4.tcp_congestion_control
# 切到 BBR(4.9+ 内核)
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_available_congestion_control

前端服务器开启 BBR 是高 ROI 操作:跨地域用户、移动网络下载速度可能翻倍。

2.6 滑动窗口与 Nagle 算法

  • 滑动窗口:接收方告诉发送方还能收多少(rwnd),流量控制
  • 拥塞窗口(cwnd):发送方根据网络状态动态调整
  • 实际窗口 = min(rwnd, cwnd)
  • BDP(带宽时延积) = 带宽 × RTT。窗口必须 ≥ BDP 才能跑满带宽。100Mbps × 100ms = 1.25MB,默认 Linux rwnd 64KB 远小于这个,需要 window scaling 扩大

Nagle 算法:合并小包发送(减少 packet 数)。但和 delayed ACK 一起会引入延迟。实时应用(游戏、SSH)通常关 Nagle:

setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));

Node 默认关:socket.setNoDelay(true),HTTP server 自动开启。

2.7 TCP keep-alive vs HTTP keep-alive

很多人混淆:

TCP keep-aliveHTTP keep-alive
层级传输层(OS)应用层(HTTP header)
目的探测对端是否还活着复用 TCP 连接发多个 HTTP 请求
默认关闭,开启后 7200s 才发探测HTTP/1.1 默认开启
配置net.ipv4.tcp_keepalive_*Connection: keep-alive + Keep-Alive

云负载均衡常配 60s 空闲断连,TCP keep-alive 默认 7200s 探测来不及,连接已被中间设备 RST。客户端要主动调小:

# 全局
net.ipv4.tcp_keepalive_time = 60 # 空闲多久开始探测
net.ipv4.tcp_keepalive_intvl = 10 # 探测间隔
net.ipv4.tcp_keepalive_probes = 3 # 几次没响应判死

或代码层面(Node):

const net = require('net')
socket.setKeepAlive(true, 60000)

3. UDP 与 QUIC

3.1 UDP

无连接、不可靠、不保序。优点:开销小(8 字节 header)、低延迟、广播组播。前端场景:

  • DNS 查询(53 端口,UDP/TCP 都可)
  • WebRTC 媒体流
  • HTTP/3(基于 QUIC,QUIC 基于 UDP)

3.2 QUIC

Google 设计,IETF 标准化(RFC 9000)。在 UDP 上重新实现可靠传输 + TLS 1.3 + 多路复用。优势:

  • 0-RTT 重连:复用 session 直接发数据
  • 没有 TCP 队头阻塞:流之间独立
  • 连接迁移:手机从 WiFi 切 4G,连接不断

HTTP/3 = HTTP semantics over QUIC。详见"HTTP 协议演进"。

4. 抓包实战

4.1 tcpdump 进阶

# 抓 80 端口三次握手
sudo tcpdump -i any -nn -S 'tcp[tcpflags] & (tcp-syn|tcp-fin) != 0 and port 80'

# 看 TCP 重传(序列号倒退)
sudo tcpdump -i any -nn -S 'tcp and port 443' -w cap.pcap
# Wireshark 过滤 tcp.analysis.retransmission

# 抓 RST 包(连接被强制重置)
sudo tcpdump -i any -nn 'tcp[tcpflags] & tcp-rst != 0'

# 抓某主机的全部交互
sudo tcpdump -i any -nn -A 'host api.example.com' -w cap.pcap

# 限制大小避免磁盘炸
sudo tcpdump -i any -nn -W 5 -C 100 -w cap.pcap # 最多 5 个 100MB 文件

4.2 Wireshark 关键过滤

ip.addr == 1.2.3.4
tcp.port == 443
http.response.code >= 400
tls.handshake.type == 1 # ClientHello
tcp.analysis.retransmission # 重传
tcp.analysis.duplicate_ack
tcp.flags.reset == 1
tcp.stream eq 0 # 第 0 个流的所有包

右键 → Follow → TCP Stream 看完整对话。Statistics → Conversations 看连接列表。

4.3 案例:诊断 Connection reset

# 现象:客户端报 ECONNRESET,前端报 net::ERR_CONNECTION_RESET

# 1. 服务器端抓包
sudo tcpdump -i any -nn -S host <client-ip> -w reset.pcap

# 2. Wireshark 找 RST 包,看是谁先发的
# - 服务端先发 RST:服务端主动重置(应用 abort、过载拒绝、超时)
# - 客户端先发 RST:客户端 abort(用户取消、超时)
# - 中间设备发 RST:防火墙、运营商 reset 攻击

# 3. 看 RST 之前的最后几个包,往往能看出原因
# - HTTP 415/413 后 RST:服务端拒绝大请求
# - SYN 后立即 RST + ACK:端口没监听
# - 长时间空闲后 RST:中间设备超时断连

5. 性能调优

5.1 关键内核参数(Web 服务器)

# /etc/sysctl.conf 高并发服务器调优
# === 连接队列 ===
net.core.somaxconn = 65535 # 全连接队列上限(应用 listen() backlog 不能超过这个)
net.ipv4.tcp_max_syn_backlog = 65535 # 半连接队列

# === TIME_WAIT ===
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 30 # FIN_WAIT2 超时

# === 端口范围 ===
net.ipv4.ip_local_port_range = 10000 65535

# === keepalive ===
net.ipv4.tcp_keepalive_time = 600

# === 缓冲 ===
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# === 拥塞控制 ===
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr

# === SYN 防护 ===
net.ipv4.tcp_syncookies = 1 # 防 SYN flood

sysctl -p

重要somaxconn 是上限,应用 listen(fd, backlog) 还要在代码层调大。Nginx 默认 backlog 511,K8s 里很多人忘记调。

5.2 Nginx 配置侧

events {
worker_connections 65535;
use epoll;
}

http {
keepalive_timeout 65;
keepalive_requests 1000;
upstream backend {
keepalive 100; # 到上游的连接池
}
}

6. 故障排查

6.1 连接数异常

# 总览
ss -s
# Total: 500 (kernel 0)
# TCP: 500 (estab 200, closed 100, orphaned 0, synrecv 0, timewait 200/0)

# TIME_WAIT 多
ss -tan state time-wait | head
ss -tan state time-wait | awk '{print $4}' | cut -d: -f1 | sort | uniq -c

# CLOSE_WAIT 多(一定是 bug)
ss -tan state close-wait
lsof -p <pid> | grep CLOSE_WAIT

# 半连接队列溢出(看 SYN flood 或 accept 慢)
netstat -s | grep -i "SYNs.*overflow"
netstat -s | grep -i "listen drops"

6.2 网络丢包

# 网卡级
ip -s link show eth0
# 看 RX/TX errors / dropped

# 协议级
netstat -s | grep -i "retrans\|drop"
# segments retransmitted: 1000

# 用 mtr 持续观测路径丢包
mtr -r -c 100 example.com

6.3 大文件下载偶尔卡住

经典原因:BDP 不够大 → 窗口太小 → 跑不满带宽。

# 看接收方窗口
ss -tin
# bbr cwnd:10 ssthresh:7 ... rcv_space:14600

# 优化(接收方)
sysctl -w net.core.rmem_max=16777216
sysctl -w net.ipv4.tcp_rmem='4096 87380 16777216'

7. 安全考量

  • SYN flood:开 tcp_syncookies = 1,攻击时不分配资源
  • 小包攻击 / Slowloris:Nginx 的 client_body_timeoutclient_header_timeout
  • 明文协议泄露:HTTP / 老 TLS 在抓包工具下无密。生产强制 HTTPS + TLS 1.2+
  • 抓包数据敏感:pcap 文件含 cookie、token,处理后立即销毁

8. 常见反模式

  • 不开 keep-alive:每次请求三次握手 + TLS 握手,性能差 5-10 倍
  • 客户端短连接 + 高并发:本地端口耗尽
  • tcp_tw_recycle = 1:NAT 环境必死,4.12 内核已删除
  • 认为 ping 通就是网络好:ICMP 和 TCP 路径可能不同,且 ICMP 经常被禁
  • 不调 somaxconn:QPS 上来后 SYN 队列溢出,连接无故失败
  • 抓包不加过滤条件:磁盘瞬间塞满
  • 看到 RST 就以为是攻击:很多正常情况会触发 RST(应用 abort、HTTP 1.0 关连接)
  • 不区分 TCP keepalive 和 HTTP keepalive:调错地方解决不了问题

9. 延伸阅读