SYN 发出去没人理?500ms 的秘密藏在半连接队列里

场景:服务连接间歇超时,重启后好转隔天复发 | 路径:告警 → ss -lnt → nstat → tcpdump → sysctl

上篇讲了 conntrack 超时让中间层偷偷断了连接,这篇来看另一个连接不通的常见场景——TCP SYN 到了服务端却没有回应的秘密。

T0.000 客户端发出 SYN。T0.312 服务端收到 SYN。T0.812 才回复 SYN-ACK——中间这 500ms,SYN 在排队等内核处理。不是网络丢包,是服务端内核说"你还没到该进来的时候"。

路况

凌晨 2 点,P0 告警

告警群通知

Prometheus 凌晨 2:13 弹出 P0 告警:订单服务连接超时率 23%,TCP 建连耗时 P99 飙到 8.2 秒(正常 <500ms)。值班同事登录服务器,ss -tlnp 看到端口 8080 在监听,ping 目标 IP 也通——一切看起来正常。

重启服务,恢复了。

三个小时后,凌晨 5:47,同一个告警又来了,这次超时率 31%。

用数字说话——别重启了,先看这里

真正的线索在端口监听状态里——但大多数人看错了地方:

SS 监听端口 Recv-Q > 0

你看到了什么?一个开发者的第一反应:"Recv-Q 是应用层没来得及处理的数据包"。错。

Listen 端口的 Recv-Q 和 ESTABLISHED 端口的 Recv-Q 含义完全不同

连接状态 Recv-Q 含义 满了怎么办
LISTEN 已完成第一次握手但未完成第三次握手的连接数(半连接数) 内核丢弃新 SYN,ListenOverflows++
ESTABLISHED 已建立连接中待应用层读取的数据字节数 对端窗口关闭,零窗口探测

图上 Recv-Q=89,Send-Q=128——队列 70% 已满。这 89 个 SYN 正在排队等 ACK。

金句:"SYN 不是丢了——是内核在说'我已经有太多连接在排队了,你等会再来'"

执行确认:

nstat -az | grep -E 'ListenOverflows|ListenDrops'

nstat 溢出计数

TcpExtListenOverflows=89,直接实锤——半连接队列溢出。

用 Grafana 看全局

Grafana P99 延迟和失败率

两个关键信号:TCP 建连耗时 P99 在告警前从 300ms 飙升到 8s(20 倍),连接失败率 31%。重启后回落到基线,但 3 小时后再次飙升——典型的"重启只清了存量,没治根"模式。

回放

正常建连——三次握手的基线

先看一次正常的 TCP 建连,作为对比基线:

正常三次握手

SYN(Synchronize Sequence Number)——TCP 建连的第一次握手,标志位为 1,携带初始序列号。 从客户端 SYN 到服务端 SYN-ACK,312ms——这是正常的网络延迟。

队列满了——SYN 被内核丢弃

并发压力上来后,再次抓包看到不同的景象:

SYN 无回应,重传三次

全流程的时间轴:

SYN 丢弃时间线

客户端在 0s、3.1s、6.2s 各发了一次 SYN,服务端一次都没回 SYN-ACK。不是防火墙拦截(防火墙拦截会回 RST 或 ICMP 不可达),不是网络丢包——SYN 已经到了服务端网卡,但内核决定不处理它

TCP 的初始 RTO 由 TCP_TIMEOUT_INIT 控制(默认 1s 级别),第一次超时后启动指数退避:1s → 2s → 4s → 8s……这也是为什么 P99 连接耗时飙到 8s——客户端在等第二次重传的超时。

路径

🔍 下次看到 Recv-Q > 0,先执行这个命令

nstat -az | grep -E 'ListenOverflows|ListenDrops'

ListenOverflows 持续增长 = 半连接队列溢出,实锤。

该调哪个参数?

这是文章的核心价值判断——80% 的开发者第一步调错了参数:

参数 作用 影响 常见误区
net.core.somaxconn 全连接队列上限 accept() 已取走的连接数上限 ❌ 半连接溢出时调它没用
应用 backlog 参数 调用 listen(fd, backlog) 传入的值 全连接队列初始容量 ❌ Java 默认 50,没调过
net.ipv4.tcp_max_syn_backlog 半连接队列上限 SYN 入队容量 ✅ 这才是问题所在
net.ipv4.tcp_syncookies SYN Cookie 保护 队列满时用加密方式绕过 默认=1 但不能完全替代调参

半连接队列满了 → 调 tcp_max_syn_backlog。全连接队列满了 → 调 somaxconn + 应用 backlog。

确认当前配置

sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.ipv4.tcp_syncookies
net.ipv4.tcp_max_syn_backlog = 1024
net.ipv4.tcp_syncookies = 1

默认 1024。在 C10K 时代这个值够用,但对于千级 QPS 且连接瞬发(burst)很强的现代服务,1024 在 1ms 内就能冲爆。

关于 tcp_syncookies=1 的一个常见误解:很多人以为开启 syncookies 就不会有连接超时了。不是。syncookies 是"队列满了后的降级策略",第一次 SYN 会因为队列满被 Sampling 机制过滤掉一部分,仍然造成首次建连超时。

实验复现——用 wrk 重现半连接队列满

空说无凭,我们来复现这个场景:

  1. 把服务的 tcp_max_syn_backlog 临时降到 128(模拟默认值太小的情况)
  2. 用 wrk 发起高并发连接
  3. 观察 Recv-Q 和 ListenOverflows
# 设置低值以触发溢出
sysctl -w net.ipv4.tcp_max_syn_backlog=128

# 高并发压测 30 秒
wrk -t4 -c512 -d30s http://10.0.2.200:8080/api/health

# 观察溢出计数
nstat -az | grep TcpExtListenOverflows
#TcpExtListenOverflows          47     0.0

47 次溢出——每秒钟约 1.5 个连接因为半连接队列满被内核丢弃。在低并发时段没问题,一旦流量 burst 上来,队列瞬间爆满。

这就是为什么"重启就好了但过一会又复发"——重启后队列清空,但只要流量 burst 来临,半连接队列再次填满。

定位

哪一跳出了问题

内核路径图

这是一个传输层(网络层)问题,不是应用层问题。

数据包到了服务端网卡,进入 TCP 协议栈的 tcp_v4_rcv()

  1. tcp_check_req() 判断为建连包(SYN)→ 是
  2. inet_csk_reqsk_queue_is_full() 检查半连接队列 →
  3. 不走 tcp_conn_request(),直接丢弃,ListenOverflows++
  4. 客户端没收到 SYN-ACK → RTO 超时 → 重传

通路

修复——调大 backlog + 验证

# 临时生效
sysctl -w net.ipv4.tcp_max_syn_backlog=4096

# 持久化
echo "net.ipv4.tcp_max_syn_backlog = 4096" >> /etc/sysctl.conf
sysctl -p

文件路径:/proc/sys/net/ipv4/tcp_max_syn_backlog

验证修复:

修复后 Recv-Q=0,溢出清零

调大后 ss -lnt 显示 Recv-Q=0,nstat 清零不再增长。用 wrk 以相同并发量压测,ListenOverflows 维持 0。客户端连接耗时恢复 287ms,问题解决。

复盘

修复复盘聊天

监控——防复发

指标 命令 告警阈值
半连接队列溢出计数 nstat -az \| grep ListenOverflows > 0 持续 1min
Recv-Q 积压 ss -lnt \| awk '$1=="LISTEN"{print $2}' > 0
SYN Cookie 触发 nstat -az \| grep TcpExtSyncookiesSent > 0

在 Prometheus/node_exporter 中对应 node_netstat_TcpExt_ListenOverflows。设 > 0 即告警,因为这代表连接被内核丢弃,用户侧感知就是建连超时。

总结——从"重启"到"看 Recv-Q"的认知升级

本文解决的问题不只是一个内核参数调优——核心是理解 Listen 端口 Recv-Q 的真正含义

如果你从这篇文章只带走一件事:

下次看到 ss -lnt 中 LISTEN 端口的 Recv-Q > 0 → 先执行 nstat -az | grep ListenOverflows → 溢出计数 > 0 就是半连接队列满了。

隐性对手线在此闭环: | 排查层次 | 做法 | 结果 | |---------|------|------| | Naive | "网络问题?先重启试试" | 好了又复发 | | 有经验 | "tcpdump 抓包看看" | 看到 SYN 重传但不知道为什么 | | 正确 | "ss -lnt 看 Recv-Q → nstat 找 ListenOverflows → 调 tcp_max_syn_backlog" | 根因修复 + 监控布防 |

下篇预告

半连接队列是"满得进不去",TIME_WAIT 则是"走了出不来"。下篇我们聊 TIME_WAIT 堆积如何耗尽可用端口——被主动关闭的连接比你想象的更危险。


附:完整命令清单

# 1. 看连接队列状态
ss -lnt

# 2. 查队列溢出计数
nstat -az | grep -E 'ListenOverflows|ListenDrops'

# 3. 确认当前内核参数
sysctl net.ipv4.tcp_max_syn_backlog
sysctl net.ipv4.tcp_syncookies

# 4. 抓包看 SYN 交互
tcpdump -nn -i any port 8080 and 'tcp[tcpflags] & (tcp-syn) != 0'

# 5. 调试修复
sysctl -w net.ipv4.tcp_max_syn_backlog=4096

# 6. 验证修复
nstat -az | grep TcpExtListenOverflows

# 7. 压测验证
wrk -t4 -c512 -d30s http://localhost:8080/api/health
nstat -az | grep TcpExtListenOverflows