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 中协商,连接建立后不能更改。这意味着:
- 如果 SYN 中没有带
sackOK,整条连接都不能使用 SACK - 如果 SYN 中没有带
wscale,整条连接的窗口上限是 65535 字节 - 如果 SYN 中没有带
TS,整条连接都不能使用 TCP 时间戳
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 个状态。每个状态都可以用 ss 或
netstat 观察到:
| 状态 | 含义 | 持续时间 | 告警阈值 |
|---|---|---|---|
| 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 秒),有两个工程目的:
确保最后一个 ACK 到达对端。 如果对端没收到 ACK,会重发 FIN。如果主动关闭方已经释放了连接,收到重发的 FIN 后会回复 RST——对端会认为连接异常关闭。TIME_WAIT 状态让主动关闭方可以正确响应重发的 FIN。
防止旧连接的延迟包干扰新连接。 如果立即复用同一个四元组(源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.4TIME_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 Cookie 的工程细节
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 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,但有功能限制:
- 不能使用 TCP 选项:MSS 只能编码 8 种值(3 bit)、窗口缩放和 SACK 无法传递。这意味着 SYN Cookie 建立的连接不支持 SACK 和窗口缩放
- 每次都要计算 hash:有一定的 CPU 开销
- 时间窗口: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 个状态不是为了考试——是为了在生产环境中快速定位”连接超时”“连接被拒”“连接泄漏”等问题的根因。
几个关键判断标准:
TIME_WAIT 大量堆积:先确认是否真的影响了服务(端口耗尽?),如果是,优先用连接池减少短连接,其次开启
tcp_tw_reuse。永远不要用已被移除的tcp_tw_recycle。CLOSE_WAIT 持续增长:这几乎 100% 是应用 Bug——某个代码路径没有关闭 socket。用
ss -tnp state close-wait找到进程,检查代码中的 close/defer/finally 逻辑。SYN Flood 防御:Linux 默认的 SYN Cookie(
tcp_syncookies=1)在大多数场景下足够。但要注意 SYN Cookie 会禁用 SACK 和窗口缩放——如果你的高带宽长距离连接被 SYN Cookie 影响,需要增大 SYN 队列而不是依赖 Cookie。全连接队列溢出:
ss -tnl看到 Recv-Q 接近 Send-Q 时,说明应用 accept() 太慢。增大somaxconn只是缓解,根本原因通常是应用层处理瓶颈(GC 停顿、锁竞争、I/O 阻塞)。
我的经验是:大多数 TCP 连接问题的根因不在 TCP 本身,而在应用层。 TCP 状态机只是忠实地反映了应用的行为——应用不 close,就有 CLOSE_WAIT;应用频繁短连接,就有 TIME_WAIT。理解 TCP 状态机的价值在于:它是你诊断应用层问题的窗口。
上一篇:ICMP 与网络诊断:ping 和 traceroute 的工程本质
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
网络工程索引
汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。
【网络工程】内核网络参数调优:sysctl 全景与实战
Linux 内核网络参数是系统网络性能的基础旋钮。本文从 /proc/sys/net/ 的参数体系出发,系统讲解收发缓冲区自动调优、TCP Backlog 队列、conntrack 连接追踪表、SYN Flood 防护参数、TIME_WAIT 管理,以及参数调优的系统化方法论——先基准、再调整、后验证。
【网络工程】DDoS 防御架构:容量型、协议型与应用层攻击
DDoS 攻击分为容量型、协议型和应用层三大类,防御策略截然不同。本文从攻击分类学出发,系统讲解 SYN Flood 的 SYN Cookie 防御、UDP 反射放大的 BGP Flowspec 清洗、HTTP Flood 的速率限制与行为分析,以及 Anycast 清洗中心的工作原理,构建从边缘到源站的多层 DDoS 防御体系。
【网络工程】TCP 可靠传输:序列号、确认与重传机制
从工程视角剖析 TCP 可靠传输的核心机制——序列号与确认的精确语义、RTO 计算的数学基础、快速重传与 SACK 的工程价值、DSACK 的重复检测,以及重传对延迟的放大效应与实际诊断方法。