客户上周五发来一条消息:网站时好时坏,刷新十次有三四次转圈十几秒才出来,但他说 ping 我一直是通的,SSH 也能登上机器。这台机器是 4 核 8G 的云主机,上面只跑了一个 Nginx 加一个 PHP-FPM,业务量说大不大,说小不小。
我第一反应跟客户一样:带宽不够?或者 CPU 被 PHP 慢查询吃满了?登上机器一看,CPU 使用率长期在 5% 上下,load average 不到 0.3,网卡流量监控显示跑满了不到 3%。所有指标都绿得让人发慌。
既然资源都没满,那问题大概率出在资源统计看不到的地方网络协议栈内部的状态。最后排查出来的结论是:内核里的 SYN 半连接队列被大量握手中途放弃的请求占满了,新连接进不了 accept 队列,表现出来就是「机器活着但服务时好时坏」。这篇把完整的排查路径写下来,包括怎么确认、怎么止血、怎么根治。
如果你正被这种「资源明明很空、网站却打不开」的情况困扰,或者想给自己的业务配一层抗攻击的防护底座,可以先看看这几家的方案说明再决定架构:linkcli(海外高防服务器 抗DDoS云主机)把清洗能力放在网络边缘,比在应用层硬扛要划算得多。
一、先理解两个队列:SYN 队列和 accept 队列
TCP 三次握手在内核里是分两段落地的。第一段是客户端发 SYN 过来,服务端内核立刻回一个 SYN+ACK,同时把这个半连接状态放进 SYN 队列;第二段是客户端补上最后一个 ACK,内核把这条连接从 SYN 队列搬到 accept 队列,等着应用层 accept() 取走。
两个队列都有上限。SYN 队列由 net.ipv4.tcp_max_syn_backlog 控制,默认常见值是 128 或 1024;accept 队列上限由 net.core.somaxconn 控制,现代内核一般默认 4096。关键是:当 SYN 队列满了,新的 SYN 请求不会排队,而是被内核直接丢弃,客户端收不到 SYN+ACK 就会重传,重传也进不去,就变成一个持续空转的状态。
攻击者只要用大量伪造源 IP 的 SYN 包把你的半连接队列塞满,你的机器就再也接不进正常连接了。这时候最迷惑的地方在于:你的带宽没满、CPU 没满、磁盘没满,甚至 ping 一切正常,因为 ping 用的是 ICMP,压根不走 TCP 三次握手。这就是「机器活着但网站时好时坏」的根源。

二、怎么在五分钟内确认自己被半连接打满了
我一般按下面三步走,顺序别乱,越靠前越省事。
第一步,看内核的统计计数器。这条命令最快,一眼就能看出方向:
nstat -az | grep -i -E 'Syncookies|ListenOverflows|ListenDrops'
重点看两个数:TcpExtSyncookiesSent 是内核因为队列压力而启用 syncookies 的次数,TcpExtListenOverflows 和 TcpExtListenDrops 是 accept 队列溢出和丢弃的次数。只要 ListenOverflows 不是 0,就说明已经有正常连接因为队列满被丢掉了。另外 TcpExtTCPReqQFullDoCookies 也会同步上涨。
第二步,看 SYN 相关的累计计数。netstat -s 里有一行 SYN flood 或者 invalid SYN 的丢弃统计,如果这个数字在几分钟内涨得很快,基本可以确定有人在扫。
第三步,抓包确认形态。用 tcpdump -i any -nn 'tcp[tcpflags] & tcp-syn == 1 and tcp[tcpflags] & tcp-ack == 0' -c 2000 抓两千个纯 SYN 包出来看。如果这些包来自上百个不同的源 IP、源端口随机、目标端口全是你的业务端口,那就是典型的伪造源地址的半连接攻击,不是正常客户端行为。正常客户端的 SYN 会有明确的业务来源,而且握手会马上完成。

我这台机器抓出来的包就是这样:源 IP 来自二十多个完全不同的网段,很多甚至落在根本不该有用户的机房段里。当时还可以看到 ss -lnt 输出中监听套接字的 Send-Q 长期贴着上限,Recv-Q 一排 0这就是队列被占满、连接进不来的典型样子。
三、syncookies 到底干了什么,为什么它能救命
打开 net.ipv4.tcp_syncookies=1 之后,内核的处理逻辑变了。正常情况下,SYN 队列里要真的存一份半连接状态(源 IP、源端口、窗口、时间戳这些),内存开销大,而且队列一满就丢包。
启用 syncookies 后,当队列压力变大时,内核不再保存完整状态,而是把关键信息编码成一串数字,塞进发回去的 SYN+ACK 报文的序列号字段里。这个编码包含时间戳、窗口大小和一个基于四元组的哈希值。客户端回第三个 ACK 时,内核用同样的算法把这个数字解回来,解得出来就说明是正常握手,解不出来就丢掉。因为状态是「算」出来的而不是「存」出来的,队列的压力被大幅缓解了。

这就是为什么 syncookies 几乎是所有 Linux 服务器的默认推荐项:它几乎不增加延迟,只在压力出现时才介入,而且对正常业务完全透明。真正需要注意的副作用有两个:一是它会牺牲一点 TCP 选项的协商能力(比如窗口缩放、时间戳在部分场景下会退化);二是它把「队列满了」这件事从「丢包丢连接」变成了「降级但不断连」,属于兜底而不是解药。
如果你的机器是放在公网上对外提供服务的,syncookies 只是应用层的第一道缓冲,真正扛住攻击还是要靠网络边缘的流量清洗:joysol(香港/东京/新加坡高防基础设施)这类高防线路的价值就在于,把海量伪造 SYN 在进入你网卡之前就丢掉,你的机器只处理真正来自用户的握手。
四、几个必须一起改的内核参数
光开一个 syncookies 是不够的。我这次把下面这一组参数一起调了,写在 /etc/sysctl.conf 里:
net.ipv4.tcp_syncookies = 1 半连接兜底,必开。
net.ipv4.tcp_max_syn_backlog = 8192 SYN 队列从 128 拉到 8192。默认值在很多云主机的小内存规格上偏小。
net.core.somaxconn = 8192 accept 队列上限,和上面保持一致,否则 accept 队列会先崩。
net.core.netdev_max_backlog = 16384 网卡收包队列。包在软中断里排队等处理,队列太浅会在还没进协议栈之前就丢掉,这是很多「抓包能看到、协议栈却没记录」的怪象来源。
net.ipv4.tcp_abort_on_overflow = 0 队列满时默认行为是静默丢弃,客户端会重试;设成 1 则直接回 RST。生产环境我一律保持 0,宁可多几次重试也不要让正常用户看到连接被直接掐断。
还有一个被忽略的点:net.ipv4.tcp_syncookies 只保护 TCP 层,如果你的机器还跑着 UDP 业务(DNS、游戏、QUIC),那 UDP 收包队列满的时候照样会丢包,而且不会有任何 syncookies 这样的兜底机制。这种情况下要重点看 net.core.netdev_max_backlog 和网卡自身的 ring buffer。

五、调完参数之后,还要做三件事
第一,做一次限流。在业务入口限制单 IP 的新建连接速率,比如 Nginx 层的 limit_conn,或者用安全组/iptables 限制单位时间内的 SYN 速率。这能显著降低单个攻击源打满队列的概率,也能防住同一 IP 的批量建连。
第二,别把管理端口暴露在公网。我这次顺手把 SSH 的 22 端口改成了只在白名单 IP 可达。SYN 队列是全协议共享的,管理面暴露得越少,被扫到的面就越小。
第三,加一条针对性的告警。监控项不要只盯 CPU 和带宽,加两条:TcpExtListenOverflows 的五分钟增量大于 0 就告警;SyncookiesSent 的五分钟增量持续上涨也告警。这两个计数器比任何流量曲线都能更早发现问题。我把告警阈值先设成保守值,跑了一周拿到真实基线之后才调整。
六、怎么确认真的修好了
改完参数执行 sysctl --system 生效之后,我做了三组验证:一是看 ss -lnt,监听套接字的 Send-Q 从长期贴着上限变成稳定为 0;二是看 nstat 计数器,ListenOverflows 不再增长;三是实际压测,用 wrk 之类工具打一波短连接,观察 TcpExtSyncookiesSent 增长之后服务是否仍然稳定建连。
一周后回访,客户说网站已经不再「时好时坏」,那条告警也一直没再触发。
回过头看,这件事最值得记下来的地方是:「服务器资源很空但服务不稳定」这个现象,根因很可能不在你能看到的任何监控指标里。CPU、内存、带宽、磁盘都是「资源」,而 TCP 协议栈内部的状态队列是「机制」机制出问题的时候,资源曲线永远是绿的。以后再遇到类似情况,除了数据库和慢查询,把半连接队列、accept 队列、网卡收包队列这三层一起纳入排查清单,能少走很多弯路。


喜欢
顶
难过
囧
围观
无聊



