25G 大带宽服务器卡在 17.3Gbps:ethtool 与 ip -s link 全是零丢包,softnet_stat 第二列却在偷偷涨

一、故障现场

客户是视频切片回源业务,用的是轻云互联一台 25G 口的大带宽独服。8 个 RX/TX 队列,MTU 1500,跑的是纯 TCP 出向分发。上线第三天报障:带宽上不去,17.3Gbps 就横住了,客户端看速度曲线是"能跑但封顶",没有尖刺、没有超时、没有 5xx。

第一反应的战场一定在网络栈。sar -n DEV 1 三分钟采样:

IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s
eth0  2163410   2253887  2114002   2105333   # 17.0~17.6 Gbps 之间来回蹭

按平均包长 ~1000B 折算,收包 2.16M pps,距离 25G 线速(1.5KB 包约 2.06M pps 上限,这里按混合包算有富余)明显没到底。挨个看丢包计数:

ethtool -S eth0 | grep -Ei 'drop|err|miss|discard'
# 驱动侧全 0

ip -s link show eth0
# RX: bytes  packets  errors  dropped overrun mcast
#      ...      0        0       0       0

nstat -az | grep -Ei 'drop|backlog|OFO'
TcpExtTCPBacklogDrop            0
TcpExtTCPOFOQueue            1842     # 有点意思

硬件不丢,内核 TCP 层也没丢。那 17.3 这个数字是怎么来的?

二、排除法:先证明不是 TCP 的问题

随手抓几条大流看状态:

ss -tin state established '( dport = :80 or dport = :443 )' | head -40

关键字段:

  • cwnd:9.8MB、snd_wnd 未顶格,拥塞窗口一直在涨
  • rtt:0.21/0.08,retrans 段总计不到 0.01%
  • pacing_rate 稳定,不是 BBR 自己踩的刹车
  • notsent 极小,说明应用层数据已经全怼进 socket buffer

窗口没限制、重传正常、应用侧源源不断有数据。TCP 层无罪,说明丢包发生在 TCP 之下的某个环节,而且它不计入任何常规统计。

三、转折点:softnet_stat

这个文件绝大多数人装了机器就再没打开过。里面每个 CPU 一行,所有字段都是十六进制,前四列依次是 processed / dropped / time_squeeze / cpu_collision:

gawk '{printf "cpu%-2d processed=%-12d dropped=%-8d time_squeeze=%d\n", \
  NR-1, strtonum("0x"$1), strtonum("0x"$2), strtonum("0x"$3)}' \
  /proc/net/softnet_stat | sort -t= -k4 -nr | head

跑一条 sleep 300 前后各打一次做差分,结果是这样:

cpu6   processed=24618411  dropped=+3612   time_squeeze=+58341
cpu7   processed=24590118  dropped=+3541   time_squeeze=+57902
cpu12  processed=18953227  dropped=+2210   time_squeeze=+44117
...
全核 time_squeeze 五分钟合计 +412k,也就是 1373 次/秒

两个数字同时对上了:

  • 第二列 dropped:五分钟涨了一万一。这跟 ethtool -S 的 0、ip -s link 的 0 完全不冲突——因为它统计的是 enqueue_to_backlog() 失败,包在**内核 backlog 队列**里就被扔了,压根没到驱动层统计。
  • 第三列 time_squeeze:net_rx_action() 每轮因预算耗尽提前退出一次,计一次。1373 次/秒意味着每个核每秒被预算掐断近 90 次。

这里还有个隐藏的放大器:backlog 里丢的是 GRO 聚合之后的 skb,一个 skb 动辄 40~64KB。按 36 个/s 的丢弃速率算,实际丢掉的带宽是 2~3MB/s——那个 17.3Gbps 的天花板,就是从这漏出去的。

四、根因:一套"10G 时代留下的默认值"+ 一段被遗忘的 RPS 配置

翻 /etc/rc.local,翻到了前任运维留下的三行:

# reset by ops 2023-xx-xx,为了让中断分散
for q in /sys/class/net/eth0/queues/rx-*/rps_cpus; do echo ffff > $q; done
echo 1000 > /proc/sys/net/core/netdev_max_backlog

这台机器本来就是 8 队列 + RSS,每个 RX 队列已经绑了自己的硬中断,RPS 完全是画蛇添足:它让每个队列收到的包被二次 hash 散到 16 个 CPU 的 per-CPU backlog 队列里,而队列深度只有默认的 1000。

再看三个真正要命的默认值(发行版原厂的,没动过):

net.core.netdev_max_backlog   = 1000    # per-CPU backlog 队列长度
net.core.netdev_budget       = 300     # 每轮 net_rx_action 全局包预算
net.core.netdev_budget_usecs = 2000    # 每轮软中断时间上限 2ms
net.core.dev_weight          = 64      # 每个 NAPI 实例单次配额(5.5+ 才有这个 sysctl)

三个数字叠在一起就是一场事故:

  • dev_weight=64,8 个网卡队列 + 16 个 backlog 实例都在 poll_list 上排队,第一圈遍历下来 24 × 64 = 1536 个包,早就把 netdev_budget=300 的全局预算吃穿了两回。
  • 预算一穿,net_rx_action() 立刻 break 退出,记一次 time_squeeze,剩下的包原封不动压在环里和 backlog 里。
  • backlog 队列只有 1000 深,在 360k pps/队列的冲击下,一次调度延迟就够它溢出。

顺带解释一下为什么 TcpExtTCPOFOQueue 会涨到 1842:RPS 把同一个 TCP 流的连续包 hash 到了不同 CPU,接收侧天然乱序。RFS 的 flow 表没配(rps_sock_flow_entries=0),等于这个 hash 是完全无状态的。

五、修复

第一步,把 RPS 摘掉,让多队列回归它本来的样子:

for q in /sys/class/net/eth0/queues/rx-*/rps_cpus; do echo 0 > $q; done
cat /sys/class/net/eth0/queues/rx-*/rps_cpus    # 全 0 确认

第二步,如果业务确实需要 RPS(比如单队列网卡、或者虚拟网卡),那就必须配套 RFS,否则散核只会换来乱序:

sysctl -w net.core.rps_sock_flow_entries=32768
for q in /sys/class/net/eth0/queues/rx-*/rps_flow_cnt; do echo 2048 > $q; done
# 32768 / 16 队列 = 2048,对得上,别乱写

第三步,预算体系按 25G 重新标定,落到 sysctl.d:

cat > /etc/sysctl.d/99-25g-napi.conf <<'EOF'
net.core.netdev_budget        = 3000
net.core.netdev_budget_usecs  = 6000
net.core.netdev_max_backlog   = 30000
net.core.dev_weight           = 256
net.core.dev_weight_rx_bias   = 1
net.core.dev_weight_tx_bias   = 1
EOF
sysctl --system

dev_weight 是内核 5.5 才进的 /proc/sys/net/core/,老内核只能在驱动 modprobe 参数里绕。上线前先 ls /proc/sys/net/core/ | grep dev_weight 确认一下。dev_weight_rx_bias / dev_weight_tx_bias 是乘数,实际配额 = dev_weight × bias,默认都是 1,除非你想收发分家,否则别动。

第四步,中断合并和 NAPI 延迟硬中断,把每包一次中断的浪费省掉(需要驱动支持这些 coalesce 参数,用 ethtool -c eth0 先看):

ethtool -C eth0 rx-usecs 32 rx-frames 64
ethtool -C eth0 napi_defer_hard_irqs 10 gro_flush_timeout 50000

napi_defer_hard_irqs 是 NAPI 软中断跑完后推迟硬中断的次数,gro_flush_timeout 单位是纳秒。这两个是 5.6 之后才有的,配好了能把高 PPS 下的中断次数压下去一个量级;配太大会把延迟拉起来,回源这种吞吐型业务可以吃,交互型业务要谨慎。

六、验证数据

重启生效后跑同样的采样:

sar -n DEV 1 60 | awk '/eth0/{print $5/125000" Gbps"}' | sort -n | tail -3
# 22.9 / 23.1 / 23.2 Gbps

# softnet_stat 差分
time_squeeze  +5 (全核五分钟合计)
dropped       0

有一个反直觉的现象值得记下来:ksoftirqd 的 CPU 占用从 12%~25% 涨到了 35%~58%。原因很简单——预算变大之后,一次 net_rx_action() 跑得更久了(实测单次最长 4.1ms),进出软中断上下文的次数少了两个数量级,CPU 时间花在真正处理包上,而不是花在"被掐断再重新调度"上。

代价也量过:调大预算后 RTT p99 从 0.31ms 涨到 0.63ms。这个业务能接受。如果换成对延迟敏感的,把 netdev_budget 收回到 1500、netdev_budget_usecs 收到 4000 更稳。

七、几个容易混淆的点,一次说清

  • netdev_budget ≠ netdev_max_backlog:前者是软中断每轮最多处理多少个包(全局),后者是 per-CPU backlog 队列能排多少个 skb。一个大,一个"深",一个是时间维度一个是空间维度,配错了哪个都翻车。
  • netdev_budget_usecs 别超过 10000:软中断是独占性质的,单次跑 10ms 以上会把同 CPU 上的 tasklet、RCU 回调全饿死,表现出来是莫名其妙的调度延迟和内存回收卡顿。
  • backlog 丢包在所有常规指标里都是隐形的。ethtool -S 看不到、ip -s link 看不到、tc -s 看不到。只有 /proc/net/softnet_stat 第二列认账。这条是我认为运维最该刻进肌肉记忆的。
  • time_squeeze 不会归零:预算机制本来就会周期性触发它,只要增速在个位数每秒、跟 processed 不在一个量级,就是健康状态。别看到非零就慌。
  • 多队列网卡不要叠 RPS。RSS/多队列已经把收包分散到各个 CPU 了,再上 RPS 是二次分发加随机乱序。真要上,必须同步配 RFS。

八、收尾

这套 sysctl 模板现在在这批 25G 线路上跑了四十多台,回源、下载、直播分发都有,没再复现。轻云互联那边默认就把 8 队列 RSS 切好了,ethtool -l 出来是 Combined: 8,省了我们手动 ethtool -L 的活;但内核侧那套默认值还是跟着发行版走,10G 口上随便跑,25G 一到就是这次的现场。

最后留一句:下次看到"带宽卡在某百分比、丢包统计全零、CPU 看着还闲",别急着怀疑网卡和交换机。cat /proc/net/softnet_stat 打两次,做差分,五分钟见分晓。