
一条长连接在两个请求之间空闲了一会儿,第二个请求发出去之后没有任何响应,服务端那边也没有新的访问记录。客户端拿到的不是超时也不是状态码,是连接被关掉了。遇到这种现象,第一反应通常是去查服务端进程是不是重启过。
只在空闲之后出现的错误
本机起一个 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 那一刻起,客户端手里的那条连接就已经死了。