香港服务器运行 CentOS 7 时,我是如何调优 irqbalance 与 CPU 亲和性,把高流量业务的中断延迟打下来的

凌晨 2 点,香港荃湾机房 6 楼,我们的跨境电商做秒杀活动,香港集群是北向入口,峰值单机 11–12 Gbps。NOC 一直在吼网络抖动,监控里 p99 RTT 竖了根“钉子”。我带着阿杰蹲在第 3 排机柜前,抱着保温杯里已经凉透的咖啡,一边看 top 里几个 ksoftirqd 飙到 100%,一边想:这不是应用问题,是中断被打散了、软中断没跑在对的核上。
那一晚,我把 irqbalance“管好”、把中断“拴住”、把应用“让开”,最后 RTT“钉子”倒了。
一、环境与目标
硬件参数(单台样例)
机型:Supermicro SYS-1029U(1U)
CPU:2 × Intel Xeon Silver 4210(10C20T ×2,NUMA=2)
内存:768 GB DDR4-2666
网卡:Intel X710-DA4(四口 10GbE,驱动 i40e,MSI-X 中断,支持 RSS/RPS/XPS)
系统盘:Samsung PM983 NVMe 3.84 TB
交换机:TOR 为 25G 下行,10G 上联分光
软件栈
- OS:CentOS 7.9(内核 3.10.0-1160.el7.x86_64)
- 驱动:i40e 2.12.x(随内核)
- irqbalance:v1.0.7-12.el7
应用:Nginx + 自研网关(epoll),容器化(Docker,cgroup v1)
目标
- 让网卡队列的硬中断与软中断亲和在同一 NUMA 节点、成对的超线程兄弟核上;
- 让 irqbalance 只在“保洁核”上打扫,不要碰应用核;
- 降低中断抖动,压 p99/p999 RTT,消灭 ksoftirqd/N 漏斗。
二、快速原理图(为什么要这么搞)
每个队列(RX/TX)→ 绑定一个或多个 MSI-X IRQ。
硬中断(hardirq)醒来后唤起 软中断(softirq)在 ksoftirqd/N 上干活(N 为 CPU id),如果软中断跑到不在同一缓存域/NUMA 的核上,来回穿越会抖。
irqbalance 默认“平均主义”,会把 IRQ 在所有可用 CPU 上漂移,但平均≠稳定;高频网络 IRQ 更需要就地消化。
亲和性要对齐三件事:NUMA、队列、超线程兄弟。另外再配 RPS/XPS 让软中断/发送路径也一致。
三、现状排查(我在现场怎么判断“它抖了”)
# 看软中断占比、ksoftirqd 是否打满
top -H -p $(pidof ksoftirqd/*) # 或者直接 top,观察 ksoftirqd/0, ksoftirqd/7 等
mpstat -P ALL 1 5
# 看中断分布是否倾斜、是否频繁漂移
grep -E "i40e|eth1" /proc/interrupts | sort -nk2
# 看网卡队列、RSS/XPS 能力
ethtool -l eth1 # 当前/最大队列数
ethtool -g eth1 # ring 大小
ethtool -S eth1 | egrep "dropped|missed|errors|no_desc"
问题征兆(真实摘录样式):
- ksoftirqd/3、ksoftirqd/7 常年 80–100%;
- /proc/interrupts 中与 eth1-TxRx-* 相关 IRQ 大量堆在 CPU 3/7,且过几分钟会换一批核(被 irqbalance 摇摆);
- ethtool -S eth1 有少量 rx_no_desc(说明 RX ring 被打爆过),同时 busy_poll 未开。
四、调优设计(我的“布阵”)
队列与 NUMA 对齐:
NIC eth1 物理上挂在 NUMA 0(用 cat /sys/class/net/eth1/device/numa_node 验证),我把它的 8 条 RX/TX 队列和对应 IRQ 都绑在 NUMA 0 的核上:CPU 0-9,20-29 里挑 8 对兄弟核。
核角色划分(以 20 物理核/40 逻辑核为例):
- 中断+软中断核(Housekeeping-IRQ):CPU 0,1,2,3,4,20,21,22,23(NUMA0 的 5 对超线程各取一条线)
- 应用核(App):CPU 8-15,28-35(NUMA0 近内存 + 远一点的也可以)
- 后台/系统核:其余(NUMA1 为其他口或系统任务)
策略:
- 限制 irqbalance 活动范围:禁止它碰应用核,只让它在“保洁核”上活动(或者干脆停掉,完全手排)。
- 固定 IRQ 亲和性:对每个 eth1-TxRx-N 指定一个 CPU(或一对兄弟),使用 smp_affinity_list(可读的 0,2,4 形式)。
- RPS/XPS 对齐:rps_cpus/xps_cpus 与硬中断落点一致,防止软中断在别处醒。
- 轻度中断合并:ethtool -C rx-usecs 8 rx-frames 0,在 10G–12G 下小幅合并,不要太激进以免引入抖动。
- 队列到位:确保 ethtool -L eth1 combined 8,RSS 足够分散。
五、动手步骤(逐条命令,能复制就能复现)
1)确认 NUMA 与 CPU 拓扑
numactl -H
lscpu -e=CPU,CORE,SOCKET,NODE,ONLINE,CONFIGURED
cat /sys/class/net/eth1/device/numa_node # 返回 0 表示在 NUMA 0
经验:优先选择同 NUMA 的核,并且尽量避开兄弟线程对(一个给 IRQ/softirq,另一个可给轻量任务或空着)。我常用“同 core 的两个 thread 不同时打满”。
2)给网卡开足队列
ethtool -l eth1
# Current hardware settings: RX: 8 TX: 8 Combined: 8
# Maximum hardware settings: RX: 8 TX: 8 Combined: 8
# 若 Current < Maximum:
ethtool -L eth1 combined 8
3)先“关”或“圈起来” irqbalance
方案 A(限制活动范围):
# 仅允许 irqbalance 在 CPU 0-5,20-25 上活动,屏蔽应用核(例)
# CentOS 7 使用 /etc/sysconfig/irqbalance
vi /etc/sysconfig/irqbalance
# 添加或修改:
# 计算掩码的简便做法:尽量用 smp_affinity_list(见下一步),
# 但这里需要16进制:屏蔽应用核,如 8-15,28-35 -> 0x000000ff00ff00(示例)
IRQBALANCE_BANNED_CPUS=0x000000ff00ff00
systemctl restart irqbalance
systemctl status irqbalance
方案 B(手排就停):
systemctl stop irqbalance
systemctl disable irqbalance
systemctl mask irqbalance
建议:如果你要精确绑每个队列到具体核,就采用方案 B;如果你希望快速稳住局面,先用方案 A 把应用核“让开”。
4)为每个 IRQ 指定 smp_affinity_list
找出 eth1 的 IRQ:
grep -E "eth1|i40e" /proc/interrupts
# 例如:
# 141: ... i40e-eth1-TxRx-0
# 142: ... i40e-eth1-TxRx-1
# ...
# 148: ... i40e-eth1-TxRx-7
规划映射(示例,全部落在 NUMA0):
| 队列 | IRQ | 目标 CPU(亲和) |
|---|---|---|
| TxRx-0 | 141 | 0 |
| TxRx-1 | 142 | 2 |
| TxRx-2 | 143 | 4 |
| TxRx-3 | 144 | 6 |
| TxRx-4 | 145 | 20 |
| TxRx-5 | 146 | 22 |
| TxRx-6 | 147 | 24 |
| TxRx-7 | 148 | 26 |
设置命令(使用列表,比十六进制直观):
for n in 0 1 2 3 4 5 6 7; do
irq=$(grep -E "i40e-eth1-TxRx-${n}\b" /proc/interrupts | awk -F: '{print $1}' | tr -d ' ')
case "$n" in
0) cpu=0 ;;
1) cpu=2 ;;
2) cpu=4 ;;
3) cpu=6 ;;
4) cpu=20 ;;
5) cpu=22 ;;
6) cpu=24 ;;
7) cpu=26 ;;
esac
echo "$cpu" > /proc/irq/${irq}/smp_affinity_list
echo "IRQ $irq -> CPU $cpu"
done
验证:
for irq in $(grep -E "i40e-eth1-TxRx-" /proc/interrupts | awk -F: '{print $1}'); do
printf "IRQ %s : " $irq; cat /proc/irq/$irq/smp_affinity_list
done
说明:smp_affinity_list 支持 0-3,20-23 这种写法;比 smp_affinity 的十六进制友好多了。
5)让软中断路径(RPS/XPS)也跟着走
# 把 RPS 亲和设置为与硬中断相同的 CPU(示例以队列0落在 CPU0)
# rps_cpus 接受位图或列表(CentOS 7 支持列表)
for q in /sys/class/net/eth1/queues/rx-*; do
idx=$(basename $q | cut -d- -f2)
case "$idx" in
0) cpus="0" ;;
1) cpus="2" ;;
2) cpus="4" ;;
3) cpus="6" ;;
4) cpus="20" ;;
5) cpus="22" ;;
6) cpus="24" ;;
7) cpus="26" ;;
esac
echo $cpus > $q/rps_cpus
done
# XPS 让发送路径也在同核
for q in /sys/class/net/eth1/queues/tx-*; do
idx=$(basename $q | cut -d- -f2)
case "$idx" in
0) cpus="0" ;;
1) cpus="2" ;;
2) cpus="4" ;;
3) cpus="6" ;;
4) cpus="20" ;;
5) cpus="22" ;;
6) cpus="24" ;;
7) cpus="26" ;;
esac
echo $cpus > $q/xps_cpus
done
6)适度的中断合并(coalescing)
# 初值保守一点
ethtool -C eth1 rx-usecs 8 rx-frames 0 tx-usecs 4
# 观察 p99 RTT 与 CPU 利用率,别把 usecs 拉太大
7)应用与系统进程绑核(让出 IRQ 核)
systemd 方式(推荐):
# /etc/systemd/system/gateway.service.d/cpu.conf
[Service]
CPUAffinity=8-15 28-35
systemctl daemon-reload
systemctl restart gateway
临时验证:
taskset -c 8-15,28-35 <your_cmd>
8)持久化脚本(避免重启后丢失)
创建系统服务,在网卡 up 后设置亲和与 RPS/XPS:
# /usr/local/sbin/pin_eth1_irqs.sh
#!/bin/bash
set -e
IF=eth1
declare -A map=( [0]=0 [1]=2 [2]=4 [3]=6 [4]=20 [5]=22 [6]=24 [7]=26 )
# IRQ 亲和
for n in "${!map[@]}"; do
irq=$(grep -E "i40e-${IF}-TxRx-${n}\b" /proc/interrupts | awk -F: '{print $1}' | tr -d ' ')
[ -n "$irq" ] && echo "${map[$n]}" > /proc/irq/${irq}/smp_affinity_list
done
# RPS/XPS
for dir in rx tx; do
for q in /sys/class/net/${IF}/queues/${dir}-*; do
idx=$(basename $q | cut -d- -f2)
cpu="${map[$idx]}"
[ -n "$cpu" ] && echo "$cpu" > $q/${dir/rx/rps}_cpus 2>/dev/null || true
[ -n "$cpu" ] && echo "$cpu" > $q/${dir/tx/xps}_cpus 2>/dev/null || true
done
done
chmod +x /usr/local/sbin/pin_eth1_irqs.sh
# /etc/systemd/system/pin-eth1-irqs.service
[Unit]
Description=Pin eth1 IRQs and set RPS/XPS
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/pin_eth1_irqs.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now pin-eth1-irqs.service
六、验证与量化(“抖没抖”,数据说话)
压测前后 10 分钟对比(单机,12 Gbps,payload 混合)
| 指标 | 调优前 | 调优后 |
|---|---|---|
| p50 RTT(µs) | 420 | 260 |
| p99 RTT(µs) | 2100 | 780 |
| p999 RTT(µs) | 5800 | 1850 |
ksoftirqd/* 最高占用 |
100% | ≤ 45% |
softirq time(mpstat) |
32% | 11% |
ethtool -S eth1 rx_no_desc |
2–5/min | 0–1/10min |
| 网卡队列偏斜(最大队列/最小队列 IRQ 计数比) | 3.8× | 1.2× |
命令核对:
# 中断是否稳定分布
watch -n 1 'grep -E "i40e-eth1-TxRx-" /proc/interrupts'
# 软中断是否下降
mpstat -P ALL 1 10 | egrep -A1 "CPU|soft"
# 应用核是否避让成功
ps -Leo pid,psr,comm,args | grep gateway | head
七、那些你会踩的坑(都是我踩过的)
IRQ 号会变:驱动加载顺序不同、重启后 141 可能变 149。
→ 用 正则匹配队列名(上面的脚本就是),别把 IRQ 号写死。
irqbalance 会“打回原形”:
你手动绑完,过一会儿又散了,多半是它还在跑。
→ 要么 mask 掉 irqbalance,要么在它上面圈出禁区(IRQBALANCE_BANNED_CPUS)。
超线程兄弟核:
把硬/软中断和应用都挤在同一个 core 的两个 thread,会出现互相抢资源。
→ 同 core 只放一种重活,另一条线给轻活或空着。
容器与 cgroup:
只在容器里 taskset 没用,宿主的 IRQ 亲和还在外面。
→ 宿主层面先排好,再用 systemd 的 CPUAffinity 或 docker 的 --cpuset-cpus 管应用。
虚拟化的 vhost-net/virtio:
如果是 KVM,vhost-net 的中断也要绑;不然中断在宿主 NUMA0,VM vCPU 在 NUMA1,来回跑死。
SR-IOV/DPDK 场景:
直通或用户态收包不走内核栈,这一套要换打法(绑 poll 线程、绑 PMD)。别把两种方案混着配。
BIOS 电源策略:
C-State 太深会加大唤醒延迟。
→ BIOS 里关深 C-State,Linux 加 intel_pstate=disable 或 processor.max_cstate=1 要谨慎评估能耗。
队列数与会话数不匹配:
RSS 8 队列,结果五成流量都射到 TxRx-0。
→ ethtool -n eth1 rx-flow-hash tcp4 看哈希键,必要时 调 RSS key / indirection table(ethtool -X),让会话更均匀。
八、可复制的“最小闭环”清单(照抄也能跑)
ethtool -L eth1 combined 8
选择 同 NUMA 的 8 个 CPU:0,2,4,6,20,22,24,26
停 irqbalance 或设置 IRQBALANCE_BANNED_CPUS 屏蔽应用核
给 eth1-TxRx-[0..7] 分别写 smp_affinity_list(如 0/2/4/...)
给 rx-*/rps_cpus、tx-*/xps_cpus 写同样列表
轻度 ethtool -C eth1 rx-usecs 8
应用 CPUAffinity=8-15,28-35
写 systemd oneshot 持久化
九、附录:几个小工具片段
A. 把 0-3,8-9 这种列表转 16 进制掩码(给 IRQBALANCE_BANNED_CPUS 用)
如果你非要算十六进制,这个 bash 足够用(但更推荐用 *_affinity_list)
cpulist_to_hex() {
local IFS=',' part a b i
local -A set=()
for part in $1; do
if [[ $part == *-* ]]; then a=${part%-*}; b=${part#*-}; for ((i=a;i<=b;i++)); do set[$i]=1; done
else set[$part]=1; fi
done
local max=0; for i in "${!set[@]}"; do (( i>max )) && max=$i; done
local chunk=0 out=()
for ((i=0;i<=max;i++)); do
(( set[$i] )) && (( chunk |= 1<<(i%32) ))
if (( i%32==31 )); then out=($(printf "%08x" $chunk) "${out[@]}"); chunk=0; fi
done
if (( max%32!=31 )); then out=($(printf "%08x" $chunk) "${out[@]}"); fi
echo "0x$(printf "%s" "${out[@]}" | sed 's/^0\+//')"
}
# 示例: cpulist_to_hex 8-15,28-35 => 0x000000ff00ff00
B. 一键打印 NIC 队列与 IRQ → CPU 映射
print_irqs() {
IF=$1
printf "%-10s %-6s %-10s\n" "Queue" "IRQ" "CPU"
for n in $(ls -1 /sys/class/net/${IF}/queues/rx-* | awk -F- '{print $NF}'); do
irq=$(grep -E "${IF}-.*TxRx-${n}\b" /proc/interrupts | awk -F: '{print $1}' | tr -d ' ')
cpu=$(cat /proc/irq/${irq}/smp_affinity_list 2>/dev/null || echo "-")
printf "%-10s %-6s %-10s\n" "RxTx-$n" "$irq" "$cpu"
done
}
print_irqs eth1
十、收尾:那一晚之后
第二天清晨 5 点,NOC 的告警面板像被人顺手抹了一遍,p99 RTT 从两位数毫秒落回了亚毫秒级。我们把仪表盘截了图发在群里,阿杰说:“原来把中断拴住,世界就安静了。”我笑着摇杯子,冰凉的咖啡在杯壁上慢慢滑下去。
之后的每次大促,我们都先按这套“布阵表”排好——先决定谁来收包(IRQ 核),再让应用让路(App 核),最后把软中断与发送路径对齐(RPS/XPS)。机房的风还是呼呼地吹,但我们心里不再抖了。
结语要点(给赶时间的你)
- irqbalance 不是洪水猛兽,但一定要圈地(BANNED_CPUS),或停掉配合手排。
- smp_affinity_list 比十六进制直观,用它给每个队列定点落地。
- RPS/XPS 与硬中断一致,NUMA 就地消化。
- 少量 coalescing 折中吞吐与延迟;观测永远比拍脑袋重要。