一次真实事故:worker_processes=64 把线上搞崩了
周五晚高峰,告警突然响起来:Nginx的CPU 100%,响应时间从20ms飘到1s。SSH上去,top里4个worker进程全部跑满,ss -s显示连接数2万。马上查PHP-FPM、MySQL,全都正常。最后发现问题出在worker_processes——我把它配成了64,机器只有8核。改回auto,CPU立刻降到20%。这让我决心把Nginx worker模型彻底搞明白。
问题定义
Nginx的worker进程模型到底是什么?为什么它能在单worker里处理上万连接?为什么worker数量设置不当会导致性能断崖?本文从源码角度回答。
1. worker进程模型 vs 线程模型
Nginx不是第一个解决高并发的服务器。Apache prefork模型是每个连接一个进程,Tomcat传统BIO模型是每个连接一个线程。我们先对比这三个方案。
| 模型 | 代表 | 每个连接开销 | 并发上限 | 稳定性 |
|---|---|---|---|---|
| 多进程阻塞IO | Apache prefork | 约5-10MB内存 | 受进程数限制 | 高,单个进程崩溃不影响其它 |
| 多线程阻塞IO | Tomcat BIO | 内核栈+线程栈约0.5-1MB | 受线程数限制 | 中,线程崩溃可能拖垮进程 |
| 多进程事件驱动 | Nginx | 固定内存+连接内存约0.1MB | 受fd数限制 | 高,worker之间隔离 |
Nginx选择多进程事件驱动,核心原因:阻塞IO是万恶之源。每个连接阻塞一个线程/进程,当连接数超过1000,线程/进程切换成本飙升。Nginx每个worker是单线程,用非阻塞IO+事件通知,一个循环处理几千个socket。
2. 启动阶段:master怎么生worker
Nginx启动后有两个角色:master和worker。master负责读取配置、fork worker、监控信号。worker处理所有网络IO。
源码在src/os/unix/ngx_process_cycle.c。master主循环里,启动worker的调用链是:ngx_master_process_cycle -> ngx_start_worker_processes -> ngx_worker_process_cycle。
ngx_start_worker_processes循环创建子进程:
static void ngx_start_worker_processes(ngx_cycle_t *cycle, ngx_int_t n, ngx_int_t type) {
for (i = 0; i < n; i++) {
ngx_spawn_process(cycle, ngx_worker_process_cycle, NULL,
"worker process", type);
}
}
ngx_spawn_process调用fork(),子进程执行传入的ngx_worker_process_cycle。
我们来看worker进程的主循环:
static void ngx_worker_process_cycle(ngx_cycle_t *cycle, void *data) {
ngx_worker_process_init(cycle, worker);
for ( ;; ) {
ngx_process_events_and_timers(cycle);
// ... 处理退出、信号等
}
}
ngx_worker_process_init里会设置进程标题、初始化事件模块(如epoll)、根据worker_cpu_affinity绑核等。
3. 事件循环:一个worker如何管一万个连接
ngx_process_events_and_timers是worker的灵魂:
void ngx_process_events_and_timers(ngx_cycle_t *cycle) {
timer = ngx_event_find_timer();
flags = 0;
if (ngx_accept_mutex_held) {
// 持有accept锁,必须在本轮处理accept事件
flags |= NGX_UPDATE_TIME;
if (ngx_event_accept(cycle) == NGX_ERROR) return;
}
// 调用事件模块的process_events,如epoll_wait
delta = ngx_event_manager.modules->process_events(cycle, timer, flags);
// 处理post队列
ngx_event_process_posted(cycle, &ngx_posted_accept_events);
ngx_event_process_posted(cycle, &ngx_posted_events);
// 处理定时器
ngx_event_expire_timers(cycle);
}
process_events是平台相关函数,Linux下是ngx_epoll_process_events,内部执行epoll_wait。
static ngx_int_t ngx_epoll_process_events(ngx_cycle_t *cycle, ngx_msec_t timer, ngx_uint_t flags) {
events = epoll_wait(ep, event_list, NEVENT, timer);
if (events == -1) ...
for (i = 0; i < events; i++) {
c = event_list[i].data.ptr;
if (revents & EPOLLIN) { ngx_event_accept(c->read); ... }
if (revents & EPOLLOUT) { ngx_event_write(c); ... }
}
}
注意,epoll_wait返回的是“就绪”的事件。worker循环处理每个就绪事件,不会阻塞在某个socket上。
4. accept锁和惊群
多个worker进程都继承了master的listen fd,如果同时accept,会触发惊群(thundering herd)——只有一个连接,但所有worker被唤醒。Nginx默认开启accept_mutex,保证同一时刻只有一个worker在accept。
ngx_trylock_accept_mutex用原子变量或文件锁实现:
ngx_int_t ngx_trylock_accept_mutex(ngx_cycle_t *cycle) {
if (ngx_accept_mutex_held) {
return NGX_OK;
}
if (ngx_shmtx_trylock(&ngx_accept_mutex)) {
ngx_accept_mutex_held = 1;
return NGX_OK;
}
return NGX_ERROR;
}
获取到锁后,该worker会在ngx_process_events_and_timers中调用ngx_event_accept接收所有pending连接。其他worker没拿到锁,就只处理自己事件循环里的连接读写。
Nginx 1.23+,Linux 4.5+,可以在listen指令中使用reuseport,让内核在多个worker之间负载均衡分配连接,绕过用户态accept_mutex,惊群问题从源头消失。
5. 完整可运行模拟程序
我们写一个简化版:master创建listen socket,fork 4个worker,每个worker自己创建epoll实例,把listen fd加进去。没有accept锁,但可以观测惊群(可以用strace看accept调用数)。
// worker_epoll.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <sys/wait.h>
#define PORT 8080
#define MAX_EVENTS 1024
void set_nonblock(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
listen(listen_fd, 1024);
set_nonblock(listen_fd);
int workers = 4;
pid_t pid;
for (int i = 0; i < workers; i++) {
pid = fork();
if (pid == 0) break;
}
if (pid == 0) {
// worker process
int epoll_fd = epoll_create1(0);
struct epoll_event ev = {0};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);
struct epoll_event events[MAX_EVENTS];
char buf[256];
while (1) {
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == listen_fd) {
int conn_fd = accept(listen_fd, NULL, NULL);
if (conn_fd > 0) {
set_nonblock(conn_fd);
ev.events = EPOLLIN | EPOLLRDHUP;
ev.data.fd = conn_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
printf("worker %d accepted fd=%d\n", getpid(), conn_fd);
}
} else {
int fd = events[i].data.fd;
ssize_t len = read(fd, buf, sizeof(buf));
if (len <= 0) {
close(fd);
} else {
write(fd, buf, len); // echo
}
}
}
}
} else {
// master just wait
while (wait(NULL) > 0);
}
return 0;
}
编译运行:
gcc -O2 -o worker_epoll worker_epoll.c
./worker_epoll &
curl http://127.0.0.1:8080/
输出会看到多个worker打印accept信息。
6. 压测数据
测试环境:8核16G云主机,Nginx 1.24.0,CentOS 7.9,内核3.10。用wrk压测1KB静态文件。
| worker_processes | 并发100 QPS | 并发1000 QPS | worker RSS总和 |
|---|---|---|---|
| 2 | 52,318 | 21,054 | 18MB |
| 8 | 128,263 | 89,320 | 68MB |
| 16 | 121,034 | 86,215 | 132MB |
| 64 | 95,874 | 60,332 | 500MB |
可见并不是worker越多越好。8核配8个worker最好,配64个反而因为上下文切换掉到9.5万。另外Apache prefork(默认maxclients 150)并发1000时QPS只有11,238,内存占用超过1.5GB。
注意:这个数据是静态文件场景。如果反向代理到内网,瓶颈可能在upstream连接池,需要调worker_connections。
7. 避坑指南
坑1:worker_processes拍脑袋乱配
我之前把它设成64,8核机器。worker多了,锁竞争、CPU上下文切换上去了,性能反降。正确做法:设置为auto(Nginx自动检测CPU核数),或者手动等于核数。CPU密集型任务,可以设为核数-1,给系统留一个核。
坑2:文件描述符不够
只设了worker_connections,忘设worker_rlimit_nofile。实际连接数一高,accept失败,日志报“too many open files”。需要同时设置:
worker_rlimit_nofile 65535;
events {
worker_connections 10240;
}
注意ulimit -n也要调。
坑3:accept_mutex默认配置可能坑你
高版本内核配合EPOLLEXCLUSIVE,可以关闭accept_mutex。我们在Linux 5.10上关闭accept_mutex,QPS提升约15%。但Linux 3.10下关闭后惊群明显,最好保留。另外,用reuseport时不能和accept_mutex同时开,Nginx会给出警告。
坑4:epoll_wait超时导致CPU空转
如果连接数少但定时器多,epoll_wait被高频唤醒。可以调大timer_resolution(默认100ms),但会牺牲定时器精度。keepalive_timeout不要设太小,否则空闲连接频繁触发事件。
坑5:master进程没有特权,绑定端口失败
listen 80需要root权限。但worker以普通用户运行时,master先bind然后setuid?实际master保持root,worker降权。如果使用user指令,确保worker有权访问资源。另外,worker_processes设为auto时,要注意worker_cpu_affinity是否正确绑定,否则缓存失效。
一句话收尾
worker不是越多越好,事件循环才是灵魂。理解Nginx的worker进程模型,你就能在配置上少踩一半的坑。