第 09 章 中断、软中断与驱动——一个无法回避的两难
本章追一个问题:网卡收到一个数据包,它怎么”叫醒” CPU? 你会看到一个几乎无解的两难:通知 CPU 的那段代码必须极快(不能耽误正在跑的一切),但收包这件事本身要干很多活。 内核为了破这个两难,发明了上半部/下半部、软中断、NAPI、
ksoftirqd,一层套一层。 理解这一层,你才能解释那个经典画面:32 核的机器,top里 31 个核闲着,1 个核的si打到 100%,网络吞吐上不去。
一、先想清楚这个两难
墨:老哥,咱先不看代码,先想问题。网卡收到一个包,数据已经通过 DMA 放进内存了,现在它要告诉 CPU”有货到了”。
方案 A:CPU 定期去问(“你有包吗?”) —— 这叫轮询(polling)。 方案 B:网卡主动打断 CPU(“我有包!”) —— 这叫中断(interrupt)。
你先说,各有什么问题?
你:轮询的话,没包的时候白问,浪费 CPU;中断的话……包多的时候一直被打断?
墨:两条都对,我给你补精确:
| 优点 | 致命问题 | |
|---|---|---|
| 轮询 | 包多时效率极高(一次查一批),没有中断开销 | 包少时纯浪费 CPU。空转 100 万次问”有包吗”,答案都是”没有” |
| 中断 | 包少时零开销(没包就不打扰),响应及时 | 包多时被打断到死。每秒 1000 万个包 = 每秒 1000 万次中断,CPU 全耗在”进出中断”上,一个包都处理不完 |
墨:后者有个名字,叫 中断风暴(interrupt storm),也叫 receive livelock——活锁:系统看起来在拼命干活,实际上什么有效工作都没完成,全在响应中断。
你:所以得两个都用?包少用中断,包多用轮询?
墨:你已经想到 NAPI 了。 但先别急,我们得先把”中断”这个机制本身拆开,因为里面还有第二层两难。
二、第一层解法:上半部与下半部
2.1 中断处理为什么必须极快?
墨:中断来的时候,CPU 正在跑你的程序。它被硬件强行打断,跳去执行中断处理函数(ISR)。这期间发生了什么?
关键事实:在中断处理程序里,当前 CPU 上的这个中断(通常还有其他中断)是被屏蔽的。
意味着:
- 你的中断处理跑多久,这段时间内新来的中断都在排队等(甚至可能丢失!);
- 而且这个 CPU 上什么都不能跑——你的进程、内核线程、调度器,全停;
- 中断上下文里不能睡眠(没有进程上下文,睡了醒不过来)、不能调用可能阻塞的函数、不能分配可能触发回收的内存(所以只能用
GFP_ATOMIC,从第 06 章那个min_free_kbytes预留里拿)。
墨:所以规则很硬:
中断处理程序必须在几微秒内结束。 它是整个系统里”最不能拖延”的代码。
2.2 但收包要干的活很多
一个网络包收进来,要做的事:
- 从 DMA 环形缓冲区取出
skb(网络包结构); - 解析以太网头、判断协议;
- 走 IP 层:校验、查路由表、判断是本机的还是要转发的;
- 走 TCP 层:查 socket 哈希表、检查序列号、更新拥塞窗口、可能要发 ACK;
- 把数据放进 socket 的接收缓冲区;
- 唤醒等在
recv()上的进程。
墨:这一串,轻松几微秒到几十微秒。如果全在中断处理里做,那 CPU 就废了。
2.3 解法:把工作切成两半
上半部(top half / hard IRQ):在中断上下文里跑,只做最紧急、最简短的事。 下半部(bottom half / softirq):在中断返回后、开着中断的情况下跑,干那些耗时的活。
具体到网卡收包:
【硬件中断到来】
↓
【上半部 - 硬中断处理,几百纳秒】
├─ 关掉这个网卡队列的中断(重要!下面讲)
├─ 确认中断(告诉网卡"我知道了")
└─ 标记:"有网络收包工作要做" → 触发 NET_RX_SOFTIRQ
↓ 中断返回,重新开中断
↓
【下半部 - 软中断处理,可能几十微秒】
├─ 从 DMA 环里批量取包
├─ 走协议栈(IP/TCP)
├─ 放进 socket 缓冲区
└─ 唤醒进程
📦 类比:医院急诊台
- 上半部 = 分诊台护士:病人一进门(中断),护士只做最快的事——量个体温、登记、把病历卡放进”待处理”篮子。她绝不能给某个病人做半小时手术,否则门口排队的人全堵死了。 她的目标是”5 秒钟处理完一个人,让队伍动起来”。
- 下半部 = 诊室里的医生:从篮子里拿病历,慢慢看、慢慢治。这期间门口的护士还能继续接新病人(因为中断已经重新打开了)。
墨:这个划分的核心洞见是:
把”必须立刻响应”和”需要慢慢干”分离。 前者极短、屏蔽中断;后者较长、开着中断。
这跟第 08 章”读优先于写”、第 05 章 CFS 优待交互任务,是同一族的设计:识别工作的紧急程度差异,然后分开处理。
2.4 softirq:下半部的实现
Linux 的下半部主要靠 softirq(软中断)。它有固定的几种类型,你可以直接看:
cat /proc/softirqs CPU0 CPU1 CPU2 CPU3
HI: 0 0 0 0
TIMER: 12034821 11928374 11837462 11902837 ← 定时器
NET_TX: 92831 88372 91023 89372 ← 网络发包
NET_RX: 48372910 1029384 1039284 1002837 ← 网络收包 ⚠️看这个!
BLOCK: 2039485 2019384 1998374 2001928 ← 块 IO 完成
IRQ_POLL: 0 0 0 0
TASKLET: 12039 11928 12093 11029
SCHED: 28374619 27364518 28374615 27364518 ← 调度
HRTIMER: 0 0 0 0
RCU: 18374629 17263548 18273645 17263548
⚠️ 上面这份输出里藏着一个严重问题,你看出来了吗?
NET_RX 在 CPU0 上是 4837 万,其他 CPU 只有 100 万左右。 收包软中断几乎全压在 CPU0 上!
墨:这就是本章要解决的核心实战问题。我们后面会一步步解开。先把机制讲完。
2.5 ksoftirqd:软中断也可能太多
墨:软中断在哪里执行?正常情况下,在硬中断返回前顺手执行(还在中断上下文的尾巴上,叫 do_softirq)。
但这里有个问题:如果软中断处理得慢、或者持续不断有新的软中断产生,会怎样?
你:一直在跑软中断,用户进程没机会跑?
墨:对!这就是软中断的”饥饿问题”。 网卡包如洪水一样来,软中断处理一批又来一批,如果无限做下去,用户进程永远得不到 CPU —— 你的服务卡死,SSH 都进不去。
内核的解法:软中断处理有个上限。在一次 do_softirq 里:
- 最多循环处理 10 轮(
MAX_SOFTIRQ_RESTART); - 或者最多跑 2ms(
MAX_SOFTIRQ_TIME); - 超过就停手,把剩下的工作交给内核线程
ksoftirqd/N(每个 CPU 一个)。
ksoftirqd 是普通的内核线程,受调度器管辖 —— 它和你的用户进程一起被 CFS 调度,不能霸占 CPU。
这个设计的意图特别值得体会:内核给”紧急工作”设了一个上限,超过上限就把它降级成”普通工作”,让它和别人公平竞争。
这是一种防止优先级反转/饿死的通用手法:高优先级路径必须有配额,超了就降级。(对比第 05 章的
sched_rt_runtime_us那 5% 安全带——同一个思想:再紧急的事也不能无限抢占。)
⚠️ 实战信号:ksoftirqd 占用 CPU 高
top
# PID USER %CPU COMMAND
# 12 root 98.7 ksoftirqd/0 ← ⚠️ 危险信号!含义:软中断量已经超过了”顺手处理”的配额,大量工作被推给了 ksoftirqd。这说明:
- 中断/软中断压力极大(通常是网络包风暴,或者 IO 完成风暴);
- 而且因为
ksoftirqd要和用户进程竞争 CPU,网络处理延迟会变得不稳定; - 你的服务会同时受到两重打击:CPU 被抢 + 网络延迟抖动。
看到 ksoftirqd 高,第一件事是看 /proc/softirqs 找出是哪类软中断、集中在哪个 CPU。
三、NAPI:轮询和中断的联姻
墨:现在回到开头那个两难。内核最终的答案叫 NAPI(New API,名字起得很随便,但设计很漂亮)。
3.1 核心机制
【包很少时:中断模式】
包到 → 硬中断 → 处理 → 中断保持开启 → 等下一个包
(低延迟,零浪费)
【包变多时:自动切换到轮询模式】
包到 → 硬中断 → 上半部【关掉这个队列的中断!】→ 触发 NET_RX_SOFTIRQ
↓
软中断里:循环从 DMA 环里"捞"包,一次捞一批(budget)
↓
捞光了(环里没包了)?→ 【重新打开中断】,回到中断模式
还没捞光,但 budget 用完了?→ 【保持中断关闭】,让下一轮软中断继续捞
墨:看懂这个精髓了吗?
NAPI 的核心手法:在中断处理的第一步就把中断关掉,然后改用轮询把积压的包批量捞干。
- 包少时:一次中断捞到 1 个包就发现环空了,立刻重开中断 → 等价于纯中断模式,延迟最低;
- 包多时:关了中断一直捞,期间不会再产生任何中断 → 等价于纯轮询模式,零中断开销;
- 而且这个切换是自动的、连续的,不需要任何人配置阈值!
你:……漂亮。它不是”选一个”,是”让两种模式自然过渡”。
墨:对。而且注意,这个设计和第 05 章 CFS 的 vruntime 是同一种美感:不加规则去判断”现在是高负载还是低负载”,而是让机制本身自动适应。
好设计的共同特征:不靠”检测状态 + 分支处理”,靠一个统一机制让不同状态自然涌现出正确行为。
3.2 参数详解:net.core.netdev_budget 与 netdev_budget_usecs
net.core.netdev_budget(默认 300)
- 控制什么:一次
NET_RX_SOFTIRQ处理中,所有网卡加起来最多处理多少个包。 - 控制背景:这是”轮询捞包”的配额。上面讲过,软中断不能无限跑,否则饿死用户进程。这个数就是那个刹车。
- 权衡:
- 调大(如 600~1000):一次软中断捞更多包 → 减少软中断进出次数、吞吐提升;代价是单次软中断占用 CPU 更久 → 用户进程和其他软中断(定时器!)的延迟变差。
- 调小:延迟更平滑,但高吞吐时更多包会被推给
ksoftirqd,甚至丢包。
net.core.netdev_budget_usecs(默认 2000,即 2ms)
- 控制什么:一次软中断轮询的时间上限(内核 4.12+ 加的)。
- 为什么需要时间限制:光按包数限制不够——包的处理时间差别很大(一个 64 字节的 TCP ACK 和一个需要走复杂 iptables 规则的转发包,耗时差几十倍)。300 个包可能是 100μs,也可能是 10ms。按时间限制更贴合”不要占用 CPU 太久”这个真实意图。
-
这是个很好的演化例子:原来用”数量”做代理指标,后来发现数量和真实成本不成比例,于是改用”时间”直接度量成本。 你在设计限流、批处理、超时时都该想到这一点——限制的应该是成本,不是数量。
怎么知道 budget 不够?—— 一个精确的诊断指标
cat /proc/net/softnet_stat00a1b2c3 00000000 00001f4a 00000000 00000000 ...
00000123 00000000 00000000 00000000 00000000 ...
↑ ↑ ↑
processed dropped time_squeeze
(处理的包) (丢的包) (⚠️ budget/时间用完还没捞干的次数)
每行是一个 CPU。三个关键列(十六进制!):
- 第 1 列
processed:该 CPU 处理的包数。各 CPU 之间严重不均 = 中断没分散好(本章核心问题)。 - 第 2 列
dropped:因为netdev_max_backlog满而丢的包。非零就是在丢包! - 第 3 列
time_squeeze:budget 或时间用完了,但环里还有包没捞完的次数。- 这个数持续增长 =
netdev_budget不够,应该调大; - 也可能是软中断处理太慢(比如复杂的 iptables 规则、或 CPU 被别的东西抢了)。
- 这个数持续增长 =
⚠️ 这三列是网络收包路径最重要的三个体检指标,而且几乎没人看。 建议直接做成监控。
net.core.netdev_max_backlog(默认 1000)
- 控制什么:每个 CPU 的积压队列长度。当收包速度超过协议栈处理速度时,包先进这个队列排队。
- 控制背景:这是个缓冲区,吸收突发。满了就丢包(体现在
softnet_stat第 2 列)。 - 权衡:调大(如 16384~30000)= 能吸收更大突发、少丢包;代价是内存占用 + 排队延迟变长(包在队列里躺着,延迟增加),而且掩盖了”处理能力不足”这个根本问题(缓冲区只能推迟崩溃,不能避免)。
- ⚠️ 注意:这个队列主要在使用 RPS(软件分发,见下)时才是关键路径;纯 NAPI 直接处理的情况下作用有限。很多”调优指南”让你无脑调大它,但如果你没开 RPS,效果可能微乎其微。
四、核心实战问题:为什么只有一个核在忙?
墨:现在解开第 2.4 节那个谜。这是运维遇到的最经典的网络性能问题。
4.1 问题的物理根源
墨:先想清楚。一个中断,是投递给某一个特定 CPU 的。谁决定投给哪个?
中断亲和性(IRQ affinity) —— 每个中断号有一个 CPU 掩码,决定它能被投递到哪些 CPU。
# 看网卡的中断号
grep eth0 /proc/interrupts
# 或者
ls /sys/class/net/eth0/device/msi_irqs/
cat /proc/interrupts
# CPU0 CPU1 CPU2 CPU3
# 128: 48372910 0 0 0 PCI-MSI eth0-TxRx-0
# ↑ 全在 CPU0!
# 看某个中断的亲和性
cat /proc/irq/128/smp_affinity # 十六进制掩码
cat /proc/irq/128/smp_affinity_list # 更可读:0墨:如果一块网卡只有一个中断,那它所有的包都会:
- 中断投给 CPU0;
- 上半部在 CPU0 跑;
- 触发的
NET_RX_SOFTIRQ也在 CPU0 跑(软中断在触发它的那个 CPU 上执行!); - 整个协议栈处理都在 CPU0;
- CPU0 的
si打到 100%,其他 31 个核闲得抠脚,吞吐卡死在单核能力上。
根本原因:软中断在哪个 CPU 上执行,由”硬中断被投递到哪个 CPU”决定。 所以中断分布不均 = 软中断分布不均 = 协议栈处理不均。
4.2 解法一:RSS(硬件多队列)—— 最优解
RSS(Receive Side Scaling) 是网卡硬件的能力:
- 现代网卡有多个接收队列(如 8/16/64 个);
- 网卡内部对每个包的 五元组(源IP、目的IP、源端口、目的端口、协议)做哈希;
- 按哈希值把包分发到不同的接收队列;
- 每个队列有自己独立的中断,可以绑到不同 CPU!
# 看网卡支持多少队列、当前用了几个
ethtool -l eth0
# Pre-set maximums:
# RX: 8
# Combined: 8
# Current hardware settings:
# Combined: 1 ← ⚠️ 只用了 1 个队列!
# 开启多队列
ethtool -L eth0 combined 8墨:为什么按五元组哈希,而不是简单轮询(round-robin)分发? 这个设计决策非常关键,你想想。
你:……如果轮询,同一个连接的包会被分到不同 CPU?
墨:对!而这会带来两个严重问题:
- 乱序:同一个 TCP 连接的包在不同 CPU 上并行处理,完成的顺序可能颠倒 → TCP 收到乱序包 → 触发不必要的重传和拥塞控制降速,性能暴跌。
- 缓存不亲和 + 锁竞争:同一个连接的 socket 结构、TCP 状态,会被多个 CPU 反复访问 → 缓存行乒乓 + 自旋锁竞争(和第 08 章 blk-sq 那把大锁一模一样的病)。
按五元组哈希保证了:同一个连接的所有包,永远进同一个队列、被同一个 CPU 处理。 这叫 flow affinity(流亲和性)。
设计箴言:并行化的正确单位不是”单个请求”,是”一个会话/流”。
同一个流的处理必须串行且在同一个核上(保序 + 缓存热),不同流之间才并行。这个原则在你写 Go 服务时同样适用——比如按用户 ID 分片处理,而不是把同一用户的请求随机分给不同 worker。
配套:把每个队列的中断绑到不同 CPU
# 手动绑定:队列 0 的中断绑 CPU0,队列 1 绑 CPU1 ...
echo 1 > /proc/irq/128/smp_affinity # 掩码 0x1 = CPU0
echo 2 > /proc/irq/129/smp_affinity # 0x2 = CPU1
echo 4 > /proc/irq/130/smp_affinity # 0x4 = CPU2
echo 8 > /proc/irq/131/smp_affinity # 0x8 = CPU3⚠️ irqbalance 服务的坑:系统上通常有个 irqbalance 守护进程自动分配中断亲和性。它的问题是:
- 它是通用的、不懂你的负载,可能把网卡中断和你的关键进程放到同一个核;
- 它会动态迁移中断,导致缓存亲和性被破坏、性能抖动;
- 它可能不理解 NUMA 拓扑,把中断绑到远离网卡 PCIe 通道的 node 上。
高性能网络场景的常见做法:关掉 irqbalance,手动静态绑定。
systemctl stop irqbalance && systemctl disable irqbalance
# 然后手动绑定(通常有厂商脚本,如 Mellanox 的 set_irq_affinity.sh)- 权衡:失去自动适应能力,但换来确定性。这又是”通用自动化 vs 专家手工控制”的权衡——和第 07 章
O_DIRECT、第 05 章nohz_full同族。
⚠️ NUMA 亲和性:一个容易漏掉的关键点
# 网卡在哪个 NUMA node 上?
cat /sys/class/net/eth0/device/numa_node # 比如返回 0
# 那么中断和处理线程都应该绑在 node 0 的 CPU 上
lscpu | grep NUMA- 如果网卡在 node 0,你把中断绑到 node 1 的 CPU,每个包的数据都要跨 NUMA 总线搬,延迟和带宽双输。
- 这是高性能网络调优里最常被忽略的一环。
4.3 解法二:RPS(软件分发)—— 老网卡的补救
如果网卡只有一个队列(老网卡、某些虚拟网卡)怎么办?
RPS(Receive Packet Steering):在软件层面做 RSS 干的事。
- 包在收到的那个 CPU 上被取出后,内核计算它的哈希;
- 按哈希把它放进目标 CPU 的 backlog 队列;
- 通过 IPI(处理器间中断) 通知目标 CPU;
- 目标 CPU 在自己的软中断里处理协议栈部分。
# 让 eth0 的队列 0 的包分发到 CPU 0-7(掩码 0xff)
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# 配套:每个流的哈希表大小
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt- 权衡:
- 得到:协议栈处理被分散到多核,单核不再是瓶颈;
- 代价:多了一次 IPI 中断 + 跨 CPU 的数据搬运(包数据在 CPU A 的缓存里,却要 CPU B 处理 → 缓存不亲和)。所以 RPS 的效率不如 RSS。
- 有 RSS 就别用 RPS。 RPS 是硬件不给力时的软件补救。
RFS(Receive Flow Steering)—— RPS 的升级
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt- RPS 的问题:它按哈希分发,但分到的 CPU 未必是”那个应用进程正在跑的 CPU”。结果:协议栈在 CPU 3 处理,数据放进 socket 缓冲区;而调
recv()的进程在 CPU 7 上 → 数据又要跨核搬一次。 - RFS 的改进:内核记录”每个流最近是被哪个 CPU 上的进程读取的”,然后把这个流的协议栈处理也放到那个 CPU 上。
- 于是:网卡收包 → 协议栈处理 → 应用读取,全在同一个 CPU,缓存全程热的。
RFS 的设计意图值得记:让数据的”处理位置”追随”消费位置”。
这是个非常普适的优化思路——把计算搬到数据旁边(或反过来)。你在做分布式系统时的”数据本地性”、Spark 的”移动计算而非移动数据”、CDN 的边缘缓存,全是同一个母题。
XPS(Transmit Packet Steering)—— 发送方向的对应物
echo 1 > /sys/class/net/eth0/queues/tx-0/xps_cpus- 让某个 CPU 上产生的发送包,固定用某个发送队列 → 避免多个 CPU 争抢同一个发送队列的锁。
4.4 中断合并:另一个维度的减负
ethtool -c/-C —— 网卡的中断合并(interrupt coalescing)
ethtool -c eth0
# rx-usecs: 8 ← 收到包后,最多延迟 8 微秒才发中断
# rx-frames: 16 ← 或者攒够 16 个包就发中断
# adaptive-rx: on ← 自适应模式
ethtool -C eth0 rx-usecs 50 rx-frames 64 # 更激进的合并- 原理:网卡不是收到一个包就立刻发中断,而是攒一小会儿/攒几个包再发一次中断。
- 权衡(非常清晰):
- 合并更多(
rx-usecs调大):中断次数大幅减少 → CPU 开销降低、吞吐提升;代价是延迟增加(包在网卡里等着)。 - 合并更少(调小甚至设 0):延迟最低;代价是中断风暴风险、CPU 开销大。
- 合并更多(
- 场景:
- 高频交易、游戏服务器等极低延迟场景:调到最小,甚至用 busy polling(见下);
- 大吞吐、批量传输场景:调大,省 CPU。
- 通常保持
adaptive-rx on(网卡按负载自动调)是不错的默认。
Busy Polling(极低延迟的终极手段)
sysctl -w net.core.busy_poll=50 # socket 级别,微秒
sysctl -w net.core.busy_read=50- 原理:应用调
recv()时,如果没数据,不睡眠,而是直接在用户上下文里主动去网卡轮询几十微秒。 - 收益:彻底绕过中断和软中断的调度延迟,延迟可以降到几微秒。
- 代价:烧 CPU(空转轮询)。只在”延迟比 CPU 成本重要得多”的场景用。
-
这是 NAPI 思想的极端化:既然中断/唤醒的调度延迟是瓶颈,那就干脆不睡,一直守着。 DPDK/内核旁路方案本质上就是把这个做到极致(100% 轮询,完全不用中断)。
五、真实事故:32 核的机器,吞吐卡在 40 万 PPS
墨:走诊断环。这个案子是网络性能问题的教科书。
现象
一个自研的 UDP 日志接收服务(接收各业务上报的日志),部署在 32 核物理机上,万兆网卡。
问题:
- 吞吐卡在约 40 万 PPS(每秒包数),再加压就开始丢包;
- 业务侧报告日志丢失率约 3%;
- 服务器
top显示:总 CPU 使用率只有 12%; - 应用自己的处理逻辑很简单,压测单核能处理 20 万条/秒。
墨:“32 核只用了 12%,吞吐上不去还丢包”。第一步该看什么?
你:看是不是某个核被打满了?top 按 1 展开每个核?
墨:对!总量掩盖分布,这是排障的基本功。
第一步:展开每核视图
top # 按 1 展开%Cpu0 : 2.1 us, 3.2 sy, 0.0 ni, 0.0 id, 0.0 wa, 94.7 si ← ⚠️⚠️
%Cpu1 : 8.3 us, 2.1 sy, 0.0 ni, 89.2 id, 0.0 wa, 0.4 si
%Cpu2 : 7.9 us, 1.8 sy, 0.0 ni, 89.9 id, 0.0 wa, 0.4 si
... (其余 29 个核类似,都很闲)
CPU0 的 si(软中断)94.7%,其余核基本空闲。
墨:画面清晰了——所有收包软中断都压在 CPU0 上,单核成了瓶颈。总使用率 12% 是 (94.7 + 8×31) / 32 平均出来的假象。
教训:平均值掩盖分布。看到”总 CPU 不高但吞吐上不去”,第一件事永远是展开每核视图。
第二步:确认软中断类型和分布
cat /proc/softirqs | grep -E "CPU|NET_RX"
# CPU0 CPU1 CPU2 ...
# NET_RX: 48372910 1029384 1039284 ...确认:NET_RX 集中在 CPU0。
cat /proc/interrupts | grep eth0
# CPU0 CPU1 ...
# 128: 48372910 0 ... PCI-MSI eth0-TxRx-0只有一个中断,全投给 CPU0。
第三步:检查丢包发生在哪一层(这一步很关键)
墨:丢包可能在很多层,必须定位准。逐层检查:
# 1. 网卡硬件层丢包(环形缓冲区满)
ethtool -S eth0 | grep -iE "drop|discard|error|miss|fifo"
# rx_missed_errors: 0
# rx_no_buffer_count: 8291032 ← ⚠️ 网卡环形缓冲区没有可用描述符!
# rx_dropped: 0
# 2. 内核 softnet 层丢包
cat /proc/net/softnet_stat
# 00b8c2f1 0004a3c2 000129af 00000000 ...
# ↑dropped ↑time_squeeze ← ⚠️ 两个都非零!
# 第 2 列 dropped = 0x4a3c2 = 304,066 个包被丢
# 第 3 列 time_squeeze = 0x129af = 76,207 次 budget 用完
# 3. UDP 协议栈层丢包
netstat -su | grep -iE "error|drop"
# 1029384 packet receive errors
# 1029384 RcvbufErrors ← ⚠️ socket 接收缓冲区满!
# 4. 应用层
# (应用自己的丢弃计数)墨:好,现在我们有三层同时在丢包:
- 网卡环形缓冲区(
rx_no_buffer_count); - 内核 backlog(
softnet_statdropped); - UDP socket 接收缓冲区(
RcvbufErrors)。
但这三层都是”结果”,根因是同一个:CPU0 处理不过来,整条流水线全线堵塞。
重要方法论:丢包统计要逐层看,但要分清”根因层”和”被连带层”。 上游处理不过来,下游所有缓冲区都会满。只修下游(调大缓冲区)是治标不治本——包还是会丢,只是丢得晚一点。
推断一:调大缓冲区(治标,而且我一开始就这么干了,错在方向)
第一反应:缓冲区不够,调大它们。
sysctl -w net.core.netdev_max_backlog=30000
sysctl -w net.core.rmem_max=134217728
sysctl -w net.core.rmem_default=16777216
ethtool -G eth0 rx 4096 # 网卡环从 512 调到 4096观察:丢包率从 3% 降到 1.8%,吞吐从 40 万涨到 44 万 PPS。
墨:有效果,但很小。为什么?
你:因为 CPU0 的处理能力没变……缓冲区只是让包多等一会儿,处理速度还是那么快?
墨:完全正确。 这是本案最重要的认知:
缓冲区解决的是”突发”,不是”持续过载”。
如果输入速率持续大于处理速率,再大的缓冲区最终都会满 —— 它只能把”立刻丢”变成”晚几百毫秒丢”,而且代价是延迟大幅增加(包在大缓冲区里排队)。这在网络上有个名字叫 bufferbloat(缓冲区膨胀)。
持续过载只有两个真正的解法:提高处理能力,或降低输入速率。
推断一定性为”治标”。方向要转到”提高处理能力”。
推断二:提高处理能力 —— 开多队列(命中)
检查网卡能力:
ethtool -l eth0
# Pre-set maximums:
# Combined: 16 ← 硬件支持 16 个队列!
# Current hardware settings:
# Combined: 1 ← 但只用了 1 个!!墨:网卡明明支持 16 队列,却只配了 1 个。 这就是根因所在。
为什么会这样?排查发现:这批机器是从旧环境迁移过来的,用了一份从很老的机器上抄来的网卡配置脚本,里面写死了 combined 1(因为那台老机器的网卡确实只支持单队列)。
⚠️ 这是个极其常见的翻车模式:抄配置。 参数在旧环境是对的,在新硬件上就是灾难。第 08 章那个
elevator=deadline抄到 NVMe 上,和这个是同一个病。
改进 1:开启多队列 + 绑定中断
# 开 16 个队列
ethtool -L eth0 combined 16
# 关掉 irqbalance,手动静态绑定
systemctl stop irqbalance && systemctl disable irqbalance
# 确认网卡在哪个 NUMA node
cat /sys/class/net/eth0/device/numa_node # 返回 0
lscpu | grep "NUMA node0" # CPU 0-15 在 node 0
# 把 16 个队列的中断分别绑到 node 0 的 CPU 0-15
i=0
for irq in $(ls /sys/class/net/eth0/device/msi_irqs/); do
echo $i > /proc/irq/$irq/smp_affinity_list
i=$((i+1))
done观察:
/proc/softirqs的NET_RX均匀分布在 CPU0-15;- 每核
si约 18%; - 吞吐从 44 万涨到 310 万 PPS;
- 丢包率降到 0.02%。
7 倍提升。
第三轮:还有那 0.02% —— 继续观察,不宣布胜利
墨:老哥,还记得第 06 章那个教训吗?”改完好了一大半”之后必须继续观察。
再看 softnet_stat:time_squeeze 还在缓慢增长。说明偶尔还是有”budget 用完包没捞干”的情况。
改进 2:调整 NAPI budget
sysctl -w net.core.netdev_budget=600 # 300 → 600
sysctl -w net.core.netdev_budget_usecs=4000 # 2ms → 4ms- 权衡:一次软中断处理更多包 → 吞吐提升;代价是单次软中断占 CPU 更久,可能影响定时器软中断和用户进程的延迟。这里是日志接收服务(吞吐导向,不是延迟敏感),这个交易划算。
- 观察:
time_squeeze增长停止,丢包降到 0.001%。
改进 3:中断合并(省 CPU)
ethtool -C eth0 rx-usecs 30 rx-frames 64- 权衡:延迟增加约 30μs(日志上报完全不敏感),换来中断次数减少约 60%,每核
si从 18% 降到 11%。 - 这是纯赚:对这个负载,30μs 的延迟毫无意义,而 7% 的 CPU 很实在。
改进 4:应用侧配合 —— 多进程 + SO_REUSEPORT
- 之前应用是单进程收包(即使内核分散了软中断,最后所有包都汇聚到一个 socket,由一个进程读 → 应用侧又成了单点)。
- 改成 16 个进程,每个用
SO_REUSEPORT绑同一个端口。内核会按流哈希把连接/数据报分发到不同 socket。 - 原理:这是把”流亲和性”的思想从内核延伸到应用层——让整条路径(网卡队列 → 中断 → 软中断 → socket → 应用进程)都在同一个 CPU 上,缓存全程热的。
- 观察:吞吐上限进一步提升到 480 万 PPS(不过实际业务量只有 300 万,已有足够余量)。
改进 5:加监控(最重要) 把这些指标做成常驻监控和告警:
/proc/net/softnet_stat的三列(per-CPU,重点看 dropped 和 time_squeeze);ethtool -S的各类 drop/error 计数;netstat -su的RcvbufErrors;- 每核的
si分布(不是总 CPU!); /proc/interrupts的分布均匀度。
理由:这个问题在”总 CPU 12%“的监控面板上完全看不出来。团队盯着一个健康的仪表盘,业务却在丢日志。监控的粒度错了,等于没有监控。
复盘
现象:32核只用12%,吞吐卡40万PPS且丢包
→ 第一步:展开每核视图 → CPU0 的 si 94.7% ← 平均值掩盖分布!
→ 确认:NET_RX 软中断和网卡中断全在 CPU0
→ 逐层查丢包:网卡环 + backlog + UDP socket 三层都在丢
→ 判断:三层都是"被连带",根因是 CPU0 处理不过来
→ 推断1:调大所有缓冲区 → 只从 3% 降到 1.8% ← 治标!
→ 认知:缓冲区解决突发,不解决持续过载(bufferbloat)
→ 推断2:提高处理能力 → ethtool -l 发现硬件支持 16 队列却只配了 1
→ 根因:抄了老机器的配置脚本
→ 改进:开 16 队列 + 手动绑中断(NUMA 对齐)→ 310 万 PPS(7.75 倍)
→ 继续观察:time_squeeze 还在涨 → 调 netdev_budget → 0.001% 丢包
→ 中断合并 → si 从 18% 降到 11%(纯赚)
→ 应用侧 SO_REUSEPORT 多进程 → 480 万 PPS 上限
→ 加 per-CPU 监控 ← 最重要
五个教训:
- 总量掩盖分布。 “总 CPU 不高但性能上不去” → 立刻展开每核视图。同理适用于:平均延迟 vs P99、总内存 vs 各 NUMA node、总磁盘 IO vs 各设备。
- 缓冲区解决突发,不解决持续过载。 调大缓冲区能救突发,但持续过载下它只是把丢包推迟并增加延迟(bufferbloat)。持续过载只能提高处理能力或降低输入。
- 丢包要逐层统计,但要分清根因层和被连带层。 上游堵了下游全满,只修下游是徒劳。
- 抄配置是重大风险。
combined 1、elevator=deadline、read_ahead_kb=4096这类参数,在旧硬件上正确,在新硬件上就是灾难。每次迁移都要重新审视所有参数的前提假设。 - 让整条路径的处理位置对齐(网卡队列 → 中断 CPU → 软中断 → socket → 应用进程,全在同一个核/同一个 NUMA node)。这是高性能网络的核心原则,收益来自缓存亲和性和无锁竞争。
六、参数总表:中断与软中断层
| 参数 / 命令 | 控制什么 | 拿什么换什么 |
|---|---|---|
net.core.netdev_budget | 一次 NET_RX 软中断最多处理多少包 | 调大:吞吐高、少 time_squeeze ↔ 单次软中断占 CPU 久,影响其他任务延迟 |
net.core.netdev_budget_usecs | 一次软中断的时间上限(默认 2ms) | 同上。按时间限制比按数量更贴合真实成本 |
net.core.netdev_max_backlog | 每 CPU 积压队列长度 | 调大:吸收突发、少丢包 ↔ 内存 + 排队延迟(bufferbloat)+ 掩盖真实过载 |
ethtool -L combined N | 网卡硬件队列数(RSS) | 最优解。多核并行处理 ↔ 无实质代价,只需匹配 CPU 数和 NUMA |
ethtool -G rx/tx N | 网卡环形缓冲区描述符数 | 调大:吸收突发少丢 ↔ 内存 + 延迟。同样是”治突发不治过载” |
ethtool -C rx-usecs/rx-frames | 中断合并 | 合并多:中断少、CPU 省、吞吐高 ↔ 延迟增加。延迟敏感场景调小 |
/proc/irq/N/smp_affinity | 中断绑到哪些 CPU | 静态绑定 + NUMA 对齐:确定性 + 缓存热 ↔ 失去自动适应 |
irqbalance 服务 | 自动分配中断亲和性 | 开:自动适应 ↔ 不懂负载、动态迁移破坏缓存亲和。高性能场景常关掉手动绑 |
rps_cpus(per queue) | RPS:软件把包分发到多核 | 单队列网卡的补救 ↔ 多一次 IPI + 跨核数据搬运,效率不如 RSS |
net.core.rps_sock_flow_entries + rps_flow_cnt | RFS:让处理位置追随消费位置 | 全路径同核、缓存最热 ↔ 需维护流表(内存 + 少量开销) |
xps_cpus(per tx queue) | 发送队列与 CPU 绑定 | 避免多核争抢同一发送队列的锁 |
net.core.busy_poll / busy_read | 应用主动轮询而不睡眠 | 延迟降到微秒级 ↔ 烧 CPU 空转。只在延迟远比 CPU 贵时用 |
kernel.watchdog_thresh(第 05 章) | 检测 CPU 卡死 | 中断/软中断风暴导致的卡死,靠它留现场 |
七、这章的认知钉子
- 中断 vs 轮询是个真两难:中断在低负载下最优、高负载下崩(中断风暴/活锁);轮询反之。NAPI 的解法不是”选一个”,是让两者自然过渡——中断触发时立刻关中断改轮询,捞干了再开中断。
- 上半部/下半部的划分意图:分离”必须立刻响应”和”需要慢慢干”。 中断上下文屏蔽中断、不能睡眠,所以必须极短。
- 软中断在触发它的那个 CPU 上执行。 所以硬中断分布不均 = 软中断不均 = 协议栈处理不均 = 单核瓶颈。这是”一个核打满”问题的根本机理。
ksoftirqd占用高 = 软中断超过配额被降级成普通线程。 这体现”再紧急的事也不能无限抢占”这一通用原则(同第 05 章sched_rt_runtime_us的 5% 安全带)。- RSS 按五元组哈希而非轮询,是为了流亲和性——同流同核,保证不乱序 + 缓存热 + 无锁竞争。并行化的正确单位是”流”而不是”单个请求”。
- RFS 的思路是”让处理位置追随消费位置” ——这是”把计算搬到数据旁边”这一普适优化的一个实例。
/proc/net/softnet_stat的三列(processed / dropped / time_squeeze)是收包路径最重要又最少人看的体检指标。- 总量掩盖分布。 排障时永远要展开:每核 CPU、每 NUMA node、延迟分布(而非平均)。
- 缓冲区解决突发,不解决持续过载。 无脑调大缓冲区会带来 bufferbloat(延迟暴涨)且掩盖真实问题。
- 抄配置是重大风险。 每个参数都建立在特定硬件/负载假设上,迁移环境时必须重新审视。
**
实战补遗一:软中断风暴——ksoftirqd 跑满 100% 的”看不见的 CPU 税”
墨:第 9 章讲了软中断(ksoftirqd 线程)处理网络收包、定时器。正常它很忙但可控。什么时候会变成”风暴”?
你:是不是网络流量太大,收包软中断处理不过来,ksoftirqd 一直跑?
墨:方向对,但”风暴”往往不是”流量大”那么简单,而是软中断处理本身变贵或处理不完积压。讲个真实事故。
一台 Nginx 反向代理,流量平稳(日常 5 万 QPS),某天 top 看 si(软中断 CPU)列从 5% 飙到 95%,ksoftirqd/3(某个核)跑满 100%,对应核上的 Nginx worker 几乎拿不到 CPU,延迟暴涨。但网卡 PPS 没涨、in 中断数也没异常高。
推断:流量没涨,为什么软中断暴增?查 /proc/softirqs 的 NET_RX 和 NET_TX——NET_RX(收包软中断)暴涨。再看 ethtool -S eth0 | grep -E 'rx_.*_errors|rx_dropped'——发现 rx_missed_errors(网卡 FIFO 丢包)在涨。根因:网卡多队列(RSS)没开对,所有收包中断都堆在一个核(核3),而该核上的 ksoftirqd 处理不过来,软中断在那个核上积压、反复触发,把核 3 占满;top 只显示一个核 si 高,正是它。
更深一层:为什么”处理不过来”?因为 GRO(Generic Receive Offload,把小包合并成大包再送协议栈)开了,但合包逻辑在某些大包场景下反而增加了每中断的处理成本,加上单核 RSS,雪崩。
调整:① 开网卡多队列 + RSS 散列,把收包中断均摊到多核:
ethtool -L eth0 combined 8 # 8 个队列
# 用 irqbalance 或手动把队列中断绑到不同核(第 23 章 IRQ 亲和)② 关掉可能拖慢的 GRO(或调 ethtool -K eth0 gro off 测试),让单中断处理更轻;③ 配合 RPS(Receive Packet Steering,软件层把包散到多核的软中断)兜底。再观察:NET_RX 软中断均摊到 8 核,单核 si 从 95% 降到 15%,worker 不再饿死,延迟回归。
你:ksoftirqd 跑满和”进程 CPU 100%“有什么本质区别?为什么它更阴?
墨:ksoftirqd 是内核线程,处理软中断。它跑满时:
- 它占的是那个核的 CPU,且该核上的普通进程(你的 Nginx worker)被饿死——因为软中断优先于进程上下文;
top里它显示为si(system 里的软中断),不是us,新手容易误判为”内核忙”而非”某核被中断绑死”;- 它不参与 CFS 的公平调度(软中断在中断上下文,抢在所有进程前),所以你给 Nginx 提
cpu.weight也没用——中断不在 CFS 管辖内,只能靠”把中断分散到别的核”解决,这就是为什么 IRQ 亲和(第 23 章)是对症药。
所以”软中断风暴”的本质是单核中断亲和失衡 + 软中断处理变贵,解法永远是”均摊中断到多核”(RSS/RPS/IRQ 亲和),而不是”调进程优先级”(治标不治本,因为软中断压根不归 CFS 管)。这也是全书反复出现的主题:找对作用域,才能对症下药。
🔧 思考题:net.core.netdev_budget(默认 300)和 net.core.netdev_budget_usecs 是什么?它们能缓解软中断风暴吗?
【参考答案】
netdev_budget(默认 300)= 一次软中断轮次里最多处理多少个收包;netdev_budget_usecs(默认 2000µs=2ms)= 一次轮次最多花多少时间处理收包。内核每次触发 NET_RX 软中断,就在这俩预算内”尽量多收”,超了就暂停、等下次软中断——目的是防止软中断霸占 CPU 太久、饿死进程。
它们能”缓解”但不能”根治”风暴:
- 调小 budget:单次软中断收得少,给进程让出更多 CPU,缓解某核被占满的饥饿,但代价是收包吞吐下降、可能
rx_dropped(网卡 FIFO 溢出)增加; - 调大 budget:收得快,但软中断占 CPU 久,进程更饿。
所以 budget 是”软中断 vs 进程”的时间切片阀,是风暴时的止血阀(临时调小让业务喘气),但根治还是 RSS 多队列均摊中断(让多个核分担,而不是在一个核上靠 budget 限时间)。口诀:budget 是”限流阀”、RSS/IRQ 亲和是”分流阀”,止血用前者、根治用后者,别只调 budget 不治本——又是”参数缓解现象、架构解决根因”的呼应。
实战补遗二:IRQ 亲和性 smp_affinity 与 RPS/RFS——把网卡中断绑到对的核
墨:老哥,你服务器网卡收包,CPU 是哪个核在干活,你关心过吗?还是”内核爱分给谁就谁”?
你:没特意管过,多队列网卡不是自动分摊吗?
墨:多队列是自动分摊了队列,但队列对应到哪个核这个映射,默认往往是”队列 0 绑核 0、队列 1 绑核 1……”,听起来均匀,实则埋坑。我拆一次”网卡把单核打满”的真实事故。
现象:一台 16 核机器,网卡是 8 队列,跑 Nginx。流量一高,top 里 ksoftirqd/3 这一个核的软中断跑到 100%,其他核闲着,Nginx 吞吐上不去、延迟抖。
推断:以为是 Nginx 的 worker 分配不均。
可能推错:Nginx worker 是均匀的,问题在于收包软中断全压在核 3(对应队列 3),应用数据再分给各 worker 时,核 3 已经在处理网卡中断,缓存被冲、上下文来回切。根因在网卡的 IRQ 亲和性,不在应用。
调整:看 cat /proc/interrupts | grep eth0,列出 8 个队列的中断号,再看每个中断的亲和性 cat /proc/irq/<num>/smp_affinity——默认是 00000008(即核 3)。用 irqbalance 服务会自动均摊,但它有时偷懒不均。手动改:
# 把 8 个队列分别绑到核 0-7
for i in $(seq 0 7); do
echo $((1<<$i)) > /proc/irq/$(队列i中断号)/smp_affinity
done
或更稳的是开 irqbalance 并确认它在跑(多数发行版默认开)。
再观察:还有个更隐蔽的场景——单队列老网卡或虚拟网卡(如容器 veth、云内网),硬件队列只有 1 个,全压一个核。这时要用 RPS(Receive Packet Steering):用 /sys/class/net/<dev>/queues/rx-0/rps_cpus 指定”哪些核可以参与软中断处理”,把单队列的收包软中断软件层分摊到多核。配合 RFS(Receive Flow Steering) rps_sock_flow_entries 让”同一连接的包落到同一核”,保住缓存局部性。
改进:
- 物理多队列网卡:用
irqbalance或手动smp_affinity绑核,别让单核扛中断。 - 单队列/虚拟网卡:开 RPS 把软中断摊开;开 RFS 避免连接在不同核间跳。
- 容器里 veth 的软中断在宿主核上跑,不在容器里——绑核要绑宿主侧,容器内调
smp_affinity是找错门(见第 23 章容器篇)。
🔧 思考题:RPS 用软件把包分给多核,和硬件多队列 smp_affinity 绑核,性能代价有什么不同?为什么 RFS 要”按连接”而不是”按包”分摊?
【参考答案】
硬件多队列 smp_affinity:中断由网卡 DMA 直接投递到指定核,CPU 在正确的核上做软中断,缓存命中好、零额外拷贝,是最优解,但受硬件队列数限制(老网卡/虚拟网卡只有 1 队列就无解)。
RPS:硬件只把包送到一个核,内核软中断里再软件重定向到 rps_cpus 里指定的其他核。代价是多了一次”跨核唤醒 + 接收核重新处理”,有缓存/迁移开销,所以 RPS 是”没有硬件队列时的妥协方案”, throughput 略降但延迟更平稳、不压单核。
RFS 为什么按连接:同一个 TCP 连接的包如果在不同核间跳,核上的 sk_buff、socket 状态、L1/L2 缓存就一直冷,每次都要重新热身,反而更慢。RFS 用一张”流→核”的哈希表,保证同一五元组的包落到同一核,牺牲一点绝对均衡换缓存局部性。典型配置:先设 rps_sock_flow_entries(全局流表大小),再按队列设 rps_flow_cnt。
一句话:硬件队列优先,单队列才上 RPS+RFS;绑核要绑对层级(物理网卡绑宿主,容器 veth 同样在宿主)。
实战补遗三:workqueue 与 kworker——为什么你的”内核任务”跑在某个 kworker 线程、还绑在特定核
墨:老哥,你 top 里见过 kworker 这名字吗?它是什么,为什么有时它吃满一个核?
你:见过,应该是内核干活的线程吧,但具体干嘛不清楚。
墨:对,kworker 是内核的”打工线程”,跑 workqueue 上的延迟任务。但 kworker 在哪个核跑、为什么跑,直接影响你用户态任务的延迟——这跟第 9 章软中断是”内核另一类后台活”。这节讲 workqueue/kworker 的账。
现象:一个延迟敏感服务,P99 偶发抖,排查发现抖动时某核 kworker 线程 CPU 冲高。看 cat /proc/<kworkerpid>/tasks 和 perf 栈,kworker 在跑某驱动的 work 回调(如网卡收包后的”下半部”处理、或写回的 writeback 工作)。它和你的服务抢同一个核,且 kworker 优先级未必低。
推断:kworker 是内核线程,应该不抢用户态的 CPU?
可能推错:kworker 是普通内核线程(SCHED_NORMAL,和 CFS 用户任务同优先级类),在它跑的那个核上,和同核的用户任务按 CFS 权重瓜分 CPU。如果 kworker 接了大量 work(如某驱动 burst、或 writeback 洪峰),它就在那个核上和用户任务抢时间,用户任务被挤、延迟抖。更隐蔽:kworker 的 workqueue 可以绑核(WQ_UNBOUND 不绑、WQ_CPU_BOUND 绑到特定核),绑核的那种 work 固定跑在某核,正好压在你服务所在的核。
调整:分类看 kworker:
kworker/u<num>:<id>:u=unbound workqueue(不绑核,调度器随便放),通常是通用工作。kworker/<cpu>:<id>:绑到某 CPU 的 per-cpu workqueue,固定在该核跑。 用cat /proc/<pid>/stack或perf看 kworker 在跑哪个work函数;用drgn/trace-cmd能跟到具体 workqueue 名。
再观察:常见 kworker 大户:
- writeback(
flush-/writeback 工作):脏页回写(见第 20 章 wbt 实战补遗),脏页多时 kworker 跑wb_workfn刷盘,绑核的话压该核。 - 网卡收包下半部:某些驱动把部分收包处理放 workqueue(而非软中断),burst 时 kworker 忙。
- cgroup/内存回收的
kswapd(这是独立线程不是 kworker,但同类”内核后台活”)、kcompactd(内存规整,见第 3/12 章大页)。
改进:
- kworker 抢核导致延迟抖:用
isolcpus把服务核从内核调度域隔离(kworker 的 unbound work 不会进隔离核;bound work 需驱动层WQ_UNBOUND才避开),配合taskset绑服务到隔离核(见第 5 章 GOMAXPROCS 实战补遗)。 - writeback 类:调
vm.dirty_background_ratio让回写平滑,别堆积后 burst(kworker 一次刷很多)。 - 监控
top -H看 kworker 线程的核归属和 CPU,定位”哪个核被内核后台活占了”。 - 别把延迟敏感服务和”会产生大量内核 work”的设备/任务放同核。
🔧 思考题:kworker(CFS 普通线程)和软中断(ksoftirqd,也有线程)都能抢用户态任务的 CPU,它们的”抢法”有什么不同?为什么软中断更”优先”?
【参考答案】
两者都是内核后台活,但”抢 CPU 的姿势”不同:
软中断(ksoftirqd):网络收包、定时器、tasklet 等”上半部”之后的”下半部”处理。它有两种执行路径——(1) 在中断返回时就地处理(硬件中断处理完,软中断在中断上下文尾部直接跑,此时关抢占,用户态完全插不进,优先级极高);(2) 如果软中断太多、处理超时,内核把它挪到 ksoftirqd 内核线程跑(此时是可抢占的,和用户任务同 CFS 竞争)。所以软中断”平时在中断上下文抢(用户态根本没机会), overflow 才降级到线程抢”——它的抢法更霸道,因为它能在关抢占时跑,用户任务被完全挡住。这也是第 9 章”软中断风暴 ksoftirqd 跑满 100%“卡顿的根源:中断下半部太多,要么在中断上下文挡用户态,要么 ksoftirqd 线程占满核。
kworker(workqueue):纯内核线程,CFS 普通优先级(除非 workqueue 设了 WQ_HIGHPRI 变高优先级),从不关抢占、和用户任务公平抢 CPU。所以 kworker 抢 CPU 是”CFS 公平瓜分”式的,用户任务仍能按权重拿到时间,只是被分走一部分。除非 kworker 的 work 量极大(长时间跑),否则不会像软中断那样”瞬间卡死用户态”。
为什么软中断更”优先”:因为软中断的核心路径在中断/软中断上下文跑,那段是禁止内核抢占的(preempt_disable),用户态任务和别的普通内核线程都插不进——等于”硬抢”。kworker 是线程态、可抢占,用户任务能分到时间。所以”软中断风暴”比”kworker 忙”更致命:前者能短暂让核完全不理用户态,后者只是分走一部分 CPU。
排查分工:top -H 看 ksoftirqd/N 高 = 软中断风暴(查网卡中断/RPS,见第 9 章 IRQ 实战补遗);看 kworker/N 高 = workqueue 工作多(查 writeback/驱动 work,定位绑核后隔离)。两者都靠 isolcpus+taskset 把用户服务挪到干净核规避,但软中断的隔离要配合 irqbalance/RPS 把中断也挪开(只隔离服务核不够,还得让中断别往那核打)。
下一章预告(第 10 章 网络栈微观全景)**:软中断把包捞上来了,接下来它要穿过 IP 层、TCP 层,爬进你的 socket。这一章我们把 TCP 的那套精妙机制刨开——三次握手为什么是三次、accept 队列和 syn 队列的两级设计、拥塞控制在赌什么、TIME_WAIT 为什么让人恨、以及 net.ipv4.tcp_* 那几十个参数各自在防什么。外加一个”半夜网关拒绝新连接”的事故,和一个”重传率 5% 找了一周”的事故。
🔧 思考题(带参考答案)
思考题 1(机制): 为什么 RSS 按五元组哈希分发包,而不是简单地轮询(round-robin)分发到各个队列?轮询看起来负载更均衡才对。
【参考答案】
轮询虽然”数量上更均衡”,但会造成两个严重问题:
① TCP 乱序 → 触发不必要的重传和降速
- 同一个 TCP 连接的连续包被分到不同队列、不同 CPU 并行处理;
- 各 CPU 的处理进度不同(缓存状态、被抢占情况、锁等待都不同),完成顺序可能与到达顺序颠倒;
- TCP 层收到乱序包 → 发送重复 ACK → 对端可能触发快速重传;
- 更糟:某些拥塞控制算法把乱序当作丢包信号 → 主动降低发送窗口 → 吞吐暴跌。
- 明明网络一点没丢包,却因为本机处理乱序而降速,荒谬但真实。
② 缓存不亲和 + 锁竞争
- 同一个连接的状态(socket 结构、TCP 控制块、接收队列、拥塞窗口变量)是一份共享数据;
- 多个 CPU 轮流处理这个连接的包 → 这些数据结构在各核的 L1/L2 缓存之间反复失效和同步(缓存行乒乓);
- 而且访问这些结构要加锁 → 自旋锁竞争;
- 这正是第 08 章 blk-sq 单队列大锁的同一个病:共享可变状态 + 多核并发 = 缓存乒乓 + 锁竞争。
按五元组哈希的效果(flow affinity,流亲和性):
- 同一连接的所有包 → 同一队列 → 同一 CPU → 天然保序(单核串行处理);
- 该连接的所有状态数据只被一个核访问,缓存全程热的,基本无锁竞争;
- 不同连接之间才并行 —— 而不同连接本来就没有共享状态,并行是安全高效的。
代价(诚实地说):
- 可能不均衡。如果流量集中在少数几个大流(比如一条 10Gbps 的单 TCP 连接),哈希会把它全部导向一个队列,其他队列闲着。这是 RSS 的固有局限。
- 应对:大象流场景可能需要应用层拆分连接(多连接并行传输,如
iperf -P 8)、或用更细粒度的分流(如某些网卡支持基于内层头的哈希、或 flow director 规则)。
推广到你的日常开发(这是这题的真正价值):
并行化的正确单位不是”单个请求”,而是”一个会话/流/实体”。
- 写 Go 服务:按用户 ID / 订单 ID 哈希分片到 worker,而不是把同一用户的请求随机分给不同 worker —— 否则你要为”同一实体的并发修改”付出锁和一致性的代价;
- Kafka:同一个 key 进同一个 partition,保证该 key 的消息有序;
- 数据库分库分表:按用户维度切分,让一个用户的数据和操作落在一个分片;
- 有状态服务的负载均衡:用一致性哈希/session affinity,而不是纯轮询。
判断法则:如果两个任务共享可变状态,把它们放在同一个执行单元上串行处理,常常比并行 + 加锁更快。
思考题 2(诊断): 一台服务器 top 显示总 CPU 使用率 25%,但服务响应变慢、有网络丢包。请列出你的排查步骤(至少 5 步),并说明每一步在验证什么假设。
【参考答案】
核心思路:总量掩盖分布 → 先展开分布,再逐层定位丢包点。
第 1 步:展开每核 CPU 视图
top # 然后按 1
# 或
mpstat -P ALL 1- 验证的假设:是否有单个核被打满(尤其看
%si软中断和%sy)。 - 为什么第一步做这个:25% 的总使用率在 32 核机器上,完全可以是”1 个核 100% + 31 个核 0%“。这是最常见的隐藏瓶颈形态。
- 如果发现某核
si很高 → 走网络中断路线(第 3-5 步);如果某核sy高 → 可能是锁竞争或系统调用密集(第 04 章);如果所有核都不高 → 走第 2 步。
第 2 步:确认是否真的是 CPU 问题——看排队和压力指标
vmstat 1 # r 列(可运行队列)、b 列(阻塞在 IO 的)
cat /proc/pressure/cpu # PSI:因等 CPU 被卡住的时间占比
cat /proc/pressure/io
cat /proc/pressure/memory
ps -eo stat,pid,comm | grep '^D' | wc -l # D 状态进程数
uptime # load average vs 核数- 验证的假设:“慢”是因为等 CPU、等 IO、还是等内存?
- 关键区分:高 load + 低 CPU = IO 瓶颈(第 07 章);PSI cpu 高 = 真在等 CPU;PSI io 高 = IO 瓶颈。
第 3 步:定位丢包发生在哪一层(逐层统计)
# a) 网卡硬件层
ethtool -S eth0 | grep -iE "drop|discard|error|miss|fifo|no_buffer"
# b) 内核 softnet 层(per-CPU!)
cat /proc/net/softnet_stat # 第2列 dropped、第3列 time_squeeze
# c) IP/协议栈层
netstat -s | grep -iE "drop|discard|error|overflow|prune"
nstat -az | grep -iE "Drop|Err|Overflow" # 更全,可看增量
# d) socket 缓冲区层
netstat -su | grep -iE "RcvbufErrors|SndbufErrors" # UDP
ss -tin | grep -o 'rcv_space[^ ]*' # TCP
# e) 连接队列层(TCP)
netstat -s | grep -iE "listen|SYN" # listen queue overflow
# f) 应用层
# 应用自己的丢弃/超时计数- 验证的假设:丢包的第一现场在哪一层。
- 重要:要区分根因层和被连带层。上游处理不过来时,下游所有缓冲区都会满。找最上游的那个非零计数。
第 4 步:检查中断分布与网卡队列配置
cat /proc/interrupts | grep -E "eth|ens|enp" # 中断是否集中在某核
cat /proc/softirqs | grep -E "CPU|NET_RX|NET_TX"
ethtool -l eth0 # 硬件支持几个队列?当前用几个?
ethtool -g eth0 # 环形缓冲区大小
ethtool -c eth0 # 中断合并设置
cat /sys/class/net/eth0/device/numa_node # 网卡在哪个 NUMA node- 验证的假设:是否是单队列/中断未分散导致的单核瓶颈;硬件能力是否被浪费(如支持 16 队列却只配 1)。
- 这一步经常一击命中,尤其是迁移过的机器或虚拟化环境。
第 5 步:检查是否有软中断降级到 ksoftirqd
top -b -n1 | grep ksoftirqd
pidstat -t 1 | grep ksoftirqd- 验证的假设:软中断压力是否已经超过配额、被降级成普通线程(意味着网络处理延迟会不稳定)。
- 如果
ksoftirqd高 → 确认time_squeeze是否也在涨 → 考虑调netdev_budget,但更该先解决”为什么这么多软中断”(队列分散、中断合并)。
第 6 步(如果前面都不明显):看更细的分布和内核栈
# 谁在消耗 CPU?按线程看
pidstat -t 1
# 软中断具体花在哪个函数?
perf top -e cycles -C 0 # 只看 CPU0
perf record -a -g -- sleep 10 && perf report
# 进程卡在哪个内核函数?
offcputime-bpfcc -m 10000 5
cat /proc/<pid>/stack- 验证的假设:具体是协议栈的哪一段在耗时(iptables 规则过多?conntrack 表查找?)。
- 这是终极手段:当聚合指标都解释不了时,直接看代码级别的热点。
第 7 步:检查”隐形的处理成本”
iptables -L -n -v | wc -l # 规则数量,几千条规则会显著拖慢每个包
conntrack -C # conntrack 表使用量
sysctl net.netfilter.nf_conntrack_max
dmesg | grep -i "conntrack table full" # ⚠️ 经典问题- 验证的假设:是否有 netfilter/conntrack 这类”每包都要走”的重路径在拖慢处理。
- 典型场景:K8s 节点上 iptables 规则几万条、conntrack 表满导致丢包 —— 这在监控上完全看不出来。
总结这套流程的逻辑:
① 先破”平均值假象”(展开分布);② 再确定”慢在等什么”(CPU/IO/内存 PSI);③ 然后逐层定位丢包第一现场;④ 检查硬件能力是否被浪费(最常见的低垂果实);⑤ 最后才降到 perf/内核栈这一层。
顺序很重要——从最廉价、最可能命中的检查开始,逐步深入。
思考题 3(参数权衡): 两个场景:① 一个高频交易系统,要求收包延迟越低越好,CPU 成本不是问题;② 一个视频 CDN 节点,要求带宽吞吐最大化,单包延迟不敏感。请分别给出中断/软中断层的参数配置和理由。
【参考答案】
两个场景的配置几乎完全相反,这正说明”没有普适最优参数”。
① 高频交易系统(延迟优先,CPU 不是问题)
# 中断合并:关到最小甚至禁用
ethtool -C eth0 rx-usecs 0 rx-frames 1 adaptive-rx off
# 理由:每个包立刻中断,不攒批。攒批省 CPU 但增加几十微秒延迟——这里几十微秒是天大的事。
# Busy polling:应用主动轮询,不睡眠
sysctl -w net.core.busy_poll=50
sysctl -w net.core.busy_read=50
# 理由:彻底绕过"中断 → 软中断 → 唤醒进程"的调度延迟(可能几十微秒),
# 直接在用户上下文轮询网卡。延迟能降到几微秒。
# 代价:CPU 空转烧电。这里明确接受。
# 队列数:够用即可,不追求最多
ethtool -L eth0 combined 4
# 理由:交易系统连接数少、流量不大,不需要 16 队列。队列多反而分散、
# 可能让某些流落到"不理想"的核上。
# CPU 隔离 + 中断和应用绑同核
systemctl stop irqbalance && systemctl disable irqbalance
echo 2 > /proc/irq/<irq>/smp_affinity_list # 中断绑 CPU2
taskset -c 2 ./trading_app # 应用也绑 CPU2
# 内核启动参数:isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3
# 理由:让 CPU2 上"只有这一件事",没有时钟中断打扰(nohz_full,第 05 章)、
# 没有别的任务竞争。整条路径(网卡→中断→软中断→应用)全在同一个核,缓存全程热。
# NUMA 对齐(必做)
cat /sys/class/net/eth0/device/numa_node
# 确保选的 CPU 和网卡在同一个 NUMA node,否则每个包跨总线搬
# netdev_budget:适中偏小
sysctl -w net.core.netdev_budget=100
# 理由:延迟场景下,包本来不多,不需要大 budget。小 budget 让软中断更快让出 CPU。
# 缓冲区:不需要很大
# 理由:流量小,大缓冲区只会增加排队延迟(bufferbloat)。宁可小而快。
# 终极选项:考虑内核旁路(DPDK / AF_XDP / Solarflare onload)
# 理由:延迟要求到微秒级时,内核协议栈本身就是开销。100% 轮询、零中断、零系统调用。核心思路:不惜任何 CPU 代价消除延迟来源 —— 消除中断合并延迟、消除调度唤醒延迟、消除跨核跨 NUMA 搬运、消除时钟中断干扰。
② 视频 CDN 节点(吞吐优先,延迟不敏感)
# 中断合并:调到较大,大幅省 CPU
ethtool -C eth0 rx-usecs 100 rx-frames 128 tx-usecs 100 tx-frames 128
# 理由:大幅减少中断次数(可能减少 80%+),省下的 CPU 用来处理更多数据。
# 增加 100μs 延迟对视频流毫无影响(播放器有秒级缓冲)。
# 队列数:开到最大,匹配 CPU 数
ethtool -L eth0 combined 32 # 或匹配同 NUMA node 的核数
# 理由:最大化并行处理能力。
# 环形缓冲区:调大,吸收突发
ethtool -G eth0 rx 4096 tx 4096
# 理由:大文件传输有突发性,大环能减少丢包。延迟增加无所谓。
# netdev_budget:调大
sysctl -w net.core.netdev_budget=1000
sysctl -w net.core.netdev_budget_usecs=8000
# 理由:一次软中断处理更多包,减少软中断进出开销 → 吞吐提升。
# 单次占 CPU 久一些,对吞吐型负载可接受。
# backlog 和 socket 缓冲区:调大
sysctl -w net.core.netdev_max_backlog=30000
sysctl -w net.core.rmem_max=134217728
sysctl -w net.core.wmem_max=134217728
sysctl -w net.ipv4.tcp_rmem="4096 87380 67108864"
sysctl -w net.ipv4.tcp_wmem="4096 65536 67108864"
# 理由:大带宽长距离传输需要大窗口(BDP = 带宽 × 延迟)。
# 要跑满 10Gbps × 50ms RTT,需要约 62MB 的窗口。
# 这是吞吐场景的必调项(第 10 章细讲)。
# 中断分散 + NUMA 对齐
systemctl stop irqbalance
# 把队列中断均匀绑到网卡所在 NUMA node 的所有核
# 理由:最大化并行 + 避免跨 NUMA 搬运
# XPS(发送方向)
# 给每个 tx 队列绑定 CPU,避免多核争抢发送队列锁
# 理由:CDN 是发送为主的负载,发送路径的优化比接收更重要
# 开启硬件卸载(offload)
ethtool -K eth0 tso on gso on gro on lro on
# 理由:TSO/GSO 让网卡自己做分片(CPU 只处理大块),GRO 让接收侧合并小包。
# 对大流量传输是巨大的 CPU 节省。
# 注意:延迟场景反而可能要关掉 GRO(它会攒包增加延迟)——又是一个相反的选择
# sendfile / splice 零拷贝
# 理由:CDN 发文件,用 sendfile 让数据不经过用户态,省两次拷贝核心思路:不惜牺牲延迟换取每 CPU 周期处理更多字节 —— 中断攒批、大 budget、大缓冲区、硬件卸载、零拷贝。
对比总结表:
| 参数 | 高频交易 | 视频 CDN | 相反的原因 |
|---|---|---|---|
rx-usecs(中断合并) | 0(不合并) | 100(大量合并) | 攒批省 CPU 但加延迟 |
netdev_budget | 100(小) | 1000(大) | 大 budget 吞吐好但单次占 CPU 久 |
| 缓冲区 | 小 | 大 | 大缓冲吸收突发但增加排队延迟 |
| GRO(接收合并) | 可能关闭 | 开启 | 合并省 CPU 但攒包加延迟 |
| busy_poll | 开启 | 关闭 | 烧 CPU 换延迟 |
| 队列数 | 够用(4) | 最大(32) | 吞吐要最大并行,延迟要专核专用 |
| CPU 隔离/nohz_full | 必做 | 不需要 | 延迟场景要消除一切干扰 |
这道题的终极启示:
“网络调优参数”不存在标准答案,只存在”你在优化什么”的答案。 几乎每一个旋钮都是延迟 ↔ 吞吐/CPU 效率这条轴上的一个位置。
所以真正的调优第一步,永远不是查参数表,而是明确回答:我这个系统的成败,取决于 P99 延迟,还是取决于单位 CPU 能处理多少字节? 答错这个问题,后面所有参数都会调反。
而且——这两类负载绝不该混跑在同一台机器上,因为它们要求的参数是互斥的。这本身就是一个架构层面的结论。
思考题 4(进阶·总账): 你负责一个 K8s 节点(48 核,双 NUMA,25G 网卡),上面跑着几十个 Pod,业务反馈”偶发的网络延迟尖刺和少量丢包”。请设计一套完整的排查与优化方案,并说明 K8s 环境相比裸机额外要注意什么。
【参考答案】
K8s 节点的网络路径比裸机长得多,这题的关键是认识到额外的处理层次。
首先:画出 K8s 节点上一个包的完整路径(理解全貌才能排查)
物理网卡(25G)
↓ RSS 哈希 → 多队列 → 中断 → 软中断
↓
【额外层 1】iptables / nftables 规则链(kube-proxy 生成)
↓ Service ClusterIP → Pod IP 的 DNAT
↓ 可能有几千到几万条规则!每个包都要线性匹配
↓
【额外层 2】conntrack 连接跟踪表
↓ NAT 必须依赖它记录连接状态
↓
【额外层 3】CNI 网络插件的数据面
↓ 可能是:veth pair + bridge / IPVS / eBPF(Cilium) / overlay 封装(VXLAN/IPIP)
↓
【额外层 4】如果是 overlay:封装/解封装(额外的 IP 头 + UDP 头)
↓
veth pair → Pod 的 network namespace
↓
Pod 内的协议栈 → 应用 socket
每一层都是潜在的延迟和丢包点。这就是 K8s 网络排障比裸机难的原因。
排查步骤:
第 1 步:基础分布检查(和裸机相同)
mpstat -P ALL 1 # 每核 si 分布
cat /proc/softirqs | grep NET
cat /proc/interrupts | grep eth
cat /proc/net/softnet_stat # dropped / time_squeeze
ethtool -S eth0 | grep -iE "drop|err|miss"
ethtool -l eth0 # 队列数是否用满
cat /sys/class/net/eth0/device/numa_node第 2 步:K8s 特有 —— 检查 conntrack(最高频的元凶)
sysctl net.netfilter.nf_conntrack_max # 上限
cat /proc/sys/net/netfilter/nf_conntrack_count # 当前用量
dmesg | grep -i "nf_conntrack: table full" # ⚠️ 关键证据
sysctl net.netfilter.nf_conntrack_buckets # 哈希桶数- 为什么这是 K8s 的头号问题:Service 的 ClusterIP 依赖 NAT,NAT 依赖 conntrack。每个连接(包括每个 DNS 查询!)都占一个 conntrack 条目。
- 表满的现象:
nf_conntrack: table full, dropping packet—— 随机丢包,毫无规律,而且应用层完全不知道为什么。 - 修法:
sysctl -w net.netfilter.nf_conntrack_max=1048576 sysctl -w net.netfilter.nf_conntrack_buckets=262144 # 缩短超时,让条目更快释放 sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30 sysctl -w net.netfilter.nf_conntrack_udp_timeout=30 - 权衡:调大 conntrack_max 消耗内核内存(每条约 300 字节,100 万条约 300MB)。这是必须付的钱。
- 更好的解:用 eBPF 的 CNI(Cilium) 替代 kube-proxy 的 iptables 模式 —— 它可以绕过 conntrack 和 iptables,直接在 eBPF 里做 Service 转发。
第 3 步:K8s 特有 —— 检查 iptables 规则规模
iptables-save | wc -l # 总规则行数
iptables -t nat -L KUBE-SERVICES -n | wc -l
kubectl get svc --all-namespaces | wc -l # Service 数量
kubectl get endpoints --all-namespaces -o json | jq '[.items[].subsets[]?.addresses[]?] | length'- 问题机理:kube-proxy 的 iptables 模式为每个 Service × 每个 Endpoint 生成规则,规则是线性匹配的。1000 个 Service × 10 个 Pod = 上万条规则,每个包都要遍历。
- 现象:延迟随 Service 数量增长而上升;规则更新时
iptables-restore要几秒钟(期间可能丢包)。 - 修法:
- 切到 IPVS 模式(
--proxy-mode=ipvs):哈希表查找,O(1) 而非 O(n)。这是中大规模集群的必选项。 - 或用 Cilium(eBPF):连 IPVS 都不需要,直接在 eBPF map 里查。
- 切到 IPVS 模式(
- 这与第 08 章”成本假设”的道理一致:iptables 模式在几十个 Service 时完全够用,到几千个 Service 时前提就崩了。
第 4 步:K8s 特有 —— 检查 overlay 封装开销和 MTU
ip link show | grep -E "vxlan|flannel|cali|cilium" # 有没有 overlay
ip link show eth0 | grep mtu
# Pod 内:
kubectl exec -it <pod> -- ip link show eth0 | grep mtu- MTU 问题是 K8s 网络的经典坑:
- 物理网卡 MTU 1500,VXLAN 封装要加 50 字节头 → Pod 内 MTU 应该是 1450;
- 如果 Pod MTU 配成 1500,封装后变成 1550 > 1500 → 需要分片,或者被丢弃;
- 现象极具迷惑性:小包正常,大包丢失。表现为”HTTP 请求头能通、body 一大就卡死”、“TLS 握手失败”。
- 排查:
# 从 Pod 里测试不分片的最大包 kubectl exec -it <pod> -- ping -M do -s 1400 <目标IP> # 逐步加大 -s,找到失败的临界值 - 修法:统一配置 CNI 的 MTU,或启用 jumbo frame(物理网络支持的话,MTU 9000 能大幅减少封装开销占比)。
第 5 步:K8s 特有 —— 检查 veth 和 Pod 内的软中断
# 宿主机上看每个 veth 的丢包
ip -s link show | grep -A5 veth
# 进 Pod 的 netns 看
nsenter -t <pod-pid> -n cat /proc/net/softnet_stat
nsenter -t <pod-pid> -n netstat -s | grep -i drop- 要点:Pod 内部也有完整的协议栈和软中断!Pod 的 CPU limit 会限制它处理网络包的能力。
- 一个隐蔽的问题:Pod 被 CFS throttle(第 05 章)时,它的网络处理也被冻住,导致接收缓冲区溢出丢包。网络丢包的根因是 CPU 配额! 这种跨维度的因果很难查。
第 6 步:检查 DNS(K8s 里最常见的”网络慢”)
# 经典问题:ndots:5 导致每次查询发 5 次 DNS 请求
kubectl exec -it <pod> -- cat /etc/resolv.conf
# options ndots:5 ← 查 "api.example.com" 会先试
# api.example.com.default.svc.cluster.local 等一堆
kubectl -n kube-system logs -l k8s-app=kube-dns | grep -i timeout- 另一个经典:conntrack 的 DNS 竞态 bug(内核 5.1 前),UDP DNS 查询在 conntrack 里发生竞态导致 5 秒超时。
- 修法:
NodeLocal DNSCache、调ndots:2、用 TCP 做 DNS、升级内核。
第 7 步:定位到具体函数(终极手段)
perf top -C <忙的核> # 看软中断花在哪
# 如果看到 nf_conntrack_* 或 ipt_do_table 占大头 → 确认是 netfilter 开销
perf record -a -g -- sleep 10
# eBPF 工具追踪丢包点
dropwatch -l kas # 或
# BCC 的 skbdrop / trace tcpdropipt_do_table占 CPU 大头 = iptables 规则太多,该换 IPVS/eBPF。nf_conntrack_*占大头 = conntrack 压力大。
优化方案(按性价比排序):
1. 网卡多队列 + 中断绑定 + NUMA 对齐(基础,必做)
ethtool -L eth0 combined 24 # 匹配单 NUMA node 的核数
systemctl stop irqbalance
# 把中断绑到网卡所在 NUMA node 的核上- 权衡:无实质代价。双 NUMA 机器上尤其重要——中断绑错 node,每个包都跨总线。
2. conntrack 调优(K8s 必做)
- 调大
nf_conntrack_max,缩短超时。代价:内核内存。
3. kube-proxy 切 IPVS 模式(中大规模必做)
- 收益:Service 转发从 O(n) 变 O(1),规则更新从秒级变毫秒级。
- 代价:IPVS 需要内核模块;某些边缘行为和 iptables 模式有差异,要测试。
4. 中断合并适度调大 + 开启 GRO/TSO
ethtool -C eth0 rx-usecs 30
ethtool -K eth0 gro on tso on gso on- 权衡:增加几十微秒延迟,大幅省 CPU。对绝大多数 K8s 业务(HTTP 服务)完全可接受。
- ⚠️ 但注意:如果节点上有延迟极敏感的 Pod,这个决策会伤害它 —— 这就是为什么延迟敏感业务应该用独立节点池。
5. MTU 统一配置 / 考虑 jumbo frame
- 收益:消除分片问题;jumbo frame 让封装开销占比从 3.3% 降到 0.5%。
- 代价:整个网络路径都要支持,配置复杂度高。
6. 考虑 eBPF CNI(Cilium)替代 iptables 链路
- 收益:绕过 iptables 和 conntrack,延迟和 CPU 都显著改善,规模扩展性好得多。
- 代价:迁移成本、需要较新内核、团队学习曲线、可观测工具链要重建。
7. Pod 层面:确认 CPU limit 不会 throttle 到网络处理
- 检查
nr_throttled/nr_periods(第 05 章)。 - 这一条最容易被漏掉:网络丢包的根因可能是 CPU 配额。
K8s 相比裸机额外要注意的(总结):
- 路径长得多:多了 iptables/IPVS、conntrack、CNI 数据面、veth、可能还有 overlay 封装。每一层都能丢包。
- conntrack 是头号新增瓶颈:Service NAT 依赖它,表满就随机丢包,而且日志只在
dmesg里。 - 规则数量随集群规模线性增长:iptables 模式在几千 Service 时会崩。“规模逼结构优化”。
- MTU 必须全链路一致:overlay 封装的开销会导致”小包通、大包丢”这种迷惑性故障。
- Pod 的 CPU limit 会限制它的网络处理能力:CPU throttle → 网络丢包,跨维度的因果。
- DNS 是 K8s 里”网络慢”的最常见真实原因(ndots、conntrack 竞态、CoreDNS 容量)。
- 可观测性要进 netns:宿主机的
netstat看不到 Pod 内的协议栈统计,必须nsenter。 - 延迟敏感和吞吐型业务应该分节点池 —— 因为它们需要的网卡/中断参数是互斥的(见思考题 3)。
串起来的一句话:
K8s 网络排障的核心是认识到你面对的不是一条路径,而是七八层叠加的路径。所以方法是:① 先画出完整路径;② 逐层拿到丢包/延迟统计;③ 找最上游的非零计数;④ 优先怀疑 K8s 特有的新增层(conntrack / iptables 规模 / MTU / DNS),因为它们是裸机时代没有的、也是团队经验最薄弱的地方。