Nginx 为啥这么牛?深入聊聊事件驱动和非阻塞 I/O,看完这篇你就懂了
咱们干运维的,天天跟 Nginx 打交道,配置文件写得飞起,upstream、location 闭着眼睛都能配。但你要是问我,这玩意儿到底为啥能抗住几万甚至十几万的并发连接?以前我可能也就背背面试题:“哦,因为是异步非阻塞,基于事件驱动。”
这话没毛病,但总觉得像隔靴搔痒,没说到点子上。今天咱们就撇开那些枯燥的教科书定义,用大白话,结合着咱们生产环境的实际情况,好好把这个事儿给唠透了。不然下次面试官问你“为什么 Nginx 不用多线程”,你总不能说“因为官方文档这么写的”吧。
咱们先从“傻等”说起

要搞懂 Nginx 的牛X之处,得先看看它的“前辈”或者说是反面教材是怎么干的。咱们以前用 Apache,或者自己写个简单的 Socket 服务器,最传统的模型是啥?就是 BIO(Blocking I/O),阻塞式 I/O。
想象一下,你是个服务员(服务器进程),负责给客人点菜。
传统的阻塞模式是这样的:
你走到 1 号桌,拿着小本本准备点菜。1 号桌的大哥还在看菜单,没理你。这时候你干啥?你只能傻站在那儿等,手里的小本本也不能给别人用,整个人的状态就是“阻塞”了。这时候 2 号桌喊:“服务员,点菜!”你听到了吗?听到了,但你走不开啊,你被 1 号桌占用了。
这就尴尬了。老板(系统管理者)一看这不行啊,一个服务员根本忙不过来,于是就开始招人。来一个客人,我就派一个专门的服务员盯着。这就是所谓的“多进程”或“多线程”模型。
在早期的 Web 服务里,Apache 的 prefork 模式就是这么干的。来一个连接,fork 一个子进程。这招虽然解决了“一人一桌”的问题,但代价太大了。进程是啥?是资源的大户。每个进程都要占内存,都要占 CPU 时间片。你要是来个 1 万个并发,好家伙,1 万个进程在系统里跑,CPU 光忙着在这些进程之间切换上下文了,真正干活的时间没多少。这就好比饭店里挤满了服务员,客人没几个,服务员之间还互相撞来撞去,这生意还怎么做?
这就是著名的 C10K 问题的根源。服务器资源是有限的,你用“人海战术”去堆连接,肯定得崩。
换个思路:非阻塞 I/O

既然“傻等”效率低,那咱们能不能不等?
还是那个服务员的例子。这次你学聪明了,你走到 1 号桌,大哥还在看菜单。你这次不傻等了,你说:“大哥您先看,看好了喊我。”然后你立马转身去 2 号桌。这就是非阻塞 I/O。
你尝试去读取数据(点菜),如果没有数据(客人没想好),你不阻塞在那儿,而是直接返回一个错误(EAGAIN/EWOULDBLOCK),告诉你“现在没数据,待会儿再来”。
这听起来挺美好,但有个问题。你虽然不等了,可你不知道谁想好了啊。于是你只能像个没头苍蝇一样,一会儿跑去 1 号桌问“想好了吗?”,一会儿跑去 2 号桌问“想好了吗?”,一会儿跑去 3 号桌……如果你有 1 万桌客人,你光是跑路就把腿跑断了,CPU 也在不停地轮询,空转,这也是一种浪费。这叫非阻塞 I/O 的忙轮询。
真正的大招:事件驱动机制
这时候,如果有个大堂经理(操作系统内核),手里拿个大喇叭,这事儿就解决了。
你不用一个个去问了。你就在那儿站着喝茶。哪桌客人想好了,经理就喊一声:“5 号桌要点菜!”这时候你过去处理一下。这就是事件驱动。
在计算机的世界里,这个“大堂经理”就是操作系统内核提供的 I/O 多路复用机制。在 Linux 上,这玩意儿叫 epoll;在 BSD/Mac 上叫 kqueue;在老一点的系统上还有 select 和 poll。
Nginx 之所以牛,核心就在这儿。它不搞人海战术,它搞的是“精英战术”。一个 worker 进程,就能处理成千上万个连接。
咱们来拆解一下 Nginx 的工作流,看看它是怎么把这几个概念揉在一起的。
1. Master 和 Worker 的分工
Nginx 启动后,你会看到一个 master 进程和多个 worker 进程。Master 就像饭店老板,他负责管理 Worker,比如读取配置、绑定端口、平滑重启这些管理活儿,他不具体接客。真正干活的是 Worker 进程。
重点来了,Worker 进程通常只有几个,一般咱们配置成跟 CPU 核心数一样多。为啥?因为少了浪费多核性能,多了反而增加上下文切换的开销。
2. 抢占式处理
Worker 进程是怎么工作的?它们都监听着同一个端口(比如 80 或 443)。当一个新连接来了,谁去接?
这里有个细节,Nginx 利用了一把锁。当连接进来时,哪个 Worker 进程抢到了锁,哪个就去处理这个连接。一旦连接建立好了,这个连接后续的所有读写操作,都由这个 Worker 全权负责。这就避免了多个进程瞎抢一个连接导致的“惊群效应”。所谓惊群,就是一嗓子喊过来,所有 Worker 都醒过来抢,结果只有一个能抢到,其他的白醒了,这很伤性能。Nginx 处理得很漂亮。
3. 事件循环
这是 Nginx 的灵魂。每个 Worker 进程里,都跑着一个死循环,大概逻辑是这样的:
while (true) {
// 1. 等待事件发生
events = epoll_wait(连接池, 超时时间);
// 2. 遍历处理事件
for (event in events) {
if (event.type == '读事件') {
// 处理读数据,比如接收 HTTP 请求
handle_read(event);
} else if (event.type == '写事件') {
// 处理写数据,比如返回 HTML 页面
handle_write(event);
}
}
}你看懂了吗?它不是傻等某一个连接,而是通过 epoll_wait 这个系统调用,一次性问操作系统:“大哥,这 1 万个连接里,有哪些是有动静的?”
操作系统内核维护着一个红黑树(存储所有连接)和一个链表(存储就绪的连接)。当网卡收到数据包,内核一看,哎,这是 8888 端口的连接,就把这个连接塞进就绪链表里。然后 Nginx 的 Worker 进程醒来,拿到的就是这一小撮“有事儿”的连接。
这就好比大堂经理手里有个名单,他只喊那些举手的客人。你不需要去摇每个客人的肩膀问“吃不吃”,效率直接起飞。
为啥这就能抗高并发?
咱们得算笔账。
假设你有一台 8 核的服务器,跑了 8 个 Nginx Worker 进程。此时有 10,000 个并发连接。
如果是 Apache (prefork),你可能需要维护几千个进程。每个进程占 10MB 内存(假设),那就是几十 GB 的内存。CPU 在这几千个进程间切换,缓存命中率低得令人发指。
而在 Nginx 模型下:
- 内存占用极低: 只有 8 个进程。每个连接只需要一点点内存来存状态(比如 socket 描述符、请求头 buffer)。这 10,000 个连接,大部分可能都是闲着的(比如用户在看网页,没点下一页),它们不占 CPU,只占一点点内存。这叫“闲连接不费电”。
- CPU 利用率极高: Worker 进程醒过来,就是干活。处理完一个事件,立马处理下一个,绝无空闲。没有无谓的上下文切换。
这就像一个超级服务员,手里拿着对讲机。哪个桌按了服务铃,对讲机里就会响,他直接过去处理。处理完按个钮,继续等下一个。你说这效率能不高吗?
一个容易踩的坑:阻塞操作
说到这儿,可能有人会问:“既然 Nginx 这么强,为啥我有时候看 CPU 飙升,或者服务卡死?”
这就得提提事件驱动模型的一个天敌:阻塞调用。
Nginx 的 Worker 是单线程的(虽然可以多线程,但主要逻辑是单线程事件循环)。这意味着,一旦这个线程被卡住了,这Worker 管理的几万个连接就全废了。
比如说,你在 Nginx 里写了个 Lua 脚本,或者用了某个第三方模块,里面搞了个长时间的数学计算,或者更糟糕的,去调了一个阻塞式的磁盘 I/O(虽然 Nginx 对文件 I/O 有处理,但在高并发下,磁盘慢也是大坑)。
想象一下,那个超级服务员正在处理 5 号桌的点菜,突然 5 号桌让他帮忙算个“圆周率后一万位”。服务员要是真在那儿算,后面举手的 100 桌客人谁管?
所以,Nginx 的设计哲学里,所有的操作都必须是非阻塞的。DNS 解析、磁盘读取、网络请求,能异步的全异步。如果必须阻塞,那也得尽量快,或者扔给别的线程池去干。
这就是为什么我们在生产环境里,尽量别在 Nginx 里搞复杂的逻辑运算,除非你用 OpenResty 这种把 LuaJIT 集成得很好的方案,而且还得确保你用的库都是非阻塞的。
再聊聊 Nginx 的细节优化
光懂原理还不够,生产环境里咱们得落地。
咱们看 Nginx 配置里几个跟这模型相关的参数,改好了性能翻倍。
worker_processes
这个参数通常设为 auto。以前咱们喜欢设成具体的数字,比如 4 或者 8。现在 Nginx 聪明了,设成 auto 它自己会检测 CPU 核心数。这对应了咱们上面说的,一个核心跑一个 Worker,减少 CPU 切换开销。
worker_connections
这个是单个 Worker 进程能处理的最大连接数。默认好像是 1024,这肯定不够看。咱们生产环境一般设成 65535,甚至更高。这取决于你的系统文件句柄限制。
这里有个公式:最大并发连接数 = worker_processes * worker_connections。
但是!注意这个但是。如果你开启了 keepalive(长连接),一个连接可能占用的资源时间更长。而且,如果做反向代理,每个请求进来,Nginx 还要跟后端服务器建立一个连接,所以实际上一个客户端请求可能占用 2 个连接数(前端一个,后端一个)。算并发量的时候心里得有数,别配小了导致 502 错误,那就尴尬了。
use epoll
在配置文件的 events 块里,咱们经常会看到 use epoll;。其实在 Linux 2.6+ 内核上,Nginx 默认就会用 epoll。写上这行主要是图个心安,显式指定一下。如果你是在 BSD 系统上,那就得用 kqueue。这就是告诉 Nginx:“嘿,用那个最高效的大堂经理模式。”
accept_mutex
这个就是刚才说的“锁”。默认是开启的。如果你的连接并发量特别特别大,瞬间涌进来几万个,这时候开锁反而可能成为瓶颈。但在绝大多数普通场景下,开着它能防止惊群,保护 CPU,建议保持默认或者按需关闭测试一下。
真实案例:一次故障排查
前两年有个金融客户,系统上线前压测。他们买的机器挺贵,配置特高,32 核 64G 内存。结果一压测,QPS(每秒查询率)死活上不去,卡在 5000 左右,CPU 使用率才 30%,看着让人抓狂。
我上去一看,Nginx 的 error.log 里没报错,系统负载也低。这就典型的“有劲使不出”。
后来排查发现,他们的后端服务响应特别慢,要 200ms。而 Nginx 的 worker_connections 设置得特别小,才 1024。
咱们算算账。QPS 5000,每个请求耗时 200ms。那意味着同一时刻,系统里积压的请求数大概是 5000 * 0.2 = 1000 个。如果 Nginx 只有几个 Worker,每个 Worker 连接数才 1024,眼看就要爆了。而且因为后端慢,Nginx 的 Worker 进程虽然不费 CPU,但是占用的连接槽位被填满了,新的请求进不来,只能在队列里排队或者被丢弃。
解决办法很简单,把系统的 ulimit 打开,文件句柄数设到 65535,然后把 Nginx 的 worker_connections 调到 3 万左右。
重载配置,再压。QPS 瞬间飙到 2 万,CPU 这才呼呼地转起来。这就是典型的配置没跟上模型原理,硬件再好也白搭。
总结一下
说了这么多,咱们总结回顾一下。
Nginx 之所以能成为 Web 服务器的扛把子,核心就在于它打破了传统的“一个进程处理一个请求”的线性思维。它采用了事件驱动和非阻塞 I/O 模型。
它不再傻傻地等待 I/O 操作完成,而是利用操作系统的 epoll 机制,像那个精明的大堂经理一样,只关注“有事儿”的连接。这让它在处理海量并发连接时,能够用极少的 CPU 和内存资源,维持住极高的吞吐量。
对于咱们运维来说,理解这个模型不仅仅是面试需要。在排查性能瓶颈、调优参数、甚至做架构选型的时候,这些底层的知识才是咱们判断问题的依据。
别光盯着配置文件里的 server_name 和 root 指令,多看看 events 块里的东西,那才是 Nginx 的心脏。下次如果再遇到服务器负载不高但请求超时的情况,不妨往这方面想想,是不是连接数满了?是不是哪里阻塞了?
技术这东西,原理通了,很多现象就解释得通了。希望这篇大白话能帮兄弟们把这些概念彻底嚼碎了咽下去。
公众号:运维躬行录
个人博客:躬行笔记