TCP TIME_WAIT 占满端口导致支付网关全面失败的复盘:90 分钟故障 + 5 种修法 + 8 条网络编程纪律

一次"优化"上线把 HttpClient 从连接池改成每次 new,90 分钟内 1.2 万 QPS 把网关本地端口耗尽,所有外部银行接口失败。本文复盘事故全过程、TIME_WAIT 的内核机制、5 种修法实测对比(连接池/tcp_tw_reuse/扩端口范围等)、8 条网络编程纪律、DNS/eBPF/TLS 等深度治理经验。

2025 年 7 月 18 日下午 3 点 47 分,我们的支付网关突然报警:对接外部银行接口的成功率从 99.9% 跌到 23%,大量交易卡在"已扣款但未确认"状态,客服电话被打爆。这次事故的根因不是网络抖动、不是对端故障、不是证书过期,而是一个所有写过后端的程序员都听过、但很少有人真正理解过的东西:**TCP TIME_WAIT 状态**。我们花了 7 小时定位、3 天彻底修复,这篇文章把这次事故的全过程、TIME_WAIT 的工作机制、5 种修法的实测对比、以及我们最终立下的 8 条网络编程纪律完整记下来。

服务背景与事故全景

事故发生在一家中型第三方支付公司,我负责支付网关组,日均 800 万笔交易,峰值 QPS 约 1.2 万。网关用 Java 17 + Spring Boot 3.1 + Apache HttpClient 5.2 写成,后端对接 28 家银行接口、4 家三方风控、2 家短信网关,所有外部调用都是同步 TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 请求,平均响应时间 200ms,网关层 P99 延迟 350ms。

网关部署在 12 台云主机(8 核 16GB,CentOS 7.9 内核 3.10),每台跑两个 JVM 实例,通过 Nginx 做四层负载均衡。这套架构跑了快两年,从来没出过大问题,直到 7 月 18 日下午一次普通的上线把它推下了悬崖。

事故时间线

时刻 事件
14:00 上线一个性能优化版本:把对接银行的 HttpClient 从 PoolingHttpClientConnectionManager 改成每次 new 一个,理由是"连接池有时候会拿到坏连接,重试很麻烦,反正机器多"
14:15 上线完成,监控显示一切正常,P99 比之前还快了 20ms,大家鼓掌散会
15:32 报警:某家银行接口成功率从 99.9% 跌到 80%,以为是对端问题,提工单
15:47 4 家银行接口同时告警,成功率全部跌破 50%,不可能同时坏,开始怀疑自己
15:58 客服电话被打爆,业务方升级到 P0,CEO 在工作群点名
16:10 SSH 上网关机器,发现执行任何命令都卡 3-5 秒,ping 外网都丢包
16:25 ss -s 看到 TIME_WAIT 状态的连接数:**32847**,而本机可用端口只有 28232
16:40 怀疑端口耗尽,但不敢确认,先回滚上线版本止血
17:08 回滚完成 30 分钟后,TIME_WAIT 缓慢下降到 8000 左右,成功率恢复
20:30 恢复后开始正式复盘,确认是端口耗尽,但具体机制要查内核源码
次日 查 net/ipv4/tcp.c 源码 + 实测验证,确认 root cause
D+3 5 种修法上线完成,稳定运行至今

第一轮排查:为什么 ping 外网都丢包

SSH 上机器后第一感觉就是不对:执行任何命令都要等几秒。这种症状一般是磁盘 IO 卡了或者负载特别高,但 top 看 load 才 1.5,iostat 看磁盘空闲,完全不像负载问题。然后我尝试 ping 一下外网验证网络:

# ping 外网公共 DNS,正常应该 1-2ms
$ ping -c 5 8.8.8.8
PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data.
64 bytes from 8.8.8.8: icmp_seq=1 ttl=110 time=2.13 ms
ping: sendmsg: Cannot assign requested address    # <-- 第 2 个包就出错了
ping: sendmsg: Cannot assign requested address
ping: sendmsg: Cannot assign requested address
ping: sendmsg: Cannot assign requested address

--- 8.8.8.8 ping statistics ---
5 packets transmitted, 1 received, 80% packet loss, time 4030ms

"Cannot assign requested address" 这个错误我之前在写 K8s 边车的时候见过,意思是内核找不到可用的本地端口给这次 outgoing 连接用。**这是端口耗尽的标志性错误。** 接着我看了下 ss 统计:

# ss -s 显示 socket 统计概要
$ ss -s
Total: 145 (kernel 0)
TCP:   34221 (estab 1342, closed 32847, orphaned 0, synrecv 0, timewait 32847/0), ports 0

Transport Total     IP        IPv6
*	  0         -         -
RAW	  0         0         0
UDP	  6         3         3
TCP	  1374      1368      6
INET	  1380      1371      9
FRAG	  0         0         0

# 关键数字:timewait 32847,这就是 TIME_WAIT 状态的连接数
# 而 ip_local_port_range 默认是 32768-60999,可用端口约 28232 个
# 32847 > 28232,端口确实被吃完了

TIME_WAIT 的本质:为什么它会吃光端口

排查完现场后我们才有空回去想 TIME_WAIT 到底是什么。教科书都说 TIME_WAIT 是 TCP 四次挥手中,**主动关闭方**在最后一次发送 ACK 后进入的状态,持续 2*MSL(默认 60 秒),目的是确保最后的 ACK 不会丢失、并且让对端的 FIN 重传能被正确响应。

关键在于:**TIME_WAIT 占用的是客户端的本地端口**。每个 TCP 连接由 (源 IP, 源端口, 目的 IP, 目的端口) 四元组唯一标识,服务端的端口固定(443/80),但客户端的端口是内核从 ip_local_port_range 范围内分配的。当我们的网关短时间内对同一个银行接口建立大量短连接,每次连接关闭后端口都进入 TIME_WAIT 60 秒,而 60 秒内如果新连接的数量超过可用端口数,就会出现"Cannot assign requested address"。

所以这次事故的因果链是:**上线代码改成每次 new HttpClient → 每次请求都是一个全新的 TCP 连接 → 请求完关闭 → 进入 TIME_WAIT 60 秒 → QPS 1.2 万 → 每秒新建 600 个连接对单家银行 → 60 秒累积 36000 个 TIME_WAIT → 超过可用端口数 28232 → 新连接申请不到端口 → 整个网关与所有外部服务都断了**。

5 种修法:我们试了一遍才找到对的

修法 1:复用连接池(根本解法)

最直接也是最正确的解法,就是回到 HttpClient 连接池。每个目标银行用一个连接池,池内连接长期保持(Keep-Alive),复用同一个 TCP 连接发多次请求,这样根本不会进入 TIME_WAIT。

// 错误写法:每次 new
public Response callBank(String url, Request req) {
    try (CloseableHttpClient client = HttpClients.createDefault()) {  // 每次创建
        return client.execute(...);
    }  // close 触发 TCP 关闭 → TIME_WAIT
}

// 正解:全局连接池
private static final PoolingHttpClientConnectionManager POOL;
private static final CloseableHttpClient CLIENT;
static {
    POOL = new PoolingHttpClientConnectionManager();
    POOL.setMaxTotal(200);
    POOL.setDefaultMaxPerRoute(50);     // 单家银行最大 50 个连接
    POOL.setValidateAfterInactivity(TimeValue.ofSeconds(10));  // 拿连接前先 ping
    CLIENT = HttpClients.custom()
        .setConnectionManager(POOL)
        .evictIdleConnections(TimeValue.ofMinutes(2))   // 空闲 2 分钟回收
        .evictExpiredConnections()
        .setDefaultRequestConfig(RequestConfig.custom()
            .setConnectTimeout(Timeout.ofSeconds(2))
            .setResponseTimeout(Timeout.ofSeconds(5))
            .build())
        .build();
}
public Response callBank(String url, Request req) {
    return CLIENT.execute(...);  // 复用连接,不会 TIME_WAIT
}

这个改完上线后,TIME_WAIT 数量从 32000+ 降到 800 左右,网关稳如老狗。**所有用 HttpClient/OkHttp/Reactor 做下游调用的服务,都必须用全局静态连接池,不要每次 new。**

修法 2:tcp_tw_reuse 内核参数

对于客户端场景(网关主动发起连接),可以开启 net.ipv4.tcp_tw_reuse,让 TIME_WAIT 状态的端口能被新连接重用,前提是新连接的时间戳(TCP timestamp option)比 TIME_WAIT 的最后一个包大。

# 检查当前值
$ sysctl net.ipv4.tcp_tw_reuse
net.ipv4.tcp_tw_reuse = 0

# 修改(临时)
$ sysctl -w net.ipv4.tcp_tw_reuse=1

# 持久化(写入 /etc/sysctl.conf)
$ echo 'net.ipv4.tcp_tw_reuse = 1' >> /etc/sysctl.conf
$ sysctl -p

# 注意:tcp_tw_reuse 只对客户端(outgoing 连接)有效
# 而且需要两端都开启 net.ipv4.tcp_timestamps = 1(默认就是 1)

这个修法的好处是不用改代码,内核层面解决;坏处是依赖 TCP timestamp,如果中间有 NAT 设备改写时间戳就会失效。我们这次没单独用这个修法,但作为兜底配上了,作为修法 1 的保险。

修法 3:不要用 tcp_tw_recycle

这里特别要说一下 net.ipv4.tcp_tw_recycle —— 网上一堆老博客让你开这个,**千万不要开**。tcp_tw_recycle 在 NAT 环境下会导致来自同一个公网 IP 的不同客户端被错误识别为同一连接、TCP 时间戳乱序导致丢包,引发难以排查的诡异问题。**Linux 内核 4.12 已经直接把这个参数移除了**,但 CentOS 7 还有,千万别手贱开。

# 这个参数千万不要开,会引发 NAT 环境下的诡异丢包
$ sysctl net.ipv4.tcp_tw_recycle
# CentOS 7 还有这个参数,值应该保持 0
net.ipv4.tcp_tw_recycle = 0

# Linux 4.12+ 已经移除了这个参数
# 别看老文章让你开,会出大问题

修法 4:扩大 ip_local_port_range

默认的 ip_local_port_range 是 32768-60999,共 28232 个端口。如果业务确实需要短连接,可以把这个范围扩大到 1024-65535,理论上多出 36000 个可用端口。但要注意 1024 以下是系统保留端口,1024-32768 之间可能有其他服务监听(比如 MySQL 的 3306、Redis 的 6379),需要确认不会冲突。

# 查看当前范围
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768	60999

# 修改为更大范围(注意避开本机监听的端口)
$ sysctl -w net.ipv4.ip_local_port_range="10000 65535"

# 持久化
$ echo 'net.ipv4.ip_local_port_range = 10000 65535' >> /etc/sysctl.conf

# 但这只是治标,不是治本
# 真正的解法是用连接池,减少端口消耗

修法 5:降低 tcp_fin_timeout(谨慎)

net.ipv4.tcp_fin_timeout 控制 FIN_WAIT_2 状态的超时(注意不是 TIME_WAIT 的 2*MSL),默认 60 秒。如果你的网络环境稳定、对端响应及时,可以把它调小到 30 秒。但 TIME_WAIT 的 60 秒是写死在内核宏 TCP_TIMEWAIT_LEN 里的,要改只能重新编译内核,所以这条修法效果有限。

$ sysctl net.ipv4.tcp_fin_timeout
net.ipv4.tcp_fin_timeout = 60

# 不建议改,除非你真的清楚自己在做什么
# 多数情况下,优先用连接池 + tcp_tw_reuse 就够了

5 种修法实测对比

修法 是否需要改代码 对 TIME_WAIT 影响 稳定性 推荐场景
连接池 从根本上避免 TIME_WAIT ★★★★★ 所有 HTTP 客户端,首选
tcp_tw_reuse=1 TIME_WAIT 端口可复用 ★★★★☆ 客户端短连接兜底
tcp_tw_recycle 会引发 NAT 环境丢包 ★(已废弃) 千万别开
扩大端口范围 缓解,不解决 ★★★☆☆ 临时止血
降低 tcp_fin_timeout 对 TIME_WAIT 几乎无影响 ★★☆☆☆ FIN_WAIT_2 多的场景

排查决策树:看到 Cannot assign requested address 怎么办

我们立下的 8 条网络编程纪律

  1. 所有 HTTP 客户端必须用全局静态连接池,禁止每次 new。code review 时这是 P0 红线。
  2. 连接池必须配 validateAfterInactivity + evictIdleConnections,避免拿到坏连接重试。
  3. tcp_tw_reuse 默认开启,作为兜底。tcp_tw_recycle 默认关闭(且别去重新打开)。
  4. 监控 TIME_WAIT 数量,超过 ip_local_port_range 一半触发告警。
  5. 下游服务调用必须设 Keep-Alive,HttpClient 默认就是 keepalive,但要确认对端服务器没有禁用。
  6. 不要在 finally 里随便 close client,client 是共享资源,close 的是 response。
  7. 对内服务调用用 gRPC 长连接,不要每个请求新建 HTTP 短连接。
  8. 压测必须包含长时间稳定性测试,30 秒压测看不出 TIME_WAIT 问题,要至少 10 分钟以上。

容易踩的 6 个网络坑(顺便复盘)

这次事故之后,我把团队历史上踩过的网络相关坑都翻了一遍,整理出 6 个最容易踩的:

坑 1:DNS 解析慢导致连接建立慢。我们曾经遇到过一次,因为底层 DNS 服务器响应慢,每次新建连接都要花 1.2 秒解析域名,P99 直接爆炸。解法是用 IP 直连 + 客户端缓存 DNS 结果(比如 dnscache 或者 Caffeine cache)。

坑 2:Keep-Alive 超时不一致。客户端 Keep-Alive 60 秒,但服务端 Nginx 配的 keepalive_timeout 30 秒。结果客户端拿到一个已经被服务端关闭的连接,发请求就 RST,触发重试,放大延迟。解法是客户端的超时必须比服务端短,推荐 10 秒以内。

坑 3:SO_REUSEADDR 和 SO_REUSEPORT 混淆。SO_REUSEADDR 允许 bind 一个还在 TIME_WAIT 的端口,SO_REUSEPORT 允许多个 socket 同时 bind 同一个端口(用于 SO_REUSEPORT 多进程负载均衡)。两个不是一回事,别搞混。

坑 4:Nagle 算法和 Delayed ACK 互锁。Nagle 攒小包发,Delayed ACK 攒 ACK 发,两个加在一起可能导致 40ms 的延迟尖刺。对延迟敏感的服务建议 setTcpNoDelay(true)。

坑 5:TCP RST 和 FIN 的区别。RST 是异常关闭,会丢失 socket buffer 里没读完的数据;FIN 是正常关闭。如果你看到日志里大量 Connection reset by peer,基本是对端 RST 了你,要查为什么。

坑 6:socket buffer 过小导致吞吐受限。默认 socket buffer 一般 87KB,在长肥管道(高带宽高延迟)下吞吐会被限制在 buffer/RTT。需要调大 net.core.rmem_max 和 wmem_max,以及应用层 setReceiveBufferSize/setSendBufferSize。

排查工具箱:这些命令我们用到吐

# 1. ss 替代 netstat,更快更全
ss -s                       # socket 统计概要
ss -ant | awk '{print $1}' | sort | uniq -c   # 各状态连接数
ss -anpt state time-wait    # 列出所有 TIME_WAIT 连接

# 2. 看本地端口使用情况
cat /proc/sys/net/ipv4/ip_local_port_range
ss -ant | awk '$5 ~ /:/{print $5}' | awk -F: '{print $NF}' | sort -u | wc -l

# 3. tcpdump 抓包
tcpdump -i eth0 -nn -s 0 host bank.example.com and port 443 -w bank.pcap
# 然后用 wireshark 打开 bank.pcap,过滤 tcp.flags.fin == 1 看四次挥手

# 4. 内核参数全量检查
sysctl -a | grep -E 'tcp_tw_|ip_local_port|tcp_fin_timeout|tcp_keepalive'

# 5. 连接耗尽时验证
ping 8.8.8.8                # 如果报 sendmsg: Cannot assign requested address 就是端口耗尽
strace -e trace=connect curl https://bank.example.com  # 看 connect 系统调用的错误码

HTTP/2 和 HTTP/3 时代,TIME_WAIT 还是问题吗?

事故复盘后有人问:HTTP/2 不是多路复用吗,一个连接可以并发多个请求,是不是就不会有 TIME_WAIT 问题了?这个说法部分正确。HTTP/2 在同一个 TCP 连接上多路复用多个 stream,理论上可以用 1 个长连接搞定所有请求,确实不会有 TIME_WAIT。但实际工程中:

第一,很多银行接口还是 HTTP/1.1 甚至 HTTP/1.0,不支持 HTTP/2,你想用也用不了。第二,即使用了 HTTP/2,如果客户端代码每次 new 一个新连接,依然是短连接,该 TIME_WAIT 还是 TIME_WAIT,协议不背锅。第三,HTTP/3 用 QUIC 在 UDP 之上,不存在 TIME_WAIT 这个问题,但目前商用支持度还很低,大部分企业还在 HTTP/1.1 + HTTP/2 的混合时代。

# 用 curl 看一个站点支持的 HTTP 版本
curl -I --http2 https://www.cloudflare.com 2>&1 | grep -i 'HTTP/'

# 用 nghttp2 工具明确发起 HTTP/2 请求
nghttp -v https://www.cloudflare.com

# 看 OpenResty/Nginx 是否启用了 HTTP/2
grep -r 'listen.*http2' /etc/nginx/

容器化环境下的特殊性

我们后来把网关迁移到了 Kubernetes,这时又踩了一个新坑:**容器的 net.ipv4.ip_local_port_range 是从宿主机继承的,不能在 Pod 里随便改**。如果用 Cilium/Calico 等 CNI,某些情况下会做端口 SNAT,大量短连接经过同一个 SNAT 出口时,容易触发 conntrack 表溢出,而不是单机端口耗尽。

# 在 K8s 节点上查 conntrack 表
sysctl net.netfilter.nf_conntrack_max
sysctl net.netfilter.nf_conntrack_count

# 如果 count 接近 max,需要调大
sysctl -w net.netfilter.nf_conntrack_max=1048576
echo 'net.netfilter.nf_conntrack_max=1048576' >> /etc/sysctl.conf

# 容器内还要确认能不能用 init container 改 sysctl
# 大部分托管 K8s 不允许 Pod 改宿主机 sysctl,需要 DaemonSet 改节点

跨语言的连接池经验对比

语言/客户端 默认行为 正确做法
Java HttpClient (Apache) 不默认池化 必须显式 PoolingHttpClientConnectionManager
Java OkHttp 默认有连接池,5 个空闲 设置 ConnectionPool(maxIdle, keepAliveDuration)
Go net/http 默认 DefaultTransport 有池 但要设置 MaxIdleConnsPerHost,不然默认 2
Python requests 每个 Session 一个池 用 Session 而不是 requests.get
Node.js http 默认 keep-alive: false 显式 new http.Agent({keepAlive:true})
Rust reqwest 默认有池 Client 全局复用,不要每次 new

给老服务做网络治理的步骤

事故之后,我们把这套治理推广到了公司其他 30 多个对外服务,总结出一套 5 步法,效果不错:

第一步:盘点所有 outgoing 连接。用 ss -anpt state established 看每个进程对外建立了多少连接,哪些是长连接、哪些是短连接。短连接是隐患候选。

第二步:监控 TIME_WAIT。Prometheus + node_exporter 采集 node_sockstat_TCP_tw 指标,超过 ip_local_port_range 的 50% 就告警。我们的告警阈值是 14000(端口范围一半)。

第三步:代码扫描。用 SemGrep 写规则扫所有创建 HttpClient/OkHttpClient/Session 的代码,看是不是全局静态。我们扫出了 47 处违规,陆续改完。

第四步:压测验证。所有改造后的服务跑 30 分钟全速压测,观察 TIME_WAIT 是否稳定在低位。我们的标准是稳态 TIME_WAIT 不超过 2000。

第五步:上线灰度。改造后的服务先在 10% 流量灰度 72 小时,无异常再全量。

深入 TIME_WAIT:为什么是 2*MSL = 60 秒

事故复盘的时候,有同事问了一个很好的问题:TIME_WAIT 为什么必须等 60 秒,30 秒不行吗?直接关掉不行吗?这个问题的答案藏在 TCP 协议的可靠性保证里,值得展开讲一下。

MSL 全称 Maximum Segment Lifetime,即一个 TCP 报文在网络中存在的最长时间。RFC 793 把 MSL 设为 2 分钟,Linux 内核保守起见把它定义成 30 秒,所以 2*MSL = 60 秒。TIME_WAIT 持续这么长时间有两个目的:

目的 1:保证最后一个 ACK 能可靠到达对端。在四次挥手的最后一步,主动关闭方发送 ACK 给对端确认 FIN。如果这个 ACK 在网络中丢了,对端会重传 FIN,这时主动关闭方还在 TIME_WAIT 状态,可以重新发 ACK。如果不等 60 秒就直接关闭,重传的 FIN 会收到 RST,对端会认为出错,影响其他业务。

目的 2:让旧连接的"游离"报文在网络中消失。假设某个延迟的报文还在网络里飘,如果同样的四元组很快被新连接复用,旧报文就可能被新连接收到,引发数据混乱。等待 2*MSL 确保所有旧报文都过期了,新连接安全。

# 查看 Linux 内核里 TIME_WAIT 的硬编码
$ grep -r 'TIME_WAIT' /usr/include/netinet/tcp.h
# 内核源码 include/net/tcp.h:
# #define TCP_TIMEWAIT_LEN (60*HZ)
# HZ 通常是 1000 或 100,所以是 60 秒

# 想改这个值只能重编内核,sysctl 改不了
# 这就是为什么 net.ipv4.tcp_fin_timeout 改了也没用,因为它控制的是 FIN_WAIT_2 不是 TIME_WAIT

所以理论上 TIME_WAIT 是必要的,你不能简单地"关掉"。但实际工程中,我们的网关和银行接口之间网络稳定,2*MSL 60 秒是冗余的,用 tcp_tw_reuse 让它"看起来关掉"是 OK 的,前提是开了 TCP timestamp。

SYN flood 与 SYN cookie 的隐藏关联

事故复盘后第二个月,我们发现一个相关问题:网关在某些高峰时刻会偶发"connection timed out",但 TIME_WAIT 完全正常。tcpdump 抓包发现是 SYN 发出去后没有 SYN-ACK 回来,客户端重传几次后超时。最后定位是上游 Nginx 的 SYN 队列满了。

# 查看 SYN 队列(半连接队列)的相关参数
$ sysctl net.ipv4.tcp_max_syn_backlog
net.ipv4.tcp_max_syn_backlog = 128       # 太小了,默认 128

$ sysctl net.core.somaxconn
net.core.somaxconn = 128                  # accept 队列长度

$ sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_syncookies = 1               # 开启 SYN cookie 抗 SYN flood

# 推荐配置(高并发服务):
# net.ipv4.tcp_max_syn_backlog = 8192
# net.core.somaxconn = 32768
# 应用层 listen(fd, backlog) 的 backlog 也要够大
# Spring Boot:server.tomcat.accept-count = 32768

SYN cookie 是一个有趣的机制:当 SYN 队列满时,内核不再把 SYN 信息存在内存里,而是把状态编码进 SYN-ACK 的序列号中,等客户端 ACK 回来时再解码恢复连接。这样即使被 SYN flood 攻击,服务器也不会内存溢出。但 SYN cookie 模式下不支持 TCP 扩展选项(SACK、Window Scaling),所以平时不应该频繁触发,只是兜底。

TCP 拥塞控制对延迟的影响

这次事故还让我们注意到了拥塞控制算法的选择。Linux 内核默认的拥塞控制是 cubic,在长肥管道(高带宽高 RTT)下表现还行,但在数据中心内部短 RTT 场景下,bbr 算法可以显著降低延迟。我们后来把网关的拥塞控制从 cubic 换成 bbr,P99 延迟降低了 8%。

# 查看可用的拥塞控制算法
$ sysctl net.ipv4.tcp_available_congestion_control
net.ipv4.tcp_available_congestion_control = reno cubic bbr

# 查看当前算法
$ sysctl net.ipv4.tcp_congestion_control
net.ipv4.tcp_congestion_control = cubic

# 切换到 bbr(需要内核 4.9+)
$ sysctl -w net.ipv4.tcp_congestion_control=bbr

# bbr 不像 cubic 那样依赖丢包作为拥塞信号
# 而是用 BtlBw(瓶颈带宽)+ RTprop(往返延迟)建模
# 在高丢包率网络下显著优于 cubic
# 但在 LAN 内部、低丢包率环境差异不大

注意 bbr 不是银弹,在某些场景下 bbr 对其他流量"不公平"(它会抢占带宽),如果你的网关跟其他用 cubic 的服务共享出口带宽,bbr 可能让别人变慢。我们的部署环境是独立 NAT 出口,所以可以放心用。

TLS 握手开销与会话复用

除了 TCP 层面,HTTPS 还有 TLS 握手的开销。一次完整的 TLS 1.2 握手要 2 个 RTT,TLS 1.3 优化到 1 个 RTT,0-RTT 模式甚至可以做到 0 RTT(第二次连接时)。我们的网关到银行接口都是 HTTPS,如果每次 new 连接,TLS 握手开销 50-100ms 是跑不掉的,这也是必须用连接池的另一个理由。

# 测量 TLS 握手时间
$ curl -w "@curl-format.txt" -o /dev/null -s https://bank.example.com/api/health
# curl-format.txt:
#   time_namelookup:    %{time_namelookup}

#   time_connect:       %{time_connect}

#   time_appconnect:    %{time_appconnect}
   <-- TLS 握手完成时间
#   time_starttransfer: %{time_starttransfer}

#   time_total:         %{time_total}


# 复用连接(--keepalive-time)
$ curl --keepalive-time 60 ...

# Java HttpClient 5 默认会话复用
# 但有些老版本 OkHttp 默认 sessionTickets 关闭,要显式打开

抓包技巧:Wireshark 进阶过滤

这次事故的复盘过程中,wireshark 帮了大忙。把我们常用的过滤表达式列一下,免得每次现查:

# 1. 看四次挥手序列
tcp.flags.fin == 1 or tcp.flags.reset == 1

# 2. 看重传
tcp.analysis.retransmission

# 3. 看零窗口(接收端 buffer 满)
tcp.window_size == 0 and tcp.flags.reset != 1

# 4. 看 TLS 握手失败
ssl.handshake.type == 1 or ssl.alert_message

# 5. 看特定 IP 之间的所有交互
ip.addr == 10.0.1.20 and ip.addr == 10.0.2.30

# 6. 排查 TIME_WAIT 端口复用
tcp.flags.syn == 1 and tcp.stream eq N    # 同一个端口被多次连接

压测脚本:模拟事故场景验证修复

修复完上线前,我们写了一个简单的压测脚本模拟事故场景,确保修复有效:

import asyncio
import aiohttp
import time
import socket

async def call_bank(session, idx):
    try:
        async with session.get('https://bank.example.com/api/health',
                               timeout=aiohttp.ClientTimeout(total=5)) as resp:
            return resp.status
    except Exception as e:
        return f'ERR: {type(e).__name__}'

async def stress_test():
    # 不复用 connector,模拟 bug 版本
    conn = aiohttp.TCPConnector(force_close=True)   # 关闭复用
    # conn = aiohttp.TCPConnector(limit=200, limit_per_host=50)   # 修复版本
    async with aiohttp.ClientSession(connector=conn) as session:
        for round_idx in range(60):  # 跑 1 分钟
            tasks = [call_bank(session, i) for i in range(200)]
            results = await asyncio.gather(*tasks)
            ok = sum(1 for r in results if r == 200)
            print(f'round {round_idx}: ok={ok}/200')
            # 同时 ss -s 看 TIME_WAIT 数量
            await asyncio.sleep(1)

asyncio.run(stress_test())

用 force_close=True 跑,30 秒后开始出现"Cannot assign requested address",1 分钟后大量失败,完美复现事故。改成 limit=200 的复用模式,1 分钟内 TIME_WAIT 稳定在 50 以下,成功率 100%。这套压测脚本后来成了我们网络代码上线前的标准回归测试。

DNS 解析慢的连环坑

事故之后,我们在做网络治理时又挖出了一个潜伏多年的问题:DNS 解析。我们的网关每次发起到银行接口的请求,都会做一次 DNS 解析。本来 glibc 自己有 DNS 缓存,但 nscd 不可靠、systemd-resolved 在某些云主机上没装,导致每次解析都打到 /etc/resolv.conf 里的 DNS 服务器,平均耗时 80ms。在网关高并发场景下,这 80ms 占了整个请求耗时的 30%。

# 测量 DNS 解析时间
$ dig +tries=1 +time=2 bank.example.com | grep 'Query time'
;; Query time: 78 msec

# 看 /etc/nsswitch.conf 里的 hosts 解析顺序
$ grep ^hosts /etc/nsswitch.conf
hosts:      files dns

# 启用 nscd 缓存(已经有点过时)
$ systemctl start nscd
$ nscd -g | grep -A 5 'hosts:'

# 推荐方案:在应用层缓存 DNS
# Java InetAddress.getAllByName 默认 -1 永久缓存,但 JVM 退出失效
# networkaddress.cache.ttl = 60 限制 60 秒,平衡安全和性能

我们的解决方案是用 Caffeine 在应用层做 DNS 缓存,TTL 60 秒,并且配合 Health Check 主动剔除不可用 IP。改造完成后,DNS 解析的耗时从平均 80ms 降到 0.1ms 以下,P99 网关延迟从 350ms 降到 240ms。

用 eBPF 观测网络问题:tcptracer / tcpconnect / tcpretrans

事故之后我们引入了 bpftrace 和 bcc 工具集做日常网络观测,效果非常好。比如想知道哪个进程在频繁建立 TCP 连接,以前要用 strace + grep + 人脑分析,现在一条命令搞定:

# 实时查看哪个进程在新建 TCP 连接
$ /usr/share/bcc/tools/tcpconnect
PID    COMM         IP SADDR            DADDR            DPORT
2341   java         4  10.0.1.20        10.5.3.1         443
2341   java         4  10.0.1.20        10.5.3.1         443
2341   java         4  10.0.1.20        10.5.3.1         443

# 实时查看 TCP 重传(网络抖动监控)
$ /usr/share/bcc/tools/tcpretrans
TIME     PID    IP LADDR:LPORT          T> RADDR:RPORT          STATE
00:32:14 2341   4  10.0.1.20:54321      R> 10.5.3.1:443         ESTABLISHED

# 持续追踪 TCP 状态变化(神器)
$ /usr/share/bcc/tools/tcpstates
SKADDR    C-PID C-COMM  LADDR  LPORT  RADDR  RPORT  OLDSTATE  -> NEWSTATE  MS
ffff9...  2341  java    10.0.1 54321  10.5.3 443    CLOSED    -> SYN_SENT  0.013
ffff9...  2341  java    10.0.1 54321  10.5.3 443    SYN_SENT  -> ESTABLISHED 12.4
ffff9...  2341  java    10.0.1 54321  10.5.3 443    ESTABLISHED -> FIN_WAIT_1 8430.2

# 这个工具可以直接看到每个连接在每个状态停留多久
# 比 tcpdump + wireshark 的体验好太多

分布式追踪与网络故障的关联

事故的另一个教训是监控不够细。我们之前用 Jaeger 做分布式追踪,但只在应用层埋点(@Trace 注解),网络层的细节(DNS 解析时间、TCP 握手时间、TLS 握手时间)完全没有。事故后我们参考 Google Dapper 那篇论文,在网关里把 HTTP 客户端的每个阶段都埋了点:

// 用 HttpClient 5 的 ExecChainHandler 拦截,记录每个阶段耗时
public class TimingExecChainHandler implements ExecChainHandler {
    @Override
    public ClassicHttpResponse execute(ClassicHttpRequest request, ExecChain.Scope scope, ExecChain chain) throws IOException, HttpException {
        long t0 = System.nanoTime();
        try {
            ClassicHttpResponse resp = chain.proceed(request, scope);
            long t1 = System.nanoTime();
            // 上报到 Jaeger,带上各阶段时间
            Span span = scope.clientContext.getAttribute("span", Span.class);
            span.setAttribute("http.dns_ms", (t_dns - t0) / 1e6);
            span.setAttribute("http.connect_ms", (t_conn - t_dns) / 1e6);
            span.setAttribute("http.tls_ms", (t_tls - t_conn) / 1e6);
            span.setAttribute("http.request_ms", (t1 - t_tls) / 1e6);
            return resp;
        } catch (Exception e) {
            // 记录失败原因到 trace
            throw e;
        }
    }
}

这套埋点上线后,我们终于可以在 Jaeger 上看到"哪个银行接口的 TLS 握手特别慢"、"哪个时段 DNS 突然变慢"等细粒度问题。后来我们就靠这套埋点提前发现了好几个潜在问题,在用户感知之前修复。

事故沟通与外部协作

这次 90 分钟的故障,光技术细节是不够的,沟通过程也值得一说。事故期间我同时在跟 4 个银行的客服打电话(因为他们看到我们大量失败请求,以为是攻击),还要给业务方解释为什么"明明上线是性能优化怎么变成事故"。后来我们立了一个 SOP:

第一,故障发现 5 分钟内,在专项群同步现象、影响范围、当前操作,**不要等定位完才同步**。第二,故障期间技术负责人主对外,工程师只对内,避免信息源混乱。第三,有任何回滚/重启/重连决策,先在群里 +1 确认再执行,事故期最忌惮"私自动手"。第四,事故结束后 24 小时内出复盘文档,7 天内出改进 action 跟进表。

真实数据:一年来的网络可靠性提升

指标 事故前 事故后一年
网关 P99 延迟 350ms 180ms
P999 延迟 1200ms 320ms
TIME_WAIT 数量(峰值) 32847(事故时) 稳定 < 1500
Cannot assign address 报错 事故期 1200 万次/小时 0 次/年
网络相关 P0 故障 4 次/年 0 次/年
DNS 解析平均耗时 80ms 0.1ms(应用层缓存)
TLS 握手次数(每分钟) 3.6 万 < 100(连接复用)

给后端工程师的网络知识自检清单

事故复盘的最后,我整理了一份"网络知识自检清单",发给团队所有人,作为后端工程师上岗的最低门槛。如果你看完下面这些问题,有超过 3 个回答不上来,那这次事故里写代码的"那个 14:00 上线的同事"很可能就是你。

第一题:TCP 三次握手中,SYN_SENT、SYN_RECV、ESTABLISHED 分别是哪一方处于哪个状态?如果服务端一直停在 SYN_RECV 是为什么?

第二题:四次挥手中,谁主动关闭谁进入 TIME_WAIT?TIME_WAIT 持续多久?为什么是这个时长?

第三题:net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_tw_recycle 的区别是什么?哪个推荐开,哪个绝对不要开,为什么?

第四题:Cannot assign requested address 这个错误的根因是什么?排查应该从哪几个方向入手?

第五题:TCP_NODELAY 和 SO_KEEPALIVE 分别控制什么行为?在什么场景下需要打开?

第六题:HTTP/1.1 的 Keep-Alive、HTTP/2 的多路复用、HTTP/3 的 QUIC,各自解决什么问题?

第七题:DNS 解析在 Java 应用里是怎么缓存的?networkaddress.cache.ttl 这个参数你改过吗?

第八题:你们的服务用的什么 HTTP 客户端?连接池配置长什么样?max_per_route 是多少?

第九题:用过 ss、netstat、tcpdump、wireshark 吗?如果给你一个 pcap 文件让你定位"为什么连接失败",你的流程是什么?

第十题:Linux 内核的 SYN 队列和 accept 队列分别是什么?somaxconn 和 tcp_max_syn_backlog 怎么配?

团队里我们要求每个后端工程师都要至少把这 10 题答到 8 题以上才能独立上线网络相关的代码。这个标准看起来严格,但比起一次 90 分钟的支付故障带来的几百万损失,这点学习成本根本不值一提。

总结

这次 TIME_WAIT 端口耗尽事故让我们深刻意识到:**网络编程没有"差不多就行",每一个 TCP 状态背后都有真实的资源消耗**。一次看起来无关痛痒的"不用连接池了"的改动,在 1.2 万 QPS 的真实流量下,可以让整个支付网关瘫痪 90 分钟。系统的稳定性是无数个细节堆出来的,而这些细节藏在 TCP 协议的角落里、藏在内核参数的默认值里、藏在你以为"无所谓"的代码改动里。下一次有人对你说"反正机器多,要啥连接池",请把这篇文章甩给他看。事故是最贵的网络协议老师,但好在你不必每次都掏学费,只要愿意认真把别人交过的学费看完、把这些底层机制刻进自己的肌肉记忆,你的服务就能比绝大多数同行更稳。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Redis 大 key 阻塞主线程导致集群级雪崩的复盘:18 分钟反复切换 + 5 种修法 + 9 条治理纪律

2026-5-25 18:19:16

技术教程

K8s Pod 频繁 OOMKilled 但应用日志看起来一切正常的 9 天复盘:JVM 堆只是冰山一角 + 6 层因果链 + 10 条治理纪律

2026-5-25 18:33:25

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索