一次接口超时排查:从应用层挖到 TCP 内核参数

偶发 timeout 不一定是接口慢。把应用层、连接池、TCP socket、NAT/LB 放进同一条时间线,才能看清旧连接为什么会在下一次复用时失败。

封面

这次 timeout,最开始看起来不像网络问题。

应用日志里能看到请求发出去了,调用方等了一段时间,然后超时返回。下游服务没有明显的整体抖动,接口耗时曲线也不像被打爆。更麻烦的是,它不是每次都失败。多数请求正常,少数请求像踩到暗坑一样卡住,重试之后又恢复。

复盘后能确认的几个判别信号是:失败集中在低峰之后的第一批请求;同一条连接首次失败、立刻重试就成功;连接池调试日志里出现 reused connection 的痕迹。这几个信号叠在一起,方向就不再像“下游接口慢”那一类。

开场:偶发 timeout 的三条判别信号

第一反应通常是看应用层:是不是业务 timeout 太短?是不是下游偶发慢?是不是连接池满了?这些方向都该查。但这次排查转向,是从一张连接时间线开始的:把那条连接从应用层一路画到 NAT/LB,再画回 TCP 内核参数。

画完之后,结论比想象中朴素:这条连接在不同层的生命周期没有对齐。应用以为连接还活着,中间设备早就把它回收了;TCP keepalive 太晚才来敲门,应用只能在下一次复用旧连接时才发现不对。

一、先别看代码,先画连接时间线

排查接口超时,最容易犯的错,是只盯着“这次请求花了多久”。

但长连接复用场景里,一次失败请求的根因,可能发生在很久之前。请求开始时你看到的是 timeout,但出问题的时刻,可能是几分钟前那条连接空闲到被中间设备回收。

我更建议先画一条连接时间线,而不是马上改业务 timeout。

一条请求链路至少有四层状态:

层级 它眼里的连接 常见误判
应用层 我拿到一个连接,发请求,等响应 “下游接口慢”
连接池 这条连接还在池里,可以复用 “池里有连接就等于连接可用”
TCP 层 socket 还没被本端判死 “socket 没报错就等于链路还活着”
NAT/LB 空闲太久的连接状态可以回收 “中间设备会主动告诉应用”

这四层不是同步更新的。

第 1 章:四层连接生命周期剖面图

连接池还保存着连接,不代表 NAT/LB 还保存着这条连接的映射。本端 socket 没报错,也不代表路径中间的状态还完整。应用层看到 timeout,往往已经是最后一棒了。

把时间线展开,大概是这样:

时刻 发生了什么 应用知道吗
T=0 建连,请求成功,连接回到连接池 知道
T=0~300s 连接空闲,没有业务流量 没异常
T≈300s NAT/LB 空闲窗口到期,回收连接状态 通常不知道
T>300s 连接池仍可能持有旧连接句柄 以为还能复用
下一次复用 旧连接再次发送数据 这时才可能 timeout、RST 或 broken pipe
T=7200s TCP keepalive 默认首次探测才可能发生 已经太晚

这里的“300s”不要理解成所有 NAT 的固定默认值。它代表的是“分钟级 idle timeout”。AWS NAT Gateway 文档里有 350 秒的例子,Azure Load Balancer 默认是 4 分钟。不同基础设施不同,现场要查自己的配置。

但不管是 240 秒、300 秒还是 350 秒,只要它远早于 TCP keepalive 的首次探测时间,结论都一样:TCP 层来不及替你提前发现这条连接已经不可靠。

这一步的目的不是背参数。

它只确认一件事:谁先判这条连接死。

二、2 小时的 keepalive,为什么救不了 5 分钟级回收

很多人听到 keepalive,会下意识以为“TCP 不是有保活吗?为什么还会有死连接?”

这里有两个容易混在一起的点。

第一,TCP keepalive 不是默认立刻工作。应用需要对 socket 启用 SO_KEEPALIVE,它才会参与探活。第二,就算启用了,系统默认参数也可能非常慢。

先看 Linux tcp(7) 文档里的三个默认值:

参数 默认值 含义
tcp_keepalive_time 7200 秒 空闲多久后开始第一次 keepalive 探测
tcp_keepalive_intvl 75 秒 探测包之间的间隔
tcp_keepalive_probes 9 次 放弃前发送多少次探测

7200 秒,就是 2 小时。

如果你的 NAT/LB 在 5 分钟级别回收空闲连接,默认 TCP keepalive 的第一次探测会晚到离谱。中间设备 5 分钟左右就清掉状态,本端要等 2 小时才第一次敲门。

我在本机也读了一下 TCP keepalive 参数。我的机器是 macOS,所以不能把结果直接写成 Linux 事实,但它能说明这类默认值的量级并不罕见:

net.inet.tcp.keepidle: 7200000
net.inet.tcp.keepintvl: 75000
net.inet.tcp.keepcnt: 8

换算后是:首次探测 7200 秒,探测间隔 75 秒,放弃前 8 次。注意这里的 keepcnt 和 Linux 文档里的默认 9 次不同,这正好提醒我们:排查时不能只记一个网上答案,要查运行环境自己的参数。

问题出在时间差。

如果一条连接在 T=0 被放回连接池,T≈300s 被 NAT/LB 回收,而本端 T=7200s 才首次 keepalive 探测,那么从第 300 秒到第 7200 秒之间,应用和连接池都可能活在一种错觉里:连接句柄还在,但链路中间的状态已经不完整。

第 2 章:T=0、T≈300s、T=7200s 时间线

下一次复用旧连接时,会出现几种不同表现:

  • 中间设备返回 RST:应用很快看到 connection reset。抓包能看到一个孤立的 RST,方向通常是中间设备 → 客户端。
  • 中间链路像黑洞:本端连续重传,看不到任何回包,直到业务 timeout。抓包只看到本端不断 retransmit。
  • 客户端库自动重试:你只看到偶发慢请求,底层其实失败过一次,日志里能看到 retry attempt 关键词,库已经替你吞掉了第一次错误。
  • 连接池探活较弱:旧连接被借出去后才暴露问题,连接池本身没拦住。
  • 本端写入已关闭/被重置的连接:表现为 broken pipeEPIPE)或读到 EOF。具体形态取决于协议栈和客户端库——很多库会吞掉 SIGPIPE,应用最终只看到 EPIPE 错误码。

第 2 章:旧连接失败表现分叉图

所以“静默杀死”这句话也要谨慎。

有些设备会显式返回 RST,有些表现为黑洞 timeout,有些错误被连接池或客户端库吞掉后重试。更准确的说法是:中间设备可能已经回收了连接状态,但应用层不一定在回收那一刻感知到。

这就是它难排查的地方。

应用看到的是当前请求 timeout,但你要解释的是:为什么这条连接在发起请求之前就已经不可靠。

三、排查路径:从 timeout 日志到内核参数

如果只看应用日志,这类问题会长得很像下游慢。

请求发出去,等不到响应,最后超时。你很容易去拉下游耗时、数据库慢查询、CPU、GC、线程池。查这些没有错,但如果这些指标都解释不了“偶发、空闲后、重试恢复”,就该换一张表看。

我会按这个顺序排:

看到的信号 优先怀疑 下一步检查
下游整体耗时升高,所有连接都慢 下游服务慢或网络整体拥塞 查下游 SLO、服务端日志、链路追踪
只有复用连接的首次请求偶发失败,重试后恢复 陈旧连接 / 中间设备回收 查连接池 idle timeout、max lifetime、健康检查
错误集中在长时间空闲后 idle timeout 错配 对齐业务低峰、连接空闲时间、NAT/LB idle timeout
抓包看到 RST 中间设备显式重置或对端关闭 判断 RST 来源方向,结合 NAT/LB 文档
抓包只看到重传直到应用超时 链路黑洞或状态不一致 查安全组、NAT/LB 状态、长连接保活
keepalive 首次探测远晚于 idle timeout TCP 探活来得太晚 调整 socket keepalive 或连接池生命周期

看这张表时,先别急着把所有 timeout 归成一类。

第 3 章:从 timeout 日志到 TCP 参数的排查路径图

应用层 timeout 只是最后的表现。你要问的是:这个 timeout 之前,连接经历了什么?

先问连接自身的状态:它是不是一条刚建的新连接?是不是从连接池里拿出来的旧连接?它在池里空闲了多久?再问链路中间的状态:路径上有哪些 NAT、LB、Proxy、网关?这些设备的 idle timeout 是多少?最短窗口是哪一层?

如果失败集中在“长时间空闲后的第一次复用”,方向就变了。

你不再是在问“下游为什么慢”。你是在问“我拿到的这条旧连接,为什么我以为它还活着”。

这时有三个检查特别有价值。

第一,看连接池配置。

很多客户端连接池都有 idle timeout、max lifetime、validation、keepalive ping 一类配置。它们的名字不同,但目标类似:不要把太旧、太久没用、状态不确定的连接继续借给业务请求。

第二,看中间设备 idle timeout。

这一步不要靠猜。云厂商 NAT、负载均衡、网关、代理都有自己的默认值和可配置范围。你要查的是你实际链路上的那一层,而不是网上某篇文章里的默认值。

第三,看 TCP keepalive 是否启用,以及参数是多少。

系统默认参数存在,不等于应用 socket 启用了 SO_KEEPALIVE。启用了 SO_KEEPALIVE,也不等于参数适合你的链路。很多语言或客户端库还允许单独设置 socket 级别的 keepalive idle、interval、count,这些设置可能覆盖系统默认值。

把这三张结果对齐后,再问一个更具体的问题:

这条连接在被中间设备回收之前,有没有任何一层会更早发现或主动丢弃它?

如果答案是没有,那么 timeout 只是迟早的事。

四、改参数之前,先区分四个容易混淆的东西

这类问题最危险的修复方式,是听到 keepalive 就马上改内核参数。

改参数可能有效,也可能完全没打中问题。

你要先区分四个东西。

TCP keepalive——TCP 层的空闲连接探活。它解决的问题是:连接长时间没有数据流动时,本端能不能主动确认对端或路径是否仍可达。

HTTP keep-alive——HTTP 层的连接复用机制。它解决的是:多个 HTTP 请求能不能复用同一条 TCP 连接,减少建连成本。它不是 TCP 探活本身。

连接池 idle timeout——应用或客户端库管理旧连接的策略。它解决的是:一条连接在池里空闲多久之后,就不要再借给业务请求。

应用 timeout——业务请求等待响应的上限。它解决的是:这次请求最多等多久,超过就失败返回。

这四个东西经常一起出现,但职责不同。

第 4 章:四个 timeout / keepalive 概念对照卡

如果 NAT/LB 5 分钟级回收空闲连接,而连接池可以在 240 秒主动丢弃旧连接,那么即使 TCP keepalive 仍是 7200 秒,业务也可能不会踩到旧连接。因为应用侧在中间设备回收前,就已经不再复用那条连接。

反过来,如果你只是把应用 timeout 从 3 秒调到 10 秒,问题大概率还在。你只是让用户多等 7 秒,并没有让旧连接更早变健康。

我用一个小脚本把几组配置放在同一张表里算了一下。计算口径很简单:

最晚判死时间 ≈ keepalive_time + keepalive_intvl × keepalive_probes

按 Linux 默认 7200 + 75 × 9 = 7875 秒估算,本表整体按这个口径走,是配置推演不是生产压测,不同内核版本和 RTO 实现可能有数秒级偏差。

场景 中间设备 idle timeout TCP 首次 keepalive TCP 最晚判死时间(约) 连接池 idle timeout 判定
Linux 默认 keepalive + 5 分钟级 NAT 300s 7200s 约 7875s 未设置 探测晚于回收
AWS NAT Gateway 350s + Linux 默认 keepalive 350s 7200s 约 7875s 未设置 探测晚于回收
Azure LB 默认 240s + Linux 默认 keepalive 240s 7200s 约 7875s 未设置 探测晚于回收
调低 TCP keepalive:60/10/3 300s 60s 约 90s 未设置 TCP 探测早于回收
连接池 idle timeout 240s 300s 7200s 约 7875s 240s 连接池先丢弃
错误调参:keepalive_idle=600s 300s 600s 约 690s 未设置 仍晚于回收

第 4 章:配置组合矩阵可视化

这张表说明两件事:默认 7200 秒和分钟级回收不是一个量级;把 keepalive 改成 600 秒看似激进,但仍晚于 300 秒回收窗口,对这个场景来说并没有解决问题。

所以参数不是越小越好,也不是照抄某个推荐值。

你需要先画出链路里谁最早回收连接,再决定哪一层应该更早动作。

五、最小修复策略:探测早于回收,失败早于用户感知

这类问题的修复原则,我会压成两句话:

探测早于回收。 失败早于用户感知。

第一句说的是 TCP 或应用层保活。

如果你的链路中间有 5 分钟级 idle timeout,而你希望长连接长期保留,就必须让探测早于这个窗口。比如 socket 级别 keepalive idle 设到 60 秒、间隔 10 秒、探测 3 次,这样最晚 90 秒左右就能把不可达连接判掉。

具体数字要结合链路容量、连接数量、云厂商限制和业务特征,不能照搬。高连接数的服务把首次探测从 7200 秒压到 60 秒,等于多发了上百倍的探测包,内网带宽和中间设备的状态表都会被推高;部分云厂商还会限制连接最长存活时间或 keepalive 包速率,最好先在小范围灰度。

还要明确 keepalive 的作用边界。它处理的是空闲连接的探活和保活:在中间设备 idle 计时器还没到期时发出的探测,更多是“刷新 idle timer、维持映射”;只有当路径已不可达或状态已被回收,探测才会变成 RST 或 timeout,让本端判死。换句话说,调小 keepalive 不只是为了“早点踢死连接”,更是为了在每个 idle 窗口里给中间设备一次维持映射的机会。

keepalive 也不替代请求发出后的读写 deadline。一旦请求数据已经发出,失败时长还会受 TCP 重传和应用层读写 timeout 控制;在 Linux 场景下,已发出的未确认数据最长等待时间还可由 TCP_USER_TIMEOUT 限制——超过这个值,内核会强制关闭连接并向应用返回 ETIMEDOUT。这一段属于 in-flight 阶段的兜底,和 keepalive 的空闲探活是两件事。

第 5 章:空闲探活与 in-flight 超时边界

第二句说的是连接池和业务体验。

如果你不想维护长时间空闲连接,就让连接池更早丢弃旧连接。比如中间设备 300 秒回收,连接池可以在 240 秒左右淘汰空闲连接。这样下一次请求会重新建连,而不是拿旧连接去赌。

这两种策略可以组合。

  • 对确实需要保持长连接的场景:启用并调低 TCP keepalive 或应用层心跳。
  • 对普通 HTTP/RPC 调用:优先检查连接池 idle timeout、max lifetime、健康检查和重试策略。
  • 对用户请求路径:不要靠拉长应用 timeout 掩盖旧连接问题,要让底层失败尽早暴露。
  • 对多云或多环境部署:不要假设 NAT/LB idle timeout 一样,按环境读取配置。

不同协议和库对“连接池 / 保活”的命名也不一样。HTTP/2 与 gRPC 在连接复用之外还要看 PING 间隔、max_connection_age 与服务端允许的最小 ping 间隔;数据库连接池一般用 validationQuery 或心跳 SQL;HTTP/1.1 客户端则更多依赖 idle timeout 与 max lifetime。看到“keepalive”三个字时先确认它到底落在哪个层级。

还有一个细节:非幂等请求不要随便自动重试。

很多客户端库遇到旧连接失败,会在底层重试一次。如果是查询请求,问题不大;如果是写请求,必须确认有幂等键、去重机制或明确业务语义。否则你解决了 timeout,可能制造重复写入。

这也是为什么我不喜欢把结论写成“把 keepalive 改成 60 秒就好了”。

工程里的正确答案通常不是一个数字,而是一组时间窗口的相对关系。中间设备什么时候回收?连接池什么时候丢弃?TCP 什么时候探测?应用什么时候放弃?用户什么时候收到失败?这些窗口排好,问题才算被理解。

落到操作上,可以按一个更实际的顺序展开。

先确认现象是否和空闲时间有关。把失败请求按“连接是否复用”“连接空闲多久”“是否发生在低峰后”“是否首次失败后重试成功”分组。如果失败和空闲时间没关系,直接跳到 TCP keepalive 很可能是误判。这一步不需要完整链路追踪——很多连接池或 HTTP/RPC 客户端都能打出连接复用、连接创建、连接关闭、重试次数等调试日志,足够判断“失败请求拿到的是一条旧连接”。

然后查链路上的 idle timeout,而不是只查本机参数。链路里能产生空闲回收的环节通常不止一处:NAT Gateway、Load Balancer、Ingress、API Gateway、Service Mesh sidecar、公司内的四层/七层代理。你要找的是最短的那个窗口,因为最短窗口决定了旧连接最早什么时候可能失效。很多排查会漏掉这一点:只看应用容器里的 sysctl,发现 tcp_keepalive_time=7200,就直接动系统参数;可如果真正回收连接的是外层 LB 或某个代理层,单机参数只是拼图的一块。

下一步,决定由哪一层负责更早动作。少量、长期、必须保持的连接(数据库长连接、消息队列连接、到固定后端的 RPC 长连接)适合走 TCP keepalive 或应用层心跳,让连接在中间设备回收前产生流量,或者更早发现不可达。普通请求流量,尤其是 HTTP/RPC 客户端连接池,多数情况让连接池更早淘汰旧连接更简单——连接池 idle timeout 早于 NAT/LB idle timeout,就能避开“拿旧连接去试”的风险。代价是多一点重建连接的成本,但通常比把用户请求挂到 timeout 更可控。

最后再看应用 timeout 和重试。应用 timeout 是用户体验边界,不是连接保活机制;它告诉业务“这次请求最多等多久”,不该承担“替旧连接慢慢试错”的职责。重试也一样,它能掩盖偶发旧连接失败,但不能替代根因修复。尤其是写请求,没有幂等键的自动重试比 timeout 还危险。

所以我会把修复动作拆成三类:

目标 优先动作 不建议的替代动作
避免复用陈旧连接 设置连接池 idle timeout / max lifetime 早于中间设备回收 只拉长应用 timeout
长连接必须保活 启用并调小 TCP keepalive 或应用层心跳 只依赖系统默认 2 小时探测
快速暴露失败 设置合理请求 timeout、连接建立 timeout、读写 timeout 让用户请求承担底层探活成本
保证写请求安全 幂等键、去重、明确重试边界 客户端库无脑自动重试

最后,再验证修改是否改变了时间线。

改完配置后回到同一张时间线,重新填四个窗口:连接池多少秒丢弃旧连接?TCP 多少秒首次探测?中间设备多少秒回收?应用请求多少秒 timeout?如果这些窗口的相对关系没变,问题只是暂时没出现。

更有价值的复盘会写清楚这些窗口,比如:

这条链路的最短中间设备 idle timeout 是 300 秒;客户端连接池在 240 秒丢弃空闲连接;TCP keepalive 作为兜底在 60 秒首次探测;请求 timeout 保持 3 秒,写请求重试受幂等键保护。

这句话说明排查者已经理解了连接生命周期。

第 5 章:探测早于回收,失败早于用户感知

它比“参数已优化”可信得多。

也更方便下一次排查复用:换一条链路,只要重新填这些时间窗口,就能快速判断该查应用、连接池、TCP,还是中间设备。

结尾:超时不是一个数字,是一条链路的生命周期

到最后,我反而不太在意读者能不能背下 tcp_keepalive_time=7200。我更希望你记住那张时间线。

下一次遇到偶发接口超时,尤其是长时间空闲后的首次请求失败,不要只在应用层改 timeout。先把连接从应用层、连接池、TCP socket、中间设备画一遍。如果发现 TCP keepalive 2 小时才首次探测,而 NAT/LB 5 分钟级就回收空闲连接,问题就不在“接口慢”这一头,而是探测来得太晚。

把这句话放进排查习惯里,比记住任何一个默认参数都更有用.

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →