我的服务用连接池复用长连接调下游,平时好好的,却总在低峰期之后偶发 connection reset,排查发现是连接被对端的空闲超时悄悄关了、我的连接池却还留着这条"已死"的连接照样拿来用:一次复用了半开连接、误以为"我这边没断它就还活着"的深度复盘
那个"低峰期过后第一批请求偶发 connection reset、重试又好了"的诡异 bug,让我重新认识了"连接复用"。我的服务用连接池复用到下游的长连接(避免每次新建,这是对的,同复用连接的优化)。平时高峰期一切正常。可总在低峰期之后(比如凌晨流量小、或一段时间没请求后)出怪事:那之后的第一批请求偶发失败,报 Connection reset by peer 或 Broken pipe;而重试一下又成功了。复盘了好久才搞懂,后背发凉:问题出在我复用了一条"对端已经悄悄关闭、我这边却还以为活着"的连接(半开连接)。连接池为了复用,会把用完的长连接留着、放回池里,下次直接拿来用;可一条连接空闲太久时,对端(下游服务、或中间的 LB/NAT/防火墙)会因为"空闲超时(idle timeout)"主动把它关掉——而且这个关闭,我这一端可能根本没收到通知(或通知丢了);于是就形成了"半开连接":对端已经关了,我的连接池里却还留着这条连接、以为它还活着;低峰期之后,我从池里拿出这条早已被对端关闭的"死连接"去发请求,自然就 Connection reset;而重试时,池里的死连接被剔除、新建了一条,就成功了;根子在于:我的连接池"保留空闲连接的时间",比对端的"空闲超时时间"还长——我以为"我这边没主动关,连接就还在",可连接是两端的,对端早已单方面把它关了。根本原因是:连接是两端共同维持的,对端会因空闲超时单方面关闭连接而我这端未必感知(半开连接);我的连接池保留空闲连接的时间长于对端的空闲超时,复用了已被对端关闭的死连接,导致 connection reset。问题的根,是复用了半开连接——对端因空闲超时关了连接、我这端不知道仍留在池里复用;根源是连接池空闲时长超过对端 idle timeout、且误以为"我没断它就还活着"。这篇就把这次"复用死连接"的坑,从头到尾复盘一遍。
故障现场:复用了一条对端已关闭的连接
问题在于连接的两端对"连接是否还活着"认知不一致:
# 现象:
# - 高峰期(连接频繁使用): 正常;
# - 低峰期之后(连接空闲了一阵)的第一批请求: 偶发 Connection reset by peer / Broken pipe;
# - 重试一次: 又成功了。
# 为什么会这样(半开连接 half-open):
1. 我用连接池复用长连接: 用完不关, 放回池里, 下次拿来复用(避免每次新建握手, 是优化);
2. 一条连接空闲太久 → 对端(下游服务 / 中间的LB / NAT网关 / 防火墙)的"空闲超时(idle timeout)"
到了, 对端【主动关闭】这条连接(发FIN, 或NAT直接丢弃映射);
3. 关键: 这个关闭, 我这一端【可能没收到/没处理】 → 我的连接池里还留着这条连接, 以为它活着;
→ 形成"半开连接": 对端已关, 我这端还以为开着;
4. 低峰后, 我从池里取出这条"死连接"发请求 → 数据发过去, 对端回 RST → Connection reset;
5. 重试: 死连接被剔除、新建一条好连接 → 成功。
# 根本矛盾: 连接是【两端】共同维持的, 但两端对"它还活不活"的认知可能【不一致】:
# - 我这端: "我没主动关它, 它就还在"(我的连接池保留了它);
# - 对端: "它空闲超过我的idle timeout了, 我把它关了"(我没及时知道);
# - 我的连接池"保留空闲连接的时间" > 对端的"空闲超时" → 池里攒下了已被对端关闭的死连接。
# 常见的"对端idle timeout"来源(都会悄悄关你的空闲连接):
# - 下游服务的 keep-alive timeout(如 Nginx 默认75s、各类server都有);
# - 云LB/负载均衡的 idle timeout(如AWS ELB默认60s);
# - NAT网关/防火墙的连接表项超时(空闲一段时间就丢弃映射);
★ 核心: 连接是两端的, 对端会因空闲超时单方面关闭、而你未必感知(半开连接);
连接池保留空闲连接的时间必须 < 对端(及中间设备)的idle timeout, 否则会复用到死连接;
别以为"我这端没关它就还活着"——它的存活, 取决于两端中任意一端没单方面放弃它。
看着"低峰后第一批失败、重试就好"的规律,我又困惑又恍然:"我一直觉得'连接我放池里好好的、我没关它,它当然还能用'……谁知道对端嫌它闲太久、早把它单方面关了,我却蒙在鼓里,拿着这条'尸体'去发请求。连接是两边的事,不是我一厢情愿说它活它就活。"这个坑最隐蔽的地方在于:它高峰期完全正常(连接一直在用,没机会空闲超时);它只在"连接空闲超过对端 idle timeout"后才暴露(低峰期、长间隔后);而且重试就好(死连接被剔除),让人误以为是"偶发抖动",不深究;它还取决于对端/中间设备的配置(对端 idle timeout 多长),换个环境表现就变。下面就来拆解,连接池复用到底该怎么做。
第一件事:搞懂半开连接与连接存活
我顺着这次事故,把连接复用和存活检测彻底理清了。
为什么会复用到死连接? 连接池该怎么配?
【核心: 连接是两端共同维持的, 对端(下游/LB/NAT)会因空闲超时单方面关闭、而你未必感知(半开连接);
连接池保留空闲连接的时间必须小于对端idle timeout, 并做存活检测; 别以为"我没关它就还活着"】
1. 半开连接(half-open)是怎么回事:
- TCP连接由两端共同维持; 正常关闭是四次挥手(双方都知道);
- 但对端可能"单方面"关闭(发FIN、或NAT/防火墙直接丢弃连接表项), 而你这端没收到/没处理;
- 结果: 对端认为连接已关, 你这端还以为开着 → "半开连接";
- 你往半开连接发数据 → 对端回 RST → Connection reset by peer。
2. 谁会"单方面"关掉你的空闲连接(idle timeout):
- 下游服务的 keep-alive timeout(server一段时间没收到请求就关, 如Nginx 75s);
- 云负载均衡(LB)的 idle timeout(如AWS ELB默认60s, 超时无流量就断);
- NAT网关/防火墙的连接表项超时(运营商/云网络常见, 空闲就丢映射);
- 这些都"默默"关闭你池里的空闲连接, 你未必收到通知。
3. 为什么连接池会攒下死连接:
- 连接池为复用, 会保留空闲连接一段时间(maxIdleTime / keepAliveDuration);
- 如果这个时间 > 对端的idle timeout, 那池里的连接会被对端先关掉, 而池还留着 → 死连接;
- 低峰期/长间隔后, 取出死连接用 → reset。
4. 正解方向:
① 连接池空闲时间 < 对端idle timeout: 让自己的连接在对端关它之前先主动回收/重建;
② 开启连接存活检测: 借出连接前测一下(testOnBorrow / validation query), 死的就剔除重建;
③ 设连接最大存活时间(maxLifetime): 定期重建连接, 别让一条连接用到天荒地老(同595长连接);
④ 开 TCP keepalive(探活)或应用层心跳: 定期发探测包保活/及早发现断连(但注意它和应用idle不同);
⑤ 对幂等请求做重试: 万一拿到死连接, 重试一次(剔除死连接、用新连接)兜底。
5. 本质: 连接的"存活"是两端共识, 不是你单方面说了算
- "我没主动关它" ≠ "它还活着"——对端可能已经关了;
- 依赖一个"双方共同维持的状态"(连接、会话、租约)时, 要意识到对方可能单方面改变它;
- 要么主动同步/检测双方状态, 要么对"状态可能已变"做好兜底(检测+重试)。
一句话: 连接是两端维持的、对端会因空闲超时单方面关闭而你未必感知(半开连接); 连接池空闲时间要小于对端
idle timeout、做存活检测、设maxLifetime、必要时重试; 别以为"我没关它就还活着", 存活是两端共识。
这套认知,是整个坑的根。半开连接:TCP 由两端维持,对端可能单方面关闭(发 FIN 或 NAT 丢映射)而你没感知,形成"对端已关、你以为开着"的半开连接,往里发数据就被 RST。谁会单方面关:下游 keep-alive timeout、云 LB idle timeout、NAT/防火墙连接表项超时——都默默关你的空闲连接。为什么攒死连接:连接池保留空闲连接的时间 > 对端 idle timeout,池里就攒下被对端先关的死连接。正解方向:连接池空闲时间 < 对端 idle timeout、借出前存活检测、设 maxLifetime、TCP keepalive/心跳、幂等请求重试兜底。本质:连接的存活是两端共识、不是你单方面说了算——"我没关它"≠"它还活着"。一句话:连接是两端维持的、对端会因空闲超时单方面关闭而你未必感知(半开连接);连接池空闲时间要小于对端 idle timeout、做存活检测、设 maxLifetime、必要时重试;别以为"我没关它就还活着",存活是两端共识。
第二件事:正解——空闲时间小于对端超时 + 存活检测
知道了半开连接,正解就清楚了:让连接在对端关它之前先回收,并做存活检测兜底。
# 正解1: 连接池空闲时间 < 对端idle timeout(让自己先主动回收)——本次该做的
# 先查清对端/中间设备的idle timeout(下游keep-alive、LB idle、NAT超时), 取最小值;
# 把连接池的"空闲连接最大存活时间"设得比它【更小】, 让连接在被对端关掉前就被自己回收重建。
# 例(HikariCP数据库连接池, 同理适用HTTP连接池):
# maxLifetime = 比DB/LB的idle timeout小(如对端60s, 这里设50s或更小)
# idleTimeout = 比maxLifetime小
# keepaliveTime = 定期对空闲连接发保活探测
# 正解2: 借出连接前做存活检测(死连接剔除重建)
# - 数据库连接池: testOnBorrow=true + validationQuery="SELECT 1"(或 connectionTestQuery);
# - HTTP连接池: 开启 validateAfterInactivity(空闲一段后, 借出前检测连接是否可用);
# - 检测到死连接 → 剔除、新建, 不把死连接交给业务。
# 正解3: 设连接最大存活时间(别让连接用到天荒地老, 同595)
# - 即使一直在用, 也定期重建连接(maxLifetime), 避免长期累积的各种问题(含半开)。
// 正解4: 对幂等请求做重试兜底(万一还是拿到死连接)
public Response callIdempotent(Request req) {
try {
return httpClient.execute(req); // 可能复用到死连接 → reset
} catch (ConnectionResetException | SocketException e) {
// 死连接已被剔除, 重试一次用新连接(仅对幂等请求! 同583重试)
return httpClient.execute(req);
}
}
// 正解5: 开 TCP keepalive / 应用层心跳(及早发现断连、或保活)
// - SO_KEEPALIVE: OS层定期探测对端是否还在(但默认间隔很长, 常需调参);
// - 应用层心跳: 长连接定期发ping, 既保活(刷新对端idle计时)又能及早发现断连;
// - gRPC/HTTP2等: 配置keepalive ping。
// 反例(别这样):
// - 连接池idle时间设得很长(几分钟), 远超对端idle timeout → 必然攒死连接;
// - 不做任何存活检测, 直接把池里的连接交给业务用;
// - 对非幂等请求盲目重试(可能重复执行, 要配合幂等, 同586)。
// 核心: 连接池空闲时间 < 对端idle timeout、借出前存活检测、设maxLifetime定期重建、
// 开keepalive/心跳保活探活、幂等请求重试兜底; 让"自己池里的连接状态"和"对端的实际状态"保持一致。
这套正解的关键,是让"自己连接池里的连接状态"和"对端的实际状态"尽量保持一致,并对不一致做兜底。空闲时间小于对端 idle timeout:查清对端/LB/NAT 的超时,把连接池空闲时间设得更小,让连接在被对端关掉前先被自己回收——这正是本次该做的。借出前存活检测:testOnBorrow/validateAfterInactivity,死连接剔除重建。设 maxLifetime:定期重建连接。幂等请求重试兜底:万一拿到死连接,重试一次用新连接(仅幂等)。TCP keepalive/应用层心跳:保活探活,既刷新对端 idle 计时又能及早发现断连。
第三件事:其他几个"双方对共享状态认知不一致"的坑
顺着这次半开连接,我把"双方对一个共享状态的认知不一致"的几类坑也一并理了:
几类"双方对共享状态认知不一致"的坑:
坑1: 半开连接(上文)——对端关了你以为还在; 正解: 存活检测 + 空闲时间小于对端超时。
坑2: 分布式锁过期但持有方以为还持有(同562)——锁到期被释放, 原持有方还在操作 → 两个人同时操作;
正解: 锁加fencing token, 关键操作靠幂等/唯一约束兜底。
坑3: session/token 服务端已失效但客户端还在用——服务端清了session, 客户端还拿着旧的;
正解: 客户端处理401/失效、刷新; 别假设"我有token就一定有效"。
坑4: 缓存与源数据不一致(同550/595)——一方改了, 另一方的缓存还是旧的;
正解: 失效/更新策略, 别假设缓存=真相。
坑5: 主从数据不一致(同346)——写主读从, 从还没同步, 你以为写的数据能读到;
正解: 强一致读主库, 或接受最终一致。
坑6: 客户端以为请求失败重发、服务端其实已处理(网络超时, 同586)——双方对"成功了没"认知不一致;
正解: 幂等 + 查询确认。
共同的根: 任何"双方/多方共同维持或共享的状态"(连接、锁、会话、缓存、数据副本、操作结果),
各方对它的认知都可能【因为通知丢失、超时、延迟而不一致】——你以为的状态, 可能早已不是对方的状态;
依赖这种共享状态时, 不能单方面假设"它还是我以为的样子", 要主动检测/同步, 或对"已不一致"做兜底。
这些坑看似不同,根却是同一个:任何"双方/多方共同维持或共享的状态"(连接、锁、会话、缓存、数据副本),各方对它的认知都可能因通知丢失、超时、延迟而不一致——你以为的状态,可能早已不是对方的状态。认清这个根("共享状态的认知可能不一致,别单方面假设它没变,要检测/同步或兜底"),才不会被"我以为它还在"坑到。
第四件事:常见 idle timeout / 连接池配置——两张对照表
我把常见的对端 idle timeout、以及连接池要配的项,整理成对照表,贴在了团队的网络/连接规范里:
| 谁会关空闲连接 | 典型超时 | 说明 |
|---|---|---|
| Nginx keepalive_timeout | 默认 75s | server 端空闲就关 |
| 云 LB(如 ELB)idle timeout | 默认 60s | 无流量就断 |
| NAT 网关/防火墙 | 几分钟~更短 | 连接表项超时丢映射 |
| 下游服务自定义 timeout | 各异 | 查文档/配置 |
| 数据库 wait_timeout | MySQL 默认 8h(也可改小) | 空闲连接被关 |
| 连接池配置项 | 怎么设 | 作用 |
|---|---|---|
| 空闲超时 idleTimeout | < 对端 idle timeout | 自己先回收,别等对端关 |
| 最大存活 maxLifetime | < 对端 idle timeout | 定期重建连接 |
| 借出检测 testOnBorrow | 开启 + validationQuery | 死连接剔除重建 |
| keepalive 探测 | 开启,间隔合理 | 保活/及早发现断连 |
| 幂等重试 | 对幂等请求开 | 拿到死连接时兜底 |
这两张表的核心,第一张是对端(下游/LB/NAT/DB)都有各自的 idle timeout,会默默关掉你的空闲连接;你得先查清它们的值;第二张是连接池的空闲/最大存活时间要设得比对端 idle timeout 更小,再配存活检测和重试兜底。记住一条:用连接池前,先查清对端的 idle timeout,把自己的连接生命周期设得比它短——让连接"我先回收",而不是"等对端来关"。
第五件事:关于连接复用的几组容易想当然的认知
这次事故也让我厘清了几组关于连接复用的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 我没主动关连接它就还活着 | 对端可能因空闲超时单方面关了它 |
| 连接池里的连接拿来就能用 | 可能是对端已关的死连接 |
| connection reset 是偶发网络抖动 | 常是复用了半开/死连接,有规律 |
| 连接空闲越久越省(留着复用) | 超过对端 idle timeout 就成死连接 |
| 复用长连接就一定比新建好 | 复用到死连接反而失败,要配存活检测 |
| 连接是我这端的事 | 是两端共同维持,任一端可单方面断 |
| 重试就好说明没大问题 | 是死连接被剔除,根因仍在,要治本 |
这张表里,我栽的是第一行和第六行:以为"我没关连接它就还活着"、"连接是我这端的事",没意识到连接是两端共同维持的、对端会单方面把它关掉。厘清这些,核心是一个意识:一条连接的"存活",是它两端共同维持的——任何一端因为超时等原因单方面放弃它,它就死了,而另一端未必立刻知道;所以复用长连接时,不能单方面假设"我留着的就还能用",要让自己的连接生命周期短于对端的超时、并做存活检测,让两端对"连接是否还活着"的认知尽量一致。
第六件事:配连接池 / 复用长连接时,我现在的自检习惯
现在每当我要配连接池、复用长连接,我都会先按这张图问自己:
这张图的精髓,是"先查对端idle timeout、连接池空闲时间设更小、做存活检测、幂等重试兜底"。先查对端超时、再保证自己空闲时间更小、借出前检测、幂等请求重试。这套习惯,让我从"连接留池里就以为能用"变成了"让连接生命周期短于对端超时并检测"——核心始终是:连接是两端维持的、对端会因空闲超时单方面关闭而你未必感知;连接池空闲时间要小于对端 idle timeout、做存活检测、设 maxLifetime、必要时重试;别以为"我没关它就还活着"。
我立下的几条规矩
这场"复用死连接、偶发 connection reset"的事故,换来了我用连接池时,刻进骨子里的几条铁律:
- 连接是两端共同维持的;对端(下游/LB/NAT)会因空闲超时单方面关闭连接,而你这端未必感知(半开连接)。
- 连接池保留空闲连接的时间,必须小于对端(及中间设备)的 idle timeout,否则会攒下死连接。
- 借出连接前做存活检测(testOnBorrow/validateAfterInactivity),死连接剔除重建。
- 设连接最大存活时间(maxLifetime)定期重建,别让连接用到天荒地老。
- 开 TCP keepalive 或应用层心跳,保活并及早发现断连。
- 对幂等请求做重试兜底:万一拿到死连接,重试用新连接。
- connection reset / broken pipe 偶发且"重试就好",优先怀疑复用了半开/死连接,别当偶发抖动。
写在最后
回头看,这场由"复用了对端已关闭的连接"引发的、偶发 connection reset 的事故,真正教给我的,远不止"配好连接池空闲时间和存活检测"这一个技巧。它让我对"一段'需要双方共同维持'的关系(一条连接、一份契约、一个约定), 它'是否还有效', 从来不是我单方面说了算的; 对方完全可能因为某些原因(超时、变化、放弃)单方面终止了它, 而我若没及时得到消息, 就会抱着一个'早已作废的关系'当它还在",有了一次刻骨的体会。我栽跟头,是因为我把"一段双方的关系"当成了"我一个人的东西"——我以为"连接放在我的池子里、我没主动关它, 它就还是我的、还能用";可一条连接是我和对端"两个人"共同维系的: 对端嫌它闲太久, 单方面就把它撤了, 这段关系在对方那头早已结束, 只是消息没传到我这头;我还守着我这半边"看起来完好"的连接, 以为关系如初, 一去使用, 才发现对方早已不认——connection reset, 就是对方在说"我们之间早没这条连接了"。这让我领悟到一个关于"双边关系与单方认知"的深刻认知:任何"由双方/多方共同维持"的东西(连接、合作、信任、约定、订阅、会话), 它的"有效性"是一个双边的、需要双方持续共识的状态; 任何一方都可能因为自己的原因(超时、变心、资源回收、规则变化)单方面让它失效, 而另一方未必能即时得知;所以"我这边还认这段关系" 绝不等于 "这段关系还有效"——只有"双方此刻都还认"它才真有效; 单方面的"我以为还在", 是一种危险的一厢情愿;依赖这类"双边维持的关系"做事时, 不能凭"我这边的记录"想当然, 要么主动确认对方是否还认(存活检测/心跳), 要么对"对方可能已单方面退出"做好准备(检测+重建+重试)。这给了我一种依赖"双边关系"时的清醒:每当我要复用、依赖一段"需要双方共同维持"的关系/资源时,要意识到"它在我这边还在,不代表在对方那边还在;对方可能早已单方面终止了它"——不凭我单方面的记录想当然, 而是主动检测双方是否还在这段关系里、或为'对方可能已退出'准备好兜底;"认清双边关系的有效性需双方共识、对方可能单方面终止而我未必知、用前确认或兜底",是避免'抱着作废的关系当它还在'式错误的关键。认清双边维持的关系有效性需双方共识、对方可能单方面终止而你未必感知、用前要确认或对不一致兜底——这,是我用一次复用死连接的事故,换来的、关于网络、也关于如何对待一切双边关系的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次配连接池时,先查清对端的 idle timeout、把自己的连接空闲时间设得更小、再加上存活检测,那我对着那个"低峰后第一批偶发 reset"的规律百思不解的这段时间,就值了。
—— 别看了 · 2026