内核调优与高性能网络:sysctl、eBPF、DPDK 与网卡多队列

2022-02-11T10:00:00+08:00 | 19分钟阅读 | 更新于 2022-02-11T10:00:00+08:00

@

内核调优与高性能网络

面试高频区。很多候选人能背出 tcp_tw_reuse=1,但说不清它为什么能缓解端口耗尽;能说出 DPDK 很快,但讲不明"快"在省掉了哪几步。本章不堆参数,而是先把数据包从网卡到你的 Go 服务的整条链路画清楚,再逐个讲解每个旋钮到底拧在了链路的哪个位置。


一、从直觉开始:数据包是怎么"走"到你的 Go 服务的

先建立一个画面感。把一台服务器想象成一座有 16 个收银台(CPU 核)和 1 个收货口(网卡)的大超市

  • 包裹(数据包)从网卡这个"收货口"进来;
  • 收货口把包裹交给某个收银台(CPU 核)去拆、去核对(中断处理 + 软中断收包);
  • 核对完后,包裹被放到"顾客"(进程)手里的购物篮(socket 接收缓冲区);
  • 你的 Go 程序从购物篮里 read() 取出数据开始处理。

性能瓶颈往往不是"收银慢",而是:收货口只有一个、包裹堆在门口没人搬、购物篮太小装不下、或者门口排队的规则太死板。下面所有调优,本质上都是在优化这座超市的收货、搬运、排队、存放四个环节。

先看整条链路的全景图,后面的每个参数都能在图上找到自己的位置:

flowchart TD
    A[网线/交换机] -->|电信号| B[网卡 NIC]
    B -->|DMA 写入 Ring Buffer| C[网卡驱动 硬中断]
    C -->|触发 IRQ| D[某个 CPU 核 软中断 ksoftirqd]
    D -->|协议栈处理 IP/TCP 校验| E[TCP 接收缓冲区 rmem]
    E -->|socket 队列| F[应用程序 read/recv]
    F --> G[你的 Go 服务]
    D -.conntrack 表查询.-> H[(nf_conntrack 表)]
    D -.eBPF/XDP 钩子.-> I[eBPF 程序 可在此丢包/重定向]
    B -.多队列 RSS.-> C2[多个 CPU 核并行收包]

记住这张图的几个关键节点,它们对应后面章节的调优对象:

  • Ring Buffer / 软中断:对应第二章的 netdev_max_backlogsomaxconn
  • 中断亲和 / 多队列:对应第三章的 RSS/RPS/RFS。
  • conntrack 表:对应第四章的 nf_conntrack
  • eBPF/XDP 钩子:对应第五章。
  • 接收缓冲区 rmem:对应第二章的 rmem_max 与 TCP 自动调优。

二、sysctl 网络参数调优:把"门口排队"和"回收 TIME_WAIT"搞明白

2.1 连接队列:半连接与全连接两个门槛

TCP 建连(三次握手)在服务端有两个队列,这是面试必考点:

  • 半连接队列(SYN 队列):收到客户端 SYN、还没完成三次握手时的连接。长度由 net.ipv4.tcp_max_syn_backlog 控制,同时受 somaxconntcp_syncookies 影响。
  • 全连接队列(accept 队列):三次握手完成、等待应用程序 accept() 取走的连接。长度 = min(somaxconn, listen(fd, backlog))

很多同学调大了应用层 listen 的 backlog,却发现队列还是只有 128,根因就是 net.core.somaxconn 是内核层的天花板。Go 的 net.Listen 默认 backlog 受此限制。

子题 10.1.1:如何调优 somaxconntcp_max_syn_backlog 支撑 100k RPS?

下面是可直接落地的 sysctl 配置,逐行解释:

# /etc/sysctl.d/90-network-tuning.conf
# ---- 连接队列:支撑高并发建连 ----
# 全连接队列上限(accept 队列)。默认仅 128,100k RPS 下瞬间打满导致丢 SYN/超时。
net.core.somaxconn = 65535

# 半连接队列上限(SYN 队列)。应对 SYN Flood 与突发建连。
net.ipv4.tcp_max_syn_backlog = 65535

# 每个网络接口接收数据包的速率快于内核处理速率时,允许排队的帧数。
# 百 G 网卡 + 突发流量时必须调大,否则软中断来不及处理就丢包。
net.core.netdev_max_backlog = 65535

# 端口范围与 TIME_WAIT 复用:高并发短连接(如 Nacos gRPC)容易端口耗尽。
# 扩大可用本地端口范围。
net.ipv4.ip_local_port_range = 1024 65535
# 允许将 TIME_WAIT 状态的 socket 用于新的 outbound 连接(客户端侧有效)。
net.ipv4.tcp_tw_reuse = 1

# 使改立即生效(无需重启)
sysctl --system

关于 tcp_tw_reuse 必须澄清一个常见误解:它只对作为客户端发起的连接(outbound)有效,对服务端监听的 TIME_WAIT 没有作用,也不能替代 tcp_tw_recycle(后者已在 4.12 内核删除,且在 NAT 下会导致连接失败,严禁使用)。服务端 TIME_WAIT 过多,正确做法是让客户端主动关闭、或复用连接池(HTTP Keep-Alive / gRPC 长连接)

2.2 TCP 缓冲区与窗口缩放:吞吐的命脉

rmem(接收)和 wmem(发送)缓冲区决定了单条连接能"在路上"塞多少未确认数据,直接决定长肥管道(高带宽时延积)的吞吐。

# ---- TCP 缓冲区自动调优 ----
# 格式:min default max(单位字节)。max 建议至少 16MB 以支撑万兆长时延链路。
net.core.rmem_max = 16777216        # 单 socket 接收缓冲区硬上限
net.core.wmem_max = 16777216        # 单 socket 发送缓冲区硬上限
net.ipv4.tcp_rmem = 4096 87380 16777216   # 内核自动在 min~max 间调节
net.ipv4.tcp_wmem = 4096 65536 16777216
# 启用窗口缩放(RFC 1323),否则 64KB 窗口上限会成为万兆瓶颈。
net.ipv4.tcp_window_scaling = 1

2.3 为不同 Pod/容器设置独立网络命名空间参数

子题 10.1.2:基于 Systemd + Sysctl 为不同 Pod 设置独立网络命名空间参数的方法。

每个容器有独立的网络命名空间,但 net.ipv4.* 大多在命名空间内可覆盖。思路是:在容器启动后,由 init 容器或 systemd 单元写入该 netns 的 /proc/<pid>/net/...。更标准的 Kubernetes 做法是sysctls 安全字段声明,避免特权:

# pod-with-sysctl.yaml —— 仅允许"安全 sysctl"(namespace 隔离的)
apiVersion: v1
kind: Pod
metadata:
  name: tuned-app
spec:
  securityContext:
    sysctls:
      - name: net.core.somaxconn
        value: "65535"
      - name: net.ipv4.tcp_tw_reuse
        value: "1"
  containers:
    - name: app
      image: myapp:latest

⚠️ 不是所有 net.* 都能在 Pod 里改。只有标记为"namespace 安全的" sysctl 才允许通过 securityContext.sysctls 设置;像 net.ipv4.ip_local_port_range 这类全局参数仍需在宿主机层面统一调。需要特权 sysctl 时,可用 init 容器 + sysctl 命令(需 CAP_SYS_ADMINhostNetwork 或特定 netns 挂载),但生产上更推荐 DaemonSet 在节点上统一设好。


三、网卡多队列与中断亲和:让多核真正并行收包

3.1 RSS / RPS / RFS 三兄弟

默认网卡只有一个接收队列,所有包都触发同一个 CPU 核的中断,多核机器白白浪费。解决办法是多队列

  • RSS(Receive Side Scaling):硬件层面,网卡根据哈希把不同流分到不同队列,每个队列绑定一个 CPU 核(通过中断亲和)。这是性能最好的方案,纯硬件。
  • RPS(Receive Packet Steering):软件层面,把软中断的负载从接收核再分发到其他核。适合 RSS 队列数少于 CPU 核数的场景(如虚拟机里只有 1~2 个队列)。
  • RFS(Receive Flow Steering):在 RPS 基础上,把包分发到正在运行目标应用进程的那个核,提升 CPU 缓存命中率。
flowchart LR
    NIC[网卡 RSS 哈希] --> Q0[队列0 → CPU0]
    NIC --> Q1[队列1 → CPU1]
    NIC --> Q2[队列2 → CPU2]
    NIC --> Q3[队列3 → CPU3]
    Q0 -->|硬中断 smp_affinity| CPU0[CPU0 软中断]
    Q1 -->|硬中断 smp_affinity| CPU1[CPU1 软中断]
    CPU0 --> RPS[RPS 再分发到应用所在核]
    RPS --> APP[Go 进程所在 CPU]

3.2 中断亲和性:irqbalance 与 smp_affinity

中断默认可能堆在 CPU0,导致单核 100% 而其他核闲置。两种调法:

# 方法一:交给 irqbalance 自动均衡(多数发行版默认)
# 检查是否在跑:
systemctl status irqbalance

# 方法二:手动把网卡中断绑定到指定核(更可控,适合低延迟场景)
# 1) 找到网卡中断号(以 eth0 为例)
grep eth0 /proc/interrupts | awk '{print $1, $NF}'
# 输出类似: 89: eth0-TxRx-0   90: eth0-TxRx-1 ...

# 2) 把第 0 个队列的中断绑定到 CPU 核 2(十六进制掩码 0x4 = 第2位)
echo 4 > /proc/irq/89/smp_affinity

# 3) 或用 cpulist 写法(更直观,绑定到核 2,3)
echo 2,3 > /proc/irq/89/smp_affinity_list

生产建议:把网卡中断分散到专属核,并将业务进程用 taskset/numactl 绑到其余核,避免"收包"和"处理"抢同一核的缓存与时间片。NUMA 架构下还要让网卡与 CPU 在同一个 NUMA node(numactl -H 查看),跨 node 访问内存会显著拖慢。


四、conntrack 表项调优:避免 NAT 表满丢包

Kubernetes 的 Service(iptables/IPVS 模式)、Docker 网桥、以及任何做 NAT 的节点,都依赖内核的 nf_conntrack 连接跟踪表。它是一张哈希表,记录"哪条流正在被 NAT"。

子题 10.1.3:当 conntrack 表满导致丢包,如何计算并设置 hashsize 避免 Full NAT?

表满时的典型现象:dmesg 里刷 nf_conntrack: table full, dropping packet,表现为随机丢包、连接超时,但带宽和 CPU 都正常——非常隐蔽。

# 1) 查看当前表用量与上限
cat /proc/sys/net/netfilter/nf_conntrack_count      # 当前条目数
cat /proc/sys/net/netfilter/nf_conntrack_max        # 最大条目数

# 2) 计算合理的 hashsize(哈希桶数)。经验公式:hashsize ≈ nf_conntrack_max / 8
#    例如要支持 100 万条连接:
#    nf_conntrack_max = 1000000
#    hashsize         = 125000
#    在 modprobe 阶段设置(开机生效):
echo "options nf_conntrack hashsize=125000" > /etc/modprobe.d/nf_conntrack.conf

# 3) 运行时调整上限(临时)
sysctl -w net.netfilter.nf_conntrack_max=1000000
# TCP 条目的超时时间也需配合收紧,避免 TIME_WAIT 类条目长期占位:
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=86400

# 4) 验证
cat /sys/module/nf_conntrack/parameters/hashsize
conntrack -C   # 需 conntrack-tools

排查口诀:节点上出现"诡异丢包"时,第一反应就 dmesg | grep conntrack,十有八九是表满。若确认不需要连接跟踪(如纯后端、无 NAT),可用 notrack 规则或切到 Cilium eBPF 数据面(下一章)彻底绕开 conntrack,从根本上消除这张表。


五、eBPF 与 XDP:把处理逻辑搬到内核/网卡上

5.1 什么是 eBPF,为什么它快

eBPF 是一套能在内核里安全地运行用户编写小程序的机制,无需改内核源码、无需加载内核模块。在网络场景里,它可以在数据包路径的各个钩子(tc、socket、XDP)插入逻辑。

XDP(eXpress Data Path) 是 eBPF 在网络里最快的形态:它在**网卡驱动刚收到包、还没分配 skb(内核 socket buffer)**时就执行你的程序,可以在这里直接丢弃 DDoS 包、做负载均衡重定向,速度接近线速。

flowchart TD
    subgraph 传统路径
      P1[网卡] --> P2[分配 skb] --> P3[软中断] --> P4[协议栈] --> P5[conntrack] --> P6[应用]
    end
    subgraph XDP 路径
      X1[网卡] --> X2[XDP 钩子 eBPF 程序]
      X2 -->|丢弃 DDoS 包| XD[直接丢,零分配]
      X2 -->|正常包| P3
      X2 -->|重定向| AF_XDP[用户态零拷贝收包]
    end

5.2 Cilium kube-proxy 替换:验证 XDP 已加载

子题 10.2.1:使用 Cilium kube-proxy replacement 时,如何验证 XDP 程序已加载到网卡?

# 1) 查看网卡上是否挂载 XDP 程序(以 eth0 为例)
ip link show eth0
# 输出含 "xdp" 字样即说明已挂载,例如:
#   eth0: <BROADCAST,...> xdp id 1234

# 2) 用 bpftool 查看更详细信息(程序类型、已附加的设备)
bpftool net list dev eth0
# 或列出所有 XDP 程序:
bpftool prog list type xdp

# 3) 检查 Cilium 状态,确认 kube-proxy 替换已启用
cilium status --verbose | grep -i "kube-proxy replacement"

5.3 bpftool 查看 Service 负载均衡后端

子题 10.2.2:请给出基于 bpftool 查看 Service 负载均衡后端列表的命令。

# Cilium 将 Service 后端(endpoint)存于 eBPF map 中,用 bpftool 直接读
# 1) 列出所有 eBPF map,找到 cilium 的 service map
bpftool map list | grep -i service

# 2) 以该 map 的 id 转储内容(含 backend IP:port)
bpftool map dump id <map_id>

# 3) 更直观:用 cilium 自带命令看某个 Service 的后端
cilium service list
# 输出示例:
#   ID   Frontend             Backend
#   1    10.96.0.10:53       1 => 172.20.0.5:53
#                            2 => 172.20.0.6:53

5.4 eBPF 程序升级失败如何回滚且连接不中断

子题 10.2.3:当 eBPF 程序升级失败,如何回滚并保证连接不中断?

关键机制是 eBPF 的原子替换:新程序先加载并验证通过,再用 bpf_link / bpf_prog_replace 一次性切换,老程序在还被引用时不会被卸载。

# 假设用 bpftool 管理:先加载新版本程序(仅加载,不附加)
bpftool prog load new.o /sys/fs/bpf/new_prog type xdp

# 原子替换旧链接指向的程序(老连接仍由旧程序实例服务,直到自然结束)
bpftool net attach xdpdrv id <new_prog_id> dev eth0 overwrite

# 若新程序异常,再次 overwrite 回退到旧程序 id 即可,全程不丢已有连接。
# Cilium 自身通过 "canary" 升级 + 健康检查自动完成这一回滚,无需手工干预。

要点:eBPF 升级不能简单 rmmod,要靠"加载新→原子替换→失败回退"的链路。如果工具链不支持原子替换(老内核),就只能先 attach 到另一张临时网卡验证,再切换。


六、DPDK 用户态协议栈:绕过内核的极限方案

6.1 为什么需要 DPDK

前面所有优化都是在内核协议栈里挤性能。但内核协议栈有固有开销:每包走中断、分配 skb、系统调用、copy_to_user 等。当用户态应用(如高性能网关、LB、DPDK 版 Envoy)要跑满 100G/200G 网卡时,内核路径就成了瓶颈。

DPDK(Data Plane Development Kit) 的做法是:把网卡驱动搬进用户态,绕过内核协议栈。它配合内核的 uio/vfio 把网卡 DMA 内存直接映射到用户进程,用轮询(polling)替代中断,省掉中断上下文切换、(skb) 分配和内核态/用户态拷贝。

flowchart TD
    subgraph 内核态协议栈
      K1[网卡中断] --> K2[软中断] --> K3[内核 TCP/IP 栈] --> K4[copy_to_user] --> K5[用户态应用]
    end
    subgraph DPDK 用户态
      D1[网卡 PMD 轮询] --> D2[用户态协议栈 如 F-Stack/mTCP] --> D3[用户态应用 零拷贝]
    end

6.2 DPDK 适用场景与代价

  • 适用:NFV 网关、软负载均衡(如 DPDK 版 LVS/Envoy)、高频交易、DPI、需要线速小包转发的场景。
  • 不适用:普通业务服务、需要完整内核网络功能(conntrack、iptables)的场景。DPDK 进程独占网卡队列,该网卡上的内核流量会失效,需配合 SR-IOV 切出独立 VF 给 DPDK 用。

6.3 Kubernetes 中为 Pod 分配 VF 并绑定 DPDK 驱动

子题 10.3.1:在 Kubernetes 中如何为 Pod 分配 VF 并绑定 DPDK 驱动避免内核干扰?

DPDK 需要一个被用户态驱动(如 vfio-pci / uio_pci_generic)接管的 VF(虚拟功能),而不是内核默认的 ixgbe/mlx5 驱动。

# 1) 在宿主机把 VF 从内核驱动解绑,绑到 vfio-pci(DPDK 用户态驱动)
#    假设 SR-IOV 已切出 VF:0000:03:00.2
echo 0000:03:00.2 > /sys/bus/pci/drivers/mlx5_core/unbind
echo vfio-pci > /sys/bus/pci/drivers/vfio-pci/new_id
# 或一条命令查当前驱动:
lspci -k -s 0000:03:00.2

子题 10.3.2:基于 Multus CNI + SR-IOV 实现控制面与数据面分离的 YAML。

# 通过 Multus 给 Pod 加两张网卡:
#   net1 = 普通 CNI(控制面,走内核协议栈,能访问 K8s API)
#   net2 = SR-IOV 设备插件分配的 VF(数据面,交给 DPDK 轮询)
apiVersion: v1
kind: Pod
metadata:
  name: dpdk-gateway
  annotations:
    k8s.v1.cni.cncf.io/networks: |
      [
        { "name": "default-cni", "namespace": "kube-system" },
        { "name": "sriov-dpdk-net", "interface": "net2" }
      ]      
spec:
  containers:
    - name: dpdk
      image: dpdk-gateway:latest
      resources:
        requests:
          intel.com/intel_sriov_dpdk: "1"   # SR-IOV 设备插件暴露的资源名
        limits:
          intel.com/intel_sriov_dpdk: "1"
      securityContext:
        capabilities:
          add: ["IPC_LOCK"]   # DPDK 大页内存锁定需要

子题 10.3.3:当 VF 数量不足,如何动态扩容并热插拔到运行中的 Pod?

VF 是物理网卡上虚拟出来的,动态扩容在宿主机侧完成(无需重启 Pod):

# 在宿主机增大 VF 数量(以 PF 0000:03:00.0 为例,从 4 扩到 8)
echo 8 > /sys/class/net/eth0/device/sriov_numvfs

# 新 VF 出现后,通过 SR-IOV 设备插件会自动注册为可分配资源;
# 对运行中的 Pod 热插拔 VF 则需:
#   - 新版本设备插件 + 支持热插拔的运行时;或
#   - 通过 Multus 动态调整 annotation 后重建 Pod(滚动,非真正热插)。
# 注意:真正的"运行中热插拔"依赖 PCIe hotplug 与 CNI 支持,生产多用
#       "先扩容 VF 池 → 滚动重建 Pod 时分配新 VF" 的稳妥策略。

七、零拷贝、大页与文件句柄:减少不必要的搬运

7.1 零拷贝:sendfile / splice

最经典的"搬运浪费"是静态文件服务:磁盘文件 → 内核缓冲区 → 用户缓冲区 → 内核 socket 缓冲区 → 网卡,四次拷贝、两次上下文切换。

sendfile 让内核直接把文件数据 DMA 到 socket,不经过用户态splice 类似,用于管道间零拷贝。Nginx 的 sendfile on; 就是这个原理。Go 里可通过 io.Copy 配合 splice syscall 或直接使用支持零拷贝的库来获得同样收益。

# Nginx 侧开启(静态资源服务必开)
# sendfile on;
# 验证是否生效:strace 看不到 read+write 文件内容的系统调用,而是 sendfile64。
strace -e trace=sendfile64,read,write -p $(pidof nginx: worker) 2>&1 | head

7.2 大页(hugepages):减少 TLB miss

DPDK、大内存数据库(Redis、JVM)频繁访问大量内存时,标准 4KB 页会导致 TLB(页表缓存)频繁失效。用 2MB/1GB 大页可大幅减少页表项数量。

# 1) 预留 1024 个 2MB 大页(共 2GB),重启后需写入 /etc/default/grub 的
#    default_hugepagesz=2M hugepagesz=2M hugepages=1024
sysctl -w vm.nr_hugepages=1024

# 2) 挂载大页文件系统
mkdir -p /mnt/huge
mount -t hugetlbfs nodev /mnt/huge

# 3) Kubernetes 中把大页作为资源暴露给 Pod
#    (kubelet 需配置 --feature-gates=... 并在节点预留)
apiVersion: v1
kind: Pod
spec:
  containers:
    - name: dpdk
      resources:
        requests:
          hugepages-2Mi: "1Gi"
        limits:
          hugepages-2Mi: "1Gi"

7.3 文件句柄与 ulimit / fs.file-max

高并发服务(尤其是 Go 网关、agent、连接密集型应用)常遇到 too many open files。这是两个层级的天花板:

  • 系统级fs.file-max(内核全局最大打开文件数)。
  • 进程级ulimit -n(单进程上限,默认常只有 1024)。
# 1) 系统级上限
sysctl -w fs.file-max=1000000

# 2) 进程级(临时,当前 shell)
ulimit -n 1000000

# 3) 永久生效:systemd 服务文件里加(Go 二进制的 service 单元)
#    [Service]
#    LimitNOFILE=1000000
#
# 4) 查看某进程当前已用句柄数
ls /proc/$(pidof your-go-app)/fd | wc -l
# 5) 查看系统整体
cat /proc/sys/fs/file-nr   # 已分配 / 已分配未用 / 上限

排查 too many open files 时,先 cat /proc/<pid>/limits | grep "Max open files" 确认是进程级还是系统级先到顶,再对症调。


八、内核内存管理与 OOM 治理:别让内存把进程打死

8.1 cgroup v2 + memory.high:渐进式回收

传统 memory.limit_in_bytes(cgroup v1)一旦超限就直接 OOM Kill,对延迟敏感服务很粗暴。cgroup v2 引入 memory.high:这是一个"软上限",超限后内核会主动、渐进地回收该 cgroup 的内存(回收页缓存、触发直接回收),给应用腾挪空间,而不是直接杀掉。

子题 10.5.1:如何基于 cgroup v2 + memory.high 实现渐进式回收而非直接 OOM Kill?

# 假设 cgroup v2 挂载在 /sys/fs/cgroup,目标 cgroup 为 myapp
CG=/sys/fs/cgroup/myapp

# 1) 设置硬上限(最后防线,仍然 OOM Kill)
echo "2G" > $CG/memory.max

# 2) 设置软上限(推荐低于 max,例如 1.5G)。
#    超过 1.5G 后内核开始"温和"地回收,给应用自愈机会。
echo "1.5G" > $CG/memory.high

# 3) 观察是否频繁触顶(events 里的 high 计数增长即说明在渐进回收)
cat $CG/memory.events
#   low: 0
#   high: 1234      # 触发 memory.high 的次数
#   oom: 0
#   oom_kill: 0

Kubernetes 中 memory.high 对应 memory.limit 之外更细腻的控制,可通过 cgroup v2 + 第三方 QoS 控制器(如 systemd 的 MemoryHigh 或 K8s 的 cgroup v2 支持) 配置;Pod 层面目前主要靠 requests/limits--cgroup-driver=systemd 配合实现。

8.2 PSI 监控预测 OOM

子题 10.5.2:使用 psi-monitor 采集 memory stall 指标并预测 OOM 的 Python 脚本。

PSI(Pressure Stall Information)报告任务因内存/CPU/IO 不足而停滞的时间比例。内存 stall 持续走高,往往是 OOM 前兆。

#!/usr/bin/env python3
# monitor_memory_psi.py —— 读取 PSI 内存压力,超过阈值预警
import time

PSI_FILE = "/proc/pressure/memory"

def read_psi():
    # 解析如:some avg10=0.00 avg60=0.00 avg300=0.00 total=12345
    #         full avg10=0.00 avg60=0.00 avg300=0.00 total=678
    with open(PSI_FILE) as f:
        lines = f.read().strip().splitlines()
    for line in lines:
        if line.startswith("some"):
            parts = dict(kv.split("=") for kv in line.split()[1:])
            return float(parts["avg60"])  # 60 秒平均停滞比例
    return 0.0

THRESHOLD = 15.0  # 60s 内平均有 15% 时间因内存停滞,预警
while True:
    stall = read_psi()
    if stall > THRESHOLD:
        print(f"[WARN] memory stall {stall}% exceeds {THRESHOLD}%, OOM risk!")
    time.sleep(5)

8.3 用 fadvise 主动释放 Page Cache

子题 10.5.3:当容器频繁 OOM,如何利用 fadvise 系统调用主动释放 Page Cache?

某些场景下进程只读一遍大文件(如日志扫描、离线分析),内核却把它缓存进 Page Cache 不肯放,挤占匿名内存导致 OOM。可用 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 告知内核"这段我不再需要,可以回收"。

// Go 示例:读取大文件后主动告知内核释放其 page cache
package main

import (
    "os"
    "golang.org/x/sys/unix"
)

func scanAndDrop(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    buf := make([]byte, 1<<20) // 1MB
    for {
        n, err := f.Read(buf)
        if n > 0 {
            // 处理 buf ...
            // 读完一段就告诉内核这一段缓存可以丢(从文件起始偏移量推进)
            _ = unix.Posix_fadvise(int(f.Fd()), 0, 0, unix.POSIX_FADV_DONTNEED)
        }
        if err != nil {
            break
        }
    }
    return nil
}

注意:POSIX_FADV_DONTNEED建议而非强制;对于仍被映射到内存的范围,内核可能暂不回收。更彻底的做法是 echo 1 > /proc/sys/vm/drop_caches(仅调试用,生产慎用,会清掉所有缓存)。


九、高并发/高吞吐问题排查思路

面试常让你"现场给排查套路"。给出一个从外到内、从现象到根因的标准流程:

flowchart TD
    S[现象: 丢包/延迟高/RPS 上不去] --> A[网卡层: ethtool -S eth0 看 drops/errors]
    A --> B[中断/软中断: mpstat -P ALL 1 看软中断核是否打满]
    B --> C[连接队列: ss -lnt 看 Recv-Q 是否逼近 somaxconn]
    C --> D[conntrack: dmesg | grep conntrack 是否表满]
    D --> E[缓冲区: 看 tcp rmene/wmem 是否成为窗口瓶颈]
    E --> F[应用层: 连接池/goroutine 阻塞/锁竞争]
    A -.有 drops.-> FIX1[调 netdev_max_backlog/RSS]
    C -.Recv-Q 满.-> FIX2[调 somaxconn/tcp_max_syn_backlog]
    D -.表满.-> FIX3[调 nf_conntrack_max/hashsize]

常用命令速查:

# 1) 网卡丢包/错误计数
ethtool -S eth0 | grep -E "drop|error|miss"

# 2) 各 CPU 软中断分布(si 列),定位是否单核打满
mpstat -P ALL 1

# 3) 监听队列积压(Recv-Q 接近 somaxconn 即队列满)
ss -lnt | head

# 4) TCP 重传与重排(高重传通常说明缓冲区/拥塞控制问题)
nstat -az TcpRetransSeg TcpOutSegs

# 5) 当前 TIME_WAIT 数量(短连接风暴时爆增)
ss -ant | awk '{print $1}' | sort | uniq -c

排查优先级建议:先确认是网络栈丢包(网卡/队列/conntrack)还是应用自身瓶颈(goroutine 阻塞、锁、连接池耗尽)。前者调内核参数立竿见影,后者调内核是徒劳。


十、综合实战清单:支撑 100k RPS 的调优

把前面所有旋钮串成一份可直接 ansible/脚本 下发的清单(对应子题 10.1.1 的落地版):

# /etc/sysctl.d/90-highrps.conf  —— 100k RPS 节点基线
# === 连接队列 ===
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
# === 端口与 TIME_WAIT(客户端侧复用) ===
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
# === TCP 缓冲区(万兆长时延链路) ===
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.ipv4.tcp_window_scaling = 1
# === conntrack(若节点仍走 iptables 做 NAT) ===
net.netfilter.nf_conntrack_max = 1000000
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
# === 文件句柄 ===
fs.file-max = 1000000

# 应用
sysctl --system

配合:

  1. 网卡 RSS 多队列 + 中断亲和(第三章)分散到专属核;
  2. 优先用 Cilium eBPF 数据面替换 kube-proxy,绕开 conntrack(第五章);
  3. 数据面极限场景再用 DPDK + SR-IOV VF(第六章);
  4. 内存侧用 cgroup v2 memory.high 防 OOM(第八章)。

自测题与动手练习

  1. 概念辨析tcp_tw_reusetcp_tw_recycle 有什么区别?为什么说后者在 NAT 环境下是灾难、已被内核删除?它能否解决服务端 TIME_WAIT 过多?
  2. 定位根因:某节点出现"间歇性丢包、但 CPU 和带宽都正常",你第一时间会查哪条内核日志?给出命令并解释阈值如何计算(提示:conntrack 表的 hashsizemax 关系)。
  3. 参数天花板:Go 服务 listen 的 backlog 设成 10000,但 ss -lnt 看到 Recv-Q 上限仍是 128,为什么?怎么改?
  4. 架构对比:用一句话说明 RSS、RPS、RFS 分别工作在哪一层、解决什么问题。如果虚拟机只有 2 个网卡队列却想用满 16 核,该用哪个?
  5. 动手:在你的测试机上执行 ip link show <网卡>bpftool net list,判断网卡是否已加载 XDP 程序;若装了 Cilium,执行 cilium service list 读出某个 Service 的后端列表。
  6. 动手:写一段 bash,监控 /proc/sys/net/netfilter/nf_conntrack_countnf_conntrack_max 的比值,超过 80% 时打印告警(可用 bc 或整数运算)。
  7. 开放设计:假设要做一个 200G 线速的 DPDK 网关 Pod,给出它在 Kubernetes 里"控制面走 Multus 默认网络、数据面走 SR-IOV VF + vfio-pci 驱动"的资源配置要点,并指出 DPDK 进程不能用内核协议栈会带来什么限制。
  8. 内存治理memory.maxmemory.high 在 OOM 行为上有何不同?在什么业务场景你宁愿设 memory.high 也不直接卡死 memory.max

本章小结

  • 内核网络调优的本质是优化"收货(网卡多队列/中断亲和)→ 搬运(软中断/协议栈)→ 排队(连接队列/conntrack)→ 存放(缓冲区/内存)“四个环节,每个 sysctl 都能在网络栈全景图上找到自己的位置。
  • 连接队列是建连瓶颈:somaxconn 是全连接队列天花板,tcp_max_syn_backlog 是半连接队列,二者需同时调大;tcp_tw_reuse 仅对客户端 outbound 有效,不能替代连接池。
  • conntrack 表满是隐蔽丢包元凶,hashsize ≈ max/8 是经验公式,根治办法是换 Cilium eBPF 数据面绕开它。
  • eBPF/XDP 把逻辑搬到内核甚至网卡驱动层,Cilium 的 kube-proxy 替换、bpftool 查 Service 后端、原子替换回滚是生产刚需。
  • DPDK 通过用户态驱动 + 轮询绕过内核协议栈换取线速,但代价是独占 VF、失去内核网络功能,适合网关/LB 而非普通业务。
  • 零拷贝、大页、文件句柄消除"搬运"与"寻址"浪费;cgroup v2 memory.high 用渐进回收替代粗暴 OOM Kill,配合 PSI 监控可预测内存风险。
  • 排查遵循"网卡 drops → 软中断打满 → 连接队列满 → conntrack 满 → 缓冲区 → 应用层"的由外到内顺序,先辨明是网络栈问题还是应用自身瓶颈,再决定要不要动内核参数。
复习提示:
  • TCP TIME_WAITtcp_tw_reuse 클라이언트 outbound 에만 유효, 서버 측 문제 해결 못함. tcp_tw_recycle 는 NAT 환경에서 재해롭の 때문에 커널에서 삭제됨.
  • conntrack 테이블:满杯是隐蔽丢包元凶,hashsize ≈ max/8 이 경험 공식. 근본 해결은 Cilium eBPF 로 conntrack 우회.
  • backlog vs Recv-Qlisten backlog=10000 과 실제 ss -lnt Recv-Q 상한(128) 불일치는 net.core.somaxconn sysctl 로 조정 필요.
  • RSS/RPS/RFS 계층:RSS(네트워크 카드 레벨) → RPS(소프트 인터럽트 레벨) → RFS(프로세스 레벨). VM이 2개 NIC queue 있어도 16코어 활용하려면 RFS 필요.
  • DPDK tradeoff:선속도 달성 대가로 VF 독점 + 커널 네트워크 기능 상실. 게이트웨이/LB용, 일반 서비스에는 부적합.
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!