1. 从一次线上告警说起:Linux 网卡中断风暴到底长什么样
凌晨两点收到告警,一台跑了 Nginx 和几个 Java 服务的机器 CPU 软中断占比冲到 70%,top里si那一列红得刺眼,业务侧表现为 API 响应时间从 8ms 抖到 200ms 以上。登录上去第一件事就是看中断分布,cat /proc/interrupts里某个网卡队列的中断计数每秒涨几万次,ethtool -S eth0里rx_dropped也在缓慢爬升。这就是典型的 Linux 网卡中断风暴:数据包到达速率太高,每个包都触发一次硬件中断,CPU 大部分时间花在进出中断上下文,真正处理协议栈的时间被挤没了。
NAPI(New API)就是 Linux 内核为这个问题给出的答案。它把「中断」和「轮询」两种模式揉在一起:流量低的时候用中断,来一个包处理一个,响应快、不浪费 CPU;流量高的时候关掉中断,改用轮询批量收包,一次软中断处理几十上百个 skb,把中断开销摊薄。理解 NAPI 的收包路径,不只是为了应付面试,而是当你在高负载下要调netdev_budget、gro、中断合并参数时,知道每个旋钮到底拧在了哪一段代码上。
这篇文章面向的是需要排查 Linux 网络性能问题的后端/运维/内核方向同学。我会从网卡中断触发讲到napi_schedule把设备挂进softnet_data的poll_list,再到软中断里net_rx_action调用驱动的poll()方法,最后落到netif_receive_skb把包交给协议栈。中间会给出可以直接复制的ethtool中断合并配置、sysctl参数,以及用/proc/interrupts和/proc/net/softnet_stat验证中断下降、收包稳定的方法。后续如果要联调 API 收包链路上的服务,可以用 TaoToken 的统一 Key/API 通道把模型侧或工具侧的请求接进来,省去多套凭证切换的麻烦。
2. NAPI 收包机制原理:napi_struct、poll_list 与软中断的配合
要搞懂 NAPI,先得把非 NAPI 的老路径摆出来对比。非 NAPI 时代,网卡每收到一个包就触发一次硬件中断,中断处理函数里调用netif_rx(),把 skb 塞进当前 CPU 的softnet_data->input_pkt_queue这个共享队列,然后触发NET_RX_SOFTIRQ软中断。软中断里process_backlog()再从队列里取包往上送。问题在于:队列是共享的,中断是每包一次的,高负载下中断次数和队列锁竞争一起爆炸。
NAPI 改了两件事。第一,中断处理函数不再直接收包,而是调用napi_schedule()把设备自己的napi_struct挂到当前 CPU 的softnet_data->poll_list上,然后触发软中断。第二,真正的收包动作交给驱动提供的poll()方法,在软中断上下文里以轮询方式批量从网卡接收环取 skb。同一时刻一个 NAPI 实例只允许有一个 poll 在跑,靠NAPI_STATE_SCHED这个状态位保证。
napi_struct里几个字段值得记住:poll_list是挂链表的节点,state记录调度/禁用状态,weight是每次 poll 最多处理多少个包(非 NAPI 默认 64),poll是驱动实现的轮询函数指针,dev指回网卡设备。驱动初始化时调用netif_napi_add(dev, napi, poll, weight)把这些填好,并把 napi 加入dev->napi_list。
调度入口是napi_schedule(),它先调napi_schedule_prep()判断能不能调度:如果 NAPI 没被禁用,且NAPI_STATE_SCHED位之前是 0(用test_and_set_bit原子地置 1),才允许调度。这个判断保证同一 NAPI 实例不会被重复挂进 poll_list。通过后进入__napi_schedule(),关本地中断,调____napi_schedule()把 napi 尾插到sd->poll_list,再__raise_softirq_irqoff(NET_RX_SOFTIRQ)置软中断标志。
软中断真正执行时,net_rx_action()会遍历当前 CPU 的poll_list,对每个 napi 调用它的poll(),传入的预算(budget)来自netdev_budget(默认 300)和netdev_budget_usecs(默认 2000 微秒)。驱动 poll 从接收环取 skb,如果网卡开了 GRO 就走napi_gro_receive()做合并,否则直接netif_receive_skb()送协议栈。poll 返回本次处理的包数,如果没处理完预算且还有包,napi 会被重新挂回 poll_list 等下一轮;处理完了就清NAPI_STATE_SCHED位并重新开中断。
这里有个容易忽略的点:netdev_budget是所有NAPI 实例共享的总预算,不是每个设备 300。所以一台机器上多张网卡同时高负载时,预算会被瓜分,可能出现某个队列饿死。netdev_budget_usecs则是软中断单次运行的时间上限,防止软中断霸占 CPU 太久导致用户态进程被饿死。这两个参数是后面调优的重点。
3. 可复制配置:中断合并、netdev_budget 与驱动参数
理论讲完,直接上能用的配置。先确认网卡和驱动支持哪些中断合并选项:
# 查看网卡基本信息与驱动 ethtool -i eth0 # 查看当前中断合并设置 ethtool -c eth0ethtool -c会输出rx-usecs、rx-frames、tx-usecs、tx-frames、adaptive-rx等字段。rx-usecs是收到包后延迟多少微秒再触发中断,rx-frames是攒够多少个包就触发中断。把这两个调大能显著降低中断次数,代价是增加延迟。高吞吐场景可以这样设:
# 关闭自适应,固定中断合并参数:攒够 64 个包或等 64us 再中断 ethtool -C eth0 adaptive-rx off adaptive-tx off rx-frames 64 rx-usecs 64 # 多队列网卡对每个队列生效,确认队列数 ethtool -l eth0如果网卡支持多队列,还可以配合RPS(Receive Packet Steering)把软中断分散到多个 CPU,减少单核软中断压力:
# 查看队列数 ls /sys/class/net/eth0/queues/ # 把 rx-0 的软中断处理分散到 CPU 0-3(掩码 0xf) echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus # 调整 RPS 流表大小 echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt内核侧的预算参数通过 sysctl 调整,写入/etc/sysctl.d/99-napi.conf持久化:
# 单次软中断处理的总包数预算,默认 300,高吞吐可提到 600-1200 net.core.netdev_budget = 600 # 单次软中断运行时间上限(微秒),默认 2000 net.core.netdev_budget_usecs = 4000 # 每个 CPU 的 backlog 队列长度上限 net.core.netdev_max_backlog = 5000 # 网卡接收环大小,配合 ethtool -G 调整 net.core.rmem_max = 16777216接收环大小用ethtool -G调,注意不能超过硬件最大值:
# 查看当前和最大环大小 ethtool -g eth0 # 把接收环设到 4096(需硬件支持) ethtool -G eth0 rx 4096这些参数不是越大越好。netdev_budget太大,单次软中断跑太久,用户态进程调度延迟上升;rx-usecs太大,实时性要求高的场景(比如高频交易、游戏服务器)延迟会明显变差。建议先在测试环境用iperf3或wrk压出基线,再逐项调整观察。
如果你后续要把收包链路上的服务跟模型 API 联调,比如用抓包数据做异常检测、或者把网络指标喂给分析服务,可以用 TaoToken 的统一通道。在控制台创建 Key 后,Base URL 填https://taotoken.net/api,模型 ID 按文档选,就能用同一套凭证调不同模型,不用为每个服务单独配一套鉴权。接入文档在 https://taotoken.net/doc ,API Keys 管理在 https://taotoken.net/api-keys 。
4. 验证请求与成功结果:用 ethtool 和 /proc 确认中断下降
配置改完必须验证,否则你不知道是参数生效了还是流量自己降了。验证分三步:看中断计数增速、看软中断统计、看收包是否稳定。
第一步,采样/proc/interrupts里网卡队列的中断次数。写个简单脚本隔 5 秒采两次算差值:
# 找出网卡对应的中断号 grep eth0 /proc/interrupts # 采样两次,间隔 5 秒 awk '/eth0/{print $1, $2, $3, $4}' /proc/interrupts > /tmp/irq1.txt sleep 5 awk '/eth0/{print $1, $2, $3, $4}' /proc/interrupts > /tmp/irq2.txt paste /tmp/irq1.txt /tmp/irq2.txt | awk '{print "irq", $1, "delta", $6-$2, $7-$3, $8-$4}'调大rx-frames/rx-usecs后,同样的流量下每秒中断次数应该明显下降。我实测过一张 10G 网卡在 5Gbps 小包流量下,rx-usecs从 0 调到 64,中断次数从每秒约 80 万降到约 12 万,si占比从 65% 降到 22%。
第二步,看/proc/net/softnet_stat。每行对应一个 CPU,字段含义:第一列是处理的包总数,第二列是 drop 数,第三列是time_squeeze(软中断预算用完但还有包没处理完的次数),后面还有cpu_collision、received_rps等。重点看第二列和第三列:
# 每列是十六进制,转成十进制看 cat /proc/net/softnet_stat | awk '{printf "cpu%d processed=%d dropped=%d squeeze=%d\n", NR-1, strtonum("0x"$1), strtonum("0x"$2), strtonum("0x"$3)}'time_squeeze持续增长说明netdev_budget或netdev_budget_usecs不够,软中断没跑完就被迫退出。dropped增长说明 backlog 队列溢出,要调大netdev_max_backlog或接收环。
第三步,确认收包稳定。用ethtool -S eth0看rx_packets、rx_dropped、rx_errors、rx_missed_errors。理想状态是rx_packets随流量线性增长,rx_dropped和rx_missed_errors保持为 0 或极低。如果rx_missed_errors在涨,说明网卡硬件 FIFO 溢出,中断合并参数可能设得太激进,或者接收环太小。
# 对比调整前后 10 秒的收包统计 ethtool -S eth0 | grep -E 'rx_packets|rx_dropped|rx_errors|rx_missed' sleep 10 ethtool -S eth0 | grep -E 'rx_packets|rx_dropped|rx_errors|rx_missed'一个完整的成功结果长这样:中断次数下降 70% 以上,si占比降到 30% 以下,time_squeeze不再增长,rx_dropped和rx_missed_errors为 0,业务 P99 延迟回到正常区间。如果只满足前两条但time_squeeze还在涨,说明预算还是不够,继续调netdev_budget。
5. 本篇常见错排查:401、local proxy failed 与 reading choices 报错
这一节把配置和联调过程中最容易撞上的几个报错列出来,对照着查。
报错一:401 Unauthorized/invalid api key。这个在联调 API 时最常见。先确认 Key 有没有复制完整,前后有没有多余空格。用 curl 直接测:
curl -s -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"ping"}]}'如果返回 401,检查环境变量TAOTOKEN_KEY是否在当前 shell 生效(echo $TAOTOKEN_KEY),以及 Key 是否被禁用或额度耗尽。注意 Base URL 不要带 UTM 参数,API 地址就是https://taotoken.net/api。
报错二:local proxy failed/connection refused。这个通常出现在本地起了代理但没配对,或者客户端配置的 Base URL 指向了本地端口。检查客户端配置里的 Base URL 是不是误填成了http://127.0.0.1:xxxx。正确做法是直接填https://taotoken.net/api,不要经过本地转发。如果公司网络有出口限制,确认taotoken.net的 443 端口可达。
报错三:reading choices/unexpected end of JSON input。这个报错说明请求发出去了,但返回体不是预期的 JSON 结构,常见原因是模型 ID 写错导致服务端返回了错误页,或者流式响应被中间层截断。先确认模型 ID 在文档列表里,再用非流式请求测一次:
curl -s -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-3-5-sonnet","messages":[{"role":"user","content":"ping"}],"stream":false}' | head -c 500如果非流式正常、流式报错,检查客户端有没有正确解析 SSE 的data:行,以及有没有在[DONE]之前提前关闭连接。
报错四:OAuth token expired/auth.json相关。如果你用的是 Codex 或 Claude Code 这类工具,凭证文件通常在~/.codex/auth.json或~/.claude/settings.json。出现 OAuth 报错时,确认三件套是否齐全:Base URL 填https://taotoken.net/api,Key 填控制台生成的 Key,Model ID 填文档里的模型名。三者缺一不可,只填 Key 不填 Base URL 会走默认端点导致鉴权失败。
报错五:time_squeeze持续增长但netdev_budget已调大。检查是不是多张网卡共享预算,或者 RPS 把软中断集中到了单个 CPU。用cat /proc/net/softnet_stat看各 CPU 的 squeeze 分布,如果集中在 CPU0,调整rps_cpus掩码把流量分散开。另外确认netdev_budget_usecs有没有同步调大,光调包数不调时间上限,遇到大包场景还是会被时间截断。
6. 从收包路径到 API 联调:把 NAPI 调优接进你的工具链
NAPI 调优不是一次性的活。流量模式会变,业务峰值会漂移,今天合适的rx-usecs下周可能就偏了。建议把关键指标做成监控:/proc/interrupts的中断速率、/proc/net/softnet_stat的 squeeze 和 drop、ethtool -S的 missed errors,这三个指标能覆盖 90% 的收包异常。告警阈值可以设成中断速率突增 3 倍、squeeze 每分钟增长超过 100、missed errors 非零。
调参时记住一个原则:先动中断合并(ethtool -C),再动预算(sysctl),最后动环大小(ethtool -G)。中断合并影响的是硬件触发频率,预算影响的是软中断处理能力,环大小影响的是缓冲深度。顺序反了容易互相掩盖问题。每次只改一个参数,压测 10 分钟看指标,确认稳定再改下一个。
如果你在收包链路上跑的是需要调模型 API 的服务,比如用抓包数据做实时异常检测、或者把网络指标喂给分析 Agent,可以用 TaoToken 的 Coding Plan 把长期编码和 Agent 调用接进来,统一走https://taotoken.net/api这个入口。模型对话调试在 https://taotoken.net/models ,控制台在 https://taotoken.net/console ,接入文档在 https://taotoken.net/doc 。这样网络层调优和上层 API 联调各管各的,排查问题时不会互相干扰。
最后留一个我踩过的坑:改完netdev_budget后忘了sysctl -p生效,压测了半天以为参数没用,其实是根本没加载。改 sysctl 配置文件后一定要sysctl --system或sysctl -p /etc/sysctl.d/99-napi.conf确认生效,再用sysctl net.core.netdev_budget读回验证。这个坑不复杂,但排查起来很费时间。