偶发 connection reset:连接池复用死连接避坑

有个服务调用下游接口平时好好的,可总会偶发地毫无规律报一个错:Connection reset by peer——连接被对端重置了,频率不高几百次请求一两次、重试又能成功,起初没太当回事,可流量增长后积累的失败量越来越扎眼。这个错最磨人的就是随机性:同样的代码同样的接口绝大多数都好好的偏偏隔一阵冒一个失败。抓了好久包对了好久日志才慢慢拼出真相,藏在一个我从没留意的细节里:这些失败几乎都发生在一段时间没有请求之后的第一次请求上。深挖发现真凶是我的连接池和中间那层负载均衡之间的误会:为复用连接我用了连接池会把空闲长连接留在池里待命,可我和下游之间隔着负载均衡器它有自己的空闲连接超时,一条连接空闲超过比如 60 秒 LB 就单方面把它关掉,而这个关闭信号未必能及时传到我的连接池,于是池里就躺着一条它以为还活着实际对端早已关闭的死连接,新请求恰好借用它发数据就撞上对端回敬的 RST。这篇文章从这次偶发连接重置的事故出发,讲透长连接的坑:死连接如何因静默单方面关闭产生、让连接池保鲜期短于中间设备空闲超时、借出前校验、TCP Keep-Alive 要调短探测间隔、幂等请求配自动重试、用抓包锁定 RST 真凶。

有个服务调用下游接口,平时好好的,可总会偶发地、毫无规律地报一个错:Connection reset by peer——连接被对端重置了。频率不高,大概几百次请求里有那么一两次,重试一下又能成功,所以一开始没太当回事。可随着流量增长,这个"偶发"积累起来的失败量也越来越扎眼,对账、监控告警时不时被它触发,我不得不正经地把它揪出来。

这个错最磨人的地方,就是它的随机性:同样的代码、同样的接口,绝大多数请求都好好的,偏偏隔一阵冒一个失败。我抓了好久的包、对了好久的日志,才慢慢拼出真相,而真相藏在一个我从没留意过的细节里:这些失败,几乎都发生在"一段时间没有请求之后的第一次请求"上。顺着这条线索深挖,真凶浮出水面——问题出在我的连接池和中间那层负载均衡(LB)之间一个不为人知的"误会"。

原来,为了复用连接、避免反复握手,我的客户端用了连接池,会把空闲的长连接留在池子里待命。可我和下游之间隔着一个负载均衡器,它有自己的空闲连接超时——一条连接如果空闲超过比如 60 秒没有数据,LB 就会单方面把它关掉,以回收资源。问题是,LB 关闭连接时,这个"关闭"的信号未必能及时、可靠地传达到我的连接池;于是我的连接池里,就躺着一条它以为"还活着、可以用"、实际上对端早已关闭的"死连接"。当一个新请求恰好借用了这条死连接去发数据,对端(LB)一看"这条连接我早关了",就回敬一个 RST,于是我就收到了 Connection reset by peer。这篇文章,就从这次"偶发连接重置"的事故出发,把连接池与空闲超时、死连接、保活机制这些坑,一次讲透。

先摆几个关于长连接的想当然

动手复盘前,先把我自己曾经深信、后来被这个"偶发"教育的几个念头摆出来。

想当然的念头 残酷的真相
"连接池里的连接, 拿来就能用" 它可能是条被对端悄悄关掉的"死连接"
"对端关连接, 我这边会马上知道" 静默关闭/网络中断时, 我这边可能毫不知情
"偶发的 reset, 重试一下就好, 不用深究" 它是连接池与空闲超时不匹配的信号, 该根治
"连接建好了就一直有效" 中间的 LB/防火墙都有空闲超时, 会主动断开
"长连接只管复用, 不用管它的'保鲜'" 必须有保活/校验机制, 否则会用到失效连接

这些念头的共同病根,是把"连接池里的一条连接"想当然地当成了"一条永远可用的管道",却忽略了一个事实:一条 TCP 连接的两端之间,往往还隔着 LB、网关、防火墙等中间设备,它们每一个都可能因为各自的策略(比如空闲超时)而单方面切断连接,而我这一端未必能及时知道。要看清这次事故,得先理解"死连接"是怎么产生的。

第一件事:"死连接"是怎么来的——一场静默的单方面关闭

TCP 连接正常关闭时,会有"四次挥手",两端都会知道连接关了。但现实中,连接经常是被单方面、甚至静默地切断的:负载均衡器到了空闲超时,直接关掉它那一端;防火墙清理长时间不活跃的连接表项;网络抖动导致某一端的关闭信号(FIN/RST)在路上丢了……这些情况下,一端已经认为连接结束了,而另一端却还蒙在鼓里,以为连接好端端地活着。这条"一端已死、一端不知"的连接,就是"死连接"(stale connection)。

在我的场景里,这个"被蒙在鼓里"的就是我的连接池。它持有一条空闲长连接,LB 那头因为空闲超时把它关了,但我的连接池没收到、或没处理这个关闭信号,依然把这条连接当作"健康、可复用"留在池里。直到下一个请求借走它、往里写数据,才一头撞上对端的 RST。下面这张图,把这次事故的来龙去脉画出来:

看懂这张图,事故的"偶发"之谜就解开了:为什么总在"空闲一段时间后的第一次请求"出错?因为只有连接空闲得足够久、触发了 LB 的超时被关掉,它才会变成死连接;而高频请求时,连接一直在用、不空闲,LB 不会关它,自然不出错。问题的根源,是我的连接池的"连接保鲜期",比中间 LB 的"空闲容忍期"要长——于是总有一些连接,在我以为它还能用的时候,其实早被 LB 判了死刑。接下来,我们就看怎么消除这个"误会"。

第二件事:让连接池的"保鲜期"短于中间设备的"超时期"

既然根因是"连接池保留连接的时间,比中间 LB 容忍空闲的时间长",那最直接的解法就是:把连接池里连接的最大存活时间 / 最大空闲时间,设得比 LB(或防火墙)的空闲超时更短。这样,在 LB 主动关掉一条空闲连接之前,我的连接池就已经先一步把它回收、淘汰掉了,根本不会留下让它变成死连接的机会。

// 以 HikariCP(数据库)/ HTTP 连接池为例, 核心思路一致:
// 让"连接在池里的最长寿命"短于"中间设备的空闲超时"
// 假设 LB 空闲超时是 60 秒, 那么:

// HTTP 连接池(如 Apache HttpClient)
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
// 连接最长存活 50 秒(< LB 的 60 秒), 到期前主动淘汰重建
cm.setValidateAfterInactivity(5_000);        // 空闲超 5 秒, 用前先校验
CloseableHttpClient client = HttpClients.custom()
    .setConnectionManager(cm)
    .setConnectionTimeToLive(50, TimeUnit.SECONDS)   // < LB 的 60 秒!
    .evictIdleConnections(40, TimeUnit.SECONDS)      // 空闲 40 秒就清理掉
    .build();

// 数据库连接池 HikariCP 同理
config.setMaxLifetime(50_000);   // 连接最长存活 50 秒, 务必 < 数据库/LB 的超时
config.setIdleTimeout(40_000);   // 空闲 40 秒回收
// 黄金法则:连接池的 maxLifetime < 链路上任何中间设备的空闲超时

这里的核心,是一条"黄金法则":连接池里连接的最大寿命,必须小于这条链路上任何一个中间设备(LB、网关、防火墙、数据库本身)的空闲超时时间。只要满足这一条,连接永远是"我先主动淘汰它",而不是"它先被对端悄悄关掉"——死连接就没有了产生的土壤。我那次事故的根治,正是把 HTTP 连接池的 connectionTimeToLive 调到了比 LB 空闲超时更短的值,偶发的 reset 就此消失。

这一招的前提,是你得知道链路上各中间设备的空闲超时是多少——这往往需要去问运维、查 LB/网关配置。这个"摸清链路上每一跳的超时"的功课看似琐碎,却是根治这类问题的关键:你不了解中间设备的脾气,就没法让自己的连接池去适配它。

第三件事:借出连接前先"探一下活",别盲目使用

调好寿命是治本,但还有一道"保险":在从连接池借出一条连接、真正使用它之前,先校验一下它到底还活不活。这叫连接校验(validation)。即便因为种种意外(时钟、配置疏漏)还是留下了死连接,这道校验也能在使用前把它拦下、换一条好的,避免请求直接撞上 RST。

// 借出连接前先校验它是否还活着, 死的就丢弃换新的
// 数据库连接池:用一条轻量的探活 SQL
config.setConnectionTestQuery("SELECT 1");   // 借出前跑一下, 失败就换
// HikariCP 更推荐用 JDBC4 的 isValid(), 比 testQuery 更高效
// (现代 HikariCP 默认就会在借出前用 isValid 校验)

// HTTP 连接池:开启"非活跃后校验"
cm.setValidateAfterInactivity(5_000);
// 一条连接空闲超过 5 秒, 下次借出前先检测它是否仍有效, 失效则丢弃重建

// 思路:宁可借出前多花一点点校验开销, 也好过把请求发到死连接上撞 RST

连接校验是一种"防御性"的兜底:它承认"死连接可能存在"这个现实,于是在每次使用前先确认一下,把风险拦在发送数据之前。需要权衡的是开销——每次借出都校验会增加一点点延迟,所以通常用 validateAfterInactivity 这种"只校验空闲了一段时间的连接"的策略,在安全和性能间取平衡。"调短寿命"是从源头减少死连接,"借出前校验"是使用前拦截漏网的死连接——两者配合,双保险。

第四件事:TCP Keep-Alive——给空闲连接定时"心跳"

还有一个从更底层着手的办法:TCP Keep-Alive(保活机制)。它让操作系统对空闲的 TCP 连接,每隔一段时间自动发一个探测包("你还在吗?"),如果对端正常回应,连接就保持活跃;如果连续几次没回应,系统就判定连接已死、主动关闭它。这有两个作用:一是定时的探测包能让连接"看起来一直有数据往来",从而避免被 LB 的空闲超时盯上;二是能让我方更早地发现并清理掉真正断掉的连接。

// 开启 TCP Keep-Alive, 让 OS 给空闲连接定时发探测包
// HTTP 客户端层面
SocketConfig socketConfig = SocketConfig.custom()
    .setSoKeepAlive(true)        // 开启 TCP keep-alive
    .build();
cm.setDefaultSocketConfig(socketConfig);

// 注意:操作系统默认的 keep-alive 探测间隔很长(Linux 默认 2 小时!)
// 远大于 LB 的几十秒超时, 所以光开开关常常不够, 还要调短探测间隔:
//   net.ipv4.tcp_keepalive_time = 30    (空闲 30 秒就开始探测)
//   net.ipv4.tcp_keepalive_intvl = 10   (探测间隔 10 秒)
//   net.ipv4.tcp_keepalive_probes = 3   (连续 3 次失败判定连接死亡)
// 把探测间隔调到 < LB 空闲超时, keep-alive 才能真正起到"保活"作用

这里有个常见的误区要点破:很多人以为开了 TCP Keep-Alive 就万事大吉,但操作系统默认的探测间隔长达 2 小时,远远超过 LB 那几十秒的超时——也就是说,在系统发出第一个探测包之前,LB 早就把连接关了。所以要让 keep-alive 真正发挥"保活"作用,必须把探测间隔调到比 LB 超时更短。不过相比"调短连接寿命"那个应用层方案,keep-alive 涉及操作系统参数,更底层、也更需要权衡,通常作为辅助手段。它和应用层的连接寿命管理,是从不同层次解决同一个问题,可以配合使用。

第五件事:幂等的请求,配上自动重试

无论你怎么防,在分布式网络里,连接的偶发失效是无法 100% 杜绝的——网络本身就是不可靠的。所以除了"减少死连接",还要有一层"即便撞上了也能优雅恢复"的能力:对幂等的请求,配上自动重试。当一个请求因为命中死连接而收到 connection reset 时,连接池会丢弃这条坏连接,而重试机制会用一条新的、健康的连接重新发一次,对用户来说就是无感的。

// 对幂等请求(GET/查询类), 配置自动重试, 把偶发的连接失效兜住
HttpRequestRetryHandler retryHandler = (exception, executionCount, context) -> {
    if (executionCount > 2) return false;        // 最多重试 2 次
    // 连接重置/无响应这类"连接问题", 用新连接重试是安全的
    if (exception instanceof NoHttpResponseException
            || exception instanceof SocketException) {
        return true;   // 换条连接重来, 大概率就成功了
    }
    return false;
};
// 关键前提:只对【幂等】的请求重试! 重试一个"扣款""下单"可能造成重复操作
// 非幂等请求要重试, 必须配合幂等键(idempotency key)等机制保证安全

重试是分布式系统对抗"网络不可靠"的标准手段,但有一条不可逾越的红线:只能对幂等的操作无脑重试。查询(GET)天然幂等,重试无害;可"扣款""下单"这类非幂等操作,盲目重试可能造成重复扣款、重复下单的严重后果——这和我们之前聊分布式事务时强调"重试必须以幂等为前提"是完全一致的。对非幂等请求,要么不重试,要么用幂等键等机制确保"重试多次也只生效一次"。把"减少死连接(防)"和"幂等重试(治)"结合起来,才能让服务在不可靠的网络里既少出错、又能自愈。

第六件事:这类"偶发"问题,要靠抓包来锁定真相

最后说排查。像 connection reset 这种偶发、又夹在好几层中间设备之间的问题,光看应用日志往往只能看到一个笼统的报错,定位不到根因。这时,抓包(tcpdump / Wireshark)是穿透层层迷雾、看清真相的终极武器。通过抓包,你能亲眼看到:是谁发出了 RST?在连接空闲了多久之后发的?这些铁证能帮你精确锁定"是中间哪一跳、因为什么策略关掉了连接"。

# 抓包定位 connection reset 的真凶
# 抓与下游/LB 之间的包, 重点看 RST 标志
tcpdump -i any host downstream-lb-ip and port 443 -w capture.pcap
# 然后用 Wireshark 打开, 过滤 tcp.flags.reset == 1
# 观察:
#   - RST 是哪一方发出的?(源 IP 告诉你是 LB 还是下游发的)
#   - RST 前, 这条连接空闲了多久?(若恰好 ≈ LB 超时, 真凶锁定)
#   - 是不是都发生在"空闲一段时间后的首次请求"上?

# 也可以在服务器上统计连接状态, 辅助判断
ss -tan | grep CLOSE-WAIT   # 大量 CLOSE-WAIT 说明对端关了你没处理

抓包的价值,是把"我猜可能是 LB 超时"这种推测,变成"我看到 LB 在连接空闲 60 秒后发了 RST"这种铁证。对于跨越多层、偶发、难复现的网络问题,抓包常常是唯一能给出确定性答案的手段。当应用层日志只能告诉你"出错了"、却说不清"为什么、是谁干的"时,沉到网络层去抓包,往往就能让真相无所遁形。到这儿,这次事故的方方面面就齐了。我把排查思路收成一张决策图:

把这套理解建立起来,"偶发连接重置"这类幽灵问题就能被系统地定位和根治。最后,拧成几条可直接照做的铁律:

  1. 连接池里的连接可能是"死连接",对端(尤其中间 LB)可能已静默关闭而你不知道。
  2. 连接池 maxLifetime 必须短于链路上任何中间设备的空闲超时,让自己先淘汰、别被对端先关。
  3. 摸清链路上每一跳(LB/网关/防火墙)的空闲超时,这是适配的前提。
  4. 借出连接前做校验(validation),拦截漏网的死连接, 别把请求发到坏连接上。
  5. TCP Keep-Alive 要调短探测间隔才有用,默认 2 小时远超 LB 超时, 等于没开。
  6. 幂等请求配自动重试,兜住无法杜绝的偶发失效; 非幂等重试要靠幂等键保证安全。
  7. 偶发网络问题靠抓包定位,看清 RST 是谁发的、空闲了多久, 让推测变铁证。

一张偶发连接问题速查表

把这类连接问题的现象、成因和对策汇成一张表,遇到偶发网络错时对照着查。

现象 多半的成因 对策
空闲后首次请求 reset 死连接: 连接池保鲜 > LB 超时 maxLifetime < LB 空闲超时
偶发 NoHttpResponse 连接被对端静默关闭 借出前校验 + 幂等重试
大量 CLOSE_WAIT 对端关了, 本端没处理 修复连接关闭逻辑, 设保活
开了 keep-alive 仍 reset 探测间隔(默认2h)远超 LB 超时 调短 tcp_keepalive_time
持续而非偶发 reset 下游限流/过载/主动断 抓包查 RST 来源, 查下游
重试后重复扣款 对非幂等请求盲目重试 只重试幂等请求 / 用幂等键

写在最后

这次"偶发连接重置"的事故,给我最深的体会,是它彻底改变了我对"一条连接"的认知。在写代码时,我脑海里的"连接",是一根从我直通下游、稳定可靠的"管道"——我把请求塞进去,结果就从另一头出来,简单清爽。可这次事故狠狠地提醒我:这根"管道",实际上是由我、下游、以及中间一连串我看不见的设备(负载均衡、网关、防火墙)共同维系的;它们每一个,都有自己独立的生命周期管理策略,都可能在我毫不知情的情况下,单方面地把这根管道从中间掐断。我以为我握着一根完整的管道,其实我握着的,只是一段两端都连着"陌生人"的脆弱接口。

这让我领悟到分布式系统一个深刻的本质:"网络是不可靠的",这句老生常谈,其不可靠不仅来自物理链路的抖动,更来自链路上每一个中间节点各自为政的策略与状态。你的系统,从来不是孤立地和下游对话,而是和一整条充满了独立决策者的链路在协作。一个健壮的客户端,必须建立在"对端和中间设备随时可能、且不会通知我地改变连接状态"这个悲观假设之上——于是才有了调短连接寿命(适配中间设备)、借出前校验(不轻信连接还活着)、幂等重试(为失效兜底)这一整套防御。这次事故于我,正是一堂关于"敬畏链路"的课:它教会我,在调用任何一个远程服务时,都不要把那条连接当成理所当然的可靠,而要时刻记得,它的另一端和中途,站着许多我无法控制、却时刻影响着我的"陌生人"。愿你我在构建每一次远程调用时,都带着这份对网络不确定性的清醒与谦卑——因为正是这份谦卑,让我们写出的客户端,能在那条充满变数的链路上,既跑得顺畅,又经得起偶尔的风浪。

如果你手上也有调用下游、又用了连接池的服务,不妨今天就花二十分钟做三件小事自查。第一,去问运维或查配置,搞清楚你和下游之间那条链路上,负载均衡、网关、防火墙各自的"空闲连接超时"是多少;心里有了这个数,才好对齐。第二,检查你的连接池配置,把连接的 maxLifetime(或 connectionTimeToLive)设到比上面那个最小的超时还要短,让自己永远先一步淘汰连接。第三,给查询类的幂等请求配上针对连接异常的自动重试,作为兜底。这三步成本不高,却能把那个时不时冒出来、扰得人心烦的"偶发 reset",从根上摁下去。

把这个事故放进这个系列里看,它和前面聊过的 TIME_WAIT、DNS 缓存、连接池耗尽,其实都指向同一个主题:在分布式的世界里,"连接"这个我们最习以为常的东西,远比它看起来要复杂、要脆弱。它有生命周期、有状态、有寿命,会被两端和中途的种种策略所左右。我们越是把它当成理所当然的"通了就一直通"的管道,就越容易在它某次不动声色的失效里栽跟头。而成长,往往就是从一次次这样的栽跟头里,慢慢学会去理解连接背后那套真实的运作机制,学会带着对网络不确定性的敬畏,去构建那些不仅在理想情况下能跑、更在风浪里也站得稳的系统。愿你我都能在这条路上,把每一次"偶发"的困扰,都化作一次对底层更深的洞察。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

数据库很闲却拿不到连接:连接池耗尽避坑复盘

2026-5-30 12:45:31

技术教程

日志撑爆磁盘服务全线崩:磁盘写满避坑复盘

2026-5-30 12:57:07

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索