为什么"带宽看着没跑满,网站却越来越慢"
这是个人站长最容易误判的一类故障。打开云服务商控制台,带宽曲线只有 20Mbps,买的却是 100Mbps,于是判断"服务器性能过剩、瓶颈在前端"。但用户实际访问时,页面要转两三秒才出首屏。
这类现象绝大多数不是带宽问题,而是连接建立过程中的 TCP 队列溢出。它有几个非常典型的特征:
- 带宽、CPU、内存、磁盘 IO 四项监控全部正常,唯独响应时间飘忽;
- 用
ab或wrk单机小并发压测时速度很快,一上线就慢; - 日志里看不到明显的 5xx,但 Nginx 的
$request_time忽高忽低; - 高峰期报错集中在移动网络用户,宽带用户基本正常。
本文按"现象 → 定位 → 调参 → 验证"的顺序,把这条链路彻底拆开。所有命令都在一台 2 核 4G、跑 Typecho + Nginx + PHP-FPM 的普通 VPS 上实测过。
第一步:先分清是内核丢包还是上游丢包
Linux 内核把 TCP 连接建立分为两个队列,理解这两个队列是排查的全部基础。
半连接队列(SYN Queue)
客户端发 SYN,服务器回 SYN+ACK,此时连接还没建立完成,先进半连接队列,长度由上界 net.ipv4.tcp_max_syn_backlog 和实际生效值 net.core.somaxconn 中较小者决定。这个队列溢出时,客户端表现为"连不上"、超时重试,用户看到的是"打不开"。
全连接队列(Accept Queue)
三次握手完成,连接进入全连接队列,等待应用调用 accept() 把它取走。队列长度等于 listen(fd, backlog) 的 backlog 值与 net.core.somaxconn 中较小者。这个队列溢出时,连接虽然握上了,但被内核直接丢掉或按 tcp_abort_on_overflow 复位,用户看到的是"转圈后失败"或"偶发卡顿"。
Nginx 在 listen 指令里可以显式指定 backlog:
server {
listen 443 ssl backlog=8192;
listen 80 backlog=8192;
# ...
}但注意,如果 net.core.somaxconn 只有默认的 128 或 4096,listen 里写 8192 也不会生效——取的是两者最小值。这是最常被忽略的一层。
用 ss 直接读出队列溢出计数
不要靠猜,内核自己就记着数:
# 查看监听套接字的队列情况
ss -lnt
# 输出示例
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 129 511 0.0.0.0:443 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*对于 LISTEN 状态的套接字,Recv-Q 表示当前全连接队列里等待 accept 的连接数,Send-Q 表示全连接队列的最大长度。上面例子里 443 端口 Recv-Q=129,说明有 129 个连接排着队没被取走,而队列上限只有 511——已经用掉四分之一,高峰期必然溢出。
再看内核累计的溢出次数:
# 全连接队列溢出(常见元凶)
nstat -az | grep -i -E 'ListenOverflows|ListenDrops'
# 半连接队列溢出(SYN 攻击或爬虫刷)
nstat -az | grep -i -E 'TcpExtSyncookiesSent|TcpExtTCPReqQFullDrop'
# 实时观察(每 1 秒刷新一次差值)
watch -n1 'nstat -az | grep -E "ListenOverflows|ListenDrops"'TcpExtListenOverflows 增长,就是全连接队列溢出的铁证;TcpExtSyncookiesSent 大量增长,说明半连接队列已经被打满,内核退化成 SYN Cookie 模式(这会丢失 TCP 选项,性能下降)。
第二步:别急着调参数,先看 accept 是否被阻塞
队列溢出的根因往往不是队列太小,而是应用取连接取慢了。Nginx 单 worker 的事件循环如果被阻塞,accept 就会被推迟。常见阻塞源:
- 日志写入磁盘 IO 卡顿(同步写 access_log 到机械盘);
- 磁盘满或 inode 耗尽,写日志报错反复重试;
- 上游 PHP-FPM 全忙,Nginx 反向代理连接堆积;
- 开启
reuseport后队列分散到多个 worker,单 worker 队列看起来不大但全局仍在溢出。
先确认 Nginx 侧的状态:
# 需要先开启 stub_status
location /nginx_status {
stub_status;
allow 127.0.0.1;
deny all;
}
curl -s http://127.0.0.1/nginx_status
# Active connections: 1240
# server accepts handled requests
# 981234 981200 4823110
# Reading: 12 Writing: 45 Waiting: 1183关键看两点:accepts 与 handled 不相等,差值就是被丢弃的连接数;Waiting 长期高于 Active 说明大量 keepalive 空闲连接占着资源,可以适当调小 keepalive_timeout,把资源让给真正需要的新连接。
第三步:调参——分层设置,别只改一个
确认是队列问题后,按下面这套值调整。假设单机要扛 5000 并发连接:
cat >> /etc/sysctl.d/99-tcp-tuning.conf <<'EOF'
# 全连接队列上限(Nginx listen backlog 不得超过它)
net.core.somaxconn = 8192
# 半连接队列 / SYN 积压
net.ipv4.tcp_max_syn_backlog = 8192
# 每个网卡设备接收队列长度
net.core.netdev_max_backlog = 8192
# 端口回收与复用:加速 TIME_WAIT 回收,允许重用
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_max_tw_buckets = 262144
# SYN 重试与丢弃策略(防御性)
net.ipv4.tcp_syn_retries = 2
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syncookies = 1
EOF
sysctl --system
# 验证
sysctl net.core.somaxconn net.ipv4.tcp_max_syn_backlog几个必须说清的坑:
net.core.somaxconn改大后必须重启 Nginx(或 reload 后重建监听套接字),否则旧进程仍用旧的 backlog。tcp_tw_recycle已被内核移除(5.0+ 彻底删除),网上老教程还在写它,别抄。- 不要盲目把
somaxconn调到 65535。队列越大,应用已经严重落后时你越不容易发现,而且会占内核内存。压测出合适值即可。 - 开了
reuseport时,ss -lnt会看到每个 worker 都有独立的 LISTEN 套接字,队列上限要按单 worker 算。
第四步:Nginx 侧配套参数
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 16384; # 单 worker 最大连接数
multi_accept on; # 一次事件循环尽量多 accept
use epoll;
}
http {
# keepalive 不宜过长,否则空闲连接占满 worker_connections
keepalive_timeout 30;
# 允许客户端快速重试
reset_timedout_connection on;
client_header_timeout 15;
client_body_timeout 15;
send_timeout 15;
# 上游长连接,减少 TIME_WAIT 堆积
upstream php-fpm {
server 127.0.0.1:9000;
keepalive 64;
}
}注意 worker_connections 与 worker_rlimit_nofile 的关系:每个连接占一个 fd,反向代理场景一个客户端连接还要占用一个上游连接,所以 worker_rlimit_nofile 至少要是 worker_connections × 2。改完记得核对系统级限制:
# systemd 管理的 Nginx,改 LimitNOFILE 最稳
systemctl edit nginx
# [Service]
# LimitNOFILE=65535
systemctl daemon-reload && systemctl restart nginx
# 验证进程实际拿到的 fd 上限
cat /proc/$(pidof nginx | awk '{print $1}')/limits | grep 'open files'第五步:用压测和监控闭环验证
# 模拟 500 并发、持续 60 秒
wrk -t 8 -c 500 -d 60s --latency https://www.example.com/
# 只看连接建立能力(不跑业务逻辑)
ab -n 20000 -c 800 -k http://127.0.0.1/压测期间同时另开一个终端跑:
watch -n1 'nstat -az | grep -E "ListenOverflows|ListenDrops|SyncookiesSent"; ss -lnt | head'验收标准应该是:整个压测过程中 ListenOverflows 零增长,ss -lnt 里 Recv-Q 不持续维持在队列上限附近,P99 延迟平稳。如果加了参数之后溢出计数还在涨,那说明瓶颈在 accept 之外——回到第二步查日志 IO 和 PHP-FPM 是否阻塞。
一张速查表:症状 → 队列 → 处理
| 症状 | 可疑队列 | 确认命令 | 处理方向 |
|---|---|---|---|
| 偶发"无法建立连接"、超时 | 半连接队列 | SyncookiesSent 增长 | 调大 tcp_max_syn_backlog,开 syncookies |
| 转圈后失败、Nginx 无 5xx | 全连接队列 | ListenOverflows 增长 | somaxconn + listen backlog 同步调大 |
| accepts 与 handled 不相等 | 全连接队列 | stub_status | 查日志 IO / accept 被阻塞 |
| Waiting 长期远高于 Active | 非溢出,是 keepalive 占用 | stub_status | 调小 keepalive_timeout |
| 带宽未满但移动端慢 | TCP 层重传/队列 | ss -s、nstat | 综合调参 + 上 CDN |
踩坑与排查顺序总结
- 不要一上来改 sysctl。先
nstat拿到溢出计数,有数才有证据,否则改了半天不知道有没有用。 net.core.somaxconn是所有 backlog 的天花板,listen 里写再大也会被它压回。- 改内核参数后 Nginx 必须重启(不是 reload)才会用新的 somaxconn 重建监听套接字。
- 压测机本身也会成为瓶颈——单机 wrk 到 1000 并发就受限于本机端口和 CPU,必要时多机施压。
- 调参只能缓解,真正的根治是消除 accept 阻塞源:把 access_log 换成
buffer=64k flush=5s,或直接上异步日志。 - 如果服务器在网络边缘(小带宽 VPS),先把静态资源交给 CDN,再谈 TCP 队列调优。
常见问题(FAQ)
Q:ss -lnt 的 Recv-Q 一直显示 0,还会有溢出吗?
不会同时出现。Recv-Q 是瞬时值,溢出是累计值。你在低谷期看当然是 0,要在高峰期或压测时看,同时用 nstat 看累计计数。
Q:开了 tcp_syncookies=1 之后半连接队列还有意义吗?
有意义。Syncookies 是队列满之后的兜底手段,代价是丢失 TCP 时间戳、窗口缩放等选项,会让长肥管道(长距离高带宽连接)性能下降。所以能靠调大队列解决就别依赖 Syncookies。
Q:为什么 ab 压测很快,真实用户却慢?
ab 从本机或同机房发起,RTT 极低,且不带浏览器行为(不拉 JS/CSS/图片、不复用连接)。真实用户从移动网络接入,RTT 高、丢包多,对队列溢出和 TCP 重传极其敏感。压测要加 --latency 看分位值,别只看平均值。
Q:改完 somaxconn 需要重启应用吗?
需要。监听套接字在进程启动时创建,backlog 参数此时才被内核记录。reload 不会重建套接字。
Q:队列调大之后,服务器内存会不会被吃光?
队列本身占用很小(每个排队连接一个内核 socket 结构),真正吃内存的是连接建立后的收发缓冲区。所以调大队列是安全的,失控的是连接数本身。
写在最后
"带宽没满但很慢"这个现象,九成以上的个人站都能用本文的路径定位到根因。核心心法只有一句:用 nstat 和 ss -lnt 拿到内核给的证据,再决定改哪个参数。跳过取证直接抄调优参数,是运维里最常见也最浪费时间的一种做法。
把这套检查固化成一个巡检脚本,每天早上看一眼溢出计数有没有增长,比任何事后救火都划算。对个人站长来说,可观测性永远比调优技巧更值钱。