土法炼钢兴趣小组的算法知识备份

【网络工程】TCP 连接管理:三次握手、四次挥手与状态机

文章导航

分类入口
network
标签入口
#tcp#connection#handshake#state-machine#syn-flood

目录

TCP 连接管理:三次握手、四次挥手与状态机

TCP 是一个有状态协议。每条 TCP 连接都有一个明确的生命周期——建立、数据传输、关闭——每个阶段都对应着一组状态转换。大多数后端工程师对”三次握手”和”四次挥手”的流程烂熟于心,但真正在生产环境中遇到 TIME_WAIT 堆积到几万条、SYN Flood 攻击打满半连接队列、CLOSE_WAIT 泄漏导致文件描述符耗尽时,才发现教科书上的知识远远不够。

本文不重复教科书内容,而是从工程视角剖析 TCP 连接管理的每一个细节——每个状态意味着什么、每个参数控制什么、每个异常怎么诊断。

一、三次握手:不只是 SYN-SYN/ACK-ACK

握手的完整过程

三次握手的教科书描述很简单:客户端发 SYN,服务端回 SYN+ACK,客户端回 ACK。但在工程层面,每一步都携带了关键的协商信息。

客户端                              服务端
  |                                   |
  |  SYN  seq=x                       |
  |  [MSS=1460, WS=7, SACK, TS]      |
  | --------------------------------> |
  |                                   |  → 半连接队列(SYN Queue)
  |                                   |
  |  SYN+ACK  seq=y, ack=x+1         |
  |  [MSS=1460, WS=7, SACK, TS]      |
  | <-------------------------------- |
  |                                   |
  |  ACK  seq=x+1, ack=y+1           |
  | --------------------------------> |
  |                                   |  → 全连接队列(Accept Queue)
  |                                   |  → 等待 accept() 系统调用

第一个 SYN 包携带的信息

# 用 tcpdump 抓取一个真实的 SYN 包
tcpdump -i eth0 -nn 'tcp[tcpflags] == tcp-syn' -c 1 -v

# 输出示例:
# 10.0.0.1.54321 > 10.0.0.2.443: Flags [S], seq 3218452761,
#   win 65535, options [mss 1460, sackOK, TS val 123456 ecr 0,
#   nop, wscale 7], length 0

# 逐项解读:
# seq 3218452761     — ISN(初始序列号),由内核随机生成
# win 65535          — 初始接收窗口大小
# mss 1460           — 最大段大小(MSS),通常 = MTU - 40
# sackOK             — 支持选择性确认(SACK)
# TS val/ecr         — TCP 时间戳(用于 RTTM 和 PAWS)
# wscale 7           — 窗口缩放因子(实际窗口 = win << 7 = 8MB)

这些 TCP 选项(Options)只能在 SYN 和 SYN+ACK 中协商,连接建立后不能更改。这意味着:

ISN(初始序列号)的安全意义

TCP 的 ISN 不是从 0 开始的——它是一个伪随机数。这不是随意的设计:

# 查看 Linux 的 ISN 生成方式
# Linux 使用 SipHash 算法,输入包括:
# - 源 IP、目标 IP、源端口、目标端口
# - 一个每次启动随机生成的密钥
# - 时间因子

# 如果 ISN 可预测,攻击者可以:
# 1. TCP 会话劫持:伪造带有正确序列号的 RST/数据包
# 2. 盲注入攻击:在不抓包的情况下向连接注入数据

# 验证 ISN 随机性(观察连续连接的 ISN 差异)
for i in $(seq 1 5); do
    # 抓取 SYN 包的序列号
    timeout 2 tcpdump -i lo -nn 'tcp[tcpflags] == tcp-syn and dst port 8080' \
        -c 1 2>/dev/null | grep -oP 'seq \K\d+'
    # 每次连接的序列号应该完全不同
    curl -s http://127.0.0.1:8080/ > /dev/null 2>&1 &
    sleep 0.5
done

握手超时与重试

SYN 包发出后没收到 SYN+ACK,客户端会重试。Linux 默认重试 6 次,使用指数退避:

# SYN 重试次数
sysctl net.ipv4.tcp_syn_retries
# 默认 6,总等待时间约 127 秒(1+2+4+8+16+32+64)

# SYN+ACK 重试次数(服务端)
sysctl net.ipv4.tcp_synack_retries
# 默认 5,总等待时间约 63 秒

# 重试的指数退避时间序列:
# 第 1 次重试: 1s 后
# 第 2 次重试: 3s 后(1+2)
# 第 3 次重试: 7s 后(1+2+4)
# 第 4 次重试: 15s 后
# 第 5 次重试: 31s 后
# 第 6 次重试: 63s 后
# 最终超时: 127s 后

# 在高延迟环境中,127 秒的超时太长了
# 生产环境通常减少到 2-3 次
sysctl -w net.ipv4.tcp_syn_retries=3
# 总等待时间降到 15 秒

用 ss 观察握手中的连接

# 查看所有 SYN_SENT 状态的连接(客户端正在握手)
ss -tn state syn-sent
# State     Recv-Q Send-Q  Local Address:Port   Peer Address:Port
# SYN-SENT  0      1       10.0.0.1:54321       10.0.0.2:443

# 查看所有 SYN_RECV 状态的连接(服务端收到 SYN,等待第三步 ACK)
ss -tn state syn-recv
# 在 SYN Cookie 启用时,这里可能看不到条目

二、四次挥手:比握手复杂得多

正常关闭流程

TCP 的关闭需要四次交互,因为 TCP 是全双工的——每个方向都需要独立关闭:

主动关闭方                          被动关闭方
  |                                   |
  |  FIN  seq=m                       |
  | --------------------------------> |  CLOSE_WAIT
  |  FIN_WAIT_1                       |
  |                                   |
  |  ACK  ack=m+1                     |
  | <-------------------------------- |
  |  FIN_WAIT_2                       |
  |                                   |  (被动方可能还有数据要发送)
  |                                   |
  |  FIN  seq=n                       |
  | <-------------------------------- |  LAST_ACK
  |                                   |
  |  ACK  ack=n+1                     |
  | --------------------------------> |  CLOSED
  |  TIME_WAIT                        |
  |  (等待 2*MSL)                   |
  |  CLOSED                           |

关键区别:shutdown vs close

// close() — 完全关闭,不能再收发数据
close(sockfd);

// shutdown() — 可以选择性关闭一个方向
shutdown(sockfd, SHUT_WR);   // 只关闭写端(发送 FIN),还能读
shutdown(sockfd, SHUT_RD);   // 只关闭读端
shutdown(sockfd, SHUT_RDWR); // 关闭双向

// 半关闭(Half-Close)的实际用途:
// HTTP/1.1 的客户端可以 shutdown(SHUT_WR) 表示请求发完了,
// 但还要读取服务端的响应

同时关闭(Simultaneous Close)

如果双方几乎同时发送 FIN,会出现”同时关闭”的情况——两端都进入 CLOSING 状态:

端 A                              端 B
  |                                 |
  |  FIN                            |
  | -------------\   /------------- |  FIN
  |  FIN_WAIT_1   \ /   FIN_WAIT_1 |
  |                X                |
  |  FIN          / \          FIN  |
  | <------------/   \------------> |
  |  CLOSING                CLOSING |
  |                                 |
  |  ACK                            |
  | -------------\   /------------- |  ACK
  |               \ /               |
  |                X                |
  |               / \               |
  | <------------/   \------------> |
  |  TIME_WAIT          TIME_WAIT   |

这在实际中很少发生,但当它发生时,如果你在 ss 中看到 CLOSING 状态的连接,不要惊慌——这是正常行为。

三、TCP 状态机完整解剖

11 个状态一览

TCP 连接有 11 个状态。每个状态都可以用 ssnetstat 观察到:

状态 含义 持续时间 告警阈值
LISTEN 服务端等待连接 持续存在
SYN_SENT 客户端已发 SYN 通常 <1s >100 个说明目标可能不可达
SYN_RECV 服务端收到 SYN 通常 <1s 大量堆积可能是 SYN Flood
ESTABLISHED 连接已建立 应用决定 看业务需求
FIN_WAIT_1 主动关闭方已发 FIN 通常 <1s 堆积说明对端不响应
FIN_WAIT_2 主动关闭方收到 ACK 通常短暂 堆积说明对端没发 FIN
CLOSE_WAIT 被动关闭方收到 FIN 应用决定 堆积是应用 Bug
CLOSING 同时关闭 通常短暂 极少见
LAST_ACK 被动关闭方已发 FIN 通常 <1s 堆积说明网络问题
TIME_WAIT 主动关闭方等待 2*MSL (60s) 大量堆积需关注
CLOSED 连接已释放
# 用 ss 统计每个状态的连接数
ss -s
# Total: 1234
# TCP:   890 (estab 650, closed 45, orphaned 12, timewait 123)
#
# Transport Total     IP        IPv6
# TCP       890       645       245

# 按状态分组统计
ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn
#  650 ESTAB
#  123 TIME-WAIT
#   45 CLOSE-WAIT
#   30 LISTEN
#   12 FIN-WAIT-2
#    8 SYN-SENT
#    2 LAST-ACK

# 查看特定状态的详细连接
ss -tnp state close-wait
# 注意:-p 选项显示进程名,需要 root 权限

用 ss -ti 观察连接的内部状态

ss -ti 提供了每条 TCP 连接的内部参数,这是排查问题的利器:

ss -ti dst 10.0.0.2

# 输出示例(每个字段的含义):
# ESTAB 0 0 10.0.0.1:54321 10.0.0.2:443
#  cubic                          ← 拥塞控制算法
#  wscale:7,7                     ← 窗口缩放因子(发送,接收)
#  rto:204                        ← 重传超时(ms)
#  rtt:1.5/0.5                    ← 平滑 RTT / RTT 偏差(ms)
#  ato:40                         ← Delayed ACK 超时(ms)
#  mss:1448                       ← 最大段大小
#  pmtu:1500                      ← 路径 MTU
#  rcvmss:1448                    ← 接收端 MSS
#  advmss:1448                    ← 通告的 MSS
#  cwnd:10                        ← 拥塞窗口(MSS 数)
#  ssthresh:20                    ← 慢启动阈值
#  bytes_sent:12345               ← 已发送字节数
#  bytes_acked:12340              ← 已确认字节数
#  bytes_received:67890           ← 已接收字节数
#  segs_out:100                   ← 发送的段数
#  segs_in:95                     ← 接收的段数
#  data_segs_out:80               ← 发送的数据段数
#  data_segs_in:90                ← 接收的数据段数
#  send 77.1Mbps                  ← 估计的发送速率
#  lastsnd:50                     ← 距上次发送的时间(ms)
#  lastrcv:30                     ← 距上次接收的时间(ms)
#  lastack:30                     ← 距上次 ACK 的时间(ms)
#  delivery_rate 75.0Mbps         ← 实际交付速率
#  rcv_space:29200                ← 接收缓冲区空间
#  rcv_ssthresh:29200             ← 接收端慢启动阈值
#  minrtt:1.2                     ← 最小 RTT(ms)

四、TIME_WAIT 工程:最常遇到的 TCP 问题

TIME_WAIT 存在的原因

TIME_WAIT 持续 2*MSL(Maximum Segment Lifetime,Linux 上硬编码为 60 秒),有两个工程目的:

  1. 确保最后一个 ACK 到达对端。 如果对端没收到 ACK,会重发 FIN。如果主动关闭方已经释放了连接,收到重发的 FIN 后会回复 RST——对端会认为连接异常关闭。TIME_WAIT 状态让主动关闭方可以正确响应重发的 FIN。

  2. 防止旧连接的延迟包干扰新连接。 如果立即复用同一个四元组(源IP:端口 → 目标IP:端口)建立新连接,旧连接在网络中滞留的包可能被新连接错误接收。等待 2*MSL 确保旧包已经在网络中消亡。

# Linux 的 MSL 值(硬编码,不可调)
grep -r 'TCP_TIMEWAIT_LEN' /usr/include/ 2>/dev/null
# 在内核源码中: #define TCP_TIMEWAIT_LEN (60*HZ)  → 60 秒

# 查看当前 TIME_WAIT 连接数
ss -tan state time-wait | wc -l

# 查看 TIME_WAIT 连接的分布(按目标地址)
ss -tan state time-wait | awk '{print $4}' | cut -d: -f1 | sort | uniq -c | sort -rn | head
#   5234 10.0.0.2     ← 到这个后端的 TIME_WAIT 最多
#   3012 10.0.0.3
#   1500 10.0.0.4

TIME_WAIT 堆积的工程影响

TIME_WAIT 本身不消耗太多资源——每条 TIME_WAIT 连接大约占 168 字节内存。真正的问题是端口耗尽:

# 本地可用端口范围
sysctl net.ipv4.ip_local_port_range
# 默认 32768 60999 → 约 28000 个端口

# 如果有 25000 个 TIME_WAIT 连接到同一个目标 IP:Port,
# 就快耗尽了——新连接无法建立

# TIME_WAIT 桶的内核限制
sysctl net.ipv4.tcp_max_tw_buckets
# 默认 262144
# 超过此限制,内核会直接销毁 TIME_WAIT 连接(并打印警告日志)

TIME_WAIT 的解决方案

# === 方案 1:tcp_tw_reuse(推荐) ===
sysctl -w net.ipv4.tcp_tw_reuse=1
# 允许在 TIME_WAIT 状态下复用连接(仅作为客户端发起新连接时)
# 条件:新连接的 TCP 时间戳必须大于旧连接的最后时间戳
# 安全:时间戳保证了不会混淆新旧连接的包

# 从 Linux 4.6 开始,tcp_tw_reuse 有三个值:
# 0 = 关闭
# 1 = 全局启用(只对客户端有效)
# 2 = 仅对 loopback 启用

# === 方案 2:SO_REUSEADDR ===
# 在 bind() 之前设置,允许绑定处于 TIME_WAIT 的地址
# 这对服务端重启非常重要——不设置的话,重启后可能几十秒无法绑定端口

# C 代码示例:
# int opt = 1;
# setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

# === 方案 3:SO_REUSEPORT ===
# 允许多个 socket 绑定同一个端口
# 主要用于多进程/多线程服务的负载均衡,不是解决 TIME_WAIT 的
# 但它隐含了 SO_REUSEADDR 的效果

# === 方案 4:扩大端口范围 ===
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# 从 28000 个端口扩展到 64000 个

# === 方案 5:使用连接池 ===
# 根本解决方案:不要频繁创建和关闭短连接
# HTTP Keep-Alive、数据库连接池、gRPC 长连接
# 减少连接创建频率 = 减少 TIME_WAIT 产生

# === 已废弃的方案:tcp_tw_recycle ===
# 这个参数在 Linux 4.12 中被移除
# 它在 NAT 环境下会导致严重问题——不同客户端通过同一 NAT IP 连接时,
# 时间戳不单调递增,导致合法连接被丢弃
# 永远不要在任何旧内核上启用它

TIME_WAIT 的真实诊断案例

场景:微服务 A 频繁调用微服务 B 的 HTTP API,服务 A 报连接超时。

# 1. 检查 TIME_WAIT 数量
ss -tan state time-wait dst 微服务B的IP | wc -l
# 28521 ← 接近端口范围上限!

# 2. 确认端口范围
sysctl net.ipv4.ip_local_port_range
# 32768 60999 → 28231 个端口,已经快耗尽

# 3. 查看 dmesg 是否有内核警告
dmesg | grep 'time wait bucket'
# TCP: time wait bucket table overflow ← 确认 TIME_WAIT 溢出

# 4. 根因分析
# 微服务 A 没有使用 HTTP Keep-Alive,每次请求都创建新连接
# 作为主动关闭方,每次关闭连接都会进入 TIME_WAIT
# QPS 500 × TIME_WAIT 存活 60s = 最多 30000 个 TIME_WAIT

# 5. 解决方案(按优先级)
# (a) 启用 HTTP Keep-Alive 连接池(根本解决)
# (b) sysctl -w net.ipv4.tcp_tw_reuse=1(快速缓解)
# (c) sysctl -w net.ipv4.ip_local_port_range="1024 65535"(扩大端口范围)

五、CLOSE_WAIT 泄漏:比 TIME_WAIT 更危险

CLOSE_WAIT 的含义

CLOSE_WAIT 意味着:对端已经发了 FIN(想关闭连接),但本地应用还没有调用 close()。

这几乎总是应用程序的 Bug——没有正确关闭 socket:

# 查看 CLOSE_WAIT 连接及其对应进程
ss -tnp state close-wait
# CLOSE-WAIT 0    0    10.0.0.1:54321  10.0.0.2:443  users:(("java",pid=12345,fd=67))

# 关键信息:
# pid=12345 — 哪个进程
# fd=67     — 哪个文件描述符

# 检查进程打开的文件描述符数量
ls /proc/12345/fd | wc -l
# 如果接近 ulimit -n 的限制,进程即将因为 fd 耗尽而崩溃

# 查看进程的 fd 限制
cat /proc/12345/limits | grep 'open files'
# Max open files  65536  65536  files

常见的 CLOSE_WAIT 泄漏原因

# 原因 1:没有关闭 HTTP 响应体(Go 语言常见问题)
# 错误代码:
#   resp, err := http.Get("http://example.com")
#   if err != nil { return err }
#   // 忘记 defer resp.Body.Close() ← BUG
#   // 或者在错误路径上没有关闭

# 原因 2:异常路径没有关闭 socket
# 错误代码:
#   conn = connect(server)
#   data = conn.read()
#   process(data)          ← 如果这里抛异常,conn 永远不会关闭
#   conn.close()

# 原因 3:连接池中的连接没有被正确回收
# 数据库连接池用完后没有 release,导致连接一直被持有

# 诊断步骤:
# 1. 找到 CLOSE_WAIT 对应的进程
ss -tnp state close-wait | awk '{print $6}' | sort | uniq -c | sort -rn
#   500 users:(("java",pid=12345,fd=...))  ← 这个 Java 进程有 500 个泄漏

# 2. 用 strace 观察进程是否在做 close() 调用
strace -p 12345 -e trace=close -f 2>&1 | head -20
# 如果看不到 close() 调用,说明应用确实没有关闭连接

# 3. 在 Java 中用 jstack 查看线程堆栈
jstack 12345 > /tmp/jstack.txt
# 搜索持有 socket 的线程,看它在干什么

CLOSE_WAIT 与 TIME_WAIT 的对比

特征 TIME_WAIT CLOSE_WAIT
出现在 主动关闭方 被动关闭方
含义 等待旧包消亡 等待本地应用 close()
持续时间 固定 60 秒 无限期(直到应用关闭)
内存占用 约 168 字节/条 完整 socket 结构(几 KB)
是否是 Bug 通常不是 几乎总是应用 Bug
根本解决 连接池/Keep-Alive 修复应用代码
数量上限 tcp_max_tw_buckets 受 fd 限制

SYN Flood 攻击原理

SYN Flood 是最经典的 DoS 攻击之一——攻击者发送大量 SYN 包但不回复第三步 ACK,让服务端的半连接队列(SYN Queue)被占满,合法连接无法建立:

# 观察 SYN Flood 的迹象

# 1. 半连接队列溢出统计
netstat -s | grep -i 'syn'
# SYNs to LISTEN sockets dropped       ← 这个数字在增长说明有 SYN Flood

# 更精确的统计(nstat)
nstat -az | grep -i 'syn'
# TcpExtTCPReqQFullDrop    12345    ← SYN 队列满导致的丢弃
# TcpExtSyncookiesSent     67890    ← 发出的 SYN Cookie 数量
# TcpExtSyncookiesRecv     67800    ← 收到的 SYN Cookie ACK 数量
# TcpExtSyncookiesFailed   90       ← SYN Cookie 验证失败的数量

# 2. 查看 SYN_RECV 状态的连接数
ss -tn state syn-recv | wc -l
# 如果接近 tcp_max_syn_backlog 的值,说明队列快满了

# 3. dmesg 中的警告
dmesg | grep -i 'syn'
# TCP: request_sock_TCP: Possible SYN flooding on port 80.
# Sending cookies.  Check SNMP counters.

SYN Cookie 是一种无状态的 SYN Flood 防御机制。核心思想是:不在服务端保存半连接状态,而是把状态编码到 SYN+ACK 的序列号中:

# 启用 SYN Cookie(大多数 Linux 发行版默认开启)
sysctl net.ipv4.tcp_syncookies
# 0 = 关闭
# 1 = 仅在 SYN 队列溢出时启用(推荐,默认值)
# 2 = 始终启用

# SYN Cookie 的序列号编码:
# ISN = hash(源IP, 目标IP, 源端口, 目标端口, 时间计数器) + (MSS编码 << 某偏移)
#
# 当收到第三步 ACK 时:
# 1. 从 ACK 号反推出 SYN Cookie
# 2. 验证 hash 是否正确(说明确实是我发的 SYN+ACK)
# 3. 从编码中恢复 MSS 值
# 4. 建立连接

SYN Cookie 的代价

SYN Cookie 虽然能防御 SYN Flood,但有功能限制:

  1. 不能使用 TCP 选项:MSS 只能编码 8 种值(3 bit)、窗口缩放和 SACK 无法传递。这意味着 SYN Cookie 建立的连接不支持 SACK 和窗口缩放
  2. 每次都要计算 hash:有一定的 CPU 开销
  3. 时间窗口:Cookie 基于时间计数器,有一个有效期窗口——太早或太晚的 ACK 会被拒绝

从 Linux 4.14 开始,SYN Cookie 部分解决了 TCP 选项的问题,通过在时间戳(TS val)中编码额外信息。

半连接队列与全连接队列

理解 TCP 的两个队列对诊断连接问题至关重要:

# === 半连接队列(SYN Queue) ===
# 存放 SYN_RECV 状态的连接(收到 SYN,等待第三步 ACK)

# 队列大小
sysctl net.ipv4.tcp_max_syn_backlog
# 默认 256 或 1024(取决于内存)

# === 全连接队列(Accept Queue) ===
# 存放已完成三次握手,等待 accept() 的连接

# 队列大小 = min(backlog, somaxconn)
# backlog 是 listen() 系统调用的参数
# somaxconn 是内核级别的上限
sysctl net.core.somaxconn
# 默认 4096(较新内核)或 128(旧内核)

# 查看每个 LISTEN socket 的队列状态
ss -tnl
# State   Recv-Q  Send-Q   Local Address:Port
# LISTEN  0       4096     *:80
#
# 对于 LISTEN socket:
#   Recv-Q = 当前全连接队列中等待 accept() 的连接数
#   Send-Q = 全连接队列的最大长度(backlog)
#
# 如果 Recv-Q 接近 Send-Q,说明应用 accept() 太慢

# 全连接队列溢出统计
nstat -az TcpExtListenOverflows
# TcpExtListenOverflows    123    ← 如果在增长,说明队列满了

nstat -az TcpExtListenDrops
# TcpExtListenDrops        123    ← 因为队列满而丢弃的连接

全连接队列溢出时的行为

# 控制全连接队列满时的行为
sysctl net.ipv4.tcp_abort_on_overflow
# 0(默认)= 丢弃 ACK,让客户端重传(客户端会重试握手)
# 1 = 发送 RST(客户端立即收到"连接被拒绝")

# 推荐:保持默认 0
# 原因:如果应用只是暂时繁忙(比如 GC 停顿),
# 丢弃 ACK 让客户端重试可以自动恢复
# 发送 RST 则是永久失败,客户端会报错

七、SO_REUSEADDR 与 SO_REUSEPORT 的真正含义

这两个 socket 选项经常被混淆。它们解决的是完全不同的问题:

SO_REUSEADDR

// SO_REUSEADDR 解决的问题:
// 服务端重启后,旧连接的 TIME_WAIT 导致 bind() 失败
// "Address already in use"

int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

// SO_REUSEADDR 允许:
// 1. 绑定处于 TIME_WAIT 状态的地址
// 2. 绑定 0.0.0.0:80 即使已有 socket 绑定在 192.168.1.1:80
//
// SO_REUSEADDR 不允许:
// 1. 两个 socket 绑定完全相同的地址(如都绑 0.0.0.0:80)
// 2. 绕过 TIME_WAIT 的安全保护(旧包仍然不会被新连接接收)
# 演示:不设置 SO_REUSEADDR 的问题
# 1. 启动服务
python3 -c "
import socket
s = socket.socket()
s.bind(('', 8080))
s.listen(1)
print('Listening...')
s.accept()
" &

# 2. 连接并关闭
nc localhost 8080 < /dev/null

# 3. 立即重启服务
python3 -c "
import socket
s = socket.socket()
s.bind(('', 8080))  # OSError: Address already in use
"
# 因为上一个连接在 TIME_WAIT 中

SO_REUSEPORT

# SO_REUSEPORT 解决的问题:
# 多个进程/线程共享同一个端口,内核在它们之间分配连接

# 传统方式:fork 后共享 listen socket
# 问题:accept() 有惊群效应——一个新连接唤醒所有 worker

# SO_REUSEPORT 方式:每个 worker 独立 bind + listen 同一端口
# 内核用一致性哈希在 worker 之间均匀分配新连接

# 实际用途:
# 1. Nginx 的 reuseport 指令
# listen 80 reuseport;
# 每个 worker 进程独立 accept(),减少锁竞争

# 2. Go 的高性能服务器
# 多个 goroutine 各自 listen 同一端口

# 验证 reuseport 是否生效
ss -tnlp src :80
# 如果看到多行 LISTEN,每行不同 PID,说明在使用 reuseport
# LISTEN  0  4096  *:80  users:(("nginx",pid=1001,...))
# LISTEN  0  4096  *:80  users:(("nginx",pid=1002,...))
# LISTEN  0  4096  *:80  users:(("nginx",pid=1003,...))

SO_REUSEADDR vs SO_REUSEPORT 对比

特性 SO_REUSEADDR SO_REUSEPORT
主要用途 服务端快速重启 多进程端口共享
允许 TIME_WAIT 绑定 是(隐含)
多进程绑同一端口
内核负载均衡 一致性哈希
安全限制 要求相同 UID
生产中必须设置 几乎总是 高性能场景

八、TCP Keepalive:不只是心跳

TCP Keepalive 是内核级别的连接存活探测。当连接空闲超过一定时间,内核自动发送探测包检查对端是否还活着。

# TCP Keepalive 的三个参数
sysctl net.ipv4.tcp_keepalive_time
# 默认 7200(秒)= 2 小时!
# 连接空闲 2 小时后才开始发 Keepalive 探测

sysctl net.ipv4.tcp_keepalive_intvl
# 默认 75(秒)
# 每次 Keepalive 探测的间隔

sysctl net.ipv4.tcp_keepalive_probes
# 默认 9
# 连续多少次探测无响应后判定连接死亡

# 总超时时间 = tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes
# 默认 = 7200 + 75 * 9 = 7875 秒 ≈ 2 小时 11 分钟
# 太长了!

# 生产环境推荐值
sysctl -w net.ipv4.tcp_keepalive_time=60
sysctl -w net.ipv4.tcp_keepalive_intvl=10
sysctl -w net.ipv4.tcp_keepalive_probes=6
# 总超时 = 60 + 10 * 6 = 120 秒

Keepalive 不等于应用层心跳——二者的区别很重要:

# TCP Keepalive 的局限性:
# 1. 只能检测"对端内核是否还在",不能检测"应用是否正常"
#    对端进程死锁但内核正常 → Keepalive 仍然响应
# 2. 默认超时太长,不适合需要快速发现故障的场景
# 3. 中间设备(NAT/防火墙)可能代理回复 Keepalive

# 因此,生产系统通常同时使用:
# - TCP Keepalive(检测网络层连通性)
# - 应用层心跳(检测应用层可用性,比如 gRPC 的 Health Check)

# 用 ss 查看连接的 Keepalive 状态
ss -to state established
# 输出中的 timer 字段:
# timer:(keepalive,45sec,0)
#   keepalive — 当前在 keepalive 定时器中
#   45sec     — 距下次探测的剩余时间
#   0         — 已发送的探测次数

NAT 超时与 Keepalive 的关系

# NAT 设备维护连接跟踪表,空闲连接会被超时清除
# 如果 NAT 清除了连接映射,但两端 TCP 不知道:
# - 客户端发数据 → NAT 找不到映射 → 丢弃或回 RST
# - 从客户端看:连接莫名断开

# 解决:Keepalive 间隔必须小于 NAT 超时
# 常见 NAT 超时:
# - Linux conntrack: 默认 5 天(tcp_timeout_established = 432000s)
# - AWS NAT Gateway: 350 秒
# - 家用路由器: 通常 300-600 秒

# 如果通过 AWS NAT Gateway,Keepalive 间隔应 < 350 秒
sysctl -w net.ipv4.tcp_keepalive_time=180

九、TCP 连接问题的系统化诊断

flowchart TD
    A["TCP 连接异常"] --> B{"连接能建立吗?"}
    B -- "不能" --> C{"telnet/nc 测试<br>目标端口"}
    C -- "Connection refused" --> D["服务未启动<br>或端口未监听"]
    C -- "超时" --> E{"traceroute -T<br>-p 端口"}
    E --> F{"中间某跳断?"}
    F -- "是" --> G["防火墙/安全组<br>ACL 规则"]
    F -- "否" --> H["检查 SYN 队列<br>是否溢出"]
    B -- "能建立<br>但有问题" --> I{"什么问题?"}
    I -- "连接后立即断开" --> J["检查 RST 原因<br>抓包分析"]
    I -- "CLOSE_WAIT 堆积" --> K["应用 Bug<br>没有 close()"]
    I -- "TIME_WAIT 堆积" --> L["开启 tcp_tw_reuse<br>使用连接池"]
    I -- "连接数达上限" --> M["检查 ulimit -n<br>和 somaxconn"]

连接建立失败的排查清单

# 1. 确认服务端在监听
ss -tnlp | grep :端口号
# 如果没有输出 → 服务没启动或绑定了错误的地址

# 2. 确认客户端能到达服务端
nc -zv 服务端IP 端口号
# Connection refused → 服务端的 ACL 拒绝了(iptables/安全组)
# Connection timed out → 包被丢弃了(防火墙静默丢弃)

# 3. 检查防火墙规则
iptables -L -n -v | grep 端口号
# 或者
nft list ruleset | grep 端口号

# 4. 检查队列状态
ss -tnl src :端口号
# 如果 Recv-Q ≈ Send-Q → 全连接队列满
# 应用 accept() 太慢或被阻塞

# 5. 检查 SYN Cookie / 队列溢出统计
nstat -az | grep -E 'SyncookiesSent|ListenOverflows|ListenDrops'

# 6. 抓包确认握手过程
tcpdump -i eth0 -nn "tcp port 端口号 and (tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst)) != 0" -c 20
# 只抓 SYN、FIN、RST 包,快速看握手和关闭过程

RST 的来源分析

收到 RST 是一个信息量很大的事件。不同来源的 RST 代表不同的问题:

# RST 的常见来源:

# 1. 服务端主动 RST(连接到未监听的端口)
# 特征:SYN → RST+ACK
# 原因:端口没有 LISTEN

# 2. 防火墙 RST(连接被拒绝)
# 特征:SYN → RST(无 ACK,TTL 可能与正常包不同)
# 原因:iptables REJECT 规则

# 3. 应用层 RST(设置了 SO_LINGER l_linger=0)
# 特征:数据传输后突然 RST(不走 FIN 四次挥手)
# 原因:应用调用 close() 时发送缓冲区还有数据

# 4. 中间设备 RST(如 IDS/IPS 阻断)
# 特征:RST 包的 TTL 与正常数据包不同
# 诊断:比较 RST 包和正常包的 TTL
tcpdump -i eth0 -nn "tcp[tcpflags] & tcp-rst != 0" -v
# 看 ttl 字段,如果 RST 包的 TTL 与数据包差异大,
# 说明 RST 不是端点发的

# 5. 连接超时被回收
# 特征:长时间空闲后突然 RST
# 原因:NAT 设备或防火墙清除了连接跟踪

连接超时参数汇总

# TCP 连接生命周期中涉及的所有超时参数

# === 连接建立阶段 ===
sysctl net.ipv4.tcp_syn_retries          # SYN 重试次数(默认 6)
sysctl net.ipv4.tcp_synack_retries       # SYN+ACK 重试次数(默认 5)
sysctl net.ipv4.tcp_max_syn_backlog      # 半连接队列大小
sysctl net.core.somaxconn                # 全连接队列上限

# === 数据传输阶段 ===
sysctl net.ipv4.tcp_retries1             # 软重传阈值(默认 3,触发路由刷新)
sysctl net.ipv4.tcp_retries2             # 硬重传阈值(默认 15,约 13-30 分钟后放弃)

# === Keepalive ===
sysctl net.ipv4.tcp_keepalive_time       # 空闲多久开始探测(默认 7200s)
sysctl net.ipv4.tcp_keepalive_intvl      # 探测间隔(默认 75s)
sysctl net.ipv4.tcp_keepalive_probes     # 探测次数(默认 9)

# === 连接关闭阶段 ===
sysctl net.ipv4.tcp_fin_timeout          # FIN_WAIT_2 超时(默认 60s)
sysctl net.ipv4.tcp_max_tw_buckets       # TIME_WAIT 上限(默认 262144)
sysctl net.ipv4.tcp_tw_reuse             # 是否复用 TIME_WAIT(默认 2)

# === 推荐的生产配置 ===
cat >> /etc/sysctl.d/tcp-tuning.conf << 'EOF'
net.ipv4.tcp_syn_retries = 3
net.ipv4.tcp_synack_retries = 3
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 6
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 30
EOF
sysctl -p /etc/sysctl.d/tcp-tuning.conf

十、结论

TCP 连接管理看似简单,实际上是后端工程中最常遇到的问题源头之一。理解 TCP 的 11 个状态不是为了考试——是为了在生产环境中快速定位”连接超时”“连接被拒”“连接泄漏”等问题的根因。

几个关键判断标准:

  1. TIME_WAIT 大量堆积:先确认是否真的影响了服务(端口耗尽?),如果是,优先用连接池减少短连接,其次开启 tcp_tw_reuse。永远不要用已被移除的 tcp_tw_recycle

  2. CLOSE_WAIT 持续增长:这几乎 100% 是应用 Bug——某个代码路径没有关闭 socket。用 ss -tnp state close-wait 找到进程,检查代码中的 close/defer/finally 逻辑。

  3. SYN Flood 防御:Linux 默认的 SYN Cookie(tcp_syncookies=1)在大多数场景下足够。但要注意 SYN Cookie 会禁用 SACK 和窗口缩放——如果你的高带宽长距离连接被 SYN Cookie 影响,需要增大 SYN 队列而不是依赖 Cookie。

  4. 全连接队列溢出ss -tnl 看到 Recv-Q 接近 Send-Q 时,说明应用 accept() 太慢。增大 somaxconn 只是缓解,根本原因通常是应用层处理瓶颈(GC 停顿、锁竞争、I/O 阻塞)。

我的经验是:大多数 TCP 连接问题的根因不在 TCP 本身,而在应用层。 TCP 状态机只是忠实地反映了应用的行为——应用不 close,就有 CLOSE_WAIT;应用频繁短连接,就有 TIME_WAIT。理解 TCP 状态机的价值在于:它是你诊断应用层问题的窗口。


上一篇:ICMP 与网络诊断:ping 和 traceroute 的工程本质

下一篇:TCP 可靠传输:序列号、确认与重传机制

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · network

网络工程索引

汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。

2025-07-30 · network

【网络工程】内核网络参数调优:sysctl 全景与实战

Linux 内核网络参数是系统网络性能的基础旋钮。本文从 /proc/sys/net/ 的参数体系出发,系统讲解收发缓冲区自动调优、TCP Backlog 队列、conntrack 连接追踪表、SYN Flood 防护参数、TIME_WAIT 管理,以及参数调优的系统化方法论——先基准、再调整、后验证。

2025-07-27 · network

【网络工程】DDoS 防御架构:容量型、协议型与应用层攻击

DDoS 攻击分为容量型、协议型和应用层三大类,防御策略截然不同。本文从攻击分类学出发,系统讲解 SYN Flood 的 SYN Cookie 防御、UDP 反射放大的 BGP Flowspec 清洗、HTTP Flood 的速率限制与行为分析,以及 Anycast 清洗中心的工作原理,构建从边缘到源站的多层 DDoS 防御体系。


By .