连接成功了,请求却一直没有响应
服务变慢的时候,调用方的日志里出现了两种超时:一部分是连接超时,一部分是读超时。连接超时还好理解,读超时的那些请求,在服务端的访问日志里根本找不到。它们连接成功了,请求也发出去了,却像从来没有到达过。
TCP 的三次握手是操作系统内核完成的,不需要应用参与。握手完成的连接被放进一个队列,等应用调用 accept 取走。应用的线程都被占住时,这个队列里的连接就停在那里:对客户端来说连接已经建立,对应用来说它还不存在。
本文在 Linux 容器里把这个状态构造出来。实验见文末。
一、应用不取连接时,客户端看到什么
服务端调用 listen(5) 之后一直不 accept,客户端同时发起 40 个连接:
服务端不 accept,同时发起 40 个连接,2 秒后:握手完成 6 个,仍在等待 34 个应用一行代码都没有执行,6 个连接已经握手成功。在其中一个连接上发请求:
在握手完成的连接上发请求:send 返回 36 字节(成功);随后读取:1 秒内没有任何响应(读超时)send 也成功了。数据被内核收下,放在这个连接的接收缓冲区里,等应用来读。应用连这个连接都还没有取走,自然不会读。客户端等到读超时为止。
这就是开头那些「服务端日志里找不到」的请求。它们停在内核里,应用的访问日志、链路追踪、业务指标都看不到,因为这些都是应用取到连接之后才开始记录的。
队列满了之后再来的连接是另一种表现:
队列满了之后再来一个连接(连接超时设 1 秒):connect 超时,耗时约 1 秒
内核计数器 ListenOverflows 增加了 = True,ListenDrops 增加了 = True握手完不成,客户端等到连接超时。所以同一个原因(应用来不及取连接)在调用方那里表现为两种超时:排进队列的是读超时,没排进去的是连接超时。
连接超时还有别的来源,例如地址不可达或被防火墙丢弃,怎么区分见请求失败时,先看它停在了哪一步。
二、队列有多长
listen 的参数给出期望的长度,内核再用 net.core.somaxconn 封顶:
listen(backlog=1000),net.core.somaxconn = 8
服务端不 accept,同时发起 40 个连接,2 秒后:握手完成 9 个,仍在等待 31 个应用要了 1000,实际只有 8 的效果。实验的内核上能完成握手的个数是队列长度加一。
两个地方都要看:
- 内核参数。 实验的容器里
somaxconn默认是 4096;较老的内核默认是 128。它是按网络命名空间设置的,容器里的值与宿主机的无关,Kubernetes 里要通过 Pod 的securityContext.sysctls设置。 - 应用传给
listen的值。 很多框架有自己的默认值,而且偏小:
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的封顶、JavaServerSocket的默认 backlog(验证记录)
参考资料
- listen(2) 手册页(backlog 的含义与
somaxconn的封顶) - Linux 内核文档:IP Sysctl(
somaxconn、tcp_max_syn_backlog、tcp_abort_on_overflow) - Java SE 25 API:ServerSocket(默认的 backlog 为 50)
- Kubernetes 文档:Using sysctls in a Kubernetes Cluster
- ss(8) 手册页