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 承接连接。