我们团队在去年重构那个负责全局流量调度的网关服务时,遇到了一个挺头疼的问题。服务跑在 96 核的 AMD EPYC 服务器上,内核还是 5.15,用的 CFS 调度器。平时 QPS 在 60 万左右稳得一批,但只要流量稍微抖动一下,或者同机房的几个容器同时启动,P99 延迟就会从 2ms 突然跳到 50ms 以上,像过山车一样。
我当时盯着 perf sched latency 的输出看了半天,发现很多延迟敏感的 worker 线程在 runqueue 里排队的时间特别长。CFS 那个基于虚拟运行时间(vruntime)的算法,虽然公平,但在这种高吞吐、短生命周期的 RPC 场景下,太过于追求“平均”了。它不会优先照顾那些马上就要处理完请求、或者等待了很久的线程,导致延迟抖动。
后来我注意到 Linux 6.6 LTS(2023年11月发布)里正式合并了 EEVDF (Earliest Eligible Virtual Deadline First) 调度器。这玩意儿的核心逻辑变了,不再单纯看 vruntime 谁小,而是看谁最早到达“虚拟截止时间”(Eligible Virtual Deadline)。简单点说,它更懂“急事急办”。
我们直接把测试环境的内核升级到了 6.8(2024年3月发布的最新稳定版),没做任何其他改动,就切到了 EEVDF。结果让我挺惊讶的,在同样的 80 万 QPS 压力下,P99 延迟的抖动幅度直接缩小了 60%。
我特意写了一段脚本,用来模拟当时那种高并发下的调度延迟情况,顺便验证 EEVDF 的表现。这个脚本会创建一堆短生命周期的进程,模拟 RPC 请求:
跑完这个脚本,我对比了 perf sched latency 的数据。在 CFS 下,很多任务的 max delay 能到 30ms+,而在 EEVDF 下,基本都压在了 5ms 以内。
为什么这么做?
因为对于网关这种东西,公平性没那么重要,重要的是确定性。EEVDF 的引入,让调度器有了“截止时间”的概念。如果一个任务等了很久(Eligible),或者它处理完了能立刻释放资源,调度器会优先把它捞起来。
不这么做会怎样?
如果不切 EEVDF,我们当时为了压住延迟,只能把容器绑核(pin core)做得更死,或者干脆加机器做物理隔离。CFS 在那种“瞬间爆发”的场景下,调度延迟是不可控的,你没法保证那个毫秒级的响应。
不过,升级内核也不是无脑升的。我在另一个跑着 Java 应用的节点上试了 6.8,发现因为调度策略变了,原本通过 nice 值调整优先级的策略有点失灵。后来我查了下社区讨论,发现 EEVDF 对 nice 值的权重计算方式和 CFS 有细微差别。我不得不把原本 nice -10 的后台计算任务调整到了 nice -15,才恢复了原来的资源抢占力度。
以前我们排查线上问题,特别是那种“幽灵”般的卡顿,真的很痛苦。有一次,一个支付接口突然变慢,从 50ms 变成了 800ms,持续了大概 10 秒又自己好了。传统的 top、vmstat 根本抓不到那一瞬间,因为采样周期太长了。
后来我彻底投奔了 eBPF。这东西现在真的太强了,尤其是 bpftrace v0.20(2024年1月刚更新),配合 Linux 6.8 内核,几乎可以无死角地观测内核。
我现在的排查思路基本是这样的:不用重启服务,不用改代码,直接上 bpftrace 挂个探针。比如那次支付接口卡顿,我怀疑是内核态的锁或者系统调用出了问题。我直接写了个脚本去抓 futex 的等待时间:
跑这个脚本,我直接定位到是某个线程在等一把互斥锁,而且这把锁被一个 write 系统调用给阻塞了。顺着往下查,发现是当时日志系统的磁盘 IO 抖动,导致 write 卡在内核态,进而拖住了业务线程。
为什么用 eBPF?
传统的 strace 开销太大了,线上跑基本等于自杀,因为它会频繁陷入内核态。eBPF 是 JIT 编译后在内核内部运行的,开销极低。我测过,在生产环境跑上面的脚本,CPU 开销基本可以忽略不计,也就是 0.1% 左右。
不这么做会怎样?
如果不懂 eBPF,我可能得在那儿瞎猜是 GC 问题、网络问题还是数据库慢查询。或者得把服务停了,挂上 gdb 或者 perf 录一大堆数据回来慢慢看,效率极低。
现在的趋势是,连 Prometheus v2.50(2024年2月版)都在考虑通过 eBPF 来采集更底层的网络指标。我自己在项目里也封装了一些基于 BCC 的工具,用来监控容器的软中断分布。比如下面这个 Python 片段,是用来统计每个 CPU 上的软中断次数的,这比读 /proc/softirqs 要灵活得多:
这个脚本帮我解决过一次 Nginx 软中断不均衡的问题。当时发现 net_rx 全打在 CPU0 上了,后来配合 irqbalance 和 smp_affinity 调整,把网卡中断打散了。
那是去年双11的复盘会,我们那个核心的订单数据库(跑在 256G 内存的物理机上)在大促峰值期间,性能曲线简直没法看。QPS 刚过 15 万,系统就开始频繁地触发内存回收,导致 kswapd 进程狂吃 CPU,数据库响应时间从 5ms 飙升到 200ms。
我上去看了一下 sar -B 的输出,pgscand 和 pgscan 的数值高得离谱。当时内核是 5.10,用的是传统的基于活跃/非活跃链表的内存回收机制。那个机制有个毛病,就是当内存压力大时,它很难精准判断哪些页面是“真的”冷数据,经常把马上要用的数据给换出去了,然后又得换回来,这就是所谓的“内存颠簸”。
后来我们决定升级到 Linux 6.6 LTS,主要是冲着它的 MGLRU (Multi-Generational LRU) 去的。MGLRU 不再用那套老旧的链表,而是引入了“代”(Generation)的概念,有点像 CPU 的缓存策略,能更精准地追踪页面的访问热度。
升级完内核,我打开了 MGLRU(默认是关闭的,需要改 sysctl):
光开 MGLRU 还不够,我们还得配合透明大页(THP)一起调。数据库这种随机 IO 大的场景,其实不太适合用 always 模式的透明大页,因为大页分配失败会导致卡顿。我们当时用的是 madvise 模式,并且针对数据库的 buffer pool 做了专门的配置。
我写了一个简单的 C 程序,用来模拟当时内存压力下的 page fault 情况,顺便验证 MGLRU 的效果:
编译运行后,我对比了 /proc/vmstat 里的 thp_fault_alloc 和 pgmajfault。在开启 MGLRU 之前,大页分配失败率很高,导致很多 4k 的小页分配,增加了 TLB Miss。开启 MGLRU 后,内核在回收内存时更聪明了,它不会轻易去拆散大页,除非真的没路走了。
为什么这么做?
因为传统的 LRU 在高内存压力下,回收策略太粗暴。MGLRU 能区分出“最近没访问但可能马上要用”的页面和“真的冷”的页面。对于数据库这种场景,它的 buffer pool 很大,传统的 LRU 可能会把 buffer pool 里的热数据误杀,导致磁盘 IO 暴增。
不这么做会怎样?
如果不上 MGLRU,我们当时只能选择加内存,或者把数据库拆得更碎。但加内存解决不了算法层面的低效,只要压力一上来,回收机制还是会乱杀。
实际效果非常明显。同样的 20 万 QPS 压测,升级 6.6 内核并开启 MGLRU 后,kswapd 的 CPU 占用从之前的 30% 降到了 5% 以下,数据库的平均延迟稳定在 8ms 左右。这次调优让我意识到,内核的版本迭代真的不是修修 bug 那么简单,像 MGLRU 这种核心机制的替换,对上层应用性能的影响是巨大的。
去年双十一大促,我负责的一个电商核心 API 网关(基于 OpenResty/Nginx 1.21)在流量峰值达到 12 万 QPS 时,出现了极其诡异的现象:服务响应延迟从平均 15ms 飙升至 800ms 以上,但 CPU 整体使用率仅 40%,系统负载却高达 30。通过 top 观察,发现 CPU 使用率呈严重的两极分化:CPU0 的 si(软中断)占用率长时间保持在 95% 以上,而其余 23 个逻辑核心却几乎处于空闲状态。
原因在于网卡的中断亲和性(IRQ Affinity)配置不当。我当时的环境是 Intel XXV710 25G 网卡,配合 Linux 内核 6.6 LTS。默认情况下,网卡的所有硬件队列中断都绑定在 CPU0 上。当网络流量暴增,CPU0 忙于处理网卡收包的中断下半部(SoftIRQ,主要是 NET_RX 和 NET_TX),导致应用进程(Nginx Worker)无法获得调度,从而引发了全局性的延迟抖动。
我当时的排查路径如下:
mpstat -P ALL 1 确认 CPU0 的软中断异常。/proc/interrupts,发现 eth0-TxRx-* 的中断几乎全部集中在 CPU0。解决方案是实施中断负载均衡。我没有直接使用 irqbalance 服务,因为在实际生产环境中,我发现 irqbalance 的策略过于保守且不可控,它无法感知 Nginx Worker 的 CPU 亲和性。我选择手动编写脚本,将网卡队列的中断绑定到特定的物理核心上,并避开 Nginx Worker 所在的核。
以下是我在生产环境中使用的核心脚本逻辑(基于 ethtool 和 taskset 的思路):
执行上述脚本后,软中断被分散到了 CPU 0-7。紧接着,我调整了 Nginx 的配置,将 Worker 进程绑定到剩余的 CPU 8-23,并开启了 reuseport 选项。
优化后的结果是立竿见影的。CPU0 的 si 使用率从 95% 降至 15%,整体 P99 延迟从 800ms 回落至 120ms,且 QPS 稳定支撑住了 12 万+ 的峰值。如果不做这个隔离,单纯增加 Nginx Worker 数量只会导致更多的上下文切换,根本无法解决软中断导致的调度饥饿问题。
在 Kubernetes 集群中,我遇到过一个典型的性能问题:一个跑着大数据计算任务的 Pod(Java 应用),在节点资源看似充足(CPU 使用率 60%)的情况下,其 Full GC 频率异常高,且计算吞吐量比物理机部署低了 30%。经过深入分析,我发现问题出在 CPU 缓存失效和跨 NUMA 内存访问上。
原因在于 Kubernetes 默认的调度机制只关注 CPU 核数的分配,并不感知 CPU 的物理拓扑(Cache 层级和 NUMA 节点)。当时我的节点是双路 Intel Xeon Silver 4314,每路 16 核,共 32 核,分为两个 NUMA 节点(node 0 和 node 1)。Pod 的 CPU 请求被随机分配到了两个 NUMA 节点上,导致进程在运行时频繁跨 NUMA 访问内存,同时由于 L3 Cache 被不同 Pod 争抢,命中率大幅下降。
为了解决这个问题,我采取了以下措施,核心是利用内核的 cgroup 特性和 numactl 工具。
首先,我确保节点内核版本在 6.6 以上,因为新内核对于 cgroup v2 的 CPU 资源隔离支持更加完善。然后,我没有直接使用 K8s 原生的 cpu-manager-policy=static(这只能做绑核,不能做 NUMA 亲和),而是采用了更底层的配置。
我编写了一个 DaemonSet,在节点上通过 sysfs 和 cgroup 手动干预特定高优先级 Pod 的亲和性。以下是一个简化的逻辑,展示如何将一个进程(假设 PID 为 12345)绑定到 NUMA 0 并限制其使用的 CPU 缓存范围:
在执行上述操作后,我使用 perf stat 验证了 L3 Cache 的命中率变化:
除了手动干预,我在后续的集群建设中引入了 Topology Manager 和 NUMA-aware Scheduler。在 K8s 1.28+ 版本中,结合内核 6.6 的特性,我配置了 topologyManagerPolicy: single-numa-node。这确保了 Pod 的所有资源(CPU、内存、设备)都来自同一个 NUMA 节点。
如果不做 NUMA 亲和性,在跨 NUMA 访问时,内存延迟会增加 1.5 到 2 倍(从约 80ns 增加到 140ns+)。对于计算密集型任务,这种延迟累积足以导致性能雪崩。此外,我还发现,如果不隔离 CPU 缓存,相邻的 Pod 可能会频繁驱逐共享的 L3 缓存行,导致 perf 显示大量的 cache-misses,这也是为什么那个 Java 应用 GC 频繁——GC 线程在扫描内存时遭遇了极高的内存访问延迟。
在 2023 年底,我负责重构公司的基础设施安全审计系统。最初的技术栈是经典的“用户态 Agent + 内核模块”模式:我们在每台主机上部署了一个基于 Go 语言开发的 DaemonSet,它通过 auditd 读取日志,并定期执行 execve 系统调用来监控进程行为。
但在实际运行中,这个方案暴露了两个致命缺陷。第一是性能开销。在 6.6 LTS 内核上,开启 auditd 全量监控后,系统调用开销增加了约 15%,对于高并发的 Nginx 节点,这直接导致了 P99 延迟上升。第二是数据盲区。我们的 Agent 为了性能,只能采样采集,导致很多短生命周期的恶意脚本(如通过 bash -c 执行的反弹 Shell)在 Agent 轮询间隙执行完毕并消失,根本无法捕获。
原因在于传统 Agent 处于用户态,它获取内核数据的路径太长了:内核 -> auditd 日志 -> 磁盘 -> Agent 读取 -> 解析 -> 上报。这个链路不仅慢,而且容易被攻击者干扰(例如清空日志文件)。
我决定全面转向 eBPF(扩展伯克利包过滤器) 技术。基于 Linux 内核 6.8 的最新特性,eBPF 允许我在内核中直接运行沙箱程序,无需修改内核源码即可实现深度观测。我选择了 bpftrace (v0.20) 和 Cilium 生态中的 Tetragon 作为核心组件。
我放弃传统 Agent 的核心逻辑是:eBPF 程序是事件驱动的,它挂载在 tracepoint 或 kprobe 上,当系统调用发生时,eBPF 程序在内核态直接处理数据,过滤掉无关信息,只将关键的安全事件(如 execve, connect, open 等)通过环形缓冲区(Ring Buffer)发送给用户态的收集器。
以下是一个我实际编写用于检测异常 execve 行为的 bpftrace 脚本,它监控所有包含 "curl" 或 "wget" 且参数可疑的进程创建:
使用 eBPF 后,效果非常显著。首先,性能损耗几乎为零。根据我在生产环境(内核 6.8)的压测,开启全量系统调用监控后,CPU 开销增加不到 1%,内存开销仅为传统 Agent 的 1/5(约 20MB vs 100MB)。其次,可见性大幅提升。我不再依赖日志轮询,而是实时捕获每一个系统调用。有一次,我们捕获到了一次通过 LD_PRELOAD 注入的提权攻击,传统 Agent 因为采样间隔错过了这次攻击,而 eBPF 程序在攻击发生的瞬间就捕获到了 mmap 和 execve 的异常序列。
当然,eBPF 也不是银弹。在开发过程中,我遇到了 eBPF 验证器(Verifier) 的限制。有一次我写了一个复杂的循环逻辑来解析网络包,结果加载失败,原因是验证器无法证明循环一定会在有限次数内结束。解决方案是进行循环展开或者简化逻辑,只在内核中做最基础的过滤,将复杂的解析逻辑放到用户态。这也是 2024 年社区讨论的热点:如何在保证安全性的前提下,让 eBPF 程序更灵活。
对比传统方案,eBPF 不仅解决了性能问题,还实现了安全与性能的融合。我不再需要担心 Agent 挂掉导致监控断点,因为 eBPF 程序是内核的一部分,只要内核在运行,监控就在运行。这种架构上的优势,让我彻底放弃了维护传统 Agent 的念头。
去年双十一前,我接了个挺头疼的活儿。我们那个做直播带货的App,平时流量还行,一到晚上八点黄金档,API网关就开始抽风。用户疯狂反映下单卡顿,后台日志里全是超时。
我接手的时候,第一反应是去看CPU和内存,但 top 命令看了一圈,机器负载也就60%左右,没跑满啊。这就很诡异了,资源没耗尽,为啥处理不动?
我蹲在电脑前盯着监控大屏发呆,后来灵机一动,去查了软中断(si)。好家伙,不看不知道,两个CPU核心的软中断直接飙到90%以上,其他核却在围观。典型的网卡中断分配不均。
当时我试了几个法子,先是把 irqbalance 关了,手动写了脚本把中断绑定到特定的物理核上,结果效果一般。最后还是上了Receive Side Scaling (RSS) 配合网卡多队列,把流量打散。折腾到凌晨三点,终于把软中断压下去了。
关于技术选型,我想啰嗦两句。
* eBPF 虽好,别乱上:现在大家都吹 eBPF,我也爱用。但如果你只是个日活几千的小站,装个 Prometheus + Node Exporter 就够了。为了炫技上 eBPF 搞可观测性,维护成本能让你哭出来。
* 调度器不是万能药:文章里提到的 EEVDF 或者 CFS 调优,那是针对那种百万并发、对延迟极其敏感的场景。普通业务改内核参数,大概率不如优化一下你的 SQL 语句或者代码逻辑来得实在。
最后给各位正在死磕性能的朋友一句劝:别一上来就盯着内核参数看。很多时候瓶颈不在 Linux,而在你写的代码里。先确认是系统问题再动手,不然改了一堆 sysctl 配置,最后发现是数据库连接池漏了,那就真的尴尬了。