Skip to content

连接成功了,请求却一直没有响应 ​

服务变慢的时候,调用方的日志里出现了两种超时:一部分是连接超时,一部分是读超时。连接超时还好理解,读超时的那些请求,在服务端的访问日志里根本找不到。它们连接成功了,请求也发出去了,却像从来没有到达过。

TCP 的三次握手是操作系统内核完成的,不需要应用参与。握手完成的连接被放进一个队列,等应用调用 accept 取走。应用的线程都被占住时,这个队列里的连接就停在那里:对客户端来说连接已经建立,对应用来说它还不存在。

本文在 Linux 容器里把这个状态构造出来。实验见文末。

客户端connect / send / recv内核:监听队列listen(5):放了 6 个已完成握手的连接应用线程都在忙,没有 accept1前 6 个连接:connect 成功,send 成功(数据进了内核缓冲区),读取一直没有响应2第 7 个起:握手完不成,connect 超时;内核的 ListenOverflows 计数增加队列长度 = min(listen 的参数, net.core.somaxconn):listen(1000) 而 somaxconn 是 8 时,只放得下 9 个Java 的 new ServerSocket(port) 不指定时是 50:实验里完成握手的是 51 个「连接成功」只说明内核在监听,不说明应用有能力处理
图 1 · 实测(Linux 容器内回环):三次握手是内核完成的,完成之后连接放在监听队列里等应用来取。应用线程都被占住、没有调用 accept 时,客户端这边连接已经建立,请求也发得出去,只是没有人读。队列放满之后,新来的连接握手完不成,客户端看到的才是连接超时。

一、应用不取连接时,客户端看到什么 ​

服务端调用 listen(5) 之后一直不 accept,客户端同时发起 40 个连接:

text
服务端不 accept,同时发起 40 个连接,2 秒后:握手完成 6 个,仍在等待 34 个

应用一行代码都没有执行,6 个连接已经握手成功。在其中一个连接上发请求:

text
在握手完成的连接上发请求:send 返回 36 字节(成功);随后读取:1 秒内没有任何响应(读超时)

send 也成功了。数据被内核收下,放在这个连接的接收缓冲区里,等应用来读。应用连这个连接都还没有取走,自然不会读。客户端等到读超时为止。

这就是开头那些「服务端日志里找不到」的请求。它们停在内核里,应用的访问日志、链路追踪、业务指标都看不到,因为这些都是应用取到连接之后才开始记录的。

队列满了之后再来的连接是另一种表现:

text
队列满了之后再来一个连接(连接超时设 1 秒):connect 超时,耗时约 1 秒
内核计数器 ListenOverflows 增加了 = True,ListenDrops 增加了 = True

握手完不成,客户端等到连接超时。所以同一个原因(应用来不及取连接)在调用方那里表现为两种超时:排进队列的是读超时,没排进去的是连接超时。

连接超时还有别的来源,例如地址不可达或被防火墙丢弃,怎么区分见请求失败时,先看它停在了哪一步。

二、队列有多长 ​

listen 的参数给出期望的长度,内核再用 net.core.somaxconn 封顶:

text
listen(backlog=1000),net.core.somaxconn = 8
服务端不 accept,同时发起 40 个连接,2 秒后:握手完成 9 个,仍在等待 31 个

应用要了 1000,实际只有 8 的效果。实验的内核上能完成握手的个数是队列长度加一。

两个地方都要看:

  • 内核参数。 实验的容器里 somaxconn 默认是 4096;较老的内核默认是 128。它是按网络命名空间设置的,容器里的值与宿主机的无关,Kubernetes 里要通过 Pod 的 securityContext.sysctls 设置。
  • 应用传给 listen 的值。 很多框架有自己的默认值,而且偏小:
text
new ServerSocket(port) 不指定 backlog、不 accept,同时发起 80 个连接(超时 2 秒):握手完成 51 个(java.version=25.0.4.1)

Java 的 ServerSocket 默认是 50。Tomcat、Netty、nginx、Redis 各有自己的配置项(acceptCount、SO_BACKLOG、listen … backlog=、tcp-backlog),调大内核参数而不改应用的配置,是没有效果的。

三、把队列调大能解决什么 ​

队列是用来吸收短时间的突发的:一瞬间进来的连接多于应用当时能取走的,先排着,稍后取走。对这种情况,把队列调大是有用的,否则突发的那一部分会直接连接超时。

它解决不了应用持续处理不过来的情况。队列再长,排在里面的请求也只是在等。排得越久,等应用终于取到它的时候,客户端越可能已经超时离开了,应用处理的是一个没有人要的请求。这时候更长的队列让情况更糟:应用的全部能力都花在已经被放弃的请求上。

所以长度的选择是两头的权衡:太短,正常的突发都会被拒;太长,过载时堆积一批注定超时的请求。一个可以参考的算法:队列里的连接等待的时间不应超过客户端的超时。应用每秒能取走 1000 个连接、客户端超时是 1 秒,那么超过 1000 的部分排进去也没有意义。

持续过载需要的是另外的手段:在应用里尽早拒绝(返回 503 而不是让请求排队)、限流、扩容。相关的讨论见容量评估与秒杀设计。

四、怎么发现 ​

这个问题难查,是因为应用自己的观测手段都看不到它。要看内核:

看什么命令说明
队列当前的长度与上限ss -ltn监听套接字那一行的 Recv-Q 是当前排队数,Send-Q 是上限
有没有溢出过nstat -az TcpExtListenOverflows TcpExtListenDrops累计值,持续增长说明一直在溢出
实际生效的上限cat /proc/sys/net/core/somaxconn在容器里执行,看的是容器自己的值

ListenOverflows 值得作为常规的监控指标采集。它不为零,说明有请求在应用看不见的地方被丢掉或者延迟了。

另一个线索来自调用方:读超时的请求在服务端没有任何记录,或者服务端记录的处理耗时很短、调用方测得的耗时很长。两边的差值就是在队列里等的时间。

五、健康检查也在这个队列里 ​

就绪探针、负载均衡的健康检查同样是连接,排在同一个队列里。应用忙的时候:

  • 只做 TCP 连接检查的探针会通过,因为握手由内核完成,与应用是否有空无关。一个已经完全卡死的进程,TCP 探针仍然显示健康。
  • HTTP 探针要等应用取走连接并响应,会跟业务请求一起排队,可能因此超时,把一个只是暂时繁忙的实例摘掉,流量转移到其他实例,引起连锁反应。

探针怎么选、超时怎么设,见 Running 不等于可用。

六、检查清单 ​

  • 知道服务实际生效的监听队列长度:应用配置与 somaxconn 取小。
  • 容器里的 somaxconn 单独确认,不假设与宿主机一致。
  • 采集 ListenOverflows,对增长告警。
  • 调用方的连接超时与读超时分开设置、分开统计。
  • 过载时应用主动拒绝,而不是依靠队列堆积。
  • 不用单纯的 TCP 连接检查判断应用是否健康。

配套实验

  • codesphere-labs/engineering/accept-backlog:Linux 容器内的回环地址(Python 3.14.8、JDK 25.0.4.1):服务端 listen 后不 accept 时握手完成的个数、已建立连接上发请求的结果、队列满后的新连接、somaxconn 的封顶、Java ServerSocket 的默认 backlog(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权