4、Nginx 端口复用实战:多Worker、Epoll与惊群效应

这一章我们聊点硬核的。Nginx 的端口复用,说白了就是多个 Worker 进程共享同一个监听端口。你可能会问:一个端口怎么让多个进程同时监听?这背后涉及 epoll、SO_REUSEPORT,还有那个让无数人头疼的惊群效应。

我个人习惯把 Nginx 的端口复用分成两个阶段来看:传统共享模式和SO_REUSEPORT 模式。两种模式我都踩过坑,今天一并讲清楚。

4.1 传统模式:单 Listener 多 Worker

Nginx 启动后,Master 进程先创建监听 socket,然后 fork 出多个 Worker 进程。所有 Worker 共享同一个监听 fd。这是最经典的架构,也是惊群效应的源头。

核心流程:

  1. Master 进程调用 socket()、bind()、listen()
  2. Master fork() 出 N 个 Worker 进程
  3. 所有 Worker 的 epoll 都注册了同一个监听 fd
  4. 新连接到达时,所有 Worker 同时被唤醒

嗯,这里要注意:accept 本身是加锁的。Nginx 内部用了一个全局的 accept_mutex,确保同一时刻只有一个 Worker 能 accept。但问题是——锁只能解决 accept 的互斥,解决不了唤醒的开销。

4.2 惊群效应:到底有多痛?

我记得第一次在生产环境遇到惊群效应,是在一个高并发网关项目上。流量峰值时,CPU 使用率飙到 90%,但实际有效吞吐量却上不去。用 perf 一看,大量时间花在了 wake_up 和 schedule 上。

为什么会这样?

当新连接到达时,内核会唤醒所有在监听 socket 上等待的进程。但最终只有一个 Worker 能抢到 accept_mutex 并成功 accept。其他被唤醒的 Worker 发现锁被占,只能重新进入睡眠。这个过程就是惊群——一次连接,N 次唤醒,N-1 次空转。

指标 无惊群 有惊群(8 Worker)
每次连接唤醒次数 1 8
上下文切换开销 低 高(7 倍)
CPU 缓存命中率 高 低(频繁切换)
高并发下吞吐量 稳定 抖动明显

我曾经踩过的坑:在 Linux 3.9 之前的内核上,即使 Nginx 用了 accept_mutex,惊群效应依然存在。因为 mutex 只能保证 accept 互斥,但无法阻止内核把多个进程同时唤醒。升级内核到 4.5+ 配合 SO_REUSEPORT 才是根治方案。

4.3 Nginx 的解决方案:accept_mutex 与负载均衡

Nginx 官方给出的方案是 accept_mutex。默认开启,它的工作原理是这样的:

  • 每个 Worker 定期检查自己的当前连接数
  • 连接数少的 Worker 更有可能抢到 accept_mutex
  • 抢到锁的 Worker 才把监听 fd 加入 epoll
  • 没抢到的 Worker 从 epoll 中移除监听 fd

说白了,就是让 Worker 之间错峰 accept。但这也带来了新的问题:负载均衡粒度太粗。Worker 的 accept 机会是轮询的,但每个连接的耗时不一样。有的 Worker 接了个长连接,有的接了个短连接,很快就出现负载不均。

# Nginx 配置示例
events {
    worker_connections  1024;
    accept_mutex        on;      # 默认开启
    accept_mutex_delay  500ms;   # 抢锁失败后的等待时间
    multi_accept        on;      # 一次 accept 多个连接
}

我的建议:如果使用传统模式,accept_mutex_delay 不要设得太小。我一般设 300-500ms。太小会导致频繁抢锁,太大又会让 Worker 空闲太久。另外 multi_accept 建议开启,一次事件循环处理多个连接,减少 epoll 调用次数。

4.4 SO_REUSEPORT:真正的端口复用

Linux 3.9 引入了 SO_REUSEPORT,这才是端口复用的正确姿势。每个 Worker 独立创建自己的监听 socket,绑定同一个端口。内核在接收连接时,直接做负载均衡,把连接分发到某个 Worker 的 socket 上。

这样一来,惊群效应从根源上消失了——每个连接只唤醒一个 Worker。

# SO_REUSEPORT 配置
server {
    listen 80 reuseport;   # 关键:加上 reuseport 参数
    # 其他配置不变
}

你想想看,这背后发生了什么?

  • Master 进程不再创建监听 socket
  • 每个 Worker 独立调用 socket()、bind()、setsockopt(SO_REUSEPORT)、listen()
  • 内核维护一个连接分发哈希表,根据源 IP + 源端口做分发
  • 同一个客户端的连接会尽量分发到同一个 Worker

4.5 内核分发策略:不只是轮询

SO_REUSEPORT 的分发策略,我简单总结一下:

内核版本 分发策略 特点
3.9 - 4.5 简单轮询 连接均匀,但长连接场景下负载可能不均
4.5+ 哈希分发 同源连接绑定同 Worker,CPU 缓存友好
5.14+ BPF 辅助分发 可自定义分发逻辑,支持权重

我个人更推荐 4.5+ 内核的哈希分发。它有一个隐藏的好处:同一个客户端的请求会落在同一个 Worker 上,这对 session 亲和性、连接池复用都非常友好。

4.6 实战对比:两种模式的性能差异

我在一个 8 核机器上做过压测,对比传统模式和 SO_REUSEPORT 模式:

# 压测条件
# CPU: 8 核 Intel Xeon
# 内存: 32GB
# 网卡: 10GbE
# 并发连接: 10000
# 请求大小: 4KB

# 传统模式(accept_mutex on)
Requests/sec:  185,432
Avg Latency:   5.4ms
CPU 使用率:    78%

# SO_REUSEPORT 模式
Requests/sec:  223,891
Avg Latency:   4.5ms
CPU 使用率:    65%

差距很明显。SO_REUSEPORT 不仅吞吐量高了 20%,CPU 使用率反而降了 13%。原因就是少了那些无意义的唤醒和上下文切换。

注意:SO_REUSEPORT 不是银弹。如果你的 Worker 数量远大于 CPU 核心数(比如 32 Worker 跑在 4 核上),内核分发反而会引入额外的哈希冲突和锁竞争。我建议 Worker 数不超过 CPU 核心数的 1.5 倍。

4.7 惊群效应的终极解决方案

结合我多年的实战经验,给出一个完整的配置方案:

# 推荐配置(Linux 4.5+)
events {
    worker_connections  4096;
    use                 epoll;
    accept_mutex        off;     # 关闭 accept_mutex
    multi_accept        on;
}

# 每个 listen 都加上 reuseport
server {
    listen 80 reuseport backlog=4096;
    listen 443 ssl reuseport backlog=4096;
    
    # 其他配置...
}

这里有几个要点:

  • 关闭 accept_mutex:SO_REUSEPORT 模式下不需要它
  • 增大 backlog:避免连接队列溢出
  • Worker 数 = CPU 核心数:最均衡的配置
  • 使用 epoll:这是 Linux 上最高效的 I/O 多路复用机制

4.8 知识体系总览

下面这张图总结了本章的核心逻辑:

Nginx 端口复用知识体系 Nginx 端口复用 传统共享模式 单 Listener 多 Worker 惊群效应 accept_mutex 方案 SO_REUSEPORT 模式 多 Listener 独立绑定 内核哈希分发 无惊群效应 推荐:SO_REUSEPORT + epoll

从图上可以清楚看到:传统模式走的是「单 Listener → 惊群效应 → accept_mutex 补救」这条路,而 SO_REUSEPORT 直接从架构上绕过了惊群问题。我个人强烈建议,只要你的内核版本 >= 4.5,就果断上 SO_REUSEPORT。

最后一个小技巧:如果你在容器环境(比如 Docker/K8s)中使用 SO_REUSEPORT,注意容器内的 net.core.somaxconn 和 net.ipv4.tcp_max_syn_backlog 参数。容器默认值通常很小,容易导致连接被丢弃。我一般会在容器启动脚本里调大这两个值。