8.3 连接复用与 TIME_WAIT
一句话
连接是要付三次握手 + TLS + 慢启动的昂贵资源,复用的本质是把这笔固定成本摊到 N 次请求上;TIME_WAIT 是主动关闭方为 “让旧连接的残包彻底死掉” 押的 60 秒保证金,不是 bug。
一条连接的固定成本
| 成本项 | 量级 | 谁付 |
|---|---|---|
| TCP 三次握手 | 1 RTT | 双方,每条新连接 |
| TLS 1.3 握手 | 1 RTT(会话复用可 0-RTT) | 双方 |
| TLS 1.2 握手 | 2 RTT | 双方 |
| 慢启动 | 前 7 个 RTT 才涨到 1MB 窗口 | 发送方 |
| 内核内存 | 每连接 2–10KB 起,收发缓冲区另计 | 双方 |
| 临时端口 | 1 个 / 连接 | 客户端 / 出口代理 |
| 建连 + 销毁 CPU | 数十 µs / 次 | 双方 |
RTT 30ms、TLS 1.3 时,每条短连接光协议开销就是 60ms (握手 30ms + TLS 30ms),还没算慢启动和请求本身。QPS 1000 就意味着每秒 1000 次握手 + 1000 次 TLS——CPU 和 P99 一起涨。
🔑 **复用不是 “省一点”,是把 O(QPS) 的建连成本变成 O (连接数)。**QPS 1000 时,两者差两个数量级以上。
TIME_WAIT 到底在保护什么
主动关闭方在发完最后一个 ACK 后进入 TIME_WAIT,持续 2×MSL:
| 平台 | TIME_WAIT 时长 | 来源 |
|---|---|---|
| Linux | 60s | 内核常量 TCP_TIMEWAIT_LEN,与 MSL 配置无关 |
| macOS | 30s | 2 × net.inet.tcp.msl(默认 15000ms) |
它解决两个问题,缺一不可:
| 问题 | 不等待会怎样 |
|---|---|
| 最后一个 ACK 丢了 | 对端会重传 FIN;本端已关闭,只能回 RST,对端认为「连接异常终止」。TIME_WAIT 期间还能补发 ACK |
| 旧连接的残包还在路上 | 新连接若复用同一四元组,旧残包会被当成新连接的合法数据(序号可能恰好落进窗口),造成数据错乱 |
四条被传歪的 “常识”:
| 误区 | 现实 |
|---|---|
调小 net.ipv4.tcp_fin_timeout 能减少 TIME_WAIT | 不能,它只管 FIN_WAIT_2,与 TIME_WAIT 无关 |
SO_REUSEADDR 能复用 TIME_WAIT 的端口 | 只能让 bind() 成功(监听端重启),不能让 connect() 复用同一四元组 |
开 tcp_tw_recycle=1 能加速回收 | 内核 4.12 已移除;NAT 环境下它会按时间戳丢弃正常连接,是事故制造机 |
| 服务端 TIME_WAIT 多了会耗尽端口 | 不会。服务端端口固定,耗的是内存和连接表;耗端口的是客户端 / 出口代理 |
谁主动关,谁就痛
| 角色 | 谁先发 FIN | TIME_WAIT 落在 | 真正的痛 |
|---|---|---|---|
| 客户端 / 出口代理 | 客户端 | 客户端 | 临时端口耗尽 |
服务端(keep-alive 超时、Connection: close) | 服务端 | 服务端 | 内存、连接表、四元组不可复用 |
| 四层 LB / NAT 网关 | LB | LB | 端口 + 会话表,通常最先炸 |
端口算术 (Linux 默认临时端口 32768–60999,共 28232 个):
QPS × TIME_WAIT 时长 = 同时存在的 TIME_WAIT 数
1000 × 60s = 60,000 条 > 28,232 个端口 → 必然耗尽
典型报错:dial tcp 1.2.3.4:80: connect: cannot assign requested address——connect() 找不到可用临时端口。它不像任何业务错误,最容易被当成 “网络不通” 去排查防火墙,方向从一开始就错了。
解法有优先级,别一上来就调内核
| 层级 | 手段 | 效果 | 代价 / 前提 |
|---|---|---|---|
| 根治 | 长连接 / 连接池 | TIME_WAIT 从 QPS×60 降到 连接数 | 要处理空闲超时、负载均衡、优雅退出 |
| 根治 | 让客户端先关(服务端 keep-alive 超时 > 客户端) | TIME_WAIT 从服务端挪到客户端 | 客户端也必须有池 |
| 缓解 | 扩大 ip_local_port_range | 端口上限 ×2 左右 | 可能与 conntrack、已有服务冲突 |
| 缓解 | tcp_tw_reuse=1 | 出向连接可复用 TIME_WAIT 端口 | 需 tcp_timestamps=1;仅对出向有效 |
| 缓解 | 多出口 IP / 多网卡 | 端口空间 ×N | 运维成本,LB 需支持 |
| 治标 | tcp_max_tw_buckets | 超限直接销毁 TIME_WAIT | 破坏协议保护语义,只当保护阀 |
| 禁止 | tcp_tw_recycle | — | 4.12 已删除;NAT 下丢正常连接 |
常用内核参数:
| 参数 | 作用 | 建议 |
|---|---|---|
net.ipv4.ip_local_port_range | 临时端口范围 | 10000 65535 |
net.ipv4.tcp_tw_reuse | 出向连接复用 TIME_WAIT | 1(配合时间戳) |
net.ipv4.tcp_fin_timeout | FIN_WAIT_2 超时 | 与 TIME_WAIT 无关,别乱调 |
net.ipv4.tcp_max_tw_buckets | TIME_WAIT 总量上限 | 只作保护,超限会打日志 |
net.core.somaxconn | accept 队列上限 | 与应用 backlog 联调(见 8.1 三次握手) |
🔑 **TIME_WAIT 的数量是 “短连接 QPS × 60” 的直接读数。**看到 6 万条 TIME_WAIT,说明系统在做 1000 QPS 的短连接——该改的是连接模型,不是内核参数。
实测:500 个请求,短连接 500 条连接、600 条 TIME_WAIT
package main
import (
"fmt"
"io"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
)
func main() {
body := make([]byte, 2048)
var newConns int64
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(body)
}),
ConnState: func(c net.Conn, s http.ConnState) {
if s == http.StateNew {
atomic.AddInt64(&newConns, 1)
}
},
}
go srv.Serve(ln)
url := "http://" + ln.Addr().String()
const total, conc = 500, 32
// A:短连接——每个请求都新建连接
atomic.StoreInt64(&newConns, 0)
start := time.Now()
run(url, total, conc, func() *http.Client {
return &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
})
short, shortConns := time.Since(start), atomic.LoadInt64(&newConns)
// B:连接池复用
atomic.StoreInt64(&newConns, 0)
tr := &http.Transport{MaxIdleConns: 100, MaxIdleConnsPerHost: conc}
start = time.Now()
run(url, total, conc, func() *http.Client { return &http.Client{Transport: tr} })
keep, keepConns := time.Since(start), atomic.LoadInt64(&newConns)
fmt.Printf("短连接:%v,新建连接 %d 条\n", short.Round(time.Millisecond), shortConns)
fmt.Printf("连接池:%v,新建连接 %d 条\n", keep.Round(time.Millisecond), keepConns)
fmt.Printf("平均每请求:短连接 %v,连接池 %v\n",
(short / total).Round(time.Microsecond), (keep / total).Round(time.Microsecond))
}
func run(url string, total, conc int, newClient func() *http.Client) {
var wg sync.WaitGroup
ch := make(chan int, total)
for i := 0; i < total; i++ {
ch <- i
}
close(ch)
for i := 0; i < conc; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c := newClient()
for range ch {
resp, err := c.Get(url)
if err != nil {
panic(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}()
}
wg.Wait()
}
本机回环实测 (Go 1.27,macOS,32 并发,500 个请求,每次响应 2KB):
短连接:39ms,新建连接 500 条
连接池:8ms,新建连接 41 条
平均每请求:短连接 77µs,连接池 15µs
$ netstat -an -p tcp | grep -c TIME_WAIT
29 # 运行前
629 # 运行后:约 600 条,主要来自 500 次短连接
回环上握手只要约 0.15ms (见 8.1 三次握手),所以差距只有 5 倍。换成 RTT 30ms 的真实链路,差距按 RTT 线性放大:
| 指标 | 短连接 | 连接池 | 差 |
|---|---|---|---|
| 每次请求往返 | 2 RTT(握手 + 请求) | 1 RTT | 1 RTT |
| 500 请求 / 32 并发 | 约 31 RTT ≈ 940ms | 约 16 RTT ≈ 470ms | ≈470ms |
| 加 TLS 1.3 | 再 +1 RTT/连接 ≈ 1.4s | 只建 41 次 ≈ 0.5s | ≈0.9s |
| TIME_WAIT | ≈500 条 / 500 请求 | ≈41 条 / 500 请求 | 一个数量级 |
🔑 表里第三行是重点:短连接的延迟不是 “多一点点”,而是每次请求多一个完整 RTT。RTT 越大、QPS 越高,代价越陡。
连接池:四个参数 + 一条铁律
| 参数 | 作用 | 配错的表现 |
|---|---|---|
maxOpen / MaxOpenConns | 并发连接上限 | 太小 → 请求排队;太大 → 打爆数据库 max_connections |
maxIdle / MaxIdleConns | 保留多少空闲连接 | 太小 → 反复建连,池形同虚设 |
idleTimeout / ConnMaxIdleTime | 空闲多久回收 | 大于对端空闲关闭时间 → 取到死连接 |
maxLifetime / ConnMaxLifetime | 连接最长寿命 | 不设 → DNS 变更、权重变更不生效 |
| 健康检查 | 取用前探活 | 缺失 → 偶发 broken pipe |
🔑 铁律:客户端的空闲超时必须小于 “对端 / LB 的空闲关闭时间”。 示例链条:客户端 50s < LB 60s < 服务端 keep-alive 75s。 反了会怎样:对端先关,池里留下半开连接,下一个请求拿到它 →
EOF/broken pipe/ 502,且概率性复现,最难查。
Go 的两处默认值会咬人:
// HTTP:MaxIdleConnsPerHost 默认是 2,高并发下等于没有池
tr := &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // 关键:默认 2,必须显式放大
IdleConnTimeout: 50 * time.Second,
MaxConnsPerHost: 0, // 0 = 不限制
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{
Timeout: 2 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
// database/sql:MaxIdleConns 默认也是 2,建议与 MaxOpenConns 对齐
db.SetMaxOpenConns(100)
db.SetMaxIdleConns(100)
db.SetConnMaxIdleTime(5 * time.Minute)
db.SetConnMaxLifetime(30 * time.Minute)
⚠️
MaxIdleConnsPerHost=2是 “压测时 QPS 上不去、连接数却一直新建” 的头号原因。并发 100 时,池里最多只留 2 条空闲连接,其余 98 条用完即关,下一波请求重新建连——连接一直在新建 / 销毁,TIME_WAIT 和延迟一起涨,而配置看起来 “明明配了池”。
连接数怎么估:利特尔法则
并发数 = QPS × 平均延迟
| 场景 | QPS | 平均延迟 | 并发 | 连接池大小 |
|---|---|---|---|---|
| 读缓存 | 5000 | 2 ms | 10 | 20–30 |
| 查数据库 | 2000 | 20 ms | 40 | 60–80 |
| 调下游 RPC | 1000 | 50 ms | 50 | 80–100 |
连接池不是越大越好:超过下游承载能力后,池只是把排队从客户端挪到下游,还会拖长尾 (排队时间计入延迟)。
多路复用是另一条路:HTTP/2、gRPC 在一条 TCP 连接上跑多个流。
| 维度 | 多连接(HTTP/1.1 池) | 多路复用(HTTP/2、gRPC) |
|---|---|---|
| 连接数 | 并发 / 每连接并发 | 并发流 / maxConcurrentStreams(常见 100–250) |
| 队头阻塞 | 连接级,互不影响 | TCP 层仍 HOL:一个包丢了,所有流一起等 |
| 适合 | 大响应、下载 | 大量小请求、RPC |
| 注意 | 连接数 × 内存 | 单连接故障影响面大,需多条连接冗余 |
长连接的三个新问题
| 问题 | 现象 | 对策 |
|---|---|---|
| 负载不均 | 扩容后新实例分不到流量;LB 按「连接」而不是「请求」分发 | 客户端定期重连 + 随机抖动;LB 用 P2C / 最少连接 |
| 发布即 5xx | 滚动发布时旧连接被强杀 | 先摘流量 → 等 LB 摘除(≥ 2 个健康检查周期)→ Server.Shutdown 优雅退出 |
| 连接数 / fd 上限 | too many open files | 估连接数、调 ulimit -n、限制每 host 连接数 |
💡 长连接把 “建连成本” 换成了 “状态维护成本”:心跳、空闲回收、优雅退出、负载均衡,一个都不能少。没有这些能力时,短连接反而更稳。
什么时候不该复用
| 场景 | 选择 | 原因 |
|---|---|---|
| 高频 RPC / DB / 缓存 | 必须池化 | 固定成本占比最高 |
| 低频、一次性、突发 | 短连接 | 池化收益低,还要维护空闲连接 |
| 强隔离(多租户、故障隔离) | 每租户独立池 | 共享池会把一个租户的慢查询传染给所有人 |
| 一次性大文件传输 | 单连接,别池化 | 连接会被长时间独占,池会被抽干 |
| HTTP/1.1 + 大响应混合 | 多连接或升 HTTP/2 | 单连接串行会队头阻塞 |
反模式
| 反模式 | 后果 |
|---|---|
看到 TIME_WAIT 多就调 tcp_fin_timeout | 参数根本不管 TIME_WAIT,白改 |
开 tcp_tw_recycle 或「定期重启机器清 TIME_WAIT」 | 前者已删除且会丢连接,后者 QPS 一回来立刻复发 |
客户端 idleTimeout 大于 LB 空闲超时 | 概率性 EOF / 502,复现困难 |
MaxIdleConnsPerHost 用默认 2 | 高并发下反复建连,池等于没有 |
不设 maxLifetime | 长连接打到已下线 IP,DNS 变更不生效 |
池开得比下游 max_connections 还大 | 下游连接被打满,所有实例一起报错 |
| 服务端主动关连接却不优雅退出 | 发布瞬间长连接被 RST,客户端 5xx 尖刺 |
用 SO_REUSEADDR 解释端口耗尽 | 它不是干这个的,方向错了永远修不好 |
自检
Q1 客户端日志大量出现 cannot assign requested address,服务端监控一切正常,TIME_WAIT 有 6 万条。根因是什么?先改哪一处?
▸ 答案:临时端口耗尽。1000 QPS 的短连接 × 60s TIME_WAIT = 6 万条,超过默认 28232 个临时端口,connect() 找不到可用源端口。先改连接模型 (连接池 / 长连接),这一步能把 TIME_WAIT 从 O(QPS×60) 降到 O (连接数);扩端口范围、tcp_tw_reuse 只是缓解。报错文案不像任何业务错误,最容易被误判成网络不通而去查防火墙。
Q2 连接池偶发 EOF / broken pipe,重启后好一阵又出现。按什么顺序排查?
▸ 答案:先查超时链:客户端 idleTimeout 是否大于 LB / 服务端的空闲关闭时间 (应为客户端 50s < LB 60s < 服务端 75s)。对端先关、池里留下半开连接,下一个请求拿到就报 EOF。其次查 maxLifetime、健康检查、以及服务端 / LB 的 idle timeout 配置是否被改过。这类问题不会稳定复现,必须对着三处配置比数值。
Q3 为什么说 “让客户端先关” 能治 TIME_WAIT,但又不能无脑用?
▸ 答案:TIME_WAIT 只落在主动关闭方。让服务端 keep-alive 超时长于客户端,客户端就会先关,TIME_WAIT 从服务端挪到客户端,服务端的内存和四元组压力消失。代价是:客户端开始积累 TIME_WAIT,如果客户端是出口代理 / NAT 网关且 QPS 高,就会换成它端口耗尽。所以真正的根治仍是池化——把关闭次数降到与连接数同阶,谁先关都不疼。
Q4 为什么 TIME_WAIT 是 2×MSL,而不是 1×MSL?
▸ 答案:因为要覆盖两个方向的残留报文:主动关闭方等待 2MSL,才能保证 “自己最后一个 ACK 若丢失,对方重传的 FIN 还能被处理”,同时让旧连接的重复报文在网络中消亡。1×MSL 不足以覆盖 “ACK 丢失 + 对方重传 FIN” 的往返。
Q5 服务端出现大量 TIME_WAIT,能不能开 tcp_tw_reuse?
▸ 答案:要谨慎。tcp_tw_reuse 只对主动发起连接的一方(客户端角色)安全,且依赖时间戳;在 NAT/负载均衡后面开启可能导致 “旧连接的迟到报文被误认为新连接”,引发数据错乱。更稳妥的做法是让服务端少主动关闭(用长连接/连接池)、扩大端口范围、或前置四层 LB 承接连接。