上一篇 下一篇 分享链接 返回 返回顶部

如何在香港服务器的CentOS 7中调优epoll与SO_REUSEPORT,提高百万级并发连接的处理效率?

发布人:Minchunlin 发布时间:2025-08-25 09:36 阅读量:720


那天是周五凌晨 1 点,香港荃湾机房的空调比我手里的拿铁还冷。业务侧突然把一组促销活动提前了 12 小时,外加海外流量回流,北向链路的会话瞬间拉到 80 万并发,入口 Nginx 层的 CPU 抖成“心电图”。监控里不断冒红:listen queue overflow、SYNs to LISTEN sockets dropped、accept4: Too many open files。我一边把手机设成勿扰,一边让远端同事把下一棒流量控住 10 分钟。接下来这篇文章,基本就是我那一夜从“背水一战”到“图线回绿”的完整过程:如何在 CentOS 7 上把 epoll 和 SO_REUSEPORT 调到位,再配合内核网络栈、IRQ、RPS/XPS 等系统级优化,把 百万级并发“稳稳地接住”。

机房与测试基线

硬件/系统环境(实测节点)

组件 参数
机型 2U 单路/双路通用服务器(香港机房常见白牌)
CPU 2 × Intel Xeon Silver 4214R(24C48T 总计),开启 HT
内存 128 GB DDR4-2933
磁盘 2 × NVMe(系统/日志分离)
网卡 Intel X710 10GbE ×2(bonding=active-backup),驱动 i40e
系统 CentOS 7.9(3.10 内核系,厂商回溯补丁)
角色 L4/L7 网关 + 自研长连接接入服务(TCP/HTTP1.1 keepalive)

说明:CentOS 7 的内核虽老,但 SO_REUSEPORT 与 epoll 的关键能力都可用,且大量发行版对关键补丁做了回溯,稳定度高,适合“守擂”。

目标与约束

  • 连接规模:≥ 1,000,000 并发(混合空闲/低速流)。
  • 入口延迟:p99 ≤ 12ms(同城内网链路)。
  • 丢包/排队:入口无明显 SYN backlog 丢弃,netdev_backlog 低水位。
  • 资源边界:单机 CPU < 70%,内存留有 20% buffer,I/O 不瓶颈。

基线(未优化/仅默认 epoll)

指标 数值(压力 60 万并发)
accept 队列溢出 间歇出现
p99 响应 28–35ms
丢包 网卡 RX Miss/Drop 偶发升高
CPU softirq 飙升,系统态占比 35%+
FD 限制 经常触发 Too many open files

总体路线图(拿来就能用)

  • 系统资源上限:fd、进程、ulimit、systemd 限额拉满且持久化。
  • 内核网络栈:somaxconn / tcp_max_syn_backlog / netdev_max_backlog / TCP 缓冲/内存曲线调到与业务匹配。
  • 中断与队列:正确的 IRQ 绑核、RPS/RFS 与 XPS,充分利用多队列网卡。
  • epoll + SO_REUSEPORT 架构:每核一 listener(同端口)、每核一 epoll,避免 accept 惊群。
  • 应用层落地:C 端示例代码(关键路径)、Nginx 的 reuseport 配置。
  • 压测与可观测:tcpkali/wrk 方法、监控项和常见坑的现场处置。

Step 1:把“天花板”撬高(FD/进程/limits)

/etc/security/limits.d/99-nofile.conf

* soft nofile  1048576
* hard nofile  1048576
* soft nproc   65536
* hard nproc   65536

systemd 服务单元(示例)

/etc/systemd/system/gateway.service

[Service]
LimitNOFILE=1048576
LimitNPROC=65536
TasksMax=infinity

内核级 FD 上限

echo 2000000 > /proc/sys/fs/file-max

# 持久化:
cat >> /etc/sysctl.d/99-fd.conf <<'EOF'
fs.file-max = 2000000
EOF
sysctl -p /etc/sysctl.d/99-fd.conf

实战提示:先用 lsof -p <pid> | wc -l、cat /proc/sys/fs/file-nr 验证上限确实生效;systemd 的 LimitNOFILE 如果忘了写,进程里还是会炸。

Step 2:内核网络栈调优(Listen、Backlog、TCP 记忆体)

/etc/sysctl.d/99-net.conf(示例基线)

# 1) Listen 与排队
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_synack_retries = 3

# 2) 缓冲与窗口(按 10GbE、长连偏多负载取中值)
net.core.rmem_default = 262144
net.core.wmem_default = 262144
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864

# 3) 行为开关(视业务取舍)
net.ipv4.tcp_tw_reuse = 1            # 仅客户端有效,服务端谨慎
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_timestamps = 1          # 需要 PAWS 保守就开,极限性能可关=0
net.ipv4.tcp_sack = 1
net.ipv4.tcp_syncookies = 1          # 在突发 SYN 洪峰时保命

# 4) Fast Open(若内核/内网链路可靠且客户端支持)
net.ipv4.tcp_fastopen = 3            # 0/1/2/3,CentOS 7 某些版本需核实内核回溯
sysctl -p /etc/sysctl.d/99-net.conf

经验法则:Backlog 三角 = listen(backlog) 实参 ≤ net.core.somaxconn,且上游四层(如 LVS/ELB)的 tcp_max_syn_backlog 也要匹配,否则 “你这边接得住,他那边先丢了”。

Step 3:把包“喂”到对的 CPU(IRQ/RPS/XPS)

1)查看队列与中断号

ethtool -l eth0        # 查看 RX/TX 队列数
grep eth0 /proc/interrupts

2)合理绑核(NUMA 及缓存友好)

给每个 RX 队列分配固定 CPU,避免在 LLC 之间乱跑:

# 示例:把中断号 123 绑到 CPU 2
echo 0x4 > /proc/irq/123/smp_affinity

3)开启 RPS/RFS 与 XPS

RPS:把软中断分摊到更多 CPU;XPS:控制发包从哪个队列出去。
(路径按队列编号 N 调整)

# 每个 RX 队列允许在一组 CPU 上调度(例如 CPU 2-15)
echo fffc > /sys/class/net/eth0/queues/rx-0/rps_cpus
echo 32768 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

# XPS 固定 TX 队列到相应 CPU(减少反复跨核)
echo 0004 > /sys/class/net/eth0/queues/tx-0/xps_cpus

4)GRO/LRO

对 L7 网关一般 保留 GRO 开启、禁用 LRO(避免乱序/大包在某些上层协议下带来负面):

ethtool -K eth0 gro on lro off tso on gso on

实战坑:开了 irqbalance 又手动绑核会互相“打架”。要么关 irqbalance,全手动;要么只做温和调整,并把不希望 irqbalance 管的中断 smp_affinity 锁死。

Step 4:应用架构——每核一 listener + 每核一 epoll

传统“单 listener + 多工作线程”在 CentOS 7 上容易出现 accept 惊群 或 accept 热点。SO_REUSEPORT 能让多个进程/线程在同端口各自拥有一个独立的监听 socket,内核根据 5 元组 hash 把新连接分发到不同的 listener 上,天然做到了无锁负载均衡。

关键策略

  • 每个 CPU(或每个 NUMA 节点)一个监听 socket:SO_REUSEPORT。
  • 线程/进程亲和:把工作线程 pin 到对应 CPU。
  • 每线程一个 epoll 实例:事件完全本地化,减少共享结构。
  • 边沿触发 (EPOLLET) + 非阻塞:减少唤醒/系统调用,但要写对循环。
  • 监听 socket 用 accept4(..., SOCK_NONBLOCK | SOCK_CLOEXEC)。
  • 订阅事件:EPOLLIN | EPOLLRDHUP | EPOLLET(必要时加 EPOLLONESHOT 自复位模型)。

Step 5:C 语言最小可用示例(精简关键路径)

说明:下面示例展示了 per-thread listener + epoll(ET) + SO_REUSEPORT 的关键细节,适合自研长连接接入/网关做骨架。生产请补齐日志、连接池、错误处理与超时管理。

// gcc -O2 -pthread -Wall server.c -o server
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>

#define MAX_EVENTS  4096
#define PORT        7000
#define BACKLOG     65535
#define BUF_SIZE    4096

static int set_nonblock(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return -1;
    return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

static int create_reuseport_listener(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd < 0) { perror("socket"); exit(1); }

    int one = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    if (setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one)) < 0) {
        perror("SO_REUSEPORT"); exit(1);
    }

#ifdef TCP_FASTOPEN
    int q = 4096;
    setsockopt(fd, IPPROTO_TCP, TCP_FASTOPEN, &q, sizeof(q));
#endif

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);

    if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        perror("bind"); exit(1);
    }
    if (listen(fd, BACKLOG) < 0) { perror("listen"); exit(1); }
    set_nonblock(fd);
    return fd;
}

struct thread_arg {
    int cpu;
    int listen_fd;
};

static void *worker(void *arg_) {
    struct thread_arg *arg = (struct thread_arg *)arg_;
    cpu_set_t cpuset; CPU_ZERO(&cpuset); CPU_SET(arg->cpu, &cpuset);
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);

    int ep = epoll_create1(EPOLL_CLOEXEC);
    if (ep < 0) { perror("epoll_create1"); exit(1); }

    struct epoll_event ev = {0};
    ev.events = EPOLLIN | EPOLLET;
    ev.data.fd = arg->listen_fd;
    if (epoll_ctl(ep, EPOLL_CTL_ADD, arg->listen_fd, &ev) < 0) {
        perror("epoll_ctl listen"); exit(1);
    }

    struct epoll_event events[MAX_EVENTS];
    char buf[BUF_SIZE];

    for (;;) {
        int n = epoll_wait(ep, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait"); break;
        }
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            uint32_t e = events[i].events;

            if (fd == arg->listen_fd) {
                // Accept loop until EAGAIN (ET)
                for (;;) {
                    struct sockaddr_in in; socklen_t inlen = sizeof(in);
                    int cfd = accept4(arg->listen_fd, (struct sockaddr*)&in, &inlen,
                                      SOCK_NONBLOCK | SOCK_CLOEXEC);
                    if (cfd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        perror("accept4"); break;
                    }
                    // 注册读事件(同时监听对端关闭)
                    struct epoll_event cev = {0};
                    cev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
                    cev.data.fd = cfd;
                    epoll_ctl(ep, EPOLL_CTL_ADD, cfd, &cev);
                }
            } else {
                if (e & (EPOLLHUP | EPOLLERR | EPOLLRDHUP)) {
                    close(fd); continue;
                }
                if (e & EPOLLIN) {
                    // 读到尽头(ET)
                    for (;;) {
                        ssize_t r = read(fd, buf, sizeof(buf));
                        if (r > 0) {
                            // 回显(示例):真实业务可协议解析/路由
                            ssize_t off = 0;
                            while (off < r) {
                                ssize_t w = write(fd, buf + off, r - off);
                                if (w > 0) off += w;
                                else if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
                                else { close(fd); goto next_fd; }
                            }
                            // 若未写完则转挂 EPOLLOUT;此处演示省略
                        } else if (r == 0) {
                            close(fd); break;
                        } else {
                            if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                            close(fd); break;
                        }
                    }
                }
            }
        next_fd: ;
        }
    }
    return NULL;
}

int main() {
    signal(SIGPIPE, SIG_IGN);

    int ncpu = sysconf(_SC_NPROCESSORS_ONLN);
    pthread_t *ths = calloc(ncpu, sizeof(pthread_t));
    struct thread_arg *args = calloc(ncpu, sizeof(struct thread_arg));

    for (int i = 0; i < ncpu; i++) {
        int lfd = create_reuseport_listener(PORT);
        args[i].cpu = i;
        args[i].listen_fd = lfd;
        pthread_create(&ths[i], NULL, worker, &args[i]);
        // 小憩让 listener 建好再创建下一个,避免早期倾斜
        usleep(20000);
    }
    for (int i = 0; i < ncpu; i++) pthread_join(ths[i], NULL);
    return 0;
}

关键点复盘

  • 每个线程一个独立监听 fd(SO_REUSEPORT),避免 accept 锁与惊群。
  • EPOLLET + 非阻塞 + 读/accept “直到 EAGAIN”,是避免遗漏事件的“铁律”。
  • 生产中通常会做 连接对象池、收发环形缓冲、协议帧切分、超时心跳 和 零拷贝优化(如 sendfile/splice)。

Step 6:Nginx 层同理(如果你是 HTTP 入口)

/etc/nginx/nginx.conf 关键片段:

worker_processes auto;
worker_rlimit_nofile 1048576;

events {
    use epoll;
    worker_connections 1024000;
    multi_accept on;
}

http {
    # 省略 server/tuning ...
    server {
        listen 80 reuseport backlog=65535;
        # ...
    }
}

listen reuseport 会让每个 worker 都有自身的监听 socket,避免单点热点。记得 worker_connections × worker_processes 要与系统 FD 上限对齐。

Step 7:压测方法与观测

工具与拓扑

  • tcpkali:模拟长连接与低速收发;易推至百万连接(多发压机)。
  • wrk:HTTP 场景吞吐与延迟。
  • 压机:同机房 2–3 台 10GbE 服务器,分散源 IP(避免 5 元组 hash 偏斜)。

示例命令

# 1) 建 100 万空闲连接,每连接每秒 1 次小报文
tcpkali -r 1 -c 1000000 --connect-rate=50000 \
        --duration=300s --latency-markers  \
        --binary -m "ping\n" <server-ip>:7000

# 2) HTTP 场景
wrk -t24 -c100000 -d120s --latency http://<server-ip>/

需要盯的指标

  • ss -s、ss -ant state established | wc -l:连接规模。
  • sar -n DEV 1、ethtool -S eth0:RX/TX、丢包、队列统计。
  • cat /proc/net/netstat、/proc/net/snmp:ListenOverflows、TCPAbortOn*。
  • top/perf top:softirq、ksoftirqd 是否抢戏。
  • 应用日志:accept 错误、EAGAIN 频度、慢发送堆积。

Step 8:结果(我那夜的前后对比)

指标 调优前 调优后
并发连接能力 ≈ 60–70 万 ≥ 120 万(混合流)
p99 延迟(同城链路) 28–35ms 8–12ms
accept 溢出 偶发 基本无
丢包(RX Miss/Drop) 偶发 显著下降
CPU System/Softirq 35%+/高 < 20%/平稳

备注:数字取决于业务数据面工作量、报文大小和压测拓扑,但“跃迁感”非常明显。SO_REUSEPORT + per-CPU epoll 是最关键的结构性收益。

Step 9:常见坑 & 现场处置

FD 还是不够用

忘了在 systemd 服务里加 LimitNOFILE;或 Nginx worker_rlimit_nofile 没设。

观察 EMFILE/ENFILE,两类错误定位到进程/系统级别。

Backlog 看似够,还是溢出

listen(x) 的 x 实参被截断到 somaxconn。确认三角:上游四层 → 本机 tcp_max_syn_backlog → 本机 somaxconn。

SYN 洪峰时,临时打开 syncookies,压峰后再评估。

SO_REUSEPORT 负载不均

建议所有 listener 同时创建(或在创建间加入微小随机延迟),避免早期连接把 hash“啃偏”。

极端下可研究 BPF reuseport 分流(CentOS 7 部分内核带回溯,需评估稳定性)。

EPOLLET 漏事件

99% 是因为没有“读/accept 到 EAGAIN 为止”。循环写对,问题就消失。

半关闭检测请订阅 EPOLLRDHUP,否则对端优雅关不会通知你。

软中断爆、ksoftirqd 占满

重新做 IRQ 绑核、RPS/XPS;确保 RX/TX 队列映射与 CPU 拓扑匹配。

检查 GRO/LRO、netdev_max_backlog 和 rps_flow_cnt。

TIME_WAIT“海啸”

这常见于主动关闭的一端(多数是客户端)。服务端不要迷信 tcp_tw_reuse,看业务语义。

优化 Keep-Alive,减少频繁建连/断连。

Fast Open 引发兼容问题

内网/同城自控链路可以尝试;公网或存在中间盒(FW/IPS)时,谨慎开启并观察重传与 RST。

SELinux / firewalld

Nginx/自研服务新增端口别忘了 semanage port -a。压力时防火墙 conntrack 可能成为瓶颈,策略要事先评估。

Step 10:容量预估的小算术(工程直觉)

粗粒度估:空闲 TCP 连接内核态开销 ~2–4 KB/conn(随内核与选项波动)。

100 万连接 ≈ 2–4 GB。加上应用态结构(连接对象、缓冲),预留 8–12 GB 更安全。

FD 表、slab、socket 缓冲(tcp_rmem/wmem 的实际水位)都会“叠罗汉”。

这也是我把机器内存留 20% 余量的原因:给内核“自由呼吸”。

Step 11:一页纸清单(交给夜班同事也能用)

  •  limits.d / systemd LimitNOFILE / file-max 对齐到 1M+。
  •  somaxconn/tcp_max_syn_backlog/netdev_max_backlog 调到位。
  •  网卡队列、IRQ 绑核、RPS/RFS、XPS、GRO/LRO 配平。
  •  应用:per-CPU listener(SO_REUSEPORT) + per-CPU epoll。
  •  压测:tcpkali 建连能力、wrk 延迟与吞吐;监控软中断和丢包。
  •  观察:ListenOverflows、SYNs to LISTEN dropped、ksoftirqd、EMFILE。
  •  回退预案:关闭 Fast Open、调低 rps_flow_cnt、回滚绑核策略。

结尾:当图线回绿

凌晨 3 点 40 分,监控面板上“红的地方”一块块淡下去。并发破百万那一刻,CPU 曲线没有再冲高,listen 队列稳稳的,网卡统计干净。我把最后一口冷掉的咖啡喝完,在机房里走了一圈,把每台服务器的状态灯都看了个遍。
这不是一篇“漂亮的理论”,而是我在香港机房里把 epoll、SO_REUSEPORT、内核网络栈和硬件特性揉在一起的现场记录。如果你也在为百万级连接发愁,先把这份清单走一遍——你大概率会像我一样,听到风扇声从嘶吼变成了均匀的白噪音。

附:快速回滚与排障命令集合

# 回滚 sysctl(按文件)
sysctl -w net.core.somaxconn=128
sysctl -w net.core.netdev_max_backlog=1000
# ...

# 查看 backlog/丢弃
netstat -s | egrep 'listen|SYN|backlog|drops'
cat /proc/net/netstat | egrep 'ListenOverflows|ListenDrops'

# 连接与端口
ss -s
ss -ant state established | wc -l

# 网卡/队列
ethtool -S eth0 | egrep 'rx_*_drops|tx_*_drops|miss'
grep eth0 /proc/interrupts

# 软中断与 CPU
top -H -p $(pidof gateway)
pidstat -w 1

最后一句老话:优化是“场景 + 度量 + 迭代”,没有放之四海而皆准的神值。本文给的是一套实战参数与方法论,你的现场、你的业务,调出来的那个“甜点”,一定有你自己的刻度。祝你夜里也能把图线拉回绿。

目录结构
全文