侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 135 篇文章
  • 累计收到 7 条评论

空闲连接被关的时刻,比 keepAliveTimeout 晚了一秒

2026-9-27 / 0 评论 / 6 阅读

空闲连接被关的时刻,比 keepAliveTimeout 晚了一秒

一条长连接在两个请求之间空闲了一会儿,第二个请求发出去之后没有任何响应,服务端那边也没有新的访问记录。客户端拿到的不是超时也不是状态码,是连接被关掉了。遇到这种现象,第一反应通常是去查服务端进程是不是重启过。

只在空闲之后出现的错误

本机起一个 http 服务,keepAliveTimeout 设成 1000 ms(Node 的默认值是 5000 ms,这里把它压小,好让边界落在几秒内),headersTimeout 设 5000 ms。客户端这边自己攥一条 socket:发第一个请求,响应读完,等一段时间,再用同一条 socket 发第二个。

等的时间跨过某个值之后,第二个响应就再也不来了。抓到的是对端发 FIN 的干净关闭,不是 RST,所以客户端看到的是「连接被关」而不是 ECONNRESET。这也是它难查的原因:错误信息里没有任何线索指向超时配置。

量出来的是两倍的超时

四组设置各测两次,间隔从第一个响应读完那一刻起算,记的是客户端观察到连接关闭的时间。

== 1. 四种 keepAliveTimeout 设置,各自量「响应读完 -> 连接被关」的间隔 ==
设置(ms)   两次实测(ms)   与设置值的差
--------------------------------------------------
     500     1504    1503     +1004, +1003
    1000     2003    2002     +1003, +1002
    1500     2502    2503     +1002, +1003
    2000     3002    3003     +1002, +1003

差值那一列是全表的重点:设置 500 ms 时实测 1504 ms,设置 2000 ms 时实测 3002 ms,四种设置全都多出 1002 到 1004 ms。倍数关系稳定到 1 ms 以内,说明这不是网络抖动,是这套组合下的固定偏移。

多出来的那一秒是怎么来的,我没查到 Node 内部对应的那段计时逻辑,只能先拿实测值当边界,不敢按设置值去算客户端的安全线。这一条以后如果在别的运行时上复现,数字可能不一样,方法可以先照搬:量出真实的关闭时刻,别信配置值。

客户端到底在哪一刻开始失败

边界取上一步实测的最小值 2002 ms,在它两侧各测三遍。

== 2. keepAliveTimeout = 1000 ms 时,在这条边界附近复用同一条连接 ==
   (边界取上一步实测最小值 2002 ms,间隔从「第一个响应读完」起算)
间隔(ms)   三次结果
--------------------------------------------------------------
     300   第二个响应收到 | 第二个响应收到 | 第二个响应收到
    1802   第二个响应收到 | 第二个响应收到 | 第二个响应收到
    1982   第二个响应收到 | 第二个响应收到 | 第二个响应收到
    2002   连接被关,第二个响应没来 | 连接被关,第二个响应没来 | 连接被关,第二个响应没来
    2022   连接被关,第二个响应没来 | 连接被关,第二个响应没来 | 连接被关,第二个响应没来
    2202   连接被关,第二个响应没来 | 连接被关,第二个响应没来 | 连接被关,第二个响应没来

1982 ms 那档三次都拿到了第二个响应,2002 ms 那档三次全失败,中间只隔 20 ms。窗口窄,所以线上表现是偶发:空闲时间刚好压在边界附近的请求中招,其余全正常。同一份配置在业务低峰期更容易出问题,因为空闲时间被拉长了。

自己管池子的客户端会中招

换 http.Agent({keepAlive:true}) 之后再跑同样两档空闲时间,结果都是 200。

  空闲 1600 ms 后复用:默认 keepAlive agent -> 第一个 200 / 第二个 200;agent 加 timeout:500 -> 第一个 200 / 第二个 200
  空闲 2600 ms 后复用:默认 keepAlive agent -> 第一个 200 / 第二个 200;agent 加 timeout:500 -> 第一个 200 / 第二个 200

Agent 会监听空闲 socket 的 close,对端关掉之后把它从空闲池里摘掉,下一个请求自然新建连接。给 agent 加上 timeout:500,让它在服务端之前主动回收空闲 socket,结果一样是 200。

真正出问题的是自己维护连接的那一类客户端:把 socket 缓存在自己的结构里,只按「上次用过」判断它可用,不监听 close。中间层做上游长连接复用、手写的连接池、把 socket 交给一个长期存活的 worker,都算这一类。这套写法在服务端重启时反而没事,因为重启会让所有连接立刻报错,错误很显眼。

该动哪一边

客户端这一侧更值得动。让空闲回收的阈值明显小于服务端的 keepAliveTimeout(服务端 5 秒就配 3 秒),或者在取出 socket 之前检查它是否已经关闭,最省事的是直接用运行时自带的连接池,close 事件的处理它已经写好了。

服务端能做的有限:keepAliveTimeout 调大能换到更长的复用窗口,但要留出比中间层空闲超时更大的余量,否则边界就挪到中间件那一层去了。这里没有通用的数字,先把两侧的实测值量出来,再决定谁让一步。

排查的顺序可以固定下来:先在服务端日志里找「第二个请求有没有到达」,没到就往下游看客户端持连接的那段代码,到了但没响应才轮到中间件。这一次的现象属于第一种,从收到 FIN 那一刻起,客户端手里的那条连接就已经死了。

    🤞 分享