
1. 一次夜班排查为什么 CPU 占用不高流量却卡在半路前阵子有台流量采集服务器半夜被业务方连环投诉现象很简单接口 P99 延迟从 3ms 一路涨到 40ms但登录上去看CPU 总占用率才 25% 左右内存、磁盘、网络带宽全都没到瓶颈。最让我起疑的是top里每个核都有负载却又没有一个核特别突出看起来“很健康”的负载分布恰恰是问题所在。我先跑了mpstat -P ALL 1发现 CPU0 的软中断softirq占比常年压在 90% 以上其他核的 softirq 加起来不到 5%。再一看cat /proc/interrupts靠网卡那一组中断号的中断计数全部堆在 CPU0 上其他核的中断计数几乎不动。这台机器有 64 个逻辑核可网卡中断全部打在一个核上等于一个核在扛所有数据包收发的硬中断和软中断处理剩下的 63 个核在旁边围观。你 CPU 平均占用率当然不高但真正的收包路径早就被压死了。这就是网卡中断绑核要解决的核心问题把网卡各队列的中断按规则分摊到指定的 CPU 核上让收包处理不再挤在某个倒霉核上也不再在各个核之间反复横跳。这篇就按我实际排查和调整的顺序把整个流程、原理和坑都写清楚。1.1 中断“均匀分布”为什么反而是坏事很多刚入门的朋友会有个直觉中断分散到所有核上不是正好利用多核吗这个想法在通用计算场景下没错CPU 调度器本来就希望你均匀使用每个核。但对网卡收包这种超高频率、状态密集的路径中断迁来迁去带来的副作用远大于均衡收益。中断处理涉及一套完整的数据结构访问链路网卡驱动的中断服务函数、softnet_data队列、NAPI 轮询链表、协议栈各层的数据结构这些数据会在线程执行过程中不断被读改写。如果中断每次落在不同 CPU 核上每个核的 L1/L2 缓存里都得重新加载这套数据缓存命中率一塌糊涂锁竞争和跨核同步开销却一路上涨。更麻烦的是 NUMA。如果中断跑到网卡所在 NUMA 节点之外的核上驱动访问 DMA 环形缓冲区时就要跨节点访问内存这个延迟在中高负载下会被放大到肉眼可见。所以绑核的真正目的不是“把负载分散”而是“把负载固定在正确的位置上”。1.2 硬中断、软中断绑核到底在绑谁这里先把两个概念掰开。网卡收到数据包后硬件通过 PCIe 总线触发一个中断信号CPU 响应这个信号并执行驱动注册的中断处理函数这层叫硬中断。硬中断里通常只做最少的确认和登记工作然后让出 CPU把真正消耗时间的收包逻辑NAPI 轮询、协议栈处理挂到软中断里执行。两层都会消耗 CPU但对绑核而言只要你把硬中断绑到某个核上软中断默认也会跟着在这个核上调度执行。之后看/proc/softirqs能明显看到 NET_RX 这一行的计数像约好了一样同步增长在同一个核上。这个特性是绑核好用的前提但也埋了坑后面我会专门讲。1.3 为什么“核多”不等于“能扛中断”有朋友可能会说就算中断全在 CPU0 上我机器有 64 个核彻底绑开不就行了这里的问题在于现代网卡的硬件队列数是有限的不像 CPU 想开多少核就有多少核。比如一张常见的 10G 网卡驱动默认可能只开 4 或 8 个 RSS 队列对应只有 4 或 8 个中断号最多就把包分发到 4 或 8 个核上。数据包进来后网卡根据流的哈希把包分配到某个队列同一个流永远进同一个队列。所以一个队列对应一个中断中断绑到哪个核这一队列的所有包都在这个核上处理。如果队列数比核数少只要每个队列绑定一个核就已经把并行度拉满了再多绑也提升不了。2. 认清你家网卡再动手中断号、队列与 NUMA 拓扑绑核这事急不得上来就 echo 一串数字很容易绑错对象。我一般按下面这个顺序先把环境摸清楚前后大概五分钟能省后面好几个小时的排障时间。2.1 从 /proc/interrupts 里找到属于你的那组 IRQ先确认网卡设备名。比如我手头这台机器上网卡叫enp3s0。然后用cat /proc/interrupts看所有中断找名字里带enp3s0的行。不同驱动命名的格式不太一样但基本都认得出CPU0 CPU1 CPU2 CPU3 ... 33: 10273031 0 0 0 IR-PCI-MSI 1048576-edge enp3s0-TxRx-0 34: 0 11002820 0 0 IR-PCI-MSI 1048577-edge enp3s0-TxRx-1 35: 0 0 9800291 0 IR-PCI-MSI 1048578-edge enp3s0-TxRx-2 36: 0 0 0 9900310 IR-PCI-MSI 1048579-edge enp3s0-TxRx-333这一列第一项是中断号IRQ number后面几列是各 CPU 上的累计中断次数最后一列是中断名称。enp3s0-TxRx-0表示这是 enp3s0 的第 0 号收发队列一般有多少个这样的队列就有多少个中断号。如果你的网卡显示不分队列比如只有enp3s0一个名字没有-TxRx-或-rx-后缀说明驱动当前只开了一个队列或者网卡型号比较老。先别急着绑核看看能不能把队列扩出来这一步我下节讲。2.2 队列数量不够先扩队列再说查询网卡当前队列数量用 ethtoolethtool -l enp3s0输出里Combined那一项是收发合并队列数。如果显示Current: 1、Maximum: 8说明硬件最多支持 8 个队列但当前只开了 1 个那绑核之前得先把队列开出来ethtool -L enp3s0 combined 8这会动态把网卡队列调整为 8 个同时/proc/interrupts里也会冒出 8 个带-TxRx-后缀的中断号。注意ethtool -L在部分驱动下可能会触发网卡重置正在跑的连接会闪断生产环境操作前记得评估。还有个细节ethtool -L调整完后中断号可能会整体变化。比如原来是 33-36扩到 8 队列后变成 33-40重新对一遍/proc/interrupts再往下走。2.3 网卡在哪个 NUMA 节点这是绑核的前提接着查网卡插在哪个 NUMA 节点上。这个信息在 sysfs 里就有但先得知道网卡的 PCI 地址。用lspci | grep -i ethernet找出网卡对应的 PCI 总线号比如06:00.0然后读 sysfscat /sys/bus/pci/devices/0000:06:00.0/numa_node如果输出1说明网卡挂在 NUMA node 1 上。后面绑核必须优先选 node 1 的核别把中断绑到 node 0 的核上。有些系统这一步输出-1多半是虚拟化环境或者 ACPI 表没提供 NUMA 信息那就按所有节点一个速度来选。再看整台机器的核分布lscpu --extended能直接看到每个 CPU 核属于哪个 NUMA 节点以及逻辑核编号。有些机器 node 0 的核是 0、2、4、6node 1 是 1、3、5、7编号交错排列别想当然认为前一半核都属于 node 0。3. 手动绑核从临时改参数到开机自启环境摸清之后绑核操作本身其实就那点命令但写法上有几个容易翻车的细节。3.1 最直接的一条 echo 命令Linux 为每个中断号提供了一个配置目录在/proc/irq/中断号/下面。其中smp_affinity文件接受十六进制 CPU 掩码smp_affinity_list则接受更直观的 CPU 编号列表我强烈建议用后者省得算二进制掩码算到头大。比如要把第 33 号中断绑到 CPU 8echo 8 /proc/irq/33/smp_affinity_list要绑到 CPU 8 到 CPU 11 这一组核允许内核在这几个核里选echo 8-11 /proc/irq/33/smp_affinity_list要绑到一组不连续的核echo 8,10,12 /proc/irq/33/smp_affinity_list写完之后立刻验证最直接的办法是盯中断计数变化watch -n 1 grep 33: /proc/interrupts如果配置生效你会看到第 33 行只有一个核的中断计数在持续增长其他核不动。如果所有核都在涨说明你写之前那个中断就分散跑了写完之后应该有变化才对如果根本没变化先检查写的语法和权限。smp_affinity掩码的规则也提一嘴因为不少年代久远的脚本和文档还在用掩码形式。CPU0 对应十六进制的1CPU1 对应2CPU2 对应4就是二进制位一一对应比如 CPU0-3 就写f。位数超过 32 时掩码写法很容易错这也是我推荐smp_affinity_list的原因。3.2 怎么选要绑的核选核这里我总结了一套比较稳的规则按优先级排必须选网卡所在 NUMA 节点上的核跨节点绑核的损失大于收益。避开 CPU0。CPU0 承担了大量系统级中断和内核线程调度把它留给网卡中断容易互相干扰。如果机器开了超线程尽量把同一条物理线程上的兄弟核分开考虑别把两个中断绑到互相共享 L2 缓存的逻辑核上。查兄弟核cat /sys/devices/system/cpu/cpu8/topology/thread_siblings_list中断和业务进程的核怎么搭配取决于你的延迟目标。如果希望收包核和业务核共享缓存就把两者绑到同一个核或相邻核如果业务核负载很高则让中断独占一个核业务在隔壁核上通过共享缓存取数据。实际操作里多队列网卡的最佳实践是中断号和核号一一对应一个中断绑一个核。比如 8 个队列、网卡在 node 1、node 1 的核是 32 到 63那么可以写一组命令echo 32 /proc/irq/33/smp_affinity_list echo 33 /proc/irq/34/smp_affinity_list echo 34 /proc/irq/35/smp_affinity_list echo 35 /proc/irq/36/smp_affinity_list echo 36 /proc/irq/37/smp_affinity_list echo 37 /proc/irq/38/smp_affinity_list echo 38 /proc/irq/39/smp_affinity_list echo 39 /proc/irq/40/smp_affinity_list这种写法下每个队列的处理核是固定的没有调度器动态跳变的问题缓存局部性最好。3.3 让配置在重启后依然生效systemd 与 tuned 两种思路/proc/irq里的配置是临时的一旦重启或者驱动重新加载大部分驱动都会把中断复位到默认状态。所以生产环境一定要做持久化。我常用 systemd oneshot 服务写起来简单、自解释[Unit] DescriptionSet NIC IRQ affinity Afternetwork.target [Service] Typeoneshot ExecStart/bin/bash /usr/local/sbin/irq-affinity.sh RemainAfterExityes [Install] WantedBymulti-user.target对应的/usr/local/sbin/irq-affinity.sh里写上前面那串 echo 命令然后systemctl daemon-reload systemctl enable irq-affinity.service systemctl start irq-affinity.service注意Afternetwork.target并不保证网卡驱动初始化完毕稳妥做法是脚本开头先轮询/proc/interrupts里出现目标中断号再执行绑定。我遇到过服务启动时网卡还没 ready脚本里 echo 到不存在的文件直接失败配置白白没生效。另一种思路是用 tuned 的 cpu-partitioning 配置集适合在 CentOS/RHEL 系环境里配合isolcpus一类的隔离参数用。tuned 的优点是能把 CPU 隔离、中断绑定、内核参数统一管理缺点是要学一套 profile 的写法和优先级规则。如果你已经有 tuned 在管理服务器我建议顺着它扩展否则没必要为绑核单独引入。4. irqbalance 的自动均衡为什么在这里是个麻烦可能有人会问既然中断绑核这么讲究系统自带的 irqbalance 不就是干这事的吗它确实在自动做中断均衡但它的策略和我们要的“稳定绑定”是两个方向。4.1 irqbalance 的周期是怎么运作的irqbalance 会周期性默认约 10 秒计算每个 CPU 的中断负载然后根据算法把中断在各个核之间搬来搬去尽量让每个核的负载接近。在通用服务器上这能让中断不至于集中压在一个核上思路没问题。但对网卡这种高频率中断源它每 10 秒搬一次家等于每 10 秒就让缓存局部性归零一次。你可能刚在核 8 上把收包路径的数据结构烤热它咣当一下把中断挪到核 20一切重来。延迟敏感型业务对这种周期性抖动非常敏感表现出来就是每 10 秒左右一次的毛刺。4.2 关掉它还是只排除特定中断网卡中断绑核场景下我的建议是把irqbalance关掉特别是在你已经手动规划好中断分配的机器上。不关掉的话你前面写的 smp_affinity 会被它周期性覆盖等于白干。systemctl stop irqbalance systemctl disable irqbalance有些环境不想全部关掉毕竟机器上还有其他设备的中断需要均衡那就用 irqbalance 的排除参数只把网卡的中断摘出去。比如要排除 33 到 40 号中断/usr/sbin/irqbalance --banirq33 --banirq34 --banirq35 --banirq36 --banirq37 --banirq38 --banirq39 --banirq40写成 systemd 服务时记得把参数加到ExecStart里。不过实测下来排除模式的手感不如直接关掉来得干脆尤其是 irqbalance 版本差异很大不同发行版的--banirq参数解析行为不完全一致我踩过一次某发行版上 ban 了之后还是被搬走的坑最后干脆全面关闭。4.3 判断 irqbalance 是否在干扰的快速方法如果你不确定自己的绑定是不是被 irqbalance 动了手脚可以连续多看几次中断计数分布for i in $(seq 1 20); do grep 33: /proc/interrupts | awk {print $2, $3, $4, $5}; sleep 2; done如果发现中断计数在多个核之间轮着涨而不是固定在某个核基本可以断定 irqbalance 在里面作乱或者你绑的核集合写了多个核且内核调度在换。这时候先停掉 irqbalance 再观察一轮。5. 绑核后性能反而下降三次踩坑记录绑核这事看着简单但我自己踩过的坑一个不少写出来帮各位省点时间。5.1 核选错了 NUMA 节点吞吐量大幅下降第一次做绑核的时候我没查网卡的 NUMA 节点以为随便挑几个空闲核就行。当时机器是双路 CPU网卡在 node 1我把中断绑到了 node 0 的 8 个核上。结果一压测吞吐量比不绑还低了差不多 30%延迟反而变高。原因不复杂网卡的 DMA 环形缓冲区、驱动分配的内存都在 node 1 上。中断跑到 node 0 的核上处理时每一次访问这些内存都要跨 NUMA 总线延时长、带宽也受限。对于网卡中断这种极致高频且依赖数据局部性的工作跨节点的惩罚会被放大得非常明显。从那以后我所有绑核操作前必查/sys/bus/pci/devices/pci/numa_node而且每个核的归属也用lscpu --extended确认过再动手。5.2 硬中断绑了但软中断没有跟着预期走第二次踩坑比较隐蔽。当时我把 8 个队列的中断分别绑到了 node 1 的 8 个核上/proc/interrupts看起来完全正常每个核的中断计数都在涨。结果mpstat -P ALL 1一看软中断还是集中压在一个核上其他核的 softirq 占比非常低。这就牵扯到我前面说的“硬中断绑到哪软中断一般就跟到哪”这个默认行为。一般情况确实如此但如果你看过/proc/softirqs会发现有些驱动或某些内核版本下软中断会在另一个核上执行特别是涉及RPS/XPS、多队列分流或者 CPU 负载不均导致的延迟调度时。排查方法很简单看中断和软中断实际落在哪。一个技巧是同时观察两个文件watch -n 1 grep -E 33:|NET_RX /proc/interrupts /proc/softirqs如果硬中断在核 8但 NET_RX 计数在核 24 涨说明软中断和硬中断分家了。这时候要么检查是不是开了 RPS 把包分发到其他核要么查驱动参数里跟 NAPI/软中断调度相关的选项。绑核的最终目标是让数据包处理路径稳定在目标核上别只看硬中断的假象。5.3 没做持久化一次驱动重启让所有配置归零还有一回配置绑核后跑了一周一切正常。结果某天网管在那边升级网卡固件、重装了驱动重启后绑核配置全部蒸发中断又回到 CPU0 上挤成一团。因为那台机器我没做持久化纯粹是手工 re 绑的固件升级后驱动重载所有中断复位我人又恰好不在线业务直接抖了一晚上。从那以后我就定了规矩任何绑核操作做完必须立刻写持久化脚本并且脚本要带幂等性和等待逻辑。所谓等待逻辑就是脚本开头循环检查目标中断号是否存在、驱动是否就绪再执行绑定。不然 systemd 启动顺序稍微一乱脚本可能跑在网卡初始化之前配置又是空的。5.4 超线程逻辑核之间互相抢资源最后一个坑是在带超线程的机器上踩的。我把两个队列的中断分别绑到了 CPU 8 和 CPU 9 上以为两个核并行处理很完美结果压测发现这两个核谁也没跑满但整体吞吐量只有预期的一半。查了thread_siblings_list才发现8 和 9 是同一个物理核上的两个逻辑核共享执行单元和 L2 缓存。两个中断虽然看起来在两个逻辑核上跑实际上物理资源是同一份互相抢到死。中断绑核应该优先绑到不同的物理核上而不是不同的逻辑核上。查物理核归属可以用cat /sys/devices/system/cpu/cpu8/topology/core_cpus_list cat /sys/devices/system/cpu/cpu9/topology/core_cpus_list如果两个文件内容一样说明是同一物理核的兄弟尽量避免绑到两个中断上。6. 用数据验证绑核效果顺便看懂旁边那堆优化项绑完核别急着收工得用数据证明这波操作确实有效。我自己常用的验证套路分两层。6.1 前后对比不要靠感觉第一层看软中断分布。在同一压测流量下绑核前和绑核后各跑一组mpstat -P ALL 1绑核前往往是 CPU0 的%soft接近 90%其他核个位数。绑核后应该是 8 个核或你绑的几个核的%soft均匀分布在中等水位。如果某个绑定的核%soft直接 100%说明这个核还是被打满得考虑扩更多队列或者从应用层减负。第二层看业务指标。拿吞吐量、P99/P99.9 延迟做差值。我记忆最深的一次对比绑核前单核扛收包导致 P99 延迟 40ms绑核后同一压测流量降到 2ms而且 CPU 总占用率只多了 8%因为缓存局部性上来了很多操作不再重复加载。还有一个经常被忽略的指标网卡丢包计数。用ethtool -S enp3s0看rx_missed和rx_dropped这俩值在中断集中时经常悄悄往上涨。6.2 什么时候该考虑 RPS/RFS绑核优化的是网卡硬件队列到 CPU 的映射。如果你的网卡本身队列数很少比如虚拟机的 virtio 网卡只有 1-2 个队列或者硬件不支持多队列那绑定能做的就有限。这时候要让多核分担收包只能靠内核层的 RPSReceive Packet Steering做一个软件分发把包从入中断的核分发到其他核上处理。开启 RPS 的方式是设置每个队列的rps_cpusecho ffff /sys/class/net/enp3s0/queues/rx-0/rps_cpus这里ffff是位掩码意思是这个 rx 队列的包可以在 CPU0 到 CPU15 之间分发。RPS 在一定程度上能把单队列处理瓶颈摊开但它本身有调度开销和缓存访问开销所以优先还是硬件多队列加绑核实在没队列了再考虑 RPS。RFS 则是 RPS 的进阶版它让同一数据流的包尽可能分发到正在处理该流的应用所在的核上对减少跨核转发有好处。但 RFS 对 CPU 核数、哈希表大小等参数有要求配置复杂度高小流量场景收益不明显我一般建议先打好绑核基础再考虑。6.3 绑核之后值得顺手做的两个延伸优化绑核做完之后通常我还会顺手检查两个参数。第一个是netdev_budget它控制一次 NAPI 轮询里最多处理多少个包默认 300在高吞吐场景经常不够。调大可以降低轮询频率但这属于压测调优的范畴别一上来就改。第二个是net.core.netdev_max_backlog软中断队列的长度数据包积压时容易丢包。如果mpstat显示%soft很高且rx_dropped在涨可以适当调大 backlog。这两个参数都在/proc/sys/net/core/下面改完记得sysctl -p持久化。最后分享一个我踩出来的小技巧绑核这事最容易被忽略的其实是每一次操作都要留痕。我后来习惯在脚本里加一段日志echo $(date %F %T) IRQ 33 affinity set to CPU 32 /var/log/irq-affinity.log这样每次重启后自动绑定也会留下记录排查中断迁移问题时直接翻日志就行。另外绑定完成后我一定会故意重启一次网卡驱动或者直接重启机器做验证确认持久化逻辑真的能跑通再收工。宁可当时多花十分钟验证也不要在半夜三更被业务叫起来看/proc/interrupts。