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 条网络编程纪律
- 所有 HTTP 客户端必须用全局静态连接池,禁止每次 new。code review 时这是 P0 红线。
- 连接池必须配 validateAfterInactivity + evictIdleConnections,避免拿到坏连接重试。
- tcp_tw_reuse 默认开启,作为兜底。tcp_tw_recycle 默认关闭(且别去重新打开)。
- 监控 TIME_WAIT 数量,超过 ip_local_port_range 一半触发告警。
- 下游服务调用必须设 Keep-Alive,HttpClient 默认就是 keepalive,但要确认对端服务器没有禁用。
- 不要在 finally 里随便 close client,client 是共享资源,close 的是 response。
- 对内服务调用用 gRPC 长连接,不要每个请求新建 HTTP 短连接。
- 压测必须包含长时间稳定性测试,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