请求进不来:Nginx 之前的三层与 accept 队列溢出排查

一根网线换掉之后,站点每逢整点就白屏 30 秒

去年冬天我把服务器从一家小服务商搬到了另一家,硬件和线路都好了很多,唯独多了一个奇怪的症状:每天上午十点和晚上八点前后,站点会白屏二三十秒,Nginx 日志里那段时间的请求全部超时,但服务器本身的负载很低,CPU 5% 以下,内存用了一半,磁盘 IO 也很平。SSH 登录正常,systemctl status 一切 active,可就是打不开页面。

折腾了将近三周,换过 PHP-FPM 的进程数、调过 Nginx 的超时、排查过 MySQL 的锁等待,全部无效。最后定位到的原因让我有点意外:问题根本不在应用层,而在TCP 连接建立阶段的后半段,内核的 SYN 队列在某类特定流量到达时被打满。换句话说,请求还没有到 Nginx 的逻辑,就已经在协议栈里被丢掉了。

这篇文章讲的就是这次排查的完整过程,以及我在之后整理出来的一套「请求进不来」类故障的分层定位方法:怎么在五分钟内判断问题出在协议栈、防火墙、还是应用,以及每一层该看哪些数字。

一、先建立一张分层的图:一个请求要过多少道关

用户点开一个页面,到 Nginx 的 access_log 里出现一行记录,中间至少要过这些关:

第一关,DNS 解析。域名解析成 IP,如果是 CDN,解析的是 CDN 节点的 IP。

第二关,网络可达。数据包能到你的服务器网卡,中间没有路由黑洞、没有被上游运营商限速或丢弃。

第三关,内核网络层。包进入网卡,经过 netfilter(iptables/nftables)的 INPUT 链,通过 conntrack 记录连接状态,TCP 握手完成并进入 accept 队列。

第四关,监听与 accept。Nginx 的 worker 进程调用 accept() 把连接从队列里取走。

第五关,Nginx 处理。TLS 握手、解析 HTTP、匹配 location、发给 upstream。

第六关,upstream 处理。PHP-FPM、Node、MySQL 等后端执行并返回。

六层里任何一层出问题,用户看到的现象都是「打不开」,但排查方法和责任方完全不同。我之前三周的弯路就是因为一直在第五、六层里找,而问题在第三层。

二、第一步:分清「打不开」是哪一种打不开

同样是打不开,浏览器给的信息其实已经很明确了,只是大部分人不看。我的判断顺序是这样:

如果浏览器显示「连接超时」「ERR_CONNECTION_TIMED_OUT」,说明 TCP 握手都没完成,问题在前三层。这时 curl -v 会卡在 Trying x.x.x.x... 就不再动了。

如果显示「连接被重置」「ERR_CONNECTION_RESET」,说明握手完成了但马上被 RST,典型是防火墙拒绝、或被中间设备(运营商、WAF)拦截。

如果显示「服务器没有响应」「ERR_EMPTY_RESPONSE」,说明 TCP 通了但 HTTP 层没回数据,问题在第五六层,或者 accept 队列满了。

如果一直在转圈但最后出来了,那是慢,不是不通,属于性能问题而不是连接问题。

# 四个时间点,一眼看清卡在哪一阶段
curl -o /dev/null -s -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} first=%{time_starttransfer} total=%{time_total}\n' \
  -H 'Host: www.example.com' https://x.x.x.x/

这条命令的价值在于它把总耗时拆成了四段。time_connect - time_namelookup 是 TCP 握手耗时,正常应该在一百毫秒以内(同城);如果这里就超过一秒甚至超时,直接锁定网络层或内核层,不用再看后面。time_appconnect - time_connect 是 TLS 握手,通常两三百毫秒;time_starttransfer - time_appconnect 是服务端处理时间,也就是 TTFB。

我那次故障的 curl 输出是这样的:前三段全部正常,只有 time_starttransfer 卡住不动,直到超时。这个组合非常关键——它说明握手成功了,但服务端没有回第一个字节。如果连 TCP 都没建起来,time_connect 就会是空的或者等于总耗时。

三、第二步:握手成功但没响应,先看 accept 队列

Linux 的 TCP 有两个队列,这是排查这类问题的核心知识点,但很多文档讲得含糊:

SYN 队列(也叫半连接队列),存的是「收到 SYN、回了 SYN-ACK、还没等到 ACK」的连接。它的长度受 net.ipv4.tcp_max_syn_backlog 控制。

accept 队列(全连接队列),存的是「三次握手已经完成、等待应用调用 accept() 取走」的连接。它的长度是 listen(fd, backlog) 里那个 backlog 与 net.core.somaxconn 的较小值。

如果 accept 队列满了,内核默认行为是静默丢弃新完成的握手(或者回 RST,取决于 tcp_abort_on_overflow),客户端看到的就是「连接建立了但服务端不说话」,正好和我那次故障的现象吻合。

# 看当前监听的 backlog 与队列溢出统计
ss -lnt | grep -E ':80|:443'
# Recv-Q 列:如果它接近 Send-Q(即 backlog 上限),队列就是满的

# 看内核累计的溢出次数(这是判定依据)
nstat -az TcpExtListenOverflows TcpExtListenDrops
# 或者
netstat -s | grep -i -A1 'listen'

TcpExtListenOverflows 是关键指标,它是一个累计计数器。我在故障发生时抓了两次,间隔一分钟,数值涨了两千多,直接确认了「有连接因为队列满被丢」。这个数字只要持续增长,不管你应用调得多好,都会有一部分用户连不上。

补充一个容易忽略的点:ss -lnt 的 Recv-Q 显示的是当前排队数,不是历史峰值。它可能在你敲命令的那一刻恰好是 0,所以不能只靠它判断,必须看内核计数器的增量。这是我第一次排查时误判的原因——我看的时候队列是空的,就排除了队列问题。

四、第三步:为什么仅仅调大 backlog 不够

发现队列溢出之后,第一反应当然是调大 backlog。我当天就改了:

# /etc/sysctl.d/99-net-tune.conf
net.core.somaxconn = 4096
net.ipv4.tcp_max_syn_backlog = 8192
net.core.netdev_max_backlog = 4096

同时把 Nginx 的 listen 443 ssl backlog=4096; 一并改大。改完当天,溢出计数器涨得慢了,但整点白屏的现象还在,只是从三十秒缩短到十秒左右。这说明队列溢出不是根因,而是症状——队列之所以满,是因为有东西在某一瞬间把大量连接堆了进来,而 accept 的速度跟不上。

accept 慢的原因通常有三个:

一是 worker 数量太少或都被占用。如果 worker_processes 1,一个 worker 在处理一个慢请求时(比如等 upstream 超时 60 秒),就没法 accept 新连接。worker_connections 也要算清楚:每个客户端连接占用一个,每个 upstream 连接又占一个,所以实际能服务的客户端数大约是 worker_connections / 2。

二是内核把 accept 分给了负载不均的进程。这是 Linux 的老问题,多进程同时监听同一个端口时会出现「惊群」,虽然现代内核有 SO_REUSEPORT 缓解,但也可能造成连接分布不均。reuseport 加不加取决于并发模型。

三是 TLS 握手占据 worker 的 CPU 时间。如果是纯软件 TLS,每个新连接都要做一次密钥交换,RSA 2048 的大约 1 到 2 毫秒,ECC 快得多。在连接数突然暴涨的时候,TLS 握手会来不及做,握手完成得慢,accept 队列自然堆积。

而我那次的现象是「整点」出现,这给了另一个重要线索:定时任务。查 crontab,十点整有一个备份脚本、一个日志轮转、还有一个爬虫的全量抓取。三件事撞在一起,同时向自己发起几百个连接(备份脚本还用 localhost 走 Nginx 下载文件)。这就解释了为什么是整点,为什么是固定的几十秒。

五、第四步:把定时任务和他们的连接行为收口

找到原因之后,处理起来反而简单了。三件事分别处理:

备份脚本绕开 Nginx。原来它通过 http://127.0.0.1/backup.zip 下载自己的站点备份,这既浪费了一次完整的 HTTP + TLS 握手,又占用 accept 队列。改成直接从文件系统读,或者至少走一个不走 TLS 的 localhost 端口。

日志轮转和备份错开时间。不要都排在整点。logrotate 默认在 cron.daily 里跑,具体时间取决于 anacron 和系统的随机延迟配置。我把备份挪到 10:07,日志轮转保持系统默认。

爬虫任务加并发上限。原来那个抓取脚本用 xargs -P 20,一次发二十个并发,在自己服务器上抓自己,等于自己 DoS 自己。改成 -P 3 并加 sleep,总时长从三分钟变成十二分钟,但完全不影响正常访问。

# 备份任务:走文件系统,不走 HTTP
# 旧:curl -s -O http://127.0.0.1/backup/$(date +%F).zip
# 新:
tar -czf /backup/site-$(date +%F).tar.gz -C /srv/www --exclude='./cache' .
find /backup -name 'site-*.tar.gz' -mtime +14 -delete

做完这三件事之后,整点白屏彻底消失,TcpExtListenOverflows 的增量从每分钟两千多降到个位数,虽然偶尔还有峰值,但完全在承受范围内。

六、一份可以照抄的分层诊断清单

把这套经验整理成清单,下次遇到「打不开」可以直接按顺序过一遍,每一步都有明确的判据和结论。

第 0 步:确认是全局还是局部。用自己的手机 4G、公司网络、以及一个外部在线检测工具,同时测一遍。如果只有一个网络打不开,问题在网络路径或对方的 DNS 缓存,不在服务器。

第 1 步:DNS。dig +short www.example.com @8.8.8.8 与 dig +short www.example.com 对比。如果两者不一致,本地 DNS 缓存或权威记录有问题。也顺手看 dig +trace 哪一跳断掉了。

第 2 步:网络可达。ping 看丢包,mtr -rwzbc 100 host 看哪一跳开始延迟上升或丢包。特别注意最后一跳的丢包,那通常是机房或本机的问题。

第 3 步:端口是否在听。ss -lntp | grep :443,确认进程名和 backlog。如果是空的,说明 Nginx 没起来或者监听在别的地址上(比如只监听 IPv6)。

第 4 步:协议栈计数。nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPReqQFullDrop,看两次的增量。有增量就是队列或 SYN 处理出问题。

第 5 步:防火墙与 conntrack。nft list ruleset | head -50 看有没有意外的 drop 规则;conntrack -C 看表的当前使用量,与 net.netfilter.nf_conntrack_max 对比,超过百分之八十就开始丢包了。

第 6 步:应用层。到这里才看 Nginx 的 error_log 和 PHP-FPM 的 slowlog。如果 error_log 里连一条记录都没有,说明请求压根没到 Nginx,回到第 4 步。

第 7 步:upstream。直接打后端,绕开 Nginx,判断是 Nginx 的问题还是后端的问题:

curl -o /dev/null -s -w '%{http_code} %{time_total}\n' http://127.0.0.1:9000/status

七、四个容易误判的地方

误判一:把「连接超时」当成防火墙问题就直接关防火墙。关掉防火墙之后问题消失了,看起来印证了猜测,其实可能只是重启了 Nginx 或某个服务顺带缓解了队列。要有证据,不要只凭现象。

误判二:只看当时的瞬时值,不看累计计数器的增量。前面说过,ss 的 Recv-Q 是个瞬时快照,而 nstat 的计数器是累计的。判断「有没有溢出」要看两次采样的差,这是关键。

误判三:以为负载低就说明服务器没问题。CPU 5%、内存用一半、IO 很低,这些指标反映的是「已经在处理的东西」,而连不上的请求根本没被处理,当然不会被算进负载。这个盲区让很多人绕了很远的路。

误判四:忽略了自己打的流量。备份、爬虫、监控探测、压测脚本,这些都是「自己人」的流量,往往最容易被忽略,而且恰好集中在整点或凌晨这类固定时间点。

八、小结:从现象倒推层级,从层级倒推证据

这次故障给我最大的收获是养成了一个习惯:遇到「打不开」,不要先想「Nginx 配置哪里错了」,而要先问「这个请求走到哪一层了,哪一层有证据表明它到过」。curl 的四个时间点和 nstat 的计数器增量,两个工具就能把问题从六层压缩到一层。

至于那台服务器本身,我在处理完之后加了两条长期习惯:所有定时任务错开时间并且加并发上限;每周跑一次 nstat 记录累积值,做趋势对比。后者让我在一次真正的 DDoS 来临前三小时发现异常,提前做了限速,没有影响正常用户。

Last modification:November 9th, 2026 at 10:23 pm

Leave a Comment