干这行七八年踩过最多的坑不是业务代码而是系统跑着跑着网络延迟突然飙起来CPU软中断堆积在单个核上业务方盯着你问“到底谁把机器搞卡了”。每次遇到这种问题排查到最后十有八九都指向一个地方——Linux的网卡调度。网卡调度这个词听起来挺唬人其实说白了就是数据包从网卡硬件进到内存再到CPU处理、进入协议栈、最后被应用进程收走这条链路上每一步到底由谁来干活、怎么分配、如何排队。别小看这条链路它决定了你机器的网络吞吐上限、延迟抖动幅度以及在双十一流量洪峰下你的nginx是稳稳扛住还是直接雪崩。这篇文章我想把网卡调度从前到后完整捋一遍包括中断与NAPI机制、多队列与RSS/RPS/XPS的选型、中断亲和性调优、irqbalance的坑以及我实战中多次排查网络问题的具体命令和排错思路。不管你是运维、后端开发还是做嵌入式Linux的这套东西都能用得上。1. 网卡调度到底在调度什么——数据面链路拆解1.1 一个数据包从网卡到应用要闯几关很多人以为网卡收到数据包之后数据就直接“嗖”一下进了内存应用直接读socket就行了。真实情况完全不是这样。一个数据包从物理线缆上过来先要经过网卡芯片的MAC层处理校验FCS、去帧头然后把数据通过DMA直接写到内核分配好的ring buffer环形队列里。这一步不需要CPU参与网卡是独立的硬件引擎。DMA做完之后网卡需要通知CPU“我有数据到了”于是拉起一个中断——这就是硬中断。CPU收到硬中断后如果中断处理函数足够轻量就只是把网卡设备挂在待处理队列上然后触发软中断softirq。整个协议栈的处理——IP层、TCP/UDP层、socket队列——都是在软中断里完成的。也就是说真正消耗CPU的是软中断而不是硬中断。软中断处理完再把数据放到对应socket的接收队列里唤醒等待的进程。应用通过read/recv系统调用把数据拷到用户态整条链路才算走完。这条链路里每跳都有调度的影子DMA要写哪个ring buffer、硬中断跑到哪个CPU核、软中断在哪个核上执行、多个队列之间怎么负载均衡。任何一个环节配置不合理都会导致CPU打架、数据倾斜、延迟毛刺。1.2 中断合并、NAPI和那点“省CPU”的门道传统的中断处理有个毛病包很多的时候网卡每来一个包就中断一次CPU大部分时间都在忙着进中断、出中断真正处理数据的时间反而少。这就像快递员每送一个包裹都按一次门铃收快递的人一整天光开门了啥正事没干。Linux为了解决这个问题早期引入了中断合并interrupt coalescing网卡攒够一批包或者等上几微秒才上报一次中断降低中断频率。代价是延迟增加。这个参数在ethtool里叫coalesce后面细说。更核心的机制是NAPINew API这是Linux网络子系统处理收包的核心框架。NAPI的思路是第一次中断来了之后处理器把自己的状态切到轮询poll模式网卡暂时不再发中断CPU主动把ring buffer里攒的数据一批一批地拿完直到没有数据了再切回中断模式。这就避免了“一包一中断”的傻办法。理解NAPI对后续调优特别重要因为很多“网卡调度异常”的表象其实是NAPI轮询逻辑在特定队列上跑得太久导致单个CPU核软中断占比居高不下。后面讲到RPS和软中断均衡时会反复回到这个点。1.3 单队列与多队列一根水管和一片喷淋头的差距老式网卡只有一个DMA队列所有收到的数据都涌进这一个ring buffer也只能由同一个CPU核的软中断来处理。即使你的机器是64核网络流量再大实际干活的只有那一个核。这像只有一根水管浇水旁边摆着一整排空着的水龙头。现代服务器网卡从Intel的82599、X710到Mellanox的CX5/CX6以及国内厂商常见的Intel I350、Realtek RTL8125基本都支持多队列multi-queue。硬件层面把数据包按照一定的哈希规则通常是对IP、端口做hash分到不同的DMA队列每个队列可以绑定到不同的CPU核。这样一进来的流量就天然被拆到16个、32个、甚至64个队列里每个核处理各自的queue吞吐量自然就上去了。网卡调度最核心的工作说白了就是确认你的网卡和驱动支持多少个队列、这些队列的中断分别落在哪些CPU核上、核之间的负载是否均衡、以及是否需要进一步用软件方式做二次分配。整个调优过程就是把“单根水管”改造成“喷淋头”再确保每个喷头的水量差不多。2. 方案选型——为什么你的网卡速度上不去2.1 先搞清硬件家底队列数、RSS与驱动动手调优之前先把家底摸清楚。我最常用的命令组合# 查看网卡名称 ip link show # 查看网卡的队列数rx/tx分别为接收和发送队列数量 ethtool -l eth0 # 查看网卡当前开启的队列数Combined ethtool -l eth0 # 查看驱动的队列配置和RSS hash设置 ethtool -n eth0 rx-flow-hash tcp4ethtool -l的输出会告诉你两行Pre-set maximums和Current hardware settings。前者是网卡驱动支持的最大队列数后者是当前生效的队列数。Channel parameters for eth0: Pre-set maximums: RX: 0 TX: 0 Other: 1 Combined: 16 Current hardware settings: RX: 0 TX: 0 Other: 1 Combined: 16看到Combined最大和当前都是16说明这是一张支持16队列的网卡且已经全开。如果Pre-set是16Current只有1说明你只开了单队列后面会讲怎么开启。网卡把数据包分流到不同队列靠的是RSSReceive Side Scaling接收端缩放核心在网卡硬件里做哈希。哈希的输入通常是四元组源IP、目的IP、源端口、目的端口有的驱动还支持扩展hash。你可以用ethtool -n eth0 rx-flow-hash tcp4看到当前tcp4流量用的hash字段。一般输出这样TCP over IPv4 flows use these fields: Source IP address Destination IP address Source port Destination port如果只显示IP没显示端口说明hash拆得不够细同一个连接的所有包都会进同一个队列多队列效果大打折扣。可以用下面的命令改ethtool -N eth0 rx-flow-hash tcp4 sdfnsdfn这四个字母分别对应s源IPd目的IPf源端口n目的端口。改完之后再查一遍确认。这里有个重要的细节RSS哈希只能保证“同一个流在一个队列”做不到“所有队列负载完全均衡”。TCP建连时哈希的随机性够用但如果你的业务主要是少量大流比如几个巨型TCP连接把流量打满哈希依然会把所有包分到同一个队列另一个队列闲着。这是硬件RSS的固有限制后面软件RPS可以在一定程度上补救。2.2 软件层补位RPS/RFS/XPS分别解决什么问题硬件RSS不够用、网卡不支持多队列或者你想在虚拟化环境里做更精细的分配时就该Linux的软件分发机制上场了。RPSReceive Packet Steering在软中断处理阶段把数据包从接收队列的CPU上重新发到其他CPU的backlog队列去处理实现协议栈处理的跨核负载均衡。说人话就是网卡只有一个队列硬中断落在CPU0上但收到包之后CPU0不亲自处理协议栈了而是把包按哈希分发给CPU1、CPU2、CPU3去处理实现多核分担。RFSReceive Flow SteeringRPS的升级版。RPS只看哈希不关心应用进程在哪里。RFS会把同一个flow的包送到正在处理这个socket的CPU上利用应用的CPU亲和性减少cache miss。这个机制对延迟敏感业务很有用但要求系统开启rps_sock_flow_table并且大小要大于最大并发连接数。XPSTransmit Packet Steering发送端的调度。它让发送queue绑定特定的CPU核使得发送数据时同一个flow的包总是由同一个CPU经同一个queue发出去提高发送路径的cache命中率和锁效率。日常调优中RPS最常用。开启方法是在sysfs里写位图echo ffff /sys/class/net/eth0/queues/rx-0/rps_cpusffff表示最低16个CPU核都允许参与收包处理。注意rps_cpus是以CPU位图形式存在的第0位对应CPU0第1位对应CPU1以此类推。单核就是1双核是3四核是f八核是ff。RFS开启方式还要配合流表echo 32768 /sys/class/net/eth0/queues/rx-0/rps_flow_cnt echo 32768 /proc/sys/net/core/rps_sock_flow_tableXPS一般不需要手动动在网卡已经通过RSS分配好队列和中断的情况下XPS的价值更多体现在多队列的发包场景。实话说大多数业务场景里RPS都够用RFS带来的收益在很多现代CPU上并不明显因为L3 cache设计已经比较强了。2.3 中断亲和性把CPU钉死在正确的核上如果说队列决定数据包怎么分那中断亲和性就决定每个queue的中断去哪个核上报。这部分可以说是网卡调度里最能直接看到效果的地方。先查当前中断情况cat /proc/interrupts | grep -i eth0输出类似CPU0 CPU1 CPU2 CPU3 128: 120456 5 3 2 IR-PCI-MSI 327680-edge eth0-TxRx-0 129: 2 110932 1 0 IR-PCI-MSI 327681-edge eth0-TxRx-1 130: 11 1 109532 0 IR-PCI-MSI 327682-edge eth0-TxRx-2 131: 0 2 1 117920 IR-PCI-MSI 327683-edge eth0-TxRx-3每一行对应一个队列的中断号128到131后面是每个CPU处理这个中断的次数。如果流量上来之后某个CPU的中断数明显高出其他CPU一大截说明中断分散得不够均匀。末列的中断名eth0-TxRx-NN代表队列编号。修改中断亲和性就是改/proc/irq/中断号/smp_affinity把某个中断固定到指定CPU。smp_affinity里也是位图直接换到CPU2就把值设为2二进制10echo 2 /proc/irq/128/smp_affinity更常见的做法是把128、129、130、131四个中断依次钉到4个不同的核上for i in 128 129 130 131; do echo 2 /proc/irq/$i/smp_affinity; done这在现代x86上很容易配置但我劝你一句别在一张网卡有8个队列、而CPU有32核时把8个中断都扔到前8个核上。前8个核可能同时还在跑业务进程最终软中断和业务进程互相抢CPU。更聪明的做法是把每个queue绑定到同一个NUMA node下的不同核上并且尽量避开宿主上高负载的业务核。这个后面实操部分细说。2.4 irqbalance是帮手还是捣乱大部分发行版默认装了irqbalance服务它会定期扫描系统的中断分布和CPU负载然后自动调整中断亲和性。听起来很智能但在网卡调度这个场景下它常常帮倒忙。irqbalance的调度算法比较粗它不知道你的业务进程分布在哪些核上也不知道你的网卡是不是RSS多队列。它只管“让每个CPU核的中断数看起来均匀”。于是你会看到明明你手动把queue0钉到CPU0queue1钉到CPU1irqbalance跑一轮又把queue0的中断挪到CPU7去了导致业务进程和软中断在CPU7上打架。在需要精细控制网卡调度的环境我的习惯是直接关掉irqbalancesystemctl stop irqbalance systemctl disable irqbalance如果是比较老的CentOS 6/7用service irqbalance stop和chkconfig irqbalance off。关掉之后所有中断亲和性就由你手工写的脚本稳态控制了。这么做的前提是你能自己把亲和性配好否则关掉irqbalance后中断全挤在CPU0比开着还糟。3. 实操——完整跑一遍网卡调度调优3.1 第一步确认环境与硬中断现状先交代一下我常用的测试环境一台双路x86服务器两个CPU每个CPU 16核开启超线程后共64个逻辑核网卡是Intel X710-DA2支持64个队列运行Debian 11内核5.10。拿到机器之后第一步永远是把状态全部打出来# CPU拓扑 lscpu # 网卡队列和驱动 ethtool -i eth0 ethtool -l eth0 # NUMA拓扑下网卡归属哪个node cat /sys/class/net/eth0/device/numa_node # 当前中断分布 cat /proc/interrupts | grep eth0 # 软中断和硬中断的CPU占用情况 mpstat -P ALL 1 5这里特别提醒一下numa_node。X710这张卡如果插在CPU0的PCIe插槽上numa_node通常返回0。此时队列中断如果落在CPU1第二个物理CPU的核上跨NUMA访问会带来可观的延迟。中断和软中断要尽量贴近网卡所在的NUMA node这是调优的大前提。3.2 第二步开启多队列与RSS哈希确认硬中断只集中在一个核上后先开多队列。如果当前队列数是1先改通道数ethtool -L eth0 combined 16这里把队列设置为16也可以直接设为最大支持数64。设多了好不好我的经验是队列数和CPU核数匹配最好多了反而会导致每个队列分配到的流量太少中断频繁跳动cache亲和性下降。在64核机器上我一般设置16个接收队列每个队列独占4个核让软中断在这4个核内流转。设置完之后确认RSS哈希包含四元组ethtool -N eth0 rx-flow-hash tcp4 sdfn ethtool -N eth0 rx-flow-hash udp4 sdfn注意每张网卡支持的hash字段不完全一样ethtool -n eth0 rx-flow-hash tcp4先看看支持哪些再改。3.3 第三步手动分配中断亲和性现在到了整个调优的高潮部分。查一下中断号grep eth0 /proc/interrupts假设输出为128到143共16个中断。用一个脚本把中断按顺序绑定到指定核上。这里我通常配合taskset看哪些核是空闲的# 查看各CPU的负载情况 top 然后按 1 # 或者用 mpstat 直接看每个核的软中断占用 mpstat -P ALL 1 5选定绑定策略之后写脚本#!/bin/bash # 绑定eth0的16个队列到CPU0-31每个队列占2个核 IRQS$(grep eth0 /proc/interrupts | awk -F: {print $1} | sed s/ //g) i0 for irq in $IRQS; do cpu$(( (i * 2) % 32 )) mask$(( 1 cpu )) printf %x $mask /proc/irq/$irq/smp_affinity i$(( i 1 )) done这里smp_affinity写成16进制。CPU0就是1CPU4就是10CPU16就是10000。多个核亲和性用或运算比如CPU0和CPU4同时允许就是1 | 10000 10001。注意写入时用字符串printf字节大小控制好就行。如果机器支持更稳妥的做法是用set_irq_affinity脚本或者irqbalance停掉后自己写udev规则持久化。我个人的习惯是把绑定脚本放进rc.local或systemd service开机自动执行。3.4 第四步RPS/RFS按需开启如果网卡本身已经多队列且中断绑好了RPS可以不额外开因为开了反而可能把已经分配好的数据流打乱。但有一种情况必须开RPS网卡只有单队列或者队列数小于CPU核数且流量并发度很高。单队列网卡开启RPS的示例# /sys/class/net/eth0/queues/rx-0/rps_cpus 设置为最近的8个核 echo ff /sys/class/net/eth0/queues/rx-0/rps_cpus # 如果有多个队列分别设置 for q in /sys/class/net/eth0/queues/rx-*/rps_cpus; do echo ff $q doneRFS开启的时机你的业务是长连接、单连接吞吐高比如视频流、消息中间件且应用绑了CPU亲和性。此时打开RFS能有效减少因为跨核处理带来的cache missfor q in /sys/class/net/eth0/queues/rx-*/rps_flow_cnt; do echo 65536 $q done echo 65536 /proc/sys/net/core/rps_sock_flow_table需要注意rps_sock_flow_table很大时内核会占更多内存但一般也就几百KB量级不是问题。3.5 第五步验证效果与参数微调所有配置到位后一定要做前后对比。我的压测工具首选iperf3和netserver。压测命令# 服务端 iperf3 -s # 客户端打到服务端多线程 iperf3 -c 10.0.0.1 -P 16 -t 60压测的同时开三个窗口分别看# 窗口一中断分布 watch -n 1 cat /proc/interrupts | grep eth0 # 窗口二软中断占比 mpstat -P ALL 1 # 窗口三吞吐与TCP重传 iperf3 -c 10.0.0.1 -P 16 -t 60 -R之前的常见结果是这样单队列时CPU0的软中断占到80%吞吐卡在3Gbps调完多队列和中断亲和性后16个核的软中断分布在15%-40%之间吞吐跑到9.4Gbps接近万兆线速同时CPU0的load彻底降下来。这里有个经验值如果配置完之后某个核的软中断占比依然超过50%大概率是哈希分流的流不均匀或者中断没绑对地方。优先检查ethtool -n确认哈希字段再确认smp_affinity没被irqbalance改掉。4. 常见问题与排查技巧实录4.1 软中断全部打在一个核上这个情况太常见了。我见过不少手上是16队列网卡、机器64核的客户跑起来流量还是全堆在CPU0。排查路径如下# 1. 确认网卡队列是否真的开了 ethtool -l eth0 # 2. 确认每个队列的中断落在哪 cat /proc/interrupts | grep eth0 # 3. 确认每个队列的smp_affinity是否都是1bitmap为1表示仅CPU0 cat /proc/irq/*/smp_affinity | sort -u多数情况是irqbalance还开着或者你自己绑亲和性的脚本没执行。关掉irqbalance手动绑定16个中断到16个核即可。还有一种情况是你用的是虚拟化平台KVM/QEMUvirtio-net的队列数默认很少需要在宿主机和虚拟机两侧都确认队列配置。4.2 开启多队列后吞吐反而下降听着很反直觉但我真遇到过。开多队列后吞吐反而下降原因多半是并发连接数太少、流量模型太单一多队列的哈希导致同一个大流量连接被切分到多个队列但队列间共享锁和内存分配器反而增加了竞争。解决办法是降低队列数或者大流场景改用RFS把同一流固定在同一个核处理。另一个坑是网卡驱动不支持动态修改队列数改了没重启driver不生效此时需要重启network服务甚至重启机器。还有个我踩得很深的坑多队列开启后部分老驱动对RSS哈希的IPv4四元组支持不完整只按目的IP哈希只要你的源IP比较单一所有包全部挤到一个队列。用ethtool -n eth0 rx-flow-hash tcp4查一下把字段改成sdfn几乎能解决一半这类问题。4.3 NUMA node失衡导致跨CPU访问双路CPU机器上网卡插在CPU0的PCIe槽位Linux默认把中断尽量往CPU0上的核心分配这是对的。但RPS开的时候rps_cpus位图如果写成全f会把包往CPU1的核上扔收包DMA写在CPU0侧的内存CPU1处理时跨NUMA访问cache miss大量增加。解决办法是让RPS的CPU掩码只包含网卡所在NUMA node的核。先确认cat /sys/class/net/eth0/device/numa_node # 假设输出0表示网卡在node0 # 查node0上有哪些CPU lscpu | grep NUMA node0然后rps_cpus只写node0对应的位图node1上的核一律不参与。4.4 虚拟机和容器环境里的网卡调度现在大量业务跑在容器和虚拟机上网卡调度的表现形式会有点不同。KVM虚拟机里的virtio-net收包路径是宿主机的tap设备 - vhost - 虚拟机的virtio队列。虚拟机里看到的队列数和宿主机配置的队列数一致才行否则虚拟机里怎么调都白搭。容器环境下更常见的问题是网卡队列被宿主机上的其他容器抢占导致你看到的软中断高、吞吐低。排查时先在宿主机上看/proc/interrupts确认物理网卡的中断分布再看容器网卡veth的软中断情况。说句实在话容器内的网卡调度能力很有限大部分调优必须在宿主机侧完成。4.5 压测时CPU软中断高但吞吐上不去这个问题的核心往往不在网卡调度本身而在协议栈更深的地方TCP内存、socket buffer、ring buffer大小。先用ethtool -g eth0看看ring buffer大小ethtool -g eth0如果rx ring只有512数据包一来容易丢包NAPI反复处理软中断忙不过来但吞吐就是起不来。把rx/tx ring调到4096甚至更大ethtool -G eth0 rx 4096 tx 4096还有somaxconn和tcp_rmem参数也值得看一眼这些配合网卡调度一起调往往能解决“软中断吃满但吞吐低”的怪现象。到这里网卡调度的核心内容就全说完了。最后提一个我踩了两次的坑无论怎么调整irqbalance和亲和性一定要确认你的bonding/网卡聚合策略。很多服务器用了bond0四个从网卡绑在一起中断和队列数量翻了几倍但如果你只调了其中一块物理网卡另一块继续裸奔整体性能还是上不去。真正做完一次网卡调度调优之后我建议把smp_affinity、rps_cpus、ethtool的channel和coalesce参数全部固化成启动脚本不要依赖现场手工改。机器一重启这些配置全丢不写进开机自启等于白干。