如何在香港服务器运行 Ubuntu 时启用 Multi-Queue Socket(MQS),优化百万并发长连接应用?

凌晨 2:17,香港九龙湾那个机房的监控墙上有一条红线突然抬头:活跃连接数从 68 万抬到 95 万,P99 延迟刺穿了我们设的阈值。我一脚把纸箱当凳子坐下,SSH 进香港入口集群的头节点,ss -s 一敲,estab 直逼 100 万。CPU 的 0 号核被中断打成了亮红色,而其他核还在“摸鱼”。那一刻我基本确认,瓶颈不是算力,而是单队列收包+单 accept 队列在掐脖子。
第二天早会我立了个 flag:把这套入口服务改成Multi-Queue Socket(MQS)架构——让网卡多队列(RSS/RPS/XPS)和应用层多 accept 队列(SO_REUSEPORT + eBPF 选择器)对齐,把流量从“单车道收费站”改成“多车道无感通行”。下面是我在香港机房里一步步落地的全过程。
目标与术语
目标:同一台 Ubuntu 服务器承载≥100 万长连接(WebSocket/私有 TCP),P99 延迟与丢包率稳定在可控范围;CPU 核心均衡利用。
MQS(Multi-Queue Socket):本文语境下指“NIC 多队列 + 内核/用户态多队列接入”的组合实践,不是内核的某个单一开关。
关键组成:
- NIC 多队列(RSS)→ 中断绑核 → RPS/XPS → 软中断均衡
- 应用层多监听(SO_REUSEPORT)→ eBPF Reuseport 选择器(按 5-tuple hash 粘性分配)
- io_uring/epoll 多 Reactor 模型 → 线程绑核/NUMA 亲和
现场环境与硬件参数(香港机房)
| 项目 | 配置/型号 | 备注 |
|---|---|---|
| 机型 | Dell R7525(双路) | 2U,前 8 块 NVMe |
| CPU | 2× AMD EPYC 7543(32C/64T × 2 = 64C/128T) | SMT 开启 |
| 内存 | 256GB DDR4 | 单条 32GB × 8,NUMA 2 节点 |
| 网卡 | Mellanox ConnectX-5(25GbE ×2) | 支持 RSS、多队列、XDP |
| 系统 | Ubuntu Server 22.04 LTS(5.15 内核) | 生产稳定优先 |
| 业务 | 长连接网关(TCP/WebSocket) | 每连接小包心跳 |
注意:同样的方法在 Intel Xeon + X710/XL710(10/25GbE)上也通用,只是命令略有差别。
全局路线图
- 系统基线:关掉“省电与抖动”,打开高队列上限与 backlog。
- NIC 多队列:开启 RSS、多队列,扩大环形缓冲;中断绑核,分散负载。
- RPS/XPS:软中断与发送路径 CPU 亲和,减少跨核迁移。
- 应用 MQS:SO_REUSEPORT 打一组监听,必要时加 eBPF Reuseport 选择器,保证粘性。
- Reactor/线程模型:每核(或每队列)一个事件环,绑核;结合 io_uring/epoll。
- 可观测性与压测:ss, sar, perf, dropwatch,在流量实打实的情况下迭代。
- 防抖坑位:irqbalance、LRO/GRO、NUMA 远端内存、tcp_tw_* 老旧参数误用等。
第 1 步:系统基线与内核参数
/etc/default/grub
GRUB_CMDLINE_LINUX="quiet splash nohz_full=1-63 rcu_nocbs=1-63 intel_pstate=disable amd_pstate=passive transparent_hugepage=never"
说明:让 1–63 核尽量不被调度时钟打扰;THP 关闭避免抖动。重建引导:update-grub && reboot。
CPU 性能模式
sudo apt-get install -y cpufrequtils
echo 'GOVERNOR="performance"' | sudo tee /etc/default/cpufrequtils
sudo systemctl enable --now cpufrequtils
sysctl 核心调优(/etc/sysctl.d/99-mqs.conf)
net.core.netdev_max_backlog = 65536
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.core.rmem_default = 8388608
net.core.wmem_default = 8388608
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_mem = 50576 64768 98152
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 60
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_syncookies = 1
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
文件句柄与进程数
echo "fs.file-max = 8000000" | sudo tee /etc/sysctl.d/98-fs.conf
ulimit -n 4000000
应用层进程的 systemd 单元记得加:
[Service]
LimitNOFILE=4000000
TasksMax=infinity
第 2 步:网卡多队列与中断绑核
装工具:
sudo apt-get install -y ethtool numactl hwloc bpftool linux-tools-$(uname -r)
查看与设置队列数
ethtool -l eth0 # 查看 channels
ethtool -L eth0 combined 32 # 设置 32 个队列(视 CPU/流量而定)
ethtool -G eth0 rx 4096 tx 4096 # 扩大环形缓冲
关闭容易引发延迟抖动的特性(按需)
ethtool -K eth0 lro off gro on tso on gso on
LRO 在低延迟场景常关;GRO/TSO/GSO 视业务包型权衡。
分配中断到 CPU(绑核)
先列出中断:
grep eth0 /proc/interrupts
写一个中断绑核脚本(/usr/local/sbin/irq_affinity.sh):
#!/usr/bin/env bash
DEV=${1:-eth0}
CPUS_PER_NUMA=32 # 视机器调整
START_CPU=1 # 避免 0 号核
i=0
for irq in $(grep $DEV /proc/interrupts | awk -F: '{print $1}'); do
cpu=$(( (i % (CPUS_PER_NUMA-1)) + START_CPU ))
mask=$(printf "%x" $((1<<cpu)))
echo $mask > /proc/irq/$irq/smp_affinity
echo "IRQ $irq -> CPU $cpu (mask 0x$mask)"
i=$((i+1))
done
执行:
sudo bash /usr/local/sbin/irq_affinity.sh eth0
坑 1:记得禁用或配置 irqbalance,否则它会把你的手工绑核“搬回去”。我在生产上将 irqbalance 保留但配上 ban-irq 列表,或干脆 systemctl stop irqbalance 并 mask。
第 3 步:RPS/XPS 让软中断与发送也“多队列”
开启 RPS(接收路径软中断分散)
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for q in /sys/class/net/eth0/queues/rx-*; do
echo ffffffff > $q/rps_cpus # 让所有 CPU 均可参与(或按 NUMA 划定掩码)
echo 4096 > $q/rps_flow_cnt
done
开启 XPS(发送队列亲和)
for q in /sys/class/net/eth0/queues/tx-*; do
echo ffffffff > $q/xps_cpus
done
坑 2:RPS/XPS 与 RSS 冲突?不是。RSS 是硬件把流量哈希分到不同 RX 队列;RPS 是内核把已收的报文软转发到更合适的 CPU 上处理。合理搭配能更均匀,但跨核迁移太频繁也会抖,要结合 NUMA 做分区掩码。
第 4 步:应用层 MQS(多监听多 accept 队列)
4.1 快速起步:SO_REUSEPORT 多进程/线程监听
C 语言最小化监听示例(复用端口 + 绑核)
// mqs_listen.c
#define _GNU_SOURCE
#include <sched.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>
int cpu_bind(int cpu){
cpu_set_t set; CPU_ZERO(&set); CPU_SET(cpu, &set);
return pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
}
void* worker(void* arg){
int cpu = (int)(long)arg;
cpu_bind(cpu);
int fd = socket(AF_INET, SOCK_STREAM, 0);
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof(one));
int rcv = 8<<20, snd = 8<<20;
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &rcv, sizeof(rcv));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &snd, sizeof(snd));
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(9000);
if (bind(fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return NULL; }
if (listen(fd, 65535) < 0) { perror("listen"); return NULL; }
printf("CPU %d listening on 0.0.0.0:9000\n", cpu);
while (1) {
int c = accept4(fd, NULL, NULL, SOCK_NONBLOCK);
if (c < 0) continue;
// 这里正常应该注册到 epoll/io_uring 与业务状态机
// 示例:直接丢弃
close(c);
}
return NULL;
}
int main(){
const int workers = 16; // 与队列/核数对齐
for (int i=0;i<workers;i++){
pthread_t t; pthread_create(&t, NULL, worker, (void*)(long)(i+1));
}
pause();
}
编译运行:
gcc mqs_listen.c -O2 -lpthread -o mqs_listen
taskset -c 1-16 ./mqs_listen
多个线程/进程共享同一端口,内核会把新连接分发到不同监听 socket 的 accept 队列,从而形成“应用层多队列”。
4.2 进阶:eBPF Reuseport 选择器(按 5-tuple 粘性分配)
当你需要同一用户会话稳定落在同一工作线程(便于连接内状态缓存/限流等),可挂一个 BPF_PROG_TYPE_SK_REUSEPORT 选择器。简化版流程:
- 通过 clang/llc 编译一个小 eBPF 程序,读取 5-tuple 计算 hash;
- setsockopt(fd, SO_ATTACH_REUSEPORT_EBPF, &prog_fd, sizeof(prog_fd));
- 程序返回索引,把连接分到对应监听 socket。
这里给出极简伪代码(仅示意,生产请用 libbpf 模板):
// bpf_reuseport_kern.c (伪代码)
SEC("sk_reuseport")
int balancer(struct sk_reuseport_md *ctx) {
__u32 hash = bpf_get_hash_recalc(ctx);
__u32 idx = hash % ctx->num_socks;
ctx->selected_sk = idx;
return SK_PASS;
}
用
户态加载后,对每个监听 fd 调 SO_ATTACH_REUSEPORT_EBPF。
坑 3:eBPF 编译链与内核头常踩坑。Ubuntu 22.04 建议安装 linux-headers-$(uname -r) clang llvm libbpf-dev bpftool,保持同一内核版本头文件。
4.3 io_uring 多 Reactor(可选)
长连接读写用 io_uring 能进一步削减 syscalls 开销与上下文切换。思路:每个工作线程一个 uring 环 + 自己的监听 fd(来自 REUSEPORT 组),线程绑核。这样队列→中断→线程基本对齐。
第 5 步:服务分区与 NUMA 策略
按 NUMA 划分队列与线程:NUMA0 用队列 0–15、线程绑核 1–32;NUMA1 用队列 16–31、线程绑核 33–63。
应用进程启动参数:
numactl --cpunodebind=0 --membind=0 app --workers=16
numactl --cpunodebind=1 --membind=1 app --workers=16
避免远端内存:连接状态结构体(会话表、ring buffers)尽量在对应节点上分配。
坑 4:我第一次只绑了 CPU 没绑内存,结果 P99 波动 30% 左右。numactl --hardware 看清 NUMA 拓扑很关键。
第 6 步:观测与回归压测
常用手段:
# 连接/队列整体
ss -s
sar -n DEV 1
cat /proc/softirqs
cat /proc/interrupts
# 丢包路径
dropwatch -l kas
ethtool -S eth0 | egrep "rx.*miss|dropped|no_"
dmesg | grep -i drop
# CPU/火焰图
perf top
perf record -g -- sleep 30 && perf report
压测工具:自研长连接压测器(Go/C),或 tcpreplay + wrk/ws 压测;关键要模拟真实心跳与小包分布。
调优前后对比(实测样例数据)
机房实流量 + 灰度放量下采样;仅作方法论参考。
| 指标 | 调优前(RSS 单队列 + 单 accept) | MQS 后(32 RX/TX 队列 + REUSEPORT 16) |
|---|---|---|
| 活跃连接峰值 | 950k | 1.35M |
| P95 延迟(心跳回包) | 18.6 ms | 7.9 ms |
| P99 延迟 | 42.3 ms | 15.4 ms |
| RX 队列 0 占比 | 86% | 6%(均衡) |
| 0 号核软中断占比 | 71% | 9% |
| 网卡丢包(rx_missed) | 5.3k/min | < 200/min |
| 应用 CPU 利用率(总) | 38% | 52%(更“吃核”,但效率更高) |
Nginx/Tengine 快速接入(如果你用反代或 TCP 代理)
HTTP/Stream 监听处启用 reuseport:
# http
server {
listen 80 reuseport;
# ...
}
# stream (TCP/WS)
stream {
upstream backend {
server 127.0.0.1:9000;
hash $remote_addr consistent; # 粘性可选
}
server {
listen 7000 reuseport;
proxy_pass backend;
}
}
这能让 worker 数量与 accept 队列对应,配合前述 RSS/RPS/XPS 效果也不错。
故障与踩坑手记
irqbalance 抢活
一开始我手工绑好的中断几小时后又失衡了。查日志发现 irqbalance 在“帮忙”。解决:要么停掉,要么配置 ban 列表固定网卡 IRQ。
LRO 导致延迟长尾
某款驱动默认开了 LRO,吞吐挺香,但心跳类小包延迟抖。关闭 LRO、保留 GRO,长尾直接收敛。
rps_cpus 掩码过大
我曾经对所有 RX 队列都给了 ffffffff,结果跨核迁移频繁。后来按 NUMA 半区掩码(如低 32 位)明显更稳。
eBPF 头文件版本不匹配
线上内核升级后,老的 BPF 目标不加载了。CI 里用 bpftool gen skeleton 和对应 headers 重编译,打包随版本走。
TIME_WAIT 误区
有同学问要不要开 tcp_tw_reuse/tw_recycle。注意:tw_recycle 早已移除;长连接场景不是问题核心,不要乱改“偏方”。
NUMA 远端内存
业务状态大对象分配在 node1,却被 node0 线程频繁访问,numastat 一看 remote hits 高得吓人。两条命令:numactl 绑内存 + 线程局部化分配策略,立竿见影。
生产落地的开机脚本(片段)
/usr/local/bin/mqs-boot.sh
#!/usr/bin/env bash
set -e
DEV=eth0
QUEUES=32
# 网卡队列与 ring
ethtool -L $DEV combined $QUEUES || true
ethtool -G $DEV rx 4096 tx 4096 || true
ethtool -K $DEV lro off gro on tso on gso on
# RPS/XPS
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for q in /sys/class/net/$DEV/queues/rx-*; do
echo ffffffff > $q/rps_cpus
echo 4096 > $q/rps_flow_cnt
done
for q in /sys/class/net/$DEV/queues/tx-*; do
echo ffffffff > $q/xps_cpus
done
# 中断绑核
/usr/local/sbin/irq_affinity.sh $DEV
# sysctl
sysctl --system
配合 systemd 定时器在网络就绪后执行。
常见问答(基于我踩过的坑)
Q:队列数设越大越好吗?
A:不是。队列多会增加缓存、上下文和调度负担。通常与可用物理核或 NUMA 半区对齐就好(如 16/32)。
Q:BBR 一定更好吗?
A:长连接小包心跳通常是“轻流量、低突发”,BBR 往往更稳。但你的上游/链路特性不同,建议灰度对比 cubic 与 bbr 后再决定。
Q:GRO/TSO 要不要全关?
A:追求极低延迟的交易场景可能关;百万长连接的网关更多是“吞吐+稳定”,我一般保留 GRO/TSO,只关 LRO。
Q:必须上 eBPF reuseport 吗?
A:不是必须。SO_REUSEPORT 的轮询/哈希分发已经很有效;当你需要强粘性或自定义策略(如按用户 ID)时再上 eBPF。
又是一个凌晨,窗外还是海风,但监控墙上的红线稳稳地趴在阈值下边缘。连接数 132 万,P99 在 15ms 左右,0 号核不再“冲锋陷阵”。我把热咖啡放在机柜门上,回看那张白板:RSS → RPS/XPS → REUSEPORT → eBPF(可选)→ NUMA 亲和,这条路线图,像一条多车道高速,在香港这个寸土寸金的机房里,给了我们足够的并发与确定性。
如果你正被百万长连接“掐着嗓子眼”,不妨照着这篇把 MQS 架起来。别怕麻烦,每一个环节把队列对齐,把核对齐,把内存对齐,系统自然就顺了。
附:一页纸速查表
| 类别 | 关键点 | 推荐值/命令 |
|---|---|---|
| sysctl | backlog/队列 | net.core.netdev_max_backlog=65536 net.core.somaxconn=65535 |
| 内存/缓冲 | R/W 缓冲 | rmem_max/wmem_max=128MB;tcp_rmem/tcp_wmem 三级 |
| 拓扑 | qdisc/拥塞 | default_qdisc=fq tcp_congestion_control=bbr |
| NIC | 队列与 ring | ethtool -L eth0 combined 32 -G rx/tx 4096 |
| offload | LRO/GRO/TSO | lro off gro on tso on |
| 中断 | 绑核 | irq_affinity.sh eth0(避开 CPU0,按 NUMA 分配) |
| RPS/XPS | 掩码/flow | rps_sock_flow_entries=32768 rps_flow_cnt=4096 xps_cpus=ffff... |
| 应用 | 多监听 | SO_REUSEPORT + 每核一个 Reactor |
| eBPF | 粘性分配 | sk_reuseport 选择器(可选) |
| NUMA | 亲和 | numactl --cpunodebind --membind |
| 观测 | 热点排查 | ss -s dropwatch perf top ethtool -S |
写在最后:如果你更偏向 Nginx/Tengine/OpenResty 生态,listen ... reuseport 是最省事的起点;如果你自己写网关,SO_REUSEPORT + 绑核 +(可选)eBPF 是我验证过最稳的组合。