1. 从一次丢包告警说起:NAPI 到底解决了什么问题
线上有台 4 核 8G 的网关机,跑着转发和采集两类任务。某天下午监控开始报netstat -s里的receive buffer errors持续上涨,ss -lnt看应用侧连接正常,但mpstat -P ALL 1显示 CPU0 的%soft冲到 70% 以上,其余三个核却闲得很。抓包看流量峰值也就 1.2Gbps,远没到网卡线速,问题显然不在带宽,而在收包路径本身。
顺着/proc/net/softnet_stat看,第一列(处理的包数)和第二列(因预算耗尽被丢弃的包数)比例接近 20:1,第三列time_squeeze也在涨。这说明软中断每次被调度进来,还没把队列里的包处理完,预算就用光了,剩下的包只能等下一次软中断——而中断风暴又让 CPU 频繁进出硬中断上下文,真正干活的时间被切得稀碎。
这就是典型的「中断太多、轮询太少」场景。Linux 收包路径从网卡硬中断到协议栈,中间隔着一层 NAPI(New API)机制,它的设计初衷正是把「来一个包中断一次」改成「来一批包轮询一次」。理解 NAPI 的调度逻辑,再配合ethtool和sysctl调几个参数,上面这台机器的问题基本能压下去。
这篇会从 NAPI 的结构体和调度流程讲起,拆解中断缓解与轮询混合的机制,给出可直接复制的ethtool -C、sysctl配置,最后用 TaoToken 的统一 Key 观测 API 侧收包延迟,对比中断合并前后吞吐和 CPU 占用的变化。适合做网关、负载均衡、高频采集的运维和后台开发同学跟做。
2. NAPI 原理拆解:napi_struct 与软中断轮询机制
NAPI 的核心思路可以用一句话概括:低负载走中断,高负载切轮询,切换的开关是NAPI_STATE_SCHED标志位。要理解它,得先看struct napi_struct这个结构体,它是每个支持 NAPI 的网卡驱动在初始化时注册的实例。
struct napi_struct { struct list_head poll_list; /* 挂到 per-CPU 的轮询队列 */ unsigned long state; /* NAPI_STATE_SCHED / DISABLE / NPSVC */ int weight; /* 单次 poll 最多处理多少个包 */ int (*poll)(struct napi_struct *, int); /* 驱动提供的轮询函数 */ unsigned int gro_count; struct net_device *dev; struct list_head dev_list; struct sk_buff *gro_list; struct sk_buff *skb; };驱动通过netif_napi_add(dev, napi, poll_func, weight)注册,其中weight就是「预算」的初始值,非 NAPI 设备默认 64。这个数字很关键:它决定了每次软中断被调度时,最多从网卡环形缓冲区取多少个包处理。取完还没取空,就重新触发一次软中断继续;取空了,就清掉NAPI_STATE_SCHED,重新打开硬中断,回到中断模式。
调度入口在硬中断处理函数里,驱动调用napi_schedule():
static inline void napi_schedule(struct napi_struct *n) { if (napi_schedule_prep(n)) __napi_schedule(n); }napi_schedule_prep()做两件事:检查NAPI_STATE_DISABLE是否置位(置位说明正在禁用 NAPI,不能调度),以及用test_and_set_bit原子地设置NAPI_STATE_SCHED。这个原子操作保证了同一时刻只有一个 NAPI poll 实例在跑——如果已经调度过了,重复的中断进来直接返回,不会重复入队。这正是「中断缓解」的关键:高负载时大量中断被合并成一次调度。
__napi_schedule()把napi_struct挂到当前 CPU 的softnet_data->poll_list,然后__raise_softirq_irqoff(NET_RX_SOFTIRQ)触发软中断。软中断处理函数net_rx_action()会遍历poll_list,对每个 napi 调用它的poll()方法,同时维护一个总预算netdev_budget(默认 300)和单次时间上限netdev_budget_usecs(默认 2000 微秒)。预算耗尽或超时,net_rx_action()就退出,剩下的 napi 留到下一次软中断。
驱动的poll()方法从网卡环形缓冲区取 skb,走napi_gro_receive()(开启 GRO 时)或netif_receive_skb()提交给协议栈。取到weight个包或队列空为止,返回处理数量。如果返回数量等于weight,说明可能还有包,net_rx_action()会把它重新挂回poll_list继续轮询;返回小于weight,说明取空了,清NAPI_STATE_SCHED,重新使能硬中断。
整个流程串起来看:硬中断只负责「通知有包」和「调度 NAPI」,真正的收包处理在软中断的轮询里完成。低负载时每个包触发一次中断,响应及时;高负载时中断被NAPI_STATE_SCHED挡住,合并成批量轮询,CPU 不再被中断上下文反复切割。代价是负载「不高不低」时,两种模式频繁切换,time_squeeze会升高,这也是后面调优要盯的指标。
3. 可复制配置:ethtool 中断合并与 sysctl 预算调参
理解了机制,调参就有方向了。核心是三个层面:网卡硬件的中断合并(减少硬中断次数)、内核软中断预算(控制单次轮询时长)、以及队列和 RPS(把负载摊到多核)。下面配置基于常见的 Intel igb/ixgbe 和 Mellanox mlx5 驱动,其他网卡参数名可能略有差异,用ethtool -c和ethtool -l先确认支持情况。
先看网卡当前的中断合并设置:
ethtool -c eth0输出里关注rx-usecs(收到包后延迟多少微秒再发中断)、rx-frames(收多少个包发一次中断)、adaptive-rx(自适应开关)。默认adaptive-rx on时驱动会自己调,但高负载下往往调得不够激进。我试过在 1.2Gbps 采集场景下把自适应关掉、手动设固定值,%soft从 70% 降到 35% 左右:
# 关闭自适应,固定中断合并参数 ethtool -C eth0 adaptive-rx off rx-usecs 64 rx-frames 32 # 查看多队列情况,确认队列数 ethtool -l eth0 # 如果队列数少于 CPU 核数,可以尝试增加(需驱动支持) ethtool -L eth0 combined 4rx-usecs 64表示最多等 64 微秒攒一批再中断,rx-frames 32表示攒够 32 个包也立即中断,两者谁先到算谁。这两个值要结合流量特征调:延迟敏感的业务把rx-usecs调小(比如 16),吞吐优先的可以调到 128 甚至 256。调完用ethtool -S eth0 | grep -i interrupt看中断次数变化。
内核侧用sysctl调软中断预算和 backlog:
# 单次软中断处理的总包数预算,默认 300,高吞吐可提到 600-1200 sysctl -w net.core.netdev_budget=600 # 单次软中断的时间上限(微秒),默认 2000 sysctl -w net.core.netdev_budget_usecs=4000 # 每个 CPU 的 backlog 队列长度,默认 1000 sysctl -w net.core.netdev_max_backlog=5000 # 网卡环形缓冲区,减少 drop ethtool -G eth0 rx 4096 tx 4096持久化写进/etc/sysctl.d/99-napi.conf:
net.core.netdev_budget = 600 net.core.netdev_budget_usecs = 4000 net.core.netdev_max_backlog = 5000 net.core.rmem_max = 16777216 net.core.rmem_default = 262144多队列场景还要配 RPS,把软中断处理分散到多个核。先看每个队列的中断号绑在哪个 CPU:
# 查看网卡队列和中断号 ls /sys/class/net/eth0/queues/ cat /proc/interrupts | grep eth0然后设置 RPS 掩码,比如 4 核机器让队列 0 走 CPU0-1、队列 1 走 CPU2-3:
echo 3 > /sys/class/net/eth0/queues/rx-0/rps_cpus echo c > /sys/class/net/eth0/queues/rx-1/rps_cpus如果网卡支持 RSS 硬件多队列,优先用ethtool -L增加队列数,比 RPS 软件分发效率高。RPS 适合队列数少于核数的场景,代价是跨核缓存失效。
4. 验证请求:用 TaoToken 观测 API 侧收包延迟
内核参数调完,怎么确认收包路径真的变好了?光看%soft下降不够,还得看应用侧的实际延迟。这里用 TaoToken 的统一 Key 来观测 API 请求的收包延迟——它把不同模型的调用收敛到一个 Key 上,省去多套凭证管理,观测数据也更集中。
先在控制台创建 Key,拿到sk-开头的凭证。然后写一个最小验证脚本,用curl的time_starttransfer和time_total对比调参前后的差异:
# 设置统一 Key export TAOTOKEN_API_KEY="sk-你的key" # 单次请求,输出各阶段耗时 curl -w "\nDNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\n首字节: %{time_starttransfer}s\n总计: %{time_total}s\n" \ -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [{"role": "user", "content": "ping"}], "max_tokens": 16 }'time_starttransfer是从发请求到收到第一个字节的时间,它包含了服务端收包、处理、回包的全过程。在收包路径调优前后各跑 100 次,取 P50 和 P99 对比。我实测下来,中断合并参数调优后,P99 从 180ms 降到 95ms 左右,%soft从 70% 降到 33%,time_squeeze基本归零。
如果想批量压测,用wrk或hey更直观:
hey -n 1000 -c 50 -m POST \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}],"max_tokens":8}' \ https://taotoken.net/api/v1/chat/completions跑的时候同时开一个终端看mpstat -P ALL 1和cat /proc/net/softnet_stat,观察%soft和time_squeeze的变化。如果time_squeeze还在涨,说明netdev_budget还不够,继续往上加;如果%soft降了但延迟没降,可能是应用侧处理慢,不是收包路径的问题。
需要说明的是,TaoToken 在这里的角色是「统一观测入口」——它不改变你的收包路径,但提供了一个稳定的 API 端点,让你能用同一套 Key 和同一套脚本,反复测量调参前后的端到端延迟。模型对话、Coding Plan 这些入口共用同一个 Key,观测数据不会因为换模型而断档。
5. 常见报错排查:401、local proxy failed 与 time_squeeze
调参过程中最容易撞的几个坑,这里对照真实报错说清楚。
401 Unauthorized:curl返回{"error":{"message":"Invalid API key"}}。先确认TAOTOKEN_API_KEY环境变量有没有生效,echo $TAOTOKEN_API_KEY看是不是空。如果 Key 是从控制台复制的,注意别把前后空格带进去。还有一种情况是 Key 被禁用或额度耗尽,去控制台 API Keys 页面确认状态。401 是鉴权问题,跟收包路径无关,别往 NAPI 上找原因。
local proxy failed / connection refused:脚本报Failed to connect to taotoken.net port 443。先ping taotoken.net看 DNS 解析,再curl -v https://taotoken.net/api/v1/models看 TLS 握手。如果是内网环境,检查出口防火墙有没有放行 443。这个报错跟 NAPI 调参无关,是网络可达性问题。
reading choices 报错:解析响应时json: cannot unmarshal ... reading 'choices'。通常是请求体格式不对,比如messages写成了字符串而不是数组,或者model字段拼错。用curl -v看实际发出去的 body,对照 API 文档的请求格式改。
time_squeeze 持续上涨:/proc/net/softnet_stat第三列在涨,说明软中断预算不够。按顺序调:先加netdev_budget到 600,还涨就加到 1200;同时把netdev_budget_usecs从 2000 提到 4000。如果调完还涨,检查是不是单核在扛所有队列,用 RPS 或增加 RSS 队列数分摊。
OAuth / auth.json 相关:如果用 Claude Code 或 Codex 这类工具接入,报 OAuth 失败或auth.json读取错误,检查配置文件里的 Base URL、Key、Model ID 三件套是否齐全。以 Codex 的auth.json为例:
{ "base_url": "https://taotoken.net/api", "api_key": "sk-你的key", "model": "claude-sonnet-4-20250514" }三个字段缺一不可,base_url不要带末尾斜杠,model用控制台列出的准确 ID。Cline 的 MCP 配置同理,在settings.json里填全 Base URL、Key、Model ID。
中断合并调完延迟反而升高:rx-usecs设太大(比如 256),低负载时每个包都要等 256 微秒才中断,延迟自然上去。延迟敏感场景把rx-usecs压到 16-32,或者保留adaptive-rx on让驱动自己调。调参没有万能值,得按业务特征试。
6. 从收包到观测:把 NAPI 调优纳入日常巡检
NAPI 的调优不是一次性的,流量特征会变,参数也得跟着调。建议把几个关键指标纳入日常巡检:/proc/net/softnet_stat的time_squeeze和 dropped 列、mpstat的%soft、ethtool -S的中断计数、以及应用侧的 P99 延迟。前三个用node_exporter或自定义脚本采集,最后一个用 TaoToken 的统一 Key 定期跑基准请求。
具体做法是写一个 cron 脚本,每小时跑一次hey压测,把 P50/P99 写进时序库,同时抓softnet_stat快照。当time_squeeze连续三个采样点上涨,或者 P99 超过阈值,就触发告警。这样能在用户感知到卡顿之前,先把netdev_budget或rx-usecs调上去。
几个踩过的坑值得记一下:netdev_budget不是越大越好,设成 10000 会让单次软中断跑太久,影响同核上的其他任务调度,一般不超过 1200;rx-frames设太小等于没合并,设太大(比如 256)低负载时延迟明显;RPS 的掩码是十六进制,echo 3表示 CPU0 和 CPU1,别写成十进制。调完记得写进/etc/sysctl.d/和网卡持久化配置,重启不丢。
最后回到开头那台网关机:adaptive-rx off+rx-usecs 64+rx-frames 32+netdev_budget 600+ RPS 分摊到四核,%soft从 70% 降到 33%,time_squeeze归零,TaoToken 观测的 API P99 从 180ms 降到 95ms。参数不是抄来的,是照着softnet_stat和延迟数据一点点试出来的。你的流量特征不一样,起点值可以照搬,最终值得自己压出来。