4. TCP 端口复用实战:Nginx 多 worker 进程端口复用、Apache 事件模型、自定义服务器实现
端口复用这个词,很多人一听就觉得是「多个进程监听同一个端口」。嗯,这个说法没错,但太笼统了。真正落地的时候,你会发现不同服务器实现方式完全不同,性能表现也天差地别。
我这些年调过不少高并发服务,踩过最多的坑就是端口复用配置不当导致的惊群效应。今天咱们就把 Nginx、Apache 和自定义服务器这三种典型实现掰开揉碎了讲清楚。
4.1 Nginx 多 worker 进程的端口复用
Nginx 的架构设计很有意思。它有一个 master 进程和多个 worker 进程。所有 worker 进程共享同一个监听套接字。这是怎么做到的?
核心就三个字:SO_REUSEPORT。
关键机制:Nginx 在 1.9.1 版本之后引入了 reuseport 参数。每个 worker 进程独立创建监听 socket,内核负责将新连接负载均衡到各个 worker 的 socket 上。
配置起来非常简单,在 listen 指令后面加上 reuseport 就行:
http {
server {
listen 80 reuseport;
server_name example.com;
...
}
}
我个人习惯在生产环境里配合 worker 数量一起调优。比如 4 核机器,我会这样配:
worker_processes 4;
events {
worker_connections 10240;
use epoll;
multi_accept on;
}
这里有个细节要注意。我遇到过不少团队把 worker_connections 设得特别大,觉得越大越好。其实不然。每个 worker 能处理的并发连接数受限于系统文件描述符上限。你想想看,如果 ulimit -n 只有 1024,你配 65535 也没用。
调优建议:worker_connections 建议设为 ulimit -n 的 80% 左右。留点余量给其他系统操作。
4.2 惊群效应与内核负载均衡
没有 reuseport 的时候,Nginx 是怎么工作的?所有 worker 进程共享一个监听 socket。新连接到达时,内核会唤醒所有在 epoll_wait 上等待的 worker。但最终只有一个 worker 能成功 accept,其他 worker 白忙一场。
这就是经典的惊群效应(Thundering Herd)。
我曾经在一个日活千万的广告系统上做过对比测试。没有 reuseport 时,CPU 上下文切换每秒高达 8 万次。开启 reuseport 后,直接降到 1.2 万次。差距就是这么明显。
为什么会这样?因为 reuseport 让内核在 TCP 层就完成了负载均衡。内核维护一个哈希表,根据源 IP、源端口、目标 IP、目标端口四元组计算哈希值,然后分配到对应的 worker socket 上。同一个连接始终落在同一个 worker 上,CPU 缓存命中率也更高。
注意:reuseport 虽然好,但有个潜在问题——连接分配不均匀。如果某个 worker 处理慢,新连接还是会继续分配给它。Nginx 官方建议配合 worker_shutdown_timeout 使用,让慢 worker 优雅退出。
4.3 Apache 事件模型(MPM Event)
Apache 的端口复用思路和 Nginx 完全不同。Apache 用的是预派生(Prefork)或工作者(Worker)模型,每个进程或线程处理一个连接。事件模型(MPM Event)算是它的一个重大改进。
MPM Event 的核心思想是:把监听和处理的职责分开。
具体来说,Apache 有一个专门的监听线程(Listener),负责 accept 新连接。然后它把连接交给工作线程池处理。工作线程处理完请求后,监听线程继续复用这个连接处理下一个请求。
配置示例:
<IfModule mpm_event_module>
StartServers 3
MinSpareThreads 75
MaxSpareThreads 250
ThreadsPerChild 25
MaxRequestWorkers 400
MaxConnectionsPerChild 1000
</IfModule>
这里我特别想强调 MaxRequestWorkers 这个参数。它决定了 Apache 能同时处理的最大请求数。我见过有人把它设成 10000,结果内存直接爆了。为什么?因为每个工作线程都要占用一定的栈空间和内存池。Apache 不像 Nginx 那样是事件驱动的,它的线程开销更大。
| 参数 | 说明 | 建议值 |
|---|---|---|
| StartServers | 启动时创建的子进程数 | CPU 核数 × 2 |
| MinSpareThreads | 最小空闲线程数 | 75 |
| MaxSpareThreads | 最大空闲线程数 | 250 |
| ThreadsPerChild | 每个子进程的线程数 | 25-64 |
| MaxRequestWorkers | 最大并发请求数 | 根据内存计算 |
经验之谈:Apache 的 MPM Event 适合长连接场景,比如 WebSocket。如果是短连接为主的 API 服务,Nginx 的 reuseport 方案更高效。
4.4 自定义服务器实现端口复用
如果你需要自己写一个高性能服务器,怎么实现端口复用?我推荐两种方式:
- SO_REUSEPORT + 多进程:每个进程独立创建 socket,设置 SO_REUSEPORT 选项。
- SO_REUSEADDR + 单进程多线程:一个 socket 监听,多个线程 accept。
下面是一个 C 语言的 SO_REUSEPORT 示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define WORKER_NUM 4
void worker_process() {
int server_fd;
struct sockaddr_in address;
int opt = 1;
// 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 设置 SO_REUSEPORT
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
bind(server_fd, (struct sockaddr *)&address, sizeof(address));
listen(server_fd, 128);
printf("Worker %d listening on port %d\n", getpid(), PORT);
while (1) {
int new_socket = accept(server_fd, NULL, NULL);
// 处理连接...
close(new_socket);
}
}
int main() {
for (int i = 0; i < WORKER_NUM; i++) {
if (fork() == 0) {
worker_process();
exit(0);
}
}
// 父进程等待
while (1) sleep(1);
return 0;
}
这段代码里最关键的就是 setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, ...)。每个 worker 进程都创建自己的 socket,但都绑定到同一个 IP 和端口上。内核会自动做负载均衡。
避坑指南:我曾经在 Linux 3.9 以下的内核上尝试 SO_REUSEPORT,结果编译都过不了。因为这个选项是 3.9 才引入的。生产环境一定要确认内核版本。
4.5 三种方案的对比与选型
说了这么多,到底该用哪种?我整理了一个对比表:
| 方案 | 模型 | 连接分配 | 惊群效应 | 适用场景 |
|---|---|---|---|---|
| Nginx reuseport | 多进程 + 事件驱动 | 内核哈希 | 无 | 高并发静态/反向代理 |
| Apache MPM Event | 多进程 + 多线程 | 监听线程分发 | 有(但可控) | 动态内容/长连接 |
| 自定义 SO_REUSEPORT | 多进程 | 内核哈希 | 无 | 定制化协议/游戏服务器 |
我个人建议:
- 如果是标准 Web 服务,直接用 Nginx 的
reuseport,省心又高效。 - 如果必须用 Apache,优先选 MPM Event,别用 Prefork。
- 如果是自定义协议或特殊需求,自己实现 SO_REUSEPORT 也不难,但要注意内核版本和负载均衡的均匀性。
下面这张图展示了三种方案的架构差异:
最后说一句,端口复用不是银弹。如果你的业务逻辑本身就很重,比如每个请求要查数据库、调外部 API,那瓶颈根本不在端口复用上。先把业务逻辑优化好,再来考虑这些底层技巧。嗯,这个顺序别搞反了。