Linux 软中断与网卡多队列调优实战:看懂 si 与 NET_RX 分布,RPS/RFS 与 NUMA 绑核完整指南

CPU 没跑满,机器却很卡:一个被忽略的指标

你有没有遇到过这种怪事:服务器 load average 飙得很高,网站响应变慢,可 top 里每个进程的 %CPU 加起来也就百分之三四十,机器明明「很闲」。这时候你盯着 CPU 使用率看半天也找不到凶手,因为凶手根本不在进程层面,而在软中断里。

网络包到达网卡之后,第一站是硬中断(IRQ)——网卡告诉内核「有数据了」。但硬中断的处理必须极短,它只做最紧急的事,然后把剩下的收包、协议栈处理工作「委托」给软中断(softirq)。当单位时间内到达的包太多,硬中断一个接一个,软中断就排起了长队,CPU 把大量时间花在 ksoftirqd 和 si(softirq)上。你在 top 里看到的 si 那一栏,就是这个时间占比。它高起来的时候,用户态进程确实拿不到 CPU,但你从「进程 %CPU」也是看不出来的。

先学会读 si 和 /proc/softirqs

最快的判断方式是 top(或 htop),看 CPU 行里的 si。经验阈值上,si 长时间超过 5% 就值得警惕,超过 20% 基本可以确定机器在软中断上烧 CPU,网站变慢就是它。要看得更细,直接读内核暴露的计数器:

# 观察一小段时间内的变化,而不是看累计值
watch -n 1 'cat /proc/softirqs'

输出里有几个类别,重点关注这几行:

  • NET_RX:网络收包软中断,最常成为瓶颈的那个。
  • NET_TX:网络发包软中断。
  • NET_RX 的分布形态:如果所有增长都压在 CPU0 这一列,说明所有网卡的收包中断都打到了同一个核上,这就是典型的单核瓶颈——哪怕你有 8 核,网络处理也只有一个核在扛。

再看中断本身的分布:

# 看每个 CPU 收到的硬中断次数
grep -E 'CPU|eth0|ens|virtio' /proc/interrupts

如果某个网卡的中断全部集中在 CPU0,那基本可以确诊了。这个现象的根源在于:很多虚拟化网卡、老网卡驱动默认只申请一个中断队列(queue),而操作系统默认把大部分中断路由到 CPU0。收包速率一高,CPU0 就在硬中断和软中断之间疲于奔命。

第一层解法:让硬件把中断分散到多核

如果你的网卡支持多队列(Multi-Queue),首选是让硬件自己分散。查看网卡支持的队列数:

ethtool -l eth0
# 或者更直观地看当前生效的组合数
ls /sys/class/net/eth0/queues/ | grep rx

ethtool -l 会输出 Pre-set maximums 和 Current hardware settings,一个是网卡能力上限,一个是当前生效值。如果 Current 的 RX 是 1 而 maximum 是 8,那就白瞎了硬件能力。把它调满,并分配对应的中断:

# 开 8 个收包队列
ethtool -L eth0 combined 8

调完之后各队列会产生独立的 IRQ 号,可以手动把中断亲和性(SMP affinity)分配到不同 CPU:

# 查看队列对应的中断号
grep eth0 /proc/interrupts

# 把某个中断绑到 CPU2(bitmask 0x04 = 二进制 100)
echo 4 > /proc/irq/123/smp_affinity_list

注意这里 smp_affinity 和 smp_affinity_list 是两种格式:前者是十六进制位掩码,后者是直接的 CPU 编号列表。用 smp_affinity_list 更不容易算错。手动绑定虽精确,但维护成本高,通常配合 irqbalance 更省事。

apt install -y irqbalance
systemctl enable --now irqbalance

irqbalance 会周期性地根据负载把中断迁移到空闲核上。它和手动绑亲和性二选一,不要同时用,否则两边打架。

第二层解法:RSS 不行就上 RPS

虚拟化环境里有个尴尬的现实:很多云主机的虚拟网卡(virtio-net)只有 1 个队列,ethtool -L 调不动,硬件层面根本没有多队列。这时候就要靠软件来分发,也就是 RPS(Receive Packet Steering)。

RPS 的原理是在软中断层面做「轮转分发」:单队列收上来的包,由内核按哈希分发到多个 CPU 的软中断队列上处理,从而把负载摊到多核。它不减少软中断总量,但能让多个核一起分担。配置方式很简单——往 /sys/class/net/eth0/queues/rx-0/rps_cpus 写一个 CPU 位掩码:

# 让 8 个 CPU(0-7)都参与 RPS,位掩码 0xff
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus

位掩码怎么算?CPU0 是 1,CPU1 是 2,CPU2 是 4……第 n 个 CPU 对应 2^n,全都要就是把它们加起来。8 个核是 0xff,4 个核是 0xf,2 个核是 0x3。别扫错位置——是 queues/rx-0/ 下的文件,不是网卡根目录。

RPS 之外还有一个常被一起提到的 RFS(Receive Flow Steering):RPS 只按哈希分散到各个 CPU,RFS 进一步让同一个 TCP 连接的包尽量落到处理这个连接的那个 CPU 上,能提高 CPU 缓存命中率。RFS 的配置是调 /proc/sys/net/core/rps_sock_flow_entries(全局连接表大小)和每个 rx 队列的 rps_flow_cnt:

echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt

一个经验配比是:所有队列的 rps_flow_cnt 之和,不要超过全局 rps_sock_flow_entries。调太高只是浪费内存,调太低则连接表不够用,RFS 会退化成 RPS。

第三层:让配置活过重启

上面所有 sysfs 和 ethtool 的改动重启后就没了。生产环境必须做成持久化。systemd 系统有个现成的机制:/etc/systemd/network/ 或老的 /etc/rc.local,但更干净的做法是写一个 systemd oneshot 服务。

[Unit]
Description=Tune NIC multi-queue and RPS
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/nic-tune.sh

[Install]
WantedBy=multi-user.target

脚本内容:

#!/bin/bash
set -euo pipefail
NIC=eth0
# 尝试开多队列,失败也不中断(单队列网卡会报错)
ethtool -L "$NIC" combined 8 || true
# 单队列时用 RPS 兜底
for q in /sys/class/net/$NIC/queues/rx-*/rps_cpus; do
    [ -e "$q" ] && echo ff > "$q"
done
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
for q in /sys/class/net/$NIC/queues/rx-*/rps_flow_cnt; do
    [ -e "$q" ] && echo 4096 > "$q"
done

注意脚本里 NIC 名字要按实际改(可能是 ens3、enp1s0、virtio0 等),用 ip link 确认。这类调整必须做成幂等的——重复执行不报错,因为服务重启时可能再跑一遍。

排障:别把 RPS 当银弹

调完 RPS 之后如果 si 还是高,先别急着加配置,按这个顺序排查:

  • 确认瓶颈真在收包:sar -n DEV 1 看每秒包速率,对比网卡标称的 PPS(每秒包数)上限。小包同大包对 CPU 的压力天差地别——10Gbps 线速小包和最优化大包差了好几个数量级。如果 PPS 已经逼近线速,靠调 RPS 是治不好的,得考虑硬件分流或换网卡。
  • 确认不是 conntrack 拖累:如果同时开了防火墙、NAT 或 Docker,每个包都要进 nf_conntrack 查表,连接数一多,conntrack 表成为瓶颈,表现也是 si 高。看 /proc/sys/net/netfilter/nf_conntrack_count 对比 nf_conntrack_max。
  • 确认不是中断风暴:某些驱动在特定流量下会疯狂触发中断。看 vmstat 1 的 in(每秒中断次数)列,如果高得离谱(几十万),可能是中断合并(interrupt coalescing)没开。用 ethtool -c eth0 查看,ethtool -C eth0 rx-usecs 64 之类适度合并。
  • 确认 RPS 真的生效了:重新看 /proc/softirqs 里 NET_RX 各 CPU 的增量。如果还是全压 CPU0,说明掩码写错位置了,或者被 irqbalance/其他脚本覆盖了。

CPU 亲和、NUMA 与网络栈的那些坑

调完多队列和 RPS,还有一个常被忽略的层面:多个 CPU 之间的数据流向。现代多核服务器普遍是 NUMA 架构——内存和 CPU 被划分成若干个「节点」,CPU 访问本地节点内存快,访问远端节点内存要跨 QPI/UPI 总线,慢一截。如果网卡接在 node 0,而收包的软中断却跑在 node 1 的核上,每个包的数据都要跨节点读一次,延迟和带宽都吃亏。

先看拓扑:

# 查看 NUMA 节点与各 CPU 归属
lscpu | grep -i numa
numactl --hardware

# 查看网卡挂在哪个 NUMA 节点
cat /sys/class/net/eth0/device/numa_node

如果 numa_node 输出 -1,表示系统没开 NUMA(或是虚拟机),那就不用操心了。如果输出 0,就应尽量让这个网卡的中断和 RPS 掩码落在 node 0 的核上。用 numactl --hardware 能看到每个节点的 CPU 列表,把 RPS 掩码按这个列表来写,比一股脑写 0xff 更精准。虚拟机环境里多数没有真实 NUMA 拓扑,这一层可以跳过——但云厂商的裸金属实例上它非常重要。

另外别忘了 irqbalance 的一个常见副作用:它为了「均衡」,可能把网卡中断在节点之间来回迁移,反而打破 CPU 缓存热度。在追求极致网络性能的机器上,很多运维会选择关掉 irqbalance,改用静态绑核,把每个队列的中断固定到固定的核上。这两条路线各有利弊:irqbalance 省心但可能抖动,手绑核稳定但要跟 CPU 数量绑定、扩容时得重配。个人站长的单机场景,irqbalance 通常够用;只有在确定网络是瓶颈、且机器核数固定时,才值得上静态绑核。

用数据验证优化有没有生效

调参最忌讳改完不测。软中断这块有两个成本极低、收益极大的观测手段。

第一个是持续采样软中断增量:

# 每秒刷新,重点看 NET_RX 这一行各 CPU 的增量是否均衡
watch -n 1 'grep NET_RX /proc/softirqs'

优化前,NET_RX 的增量几乎全在 CPU0 那一列;优化后,应该能看到增量分散到多列上。如果某一列的增量仍然占据绝对多数,说明要么掩码没写对,要么网卡队列数没真的调上去。这个对比是最直接的证据。

第二个是从系统层面看每秒中断与上下文切换:

vmstat 1
# in 列 = 每秒中断次数,cs 列 = 每秒上下文切换次数
sar -n DEV 1     # 每秒网络收发包速率(rxpck/s 是关键)

rxpck/s(每秒收包数)这个数字决定了软中断的压力天花板。一个实用的判断是:单核处理小包的软中断能力大约在几十万 PPS 量级,超过这个数,单个核就一定扛不住,必须靠多队列/RPS 分担。把你的实际 PPS 和网卡能力、核数一对照,就能算出还有多少余量,也能提前判断扩容的临界点。这样下次访问量翻倍时,你是早就知道该加机器,而不是等它半夜打不开才发现。

小结

「卡但 CPU 不满」这个现象,本质是把网络处理的成本藏在软中断里了。标准的三层处理策略是:能开硬件多队列就开(ethtool -L),硬件不行就上 RPS 做软件分发,再进一步用 RFS 提升缓存命中;最后把配置用 systemd oneshot 固化,让它活过重启。诊断口诀就一句——先看 top 的 si,再看 /proc/softirqs 里 NET_RX 是不是全压在一个核上。看到这个分布,后面该动哪个开关就一目了然了。别迷信某个参数,先量出瓶颈到底在 PPS、conntrack 还是中断风暴,再对症下药。

Last modification:October 1st, 2026 at 07:25 pm

Leave a Comment