2021 年我接手优化公司一个内部 API 网关:一场大促前要给它压测,看能扛多少 QPS。怎么把它的吞吐做上去?这件事我压根没多想。第一版我做得很顺手:网关嘛,收到请求、转发给后端服务、拿到响应再返回。我用最直接的写法——每来一个请求,就新建一个连接到后端,转发完、拿到结果,把连接关掉。就完事了。本地拿低并发一压——真不错:延迟很低,转发又快又稳。我心里很笃定:"TCP 连接不就是个透明的管道?connect 一下就有了,网络快不快只看带宽,连接本身能有什么成本?"可等压测的并发真正拉上去,一串问题冒了出来。第一种最先把我打懵:QPS 死活上不去,卡在一个数字上,可我盯着监控看——CPU、内存、带宽全都很闲。第二种最难缠:服务端用 ss -s 一看,几万个连接堆在 TIME_WAIT 状态,新连接偶尔就建立失败。第三种最头疼:并发再加一点,客户端开始报一个错——Cannot assign requested address,本地端口被耗尽了。第四种最莫名其妙:日志显示连接明明建立成功了,可应用 accept 到这个连接要等好几秒,偶尔还有连接像是凭空被丢掉了。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"TCP 连接就是个透明的管道,connect 一下就有了,网络快不快只看带宽"。这句话把 TCP 连接当成了一个零成本、无状态、随用随取的东西。可它根本不是。我脑子里,TCP 连接是一根透明的管道:我调一次 connect,管道就通了,数据从这头进、那头出,唯一影响快慢的就是管道有多粗——带宽。可这个想法,把 TCP 连接最关键的三件事全漏掉了。第一,连接的建立是有成本的:一次 TCP 连接要经过三次握手,这是一来一回的网络交互,至少耗掉一个 RTT;在短连接模式下,你每处理一个请求就握手一次、挥手一次,真正传数据的时间可能还没握手挥手长。第二,连接是有状态、有生命周期的:一个连接被关闭后并不会立刻消失,主动关闭的一方会进入一个叫 TIME_WAIT 的状态,在那里停留几十秒才释放——这几十秒里,它仍然占着一个端口、占着一份内核资源。第三,连接的建立要经过内核的两个队列:握手中途的连接排在半连接队列,握手完成、等应用 accept 的连接排在全连接队列;这两个队列都有长度上限,满了,新连接就被内核直接丢弃。所以 TCP 连接根本不是一根零成本的透明管道,它是一个有建立开销、有状态残留、要排队、会被丢弃的东西。我那个"QPS 上不去而资源都很闲"的现象,根子就在这:瓶颈压根不在带宽这根管子有多粗,而在我用短连接模式,把大量的时间和资源全耗在了反复建立、销毁连接上——CPU 闲,是因为它根本没在干传输的活,它在等握手、在处理一堆 TIME_WAIT。真正把 TCP 吞吐做上去,核心不是"加带宽、换更快的网卡",而是认清 TCP 连接有建立成本、有 TIME_WAIT 状态残留、要经过内核的半连接和全连接两个队列,理解短连接模式为什么在高并发下会把系统拖垮,学会用长连接和连接池复用连接,看懂 TIME_WAIT 和本地端口耗尽的成因与对策,并把连接队列、TIME_WAIT 数量这些指标接进监控。这篇文章就把 TCP 连接优化这个坑梳理一遍:为什么 QPS 上不去而 CPU 内存都很闲、三次握手和内核的两个连接队列是怎么回事、TIME_WAIT 为什么存在又为什么会堆积、客户端的本地端口为什么会耗尽、连接复用为什么是根治之道,以及一些把 TCP 优化做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为现在的网络库 API 包装得太友好了——一个 http.get(url)、一个 requests.get(url),连接的建立、握手、关闭全被藏在底下,让人误以为"发个请求"就是一个原子的、零成本的动作。它错得隐蔽,是因为低并发下短连接几乎没有代价:你本地测、低 QPS 测,每秒就几十个连接,握手挥手的开销淹没在噪声里,TIME_WAIT 堆积的速度也慢到看不出来,一切都很完美。它只在并发真正压上去时才暴露——每秒成千上万个连接建了又关,握手开销叠起来吃光 CPU 时间片,TIME_WAIT 以每秒上万的速度堆积、占满端口,而你还以为"我带宽明明很闲"。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:QPS 上不去而 CPU 内存带宽都很闲;服务端几万个 TIME_WAIT;客户端报本地端口耗尽;连接建立成功却迟迟 accept 不到、偶尔被丢。
- 错误认知一:以为 TCP 连接是零成本的透明管道。真相是每次连接都要三次握手,至少一个 RTT,短连接模式下这开销极其昂贵。
- 错误认知二:以为连接关闭就立刻消失了。真相是主动关闭方会进入 TIME_WAIT 停留几十秒,期间持续占用端口和内核资源。
- 错误认知三:以为连接建立成功就万事大吉。真相是连接要经过半连接和全连接两个内核队列,队列满了会被直接丢弃。
- 真相:TCP 吞吐的瓶颈往往不在带宽,而在连接管理;根治之道是用长连接和连接池复用连接,把建立成本摊薄到接近零。
一、为什么 QPS 上不去而资源都很闲
先把第一版那个转发逻辑摆出来。它就是字面意思——每个请求新建一个连接,用完就关:
# 第一版:短连接模式的转发(反面教材)
import socket
def forward_request(backend_host, backend_port, payload: bytes) -> bytes:
# 每来一个请求,就新建一个 socket、握手、连接到后端
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((backend_host, backend_port)) # 这一行 = 一次三次握手,一个 RTT
s.sendall(payload)
resp = s.recv(65536)
s.close() # 用完就关 —— 主动关闭方,这里会留下一个 TIME_WAIT
return resp
# 低并发下这段代码毫无问题,延迟也很低。
# 但每处理一个请求,都要付出:一次握手(connect)+ 一次挥手(close)
# 高并发下,握手挥手的开销会彻底盖过真正传数据的开销
这段代码没有任何语法错误,低并发下它工作得很好。它唯一的问题是模式错了:它是短连接——一个请求配一个连接,请求结束连接就销毁。在高并发下,这个模式的代价是毁灭性的。算笔账:一次 connect 三次握手要一个 RTT,假设后端在同机房、RTT 是 1 毫秒;如果后端处理这个请求本身只要 2 毫秒,那么"握手 1ms + 处理 2ms + 挥手"这套流程里,有三分之一的时间花在了和业务毫无关系的连接管理上。更糟的是 close:主动关闭连接的一方会进入 TIME_WAIT,在每秒上万请求的压力下,TIME_WAIT 会以每秒上万的速度堆积。所以"QPS 上不去而资源都很闲"一点不矛盾——CPU 不是在算业务,是在不停地握手、挥手、维护海量 TIME_WAIT;带宽闲,是因为真正传输的数据量本来就不大,瓶颈从来不在那根管子的粗细。先用 ss 把连接状态的分布看出来,这个判断就坐实了:
# 用 ss 观察连接状态的分布 —— 排查 TCP 问题的第一个动作
ss -s # 连接状态总览:established / timewait 各有多少
# 单独数一下 TIME_WAIT 有多少个
ss -tan state time-wait | wc -l
# 短连接模式高并发压测时,你会看到 timewait 的数字大得吓人,
# 比如几万个,而 established 才几百个 —— 这就是短连接的典型病征:
# 连接建了又关、关了又建,大量连接卡在 TIME_WAIT 等着被回收
这里要建立的第一个、也是最重要的认知是:当你做的事是"反复地、高频地"做某件有固定开销的操作时,真正决定性能的,往往不是那件操作本身,而是它的"固定开销"乘以"次数"。短连接模式的病根,不是 TCP 慢,而是它把"建立连接"这个有固定成本的动作,和"处理一个请求"这个高频动作绑死成了一比一——你处理一百万个请求,就得建一百万次连接、付一百万份握手成本。优化它的思路,不是去让单次握手变快(那是协议层的事,你动不了),而是去打破这个一比一的绑定:让一次建立的连接,服务成千上万个请求,把那份固定开销摊薄到每个请求身上几乎为零。这个"把固定开销从高频路径上挪走、摊薄"的思路,是性能优化里一条通用到不可思议的主线:数据库连接池,是不让每条 SQL 都新建一次数据库连接;线程池,是不让每个任务都新建一次线程;HTTP 的 Keep-Alive,是不让每个 HTTP 请求都新建一次 TCP 连接;批量写入,是不让每条记录都单独发起一次磁盘 IO。它们的内核完全一样:识别出高频路径上那个"每次都要重新付一遍"的固定成本,然后想办法让这个成本只付一次、被无数次复用。所以当你遇到一个"单看每一步都不慢,合起来就是慢"的性能问题时,别急着优化某一步的速度,先抬头找一找:这条高频路径上,有没有一个本该一次性付清、却被我傻乎乎地每次都重付一遍的固定开销?找到它,把它从高频路径上挪走,往往就是数量级的提升。
二、三次握手与内核的两个连接队列
要理解连接为什么会"建立成功却 accept 不到"、为什么会"凭空被丢",得先看清一个连接从无到有,在内核里到底经过了什么。关键是两个常被忽略的队列:半连接队列(SYN queue)和全连接队列(accept queue)。一个连接的诞生流程是这样:
[mermaid]
flowchart TD
A[客户端发起 connect 发出 SYN] --> B[服务端收到 SYN 连接进入半连接队列]
B --> C[服务端回 SYN-ACK 客户端再回 ACK]
C --> D{半连接队列满了吗}
D -->|满| E[新来的 SYN 被内核直接丢弃]
D -->|没满| F[三次握手完成 连接移入全连接队列]
F --> G{全连接队列满了吗}
G -->|满| H[连接被丢弃 或客户端触发重传]
G -->|没满| I[应用调用 accept 取走连接 开始处理]
看懂这张图,前面那几个怪现象就有了答案。一个连接发来 SYN,握手还没完成,它先排在半连接队列里;三次握手全部完成后,它被移到全连接队列,静静等着应用程序调用 accept 来把它取走。"连接建立成功却迟迟 accept 不到"——就是连接在全连接队列里排队,而你的应用 accept 太慢、取得不够快。"连接凭空被丢"——就是某个队列满了:半连接队列满了,新来的 SYN 被直接丢弃;全连接队列满了,握手完成的连接也会被丢弃。这两个队列的长度都是可以查、可以调的:
# 全连接队列(accept queue):查看与监控
# ss -lnt 看监听 socket,Recv-Q 是当前排队数,Send-Q 是队列上限
ss -lnt
# State Recv-Q Send-Q Local Address:Port
# LISTEN 0 128 0.0.0.0:8080 <- 上限 128,当前排队 0
# 全连接队列上限 = min(应用 listen() 传的 backlog, 内核 somaxconn)
sysctl net.core.somaxconn # 内核侧上限,老内核默认 128,偏小
sysctl net.ipv4.tcp_max_syn_backlog # 半连接队列上限
# 全连接队列溢出的累计计数 —— 这个数字在涨,就是队列在丢连接
netstat -s | grep -i 'listen'
# xxx times the listen queue of a socket overflowed <- 溢出次数
当 ss -lnt 里的 Recv-Q 持续逼近 Send-Q、netstat -s 里的 overflow 计数不断上涨,就说明全连接队列在丢连接。对策有两条:一是把 somaxconn 和应用 listen() 的 backlog 调大,让队列能多容纳一些;二是让应用 accept 得更快——队列只是缓冲,真正的处理能力还是看应用取走连接的速度。
这里要建立的认知是:任何一个"生产者和消费者速度不一定匹配"的系统,中间几乎都有一个队列在做缓冲——而队列这个东西,最反直觉、也最该记住的一点是:它能缓冲突发,但它绝不能解决"持续的产能不足"。半连接队列、全连接队列,就是内核为"连接进来的速度"和"应用 accept 的速度"之间设的缓冲垫。如果只是某一瞬间涌进来一批连接、而应用稍微慢了一拍,队列能稳稳接住,这是它的本职。但如果应用 accept 的速度长期、持续地跟不上连接涌入的速度,那队列做的事,仅仅是把"立刻丢弃"推迟成"几秒后丢弃"——队列迟早会被填满,填满之后,该丢的连接一个都不会少。我第一版栽的跟头里就有这一条:我以为把 backlog 调大就能解决丢连接,可那只是让队列更深,治标不治本;真正的产能瓶颈在我的应用 accept 太慢。这个认知能帮你看穿无数场景:消息队列堆积了,你不能光想着扩大队列容量,得去看消费者的消费能力是不是长期低于生产速度;线程池的任务队列爆了,加大队列长度只是延后崩溃,得去看线程数够不够、任务处理是不是太慢;限流器的缓冲窗口也是同理。所以每当你看到一个队列在堆积,要立刻分两种情况判断:这是一次性的突发(那么加大队列、扛过尖峰就行),还是持续性的产能不足(那么加大队列毫无意义,必须去提升消费端的处理能力)。把"缓冲突发"和"解决产能不足"这两件事分清楚,你才不会在错误的地方使错误的劲。
三、TIME_WAIT:连接关了为什么还赖着不走
接着治那个最扎眼的现象:服务端几万个连接堆在 TIME_WAIT。要治它,得先知道 TIME_WAIT 是什么、为什么 TCP 非要设计这么个状态。当一个连接被关闭,主动发起关闭的那一方,不会立刻让连接消失,而是进入 TIME_WAIT 状态,在那里停留 2*MSL(MSL 是报文最大生存时间,Linux 上 TIME_WAIT 通常固定 60 秒)才彻底释放:
一个连接被关闭后,主动关闭方的状态变化:
ESTABLISHED (连接正常工作中)
| 主动调用 close(),发出 FIN
v
FIN-WAIT-1 / FIN-WAIT-2 (等对方确认、等对方也发 FIN)
| 收到对方的 FIN,回 ACK
v
TIME_WAIT ← 在这里停留 2*MSL(Linux 上约 60 秒)才释放
|
v
CLOSED (连接彻底消失,端口和资源被回收)
TIME_WAIT 存在的两个理由:
1. 确保最后那个 ACK 能可靠到达对方(丢了还能重发)
2. 让本次连接的"迷途旧报文"在网络里自然过期,
不会窜进一个刚好用了相同四元组的新连接里
代价:这 60 秒里,这个连接仍占着一个本地端口和一份内核资源
这就解释了第一版为什么 TIME_WAIT 堆成山:我的网关在转发完后主动 close 了到后端的连接,所以网关这一侧成了主动关闭方,每处理一个请求就生产一个 TIME_WAIT。每秒上万请求,TIME_WAIT 就以每秒上万的速度堆积,而每个要赖满 60 秒——稳态下就是几十万个 TIME_WAIT 常驻。它们每一个都占着资源。Linux 提供了几个参数来缓解,但要分清它们的适用边界:
# 和 TIME_WAIT 相关的内核参数(查看)
sysctl net.ipv4.tcp_tw_reuse # 允许把 TIME_WAIT 的端口给新的"出站"连接复用
sysctl net.ipv4.ip_local_port_range # 本地可用端口范围,决定能同时撑住多少出站连接
# tcp_tw_reuse=1:对"主动发起连接的客户端一侧"有效,
# 它允许新的出站连接安全地复用处于 TIME_WAIT 的端口
sysctl -w net.ipv4.tcp_tw_reuse=1
# 重要警告:不要去开 tcp_tw_recycle —— 它在 NAT 环境下会错误丢包,
# 危害极大,新版 Linux 内核已经直接把这个参数删掉了
# 但要记住:这些参数只是"缓解"。真正的根治是下一节的连接复用 ——
# 让连接根本不频繁关闭,TIME_WAIT 自然就不会大量产生
这里要建立的认知是:TIME_WAIT 这个设计,要教给你的是一种看待"系统里那些看起来碍事的东西"的成熟眼光——很多让你第一反应想除掉的"麻烦",其实是某种正确性的代价,你真正该做的不是消灭它,而是搞懂它在保护什么,然后从源头上少产生它。我刚看到几万个 TIME_WAIT 时,第一念头是"这玩意儿占资源,我得把它干掉"——我到处搜怎么强制清掉 TIME_WAIT,差点就开了那个危险的 tcp_tw_recycle。可 TIME_WAIT 根本不是 bug,它是 TCP 为了"可靠关闭"和"防止旧报文串扰"特意设计的安全机制:那 60 秒的等待,是在用一点资源占用,换取连接关闭的正确性。如果我粗暴地把它清掉,我换来的可能是更隐蔽、更致命的数据错乱。这里的认知是:面对系统里一个"碍事"的机制,你要先严肃地问一句——它为什么存在?它在防止什么坏事?想清楚这个,你的应对就会从"消灭它"升级成两个更高明的选择:要么接受它的代价(它保护的东西值这个价),要么从源头减少触发它的次数。TIME_WAIT 的正解就是后者:不是去清扫已经产生的 TIME_WAIT,而是用连接复用让连接不再频繁开关——连接不关,哪来的 TIME_WAIT?这个思路到处适用:GC 停顿碍事,正解不是关掉 GC,是减少垃圾对象的产生;锁竞争碍事,正解不是去掉锁,是缩小临界区、减少加锁;缓存失效碍事,正解不是不用缓存,是设计更好的失效策略。永远记住:一个存在已久的机制让你难受,先假设它有它的道理,把它的道理搞懂——然后从源头下手,而不是抡起锤子把保护你的东西也一起砸了。
四、客户端的本地端口为什么会耗尽
还有一个现象没解释:客户端报 Cannot assign requested address。这是本地端口耗尽。每发起一个出站 TCP 连接,操作系统都要从本地分配一个临时端口(ephemeral port)给它当源端口。一个 TCP 连接由"源 IP、源端口、目的 IP、目的端口"这个四元组唯一标识——当你的客户端疯狂地向同一个后端(目的 IP、目的端口固定)发起连接,源 IP 也固定,那么唯一能变的就只剩源端口。而源端口的可用范围是有限的:
# 本地临时端口范围:决定一台机器能同时撑住多少个出站连接
sysctl net.ipv4.ip_local_port_range
# net.ipv4.ip_local_port_range = 32768 60999
# 这意味着可用临时端口只有约 28000 个
# 算一笔账:如果你用短连接疯狂连同一个后端,
# 每个连接占一个源端口,关闭后还要在 TIME_WAIT 占住 60 秒,
# 那么连接速率一旦超过 28000 / 60 ≈ 466 个/秒,
# 端口就会被 TIME_WAIT 占光,新连接报 Cannot assign requested address
# 能"缓解"的两个动作(注意只是缓解):
sudo sysctl -w net.ipv4.ip_local_port_range="1024 65535" # 把范围放到最大
sudo sysctl -w net.ipv4.tcp_tw_reuse=1 # 允许复用 TIME_WAIT 端口
这笔账算下来很惊人:在默认端口范围下,纯短连接打同一个后端,稳态速率撑死了几百个连接每秒——这远低于很多人对"一台机器能扛多少"的直觉。把端口范围放大、开 tcp_tw_reuse 能把这个上限往上抬一些,但本质没变:只要你还在用短连接,你就是在跟一个有限的端口资源池赛跑。
这里要建立的认知是:本地端口耗尽这件事,真正要让你记住的是——任何资源,哪怕它看起来"多到不像是个限制",它都是有限的;而你一旦设计出一种"高频消耗 + 缓慢释放"的使用模式,再大的资源池也一定会被你耗干。本地端口有几万个,这个数字大到让人潜意识里觉得"这哪可能用得完"。可问题不在池子有多大,在于消耗和释放的速率差:短连接模式下,我消耗端口的速度是每秒上万,而每个端口被 TIME_WAIT 锁住、要 60 秒后才归还——消耗快、归还慢,这个速率差决定了池子里的存量只会单调下降,几万个端口被填满只是个时间问题。这就是所有"资源泄漏"和"资源耗尽"类问题的统一模型:它的根因几乎从来不是"池子太小",而是"消耗速率持续高于释放速率"。数据库连接池被占满,是借出去的连接没及时还、或借得太凶;文件描述符耗尽,是打开的文件没及时 close;内存泄漏,是分配的对象没被回收。所以当你遇到任何"XX 资源不够用了"的报错,第一反应不该是"把池子调大"——调大只是把崩溃的时间点往后推。正确的第一反应是去测两个速率:这个资源被消耗的速率,和它被释放的速率。只要释放追不上消耗,无论池子调到多大,结局都注定。真正的解,永远是回到那个速率差上:要么降低消耗速率(比如连接复用,根本不频繁申请新端口),要么加快释放速率——让池子的进和出重新平衡,这才是治本。
五、连接复用:长连接和连接池才是根治之道
前面四节把病因全摆出来了,它们其实指向同一个根:短连接。握手开销、TIME_WAIT 堆积、端口耗尽,全都因为连接被反复地建立和销毁。所以根治之道只有一个——不要反复建销,把连接复用起来。具体到代码,就是用长连接(连接建立后不关,反复用它发请求)加连接池(维护一组常驻连接,谁要用就借一个、用完还回去)。在客户端这一侧,主流库都内建了这个能力,关键是你得用对:
# 正解:用连接池复用连接(以 Python requests 为例)
import requests
# 反面:每次 requests.get() 都可能新建连接(短连接的味道)
# for i in range(10000):
# requests.get('http://backend/api') # 别这么写
# 正解:用 Session,它内部维护一个连接池,连接会被复用
session = requests.Session()
# 显式配置连接池大小:对同一个后端,池子里常驻多少条连接
adapter = requests.adapters.HTTPAdapter(
pool_connections=20, # 缓存的连接池数量
pool_maxsize=100, # 单个池子里最多复用多少条连接
max_retries=3,
)
session.mount('http://', adapter)
session.mount('https://', adapter)
# 之后所有请求都走 session,底层 TCP 连接被反复复用,
# 一万个请求可能只用掉几十条连接 —— 握手成本被摊到接近零
for i in range(10000):
resp = session.get('http://backend/api')
用长连接有个绕不开的细节:连接复用,意味着连接要在两次请求之间保持空闲一段时间,这就要求两端对"连接能闲置多久"有一致的预期。如果服务端 60 秒没流量就关连接,而客户端池子以为它还活着、拿来就用,就会撞上"连接已被对端关闭"的报错。所以服务端的长连接配置也要跟上,以 Nginx 反向代理后端为例:
# Nginx 作为反向代理时,开启对上游后端的长连接复用
upstream backend {
server 10.0.0.11:8080;
server 10.0.0.12:8080;
# 关键:每个 worker 进程对这组上游保持的空闲长连接数
keepalive 64;
# 一条长连接复用多少次请求后主动关闭,防止连接过度老化
keepalive_requests 1000;
# 空闲长连接的存活时间
keepalive_timeout 60s;
}
server {
location / {
proxy_pass http://backend;
# 这两行必须有,否则 Nginx 到上游仍会用短连接(HTTP/1.0 行为)
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
这里要建立的认知是:连接池这个东西,它真正的精髓不在"池"这个数据结构,而在它体现的一种顶级的工程思想——把"昂贵的、有固定成本的资源"和"廉价的、高频的使用请求"在生命周期上彻底解耦。在短连接模式里,连接的生命周期和请求的生命周期是绑死的:请求一来,连接才生;请求一走,连接就死。这个绑定,就是一切开销的源头。连接池做的事,本质是斩断这个绑定:它让连接拥有自己独立的、长得多的生命周期——连接在系统启动时(或第一次需要时)就被批量建好,然后长期常驻;请求来了,不是"创建连接",而是从池子里"借用"一条早已存在的连接;请求走了,不是"销毁连接",而是把连接"归还"回池子。借和还,是几乎零成本的操作;而那个昂贵的"建立",在整个系统生命周期里只发生有限的几次。这个"解耦昂贵资源和高频使用"的思想,你一旦认出来,会在所有高性能系统里反复看到它的影子:线程池解耦了"线程的创建"和"任务的提交",对象池解耦了"对象的分配"和"对象的使用",数据库连接池解耦了"数据库连接的建立"和"SQL 的执行"。它们全都是同一个套路。所以这里要带走的通用能力是:当你分析一个系统的性能时,养成习惯去识别——这里面哪些资源是"昂贵的、有固定建立成本的",而它们的生命周期,是不是被错误地和某个"高频、廉价"的事件绑死了?一旦你发现了这种致命的绑定,"用一个池把它们解耦开"这个解法,往往就摆在那里了。
六、工程里那些 TCP 连接的坑
连接复用这条主线理顺了,落地时还有几个工程坑反复咬人。第一个,连接池里的连接会"死"。一条长连接闲置太久,可能已经被对端、被中间的防火墙或 NAT 设备悄悄断开,而你的池子并不知道,借出去一用就报错。所以连接池必须有健康检查:借出前探活,或定期清理空闲过久的连接。第二个,谁主动关闭,谁背 TIME_WAIT。TIME_WAIT 落在主动关闭方,如果你不希望服务端堆 TIME_WAIT,就让客户端来主动关闭——这是个可以设计的选择。第三个,backlog 要应用和内核一起调。全连接队列上限是 min(应用 listen 的 backlog, somaxconn),你只调内核的 somaxconn、不改应用代码里的 backlog,照样卡在小的那个值上。第四个,压测要用足够多的客户端机器。单台压测机自己就会先撞上本地端口耗尽,你测出来的"系统上限"其实是压测机的上限,是个假数字。第五个,keepalive 有两个,别搞混:TCP 层的 SO_KEEPALIVE 是探测死连接的保活探针,HTTP 层的 Keep-Alive 是请求间复用连接——名字像,完全是两回事。把这些信号都接进监控,你才有数据去判断连接管理健不健康:
# TCP 连接健康必须盯死的几个指标
ss -s # established / timewait 的数量与比例
# timewait 远多于 established = 短连接病征
netstat -s | grep -i overflow # 全连接队列溢出次数,持续涨说明在丢连接
netstat -s | grep -i retrans # 重传次数,偏高说明网络质量或队列有问题
cat /proc/net/sockstat # 系统范围的 socket 用量总览
# 还该接进监控系统的关键指标:
# tcp_established_count 长连接是否被有效复用(健康时它应该是稳定的)
# tcp_timewait_count TIME_WAIT 数量,飙高就是短连接在作祟
# listen_overflow 全连接队列溢出,非 0 就要立刻处理
# ephemeral_port_used 本地临时端口占用率,逼近上限要报警
这里要建立的认知是:把这一节的坑串起来看,会浮现一个对"TCP 连接优化"乃至所有底层优化的总体判断——你以为你在调的是"参数",其实你在调的是"整个链路上每一个环节对同一件事的预期是否一致"。一条长连接能不能被成功复用,从来不取决于某一个参数设得对不对,而取决于这条链路上所有的参与者——客户端的连接池、中间的 Nginx、可能存在的 NAT 和防火墙、最终的后端服务——它们对"这条连接能空闲多久、能复用多少次、由谁来关"这件事,有没有达成一致的预期。客户端池子以为连接能闲 5 分钟,后端却 60 秒就关,这条连接就会"死"在池子里;Nginx 没配 proxy_http_version 1.1,它对上游的预期就还停留在短连接,你客户端那一侧的长连接调得再好也白搭。我第一版的失败,表面看是"漏了某个参数",根子上是我只盯着自己写的那段代码,从没把整条连接链路当成一个整体去看。这里要建立的通用认知是:任何一个跨越多个组件的系统行为,它的正确性,是由所有组件之间"契约的一致性"决定的,而不是由任何单个组件的本地配置决定的。优化它、排查它,你都必须有能力退后一步,把整条链路画出来,然后逐段去问:这两个相邻的环节,它们对这件事的约定一致吗?哪一段的预期和邻居对不上,问题就在哪一段。盯着单个组件的参数表死磕,你永远找不到那个藏在两个组件"预期错位"的缝隙里的真正问题。先看见整条链路,再逐段对契约——这个视角,比记住任何一个具体参数的推荐值都重要得多。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 短连接 | 一个请求配一个连接 用完即关 | 高并发下握手挥手开销盖过传输 是性能元凶 |
| 三次握手 | 建立 TCP 连接的一来一回交互 | 每次连接至少耗一个 RTT 是固定成本 |
| 半连接队列 | 握手未完成的连接的排队处 SYN queue | 满了新来的 SYN 被直接丢弃 |
| 全连接队列 | 握手已完成等应用 accept 的排队处 | 上限是 listen backlog 与 somaxconn 取小 |
| TIME_WAIT | 主动关闭方关连接后的等待状态 | 停留约 60 秒 期间占端口和资源 |
| 本地端口耗尽 | 出站连接的临时源端口被占光 | 报 Cannot assign requested address |
| 长连接 | 连接建立后不关 反复用它发请求 | 把握手成本摊到无数请求上趋近于零 |
| 连接池 | 维护一组常驻连接 借用与归还 | 解耦连接生命周期与请求生命周期 |
| tcp_tw_reuse | 允许出站连接复用 TIME_WAIT 端口 | 只是缓解 根治靠连接复用 |
| keepalive | TCP 保活探针与 HTTP 连接复用同名 | 两个完全不同的机制 别搞混 |
避坑清单
- 高并发下别用短连接,一个请求配一个连接,握手挥手和 TIME_WAIT 会把系统拖垮。
- QPS 上不去先看连接状态,用 ss -s 看 TIME_WAIT,资源都闲多半是连接管理在耗。
- 客户端一律用连接池,requests 用 Session,别裸调 requests.get 反复新建连接。
- Nginx 反代要配 proxy_http_version 1.1,否则它到上游仍是短连接,白白浪费。
- 全连接队列要应用和内核一起调,上限取 listen backlog 与 somaxconn 的小值。
- 盯住 netstat -s 的队列溢出计数,持续上涨说明在丢连接,要扩队列或提速 accept。
- 别开 tcp_tw_recycle,它在 NAT 环境会错误丢包,新内核已删除,tcp_tw_reuse 才安全。
- 连接池必须有健康检查,空闲长连接会被对端或防火墙悄悄断开,借出前要探活。
- 压测要用多台客户端机,单台压测机自己先端口耗尽,测出的上限是假的。
- 把 TIME_WAIT 数量和端口占用率接进监控,飙高就是短连接在作祟,要主动发现。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为 TCP 连接是一根透明的零成本管道,connect 一下就有,网络快不快只看带宽。可一个 TCP 连接,有建立成本(三次握手要一个 RTT)、有状态残留(关闭后主动方进 TIME_WAIT 赖 60 秒)、要排队(经过半连接和全连接两个内核队列,满了就丢)。我用短连接,把这些成本和每一个请求一比一地绑死,于是高并发下,系统所有的力气都耗在了反复建销连接上——QPS 上不去而 CPU 内存带宽全闲,就是这么来的。
真正把 TCP 吞吐做上去,工作量不在"加带宽、换网卡",而在一次模式的转变:把短连接换成长连接,用连接池把连接的生命周期和请求的生命周期解耦开。一旦接受这一点,该做的事就都浮现出来了——客户端用 Session 连接池,Nginx 到上游开 keepalive,把全连接队列的 backlog 和 somaxconn 调匹配,让连接池带上健康检查,把 TIME_WAIT 和端口占用接进监控。每一步都不复杂,难的是先承认:你手里的不是一根零成本的管子,而是一个有建立开销、有状态、要排队的资源,它值得被当成资源来认真管理。
我后来常拿银行柜台办业务来想这件事。短连接,就像你每办一笔业务都重新开一个账户、办完就销户:开户要填表、核验、等叫号(三次握手),销户之后这个账户还要被冻结一段时间才能让别人用(TIME_WAIT)——你来办一百笔业务,就开销一百次户,真正办业务的那点时间,全淹没在开户销户里了。长连接和连接池,就是办一张卡、反复地用:卡只办一次,之后每笔业务都刷同一张卡,开户那点成本被摊到无数笔业务上,趋近于零。而柜台前那一排等候的椅子,就是全连接队列:椅子能接住一时涌来的客人,但椅子坐满了,后来的人就只能被请出去——椅子缓冲的是突发,扛不住的是持续的人手不足。
这类问题最咬人的地方,在于它在开发测试时几乎永远是"对"的:你本地低并发一测,每秒就几十个连接,握手开销看不出来,TIME_WAIT 堆积慢得像没有,短连接写法延迟低、表现完美,你压根意识不到自己埋了颗雷。它只在并发真正压上来、真实流量打进来之后才引爆——每秒上万连接建了又关,握手吃光 CPU,TIME_WAIT 堆成几十万,端口被占干,而这些征兆没有一个会在功能测试里喊疼。所以别等大促当天 QPS 卡死、客户端开始报端口耗尽,才想起去看连接管理:写下第一行网络调用代码时,就该把"我用的是长连接还是短连接、连接有没有被复用、TIME_WAIT 会堆多少"当成和写对业务逻辑同等重要的事来设计——连接池不该是"压测出问题了再补"的补丁,而该是你搭网络层时就立好的地基。把连接这件事在一开始就当成有成本的资源来对待,你才算真正跳出了那个把 TCP 当成零成本透明管道、出了事还在盯着带宽看的坑。
—— 别看了 · 2026