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%。
用数字说话——别重启了,先看这里
真正的线索在端口监听状态里——但大多数人看错了地方:

你看到了什么?一个开发者的第一反应:"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'

TcpExtListenOverflows=89,直接实锤——半连接队列溢出。
用 Grafana 看全局

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

SYN(Synchronize Sequence Number)——TCP 建连的第一次握手,标志位为 1,携带初始序列号。 从客户端 SYN 到服务端 SYN-ACK,312ms——这是正常的网络延迟。
队列满了——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 重现半连接队列满
空说无凭,我们来复现这个场景:
- 把服务的
tcp_max_syn_backlog临时降到 128(模拟默认值太小的情况) - 用 wrk 发起高并发连接
- 观察 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():
tcp_check_req()判断为建连包(SYN)→ 是inet_csk_reqsk_queue_is_full()检查半连接队列 → 满- 不走
tcp_conn_request(),直接丢弃,ListenOverflows++ - 客户端没收到 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。
验证修复:

调大后 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