去年双十一前夜,我把这台 8 核 16 线程的机器从 4 个 Nginx worker 调到了 16 个,理由听起来特别合理:核多就多开进程,一个 worker 崩了不影响别人。结果压测一跑,总吞吐从 21000 QPS 掉到 13800 QPS,CPU 使用率从 62% 冲到 91%,其中 sys 态占了一大半。后台日志干干净净,业务代码一行没动,就改了一行配置。
后来花了两天才搞清楚:这台机器上真正的问题不是 worker 不够多,而是 16 个进程在抢同一个监听 socket,也就是所谓的 accept 惊群(thundering herd)。这篇就把这个坑从头到尾拆开讲,顺带说说我在 8 核机器上实测出来的三组 worker 数量对比,以及最后怎么用一行内核参数和一次配置调整把它摁住。
如果你正在处理 Nginx 多进程高并发相关的具体问题,或者需要一台配置合适的机器先把环境跑起来,可以先看看这几家的报价和技术说明:七九网络(服务器与主机,高性价比云服务器与物理服务器)
另外,如果你的站点要长期跑多进程 Nginx,建站托管和独享主机这类方案也可以一并对比:SpartanHost(网站托管、VPS 与独立服务器)
先回忆一下 Nginx 的进程模型。master 进程不干活,只负责读配置、绑定端口、fork 出 worker。真正处理请求的是 worker,每个 worker 内部是一个事件循环,靠 epoll(或 kqueue)驱动。
关键点在于:监听 socket 是所有 worker 共享的同一个文件描述符,由 master 进程在 fork 之前创建好,然后子进程全部继承。master 负责 listen(),但 accept() 的动作是在各个 worker 里各自触发的。
当一个新连接进来,内核把它放进该监听 socket 的 accept 队列,同时唤醒等待在这个 socket 上的所有 epoll 事件。这里就出事了Nginx 默认用的是水平触发(LT)的 epoll,而 accept 队列是所有 worker 共同竞争的公共资源。
理想情况下,某个 worker 被唤醒,取走队头那个连接,其他 worker 发现队列空了,各自回去睡觉。但现实中只要唤醒是"广播式"的,就必然出现:16 个 worker 全被唤醒,15 个抢不到活儿却白白付出一次系统调用和一次上下文切换,1 个真正干活的。这 15 个就是纯粹的浪费。

这种"一个连接到来,所有等待者一起醒,然后大部分人发现没活干"的现象,就叫惊群。表现出来就是:连接数没涨,CPU 却在涨,而且涨的是 sys 态因为开销全在内核态的系统调用和调度上。
盲目调参数是最容易浪费时间的做法。当时我排除了四种常见嫌疑:
第一,看连接数是否真的上来了。用 ss -s 和 netstat -s | grep -i overflow 确认。内核统计里的 listen queue overflow 计数是 0,说明 accept 队列没有溢出,瓶颈不在 backlog 太小。然后看 ss -lnt 的 Send-Q(那个 Recv-Q 是已建立未 accept 的连接数,正常应该接近 0)。
第二,排除 CPU 亲和性没配的问题。我一开始怀疑 16 个 worker 挤在少数几个核上,用 taskset 一看果然如此。但这是结果不是原因:worker 被唤醒、抢不到连接、然后立刻又睡回去,这个过程本身就要抢 CPU,反而让亲和性问题放大了。所以先解决惊群,再谈亲和性。
第三,看系统调用分布。strace -f -c -p $(pgrep -n nginx) 跑 20 秒,输出里 accept4 和 epoll_wait 的调用次数异常高,而真正处理业务的 read/write 并不占大头。更有说服力的是 perf top:热点函数里赫然是 do_accept、inet_csk_accept 这类路径,以及 mutex_lock 在共享套接字上的争用。
第四,排除后端慢。后端响应时间曲线是平的,说明瓶颈在前端 Nginx 自己的接入层。

四项排除完,答案基本锁定:共享监听 socket 上的 accept 竞争。
确认了原因,接下来就是找最优值。我用 wrk 压了一个静态文件服务(关闭 sendfile 之外的所有干扰项,机器上不跑其他业务),每组配置跑 3 轮取中位数,连接数固定 2000,超时 10 秒。
第一组:worker_processes 4,QPS 21000,CPU 62%,sys 态 14%,上下文切换 1.2 万次/秒。这是老配置,一直稳定。
第二组:worker_processes 16,QPS 13800,CPU 91%,sys 态 53%,上下文切换暴增到 9.8 万次/秒。吞吐掉了三分之一,CPU 反而更忙。
第三组:worker_processes 8,QPS 22800,CPU 68%,sys 态 19%。8 反而比 4 更好,比 16 好了一大截。
规律很清晰:worker 数一旦超过 CPU 核数,收益立刻转负。因为每个 worker 都是独立的事件循环,超过核数之后不但没有更多 CPU 可用,还会加剧对同一个监听 socket 的争抢,sys 态和上下文切换一起飙升。

所以我的经验值是:worker_processes 设成 CPU 核数,物理机核少就用核数,虚拟机超线程多也按物理核算。想要更高的连接承载能力,靠加核或者加机器,而不是加 worker。
把 worker 调回 8 只是止血,根因还在。Nginx 提供了两个层面的解法。
第一个是 accept_mutex。这个指令控制多个 worker 争抢 accept 锁的策略。默认在 Linux 上是 on,也就是一次只让一个 worker 去 accept,其他 worker 在锁上等待。看起来是"串行",实际上避免了惊群因为不会有 15 个 worker 同时被唤醒又同时扑空。缺点是牺牲了一点并行度,而且这个锁本身也是竞争点。
但要注意:accept_mutex 配 on 时,只有 epoll 是水平触发才有效。如果你把 use 设成 epoll 同时又开了 multi_accept on,行为会变得很难预测。这两个指令的组合,是很多人"明明开了 mutex 还是抖"的根本原因。
第二个是 SO_REUSEPORT,这才是现代 Linux 上的正解。加上 listen ... reuseport; 之后,内核会把同一个端口的监听 socket 按 4 元组哈希分成多个独立的队列,每个 worker 绑定属于自己的那一份。新连接进来时,内核直接按哈希分发到目标 worker 的队列,只有那一个 worker 会被唤醒惊群从根上消失了。

代价是必须让所有 worker 都在同一个 reuseport 组里,而且这个特性需要 Linux 3.9 以上、内核编译时开启 CONFIG_REUSEPORT。我的做法是:在 Nginx 1.25 以后的版本里用 listen 443 ssl reuseport;(老版本需要 --with-http_v2_module 之类的配套),然后对比压测数据。
实测下来的差别很直观:同样是 8 worker,不开 reuseport 时 sys 态 19%,开了之后降到 7%,上下文切换从 2.1 万降到 5 千,QPS 从 22800 上到 25100。提升不算爆炸,但 CPU 曲线明显平了,这才是长期想要的形态。
把上面的结论落到配置上,我最后改动了这几行:
worker_processes 8; 放在最前面,等于 CPU 核数;worker_cpu_affinity auto; 让内核帮你把 worker 绑到各核上;use epoll;;multi_accept off;(这个是重点,很多人默认开着,开了之后 mutex 语义就乱了);监听端口加 reuseport;。
另外顺手把 worker_rlimit_nofile 和 worker_connections 对齐。前者决定 worker 能开多少文件描述符,后者决定单个 worker 的最大连接数,两个不一致就会出现"日志里报 too many open files 但连接数还没到上限"这种看着矛盾的现象。经验值是 worker_connections 设成 ulimit -n 的一半左右,给日志文件、上游连接这些非客户端连接留出余量。
最后压测:QPS 25100,CPU 61%,sys 态 7%,P99 稳定在 3 毫秒出头。折腾了两天,最后落到配置上就是几行字。

说句实话,光有配置没有对应硬件,reuseport 那套也跑不出效果。机器的核数、网络回程、带宽上限会直接决定你能开多少 worker。这块各家差异挺大的,可以按自己站点类型去挑静态站和小程序后端看的是带宽和 IO,秒杀活动那种瞬时高并发看的是 CPU 核数和突发带宽。
另外提醒一句:别一上来就照抄 worker_processes auto。这个值是按 CPU 核数算的没错,但它假设你的 worker 之间不需要争抢同一个监听 socket。一旦你的 Nginx 版本老、或者你没开 reuseport 又开着 multi_accept,auto 反而会放大惊群。工具箱里能少踩坑的,还是自己压一轮数据。
以上就是我这次的完整排查记录。核心其实就一句话:多进程 Nginx 的性能上限,取决于 worker 之间会不会互相踩脚,而不是 worker 本身有多少个。