第 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 但收包要干的活很多

一个网络包收进来,要做的事:

  1. 从 DMA 环形缓冲区取出 skb(网络包结构);
  2. 解析以太网头、判断协议;
  3. 走 IP 层:校验、查路由表、判断是本机的还是要转发的;
  4. 走 TCP 层:查 socket 哈希表、检查序列号、更新拥塞窗口、可能要发 ACK;
  5. 把数据放进 socket 的接收缓冲区;
  6. 唤醒等在 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_stat
00a1b2c3 00000000 00001f4a 00000000 00000000 ... 
00000123 00000000 00000000 00000000 00000000 ...
   ↑        ↑         ↑
 processed dropped  time_squeeze
 (处理的包) (丢的包)  (⚠️ budget/时间用完还没捞干的次数)

每行是一个 CPU。三个关键列(十六进制!):

  1. 第 1 列 processed:该 CPU 处理的包数。各 CPU 之间严重不均 = 中断没分散好(本章核心问题)。
  2. 第 2 列 dropped:因为 netdev_max_backlog 满而丢的包。非零就是在丢包!
  3. 第 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

墨:如果一块网卡只有一个中断,那它所有的包都会:

  1. 中断投给 CPU0;
  2. 上半部在 CPU0 跑;
  3. 触发的 NET_RX_SOFTIRQ 也在 CPU0 跑(软中断在触发它的那个 CPU 上执行!);
  4. 整个协议栈处理都在 CPU0;
  5. 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?

墨:对!而这会带来两个严重问题:

  1. 乱序:同一个 TCP 连接的包在不同 CPU 上并行处理,完成的顺序可能颠倒 → TCP 收到乱序包 → 触发不必要的重传和拥塞控制降速,性能暴跌。
  2. 缓存不亲和 + 锁竞争:同一个连接的 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. 应用层
# (应用自己的丢弃计数)

墨:好,现在我们有三层同时在丢包:

  1. 网卡环形缓冲区(rx_no_buffer_count);
  2. 内核 backlog(softnet_stat dropped);
  3. 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 监控 ← 最重要

五个教训:

  1. 总量掩盖分布。 “总 CPU 不高但性能上不去” → 立刻展开每核视图。同理适用于:平均延迟 vs P99、总内存 vs 各 NUMA node、总磁盘 IO vs 各设备。
  2. 缓冲区解决突发,不解决持续过载。 调大缓冲区能救突发,但持续过载下它只是把丢包推迟并增加延迟(bufferbloat)。持续过载只能提高处理能力或降低输入。
  3. 丢包要逐层统计,但要分清根因层和被连带层。 上游堵了下游全满,只修下游是徒劳。
  4. 抄配置是重大风险。 combined 1、elevator=deadline、read_ahead_kb=4096 这类参数,在旧硬件上正确,在新硬件上就是灾难。每次迁移都要重新审视所有参数的前提假设。
  5. 让整条路径的处理位置对齐(网卡队列 → 中断 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_cntRFS:让处理位置追随消费位置全路径同核、缓存最热 ↔ 需维护流表(内存 + 少量开销)
xps_cpus(per tx queue)发送队列与 CPU 绑定避免多核争抢同一发送队列的锁
net.core.busy_poll / busy_read应用主动轮询而不睡眠延迟降到微秒级 ↔ 烧 CPU 空转。只在延迟远比 CPU 贵时用
kernel.watchdog_thresh(第 05 章)检测 CPU 卡死中断/软中断风暴导致的卡死,靠它留现场

七、这章的认知钉子

  1. 中断 vs 轮询是个真两难:中断在低负载下最优、高负载下崩(中断风暴/活锁);轮询反之。NAPI 的解法不是”选一个”,是让两者自然过渡——中断触发时立刻关中断改轮询,捞干了再开中断。
  2. 上半部/下半部的划分意图:分离”必须立刻响应”和”需要慢慢干”。 中断上下文屏蔽中断、不能睡眠,所以必须极短。
  3. 软中断在触发它的那个 CPU 上执行。 所以硬中断分布不均 = 软中断不均 = 协议栈处理不均 = 单核瓶颈。这是”一个核打满”问题的根本机理。
  4. ksoftirqd 占用高 = 软中断超过配额被降级成普通线程。 这体现”再紧急的事也不能无限抢占”这一通用原则(同第 05 章 sched_rt_runtime_us 的 5% 安全带)。
  5. RSS 按五元组哈希而非轮询,是为了流亲和性——同流同核,保证不乱序 + 缓存热 + 无锁竞争。并行化的正确单位是”流”而不是”单个请求”。
  6. RFS 的思路是”让处理位置追随消费位置” ——这是”把计算搬到数据旁边”这一普适优化的一个实例。
  7. /proc/net/softnet_stat 的三列(processed / dropped / time_squeeze)是收包路径最重要又最少人看的体检指标。
  8. 总量掩盖分布。 排障时永远要展开:每核 CPU、每 NUMA node、延迟分布(而非平均)。
  9. 缓冲区解决突发,不解决持续过载。 无脑调大缓冲区会带来 bufferbloat(延迟暴涨)且掩盖真实问题。
  10. 抄配置是重大风险。 每个参数都建立在特定硬件/负载假设上,迁移环境时必须重新审视。

**


实战补遗一:软中断风暴——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 大户:

  1. writeback(flush-/writeback 工作):脏页回写(见第 20 章 wbt 实战补遗),脏页多时 kworker 跑 wb_workfn 刷盘,绑核的话压该核。
  2. 网卡收包下半部:某些驱动把部分收包处理放 workqueue(而非软中断),burst 时 kworker 忙。
  3. 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_budget100(小)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 里查。
  • 这与第 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 tcpdrop
  • ipt_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 相比裸机额外要注意的(总结):

  1. 路径长得多:多了 iptables/IPVS、conntrack、CNI 数据面、veth、可能还有 overlay 封装。每一层都能丢包。
  2. conntrack 是头号新增瓶颈:Service NAT 依赖它,表满就随机丢包,而且日志只在 dmesg 里。
  3. 规则数量随集群规模线性增长:iptables 模式在几千 Service 时会崩。“规模逼结构优化”。
  4. MTU 必须全链路一致:overlay 封装的开销会导致”小包通、大包丢”这种迷惑性故障。
  5. Pod 的 CPU limit 会限制它的网络处理能力:CPU throttle → 网络丢包,跨维度的因果。
  6. DNS 是 K8s 里”网络慢”的最常见真实原因(ndots、conntrack 竞态、CoreDNS 容量)。
  7. 可观测性要进 netns:宿主机的 netstat 看不到 Pod 内的协议栈统计,必须 nsenter。
  8. 延迟敏感和吞吐型业务应该分节点池 —— 因为它们需要的网卡/中断参数是互斥的(见思考题 3)。

串起来的一句话:

K8s 网络排障的核心是认识到你面对的不是一条路径,而是七八层叠加的路径。所以方法是:① 先画出完整路径;② 逐层拿到丢包/延迟统计;③ 找最上游的非零计数;④ 优先怀疑 K8s 特有的新增层(conntrack / iptables 规模 / MTU / DNS),因为它们是裸机时代没有的、也是团队经验最薄弱的地方。