标签: 性能调优

  • 深入 XFS 日志阻塞排查:高并发小文件写入引发的 NVMe IO Stall 与 blk-mq 调度瓶颈实战

    针对 NVMe 盘在高并发小文件(Metadata 密集型)写入场景下出现的 iowait 飙升与进程 D 状态堆积问题,核心原因是 XFS 默认 logbsize 过小导致高频元数据刷盘(xfsaild 锁竞争),叠加底层 blk-mq 错误使用 mq-deadline 调度器引发的自旋锁开销。通过挂载参数调优 logbsize=256k,logbufs=8 并将 NVMe 调度器改为 none,可直接将 IO 99线延迟从 800ms 压降至 2ms 以内。

    案发现场:Load 飙升与幽灵般的 D 状态

    排查过程中接到告警,某核心图片处理集群(Kernel 5.10, XFS v5)的 Load Average 突然飙升至节点 CPU 核数的 3 倍以上。业务反馈接口响应超时,API Gateway 层面大量 503。

    登录机器,第一反应看 iostat。结果非常反直觉:

    $ iostat -dxm 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00     0.00    0.00 8540.00     0.00    35.20     8.44   124.50   14.50    0.00   14.50   0.12 100.00
    

    磁盘 %util 打满 100%,写入 IOPS w/s 仅为 8500 左右,吞吐量 wMB/s 才 35MB/s。对于一块企业级 NVMe SSD 来说,这个负载连热身都算不上(标称 IOPS 在 40万+),但 await 已经涨到了 14.5ms,甚至偶发飙到数百毫秒。

    抓取当前处于 D(Disk Sleep)状态的进程,直接看内核调用栈:

    $ for i in $(ps -eo pid,state | awk '$2=="D"{print $1}'); do cat /proc/$i/stack; echo "-----"; done
    [<0>] xfs_log_force_lsn+0x2d1/0x3a0 [xfs]
    [<0>] xfs_bmap_extents_to_btree+0x2a2/0x7c0 [xfs]
    [<0>] xfs_bmapi_write+0x3ab/0x650 [xfs]
    [<0>] xfs_iomap_write_direct+0x1eb/0x290 [xfs]
    [<0>] iomap_apply+0x11b/0x270
    ...
    

    大量进程阻塞在 xfs_log_force_lsn。这意味着业务虽然在写数据,但实际上是被文件系统的 Journal(日志)同步刷盘机制卡住了。

    为什么超高性能的 NVMe 会被 XFS 日志拖死?

    XFS 是一种强一致性的日志文件系统。为了保证 Crash Consistency,任何对元数据(Metadata,如修改文件大小、分配新 Block、修改时间戳)的更改,都必须先写入日志(Journal),然后才能落盘到实际的设备位置。

    在这个业务场景中,大量并发写入 KB 级别的小文件,引发了海量的 Block Allocation(块分配)操作,导致元数据剧烈变化。

    默认情况下,XFS 的内存日志缓冲区大小(logbsize)为 32KB。当并发小文件写入极度密集时,这个 32KB 的 Buffer 瞬间就被填满。一旦填满,XFS 的 CIL(Committed Item List)机制就会被强制触发同步刷盘(Log Force)。 更致命的是,日志写入是串行的。成百上千个并发线程在等待这 32KB 的日志落盘,底层 NVMe 的并发优势被文件系统层的全局自旋锁(Spinlock)和同步等待队列彻底抹平。

    可以通过 xfs_info 查看当前挂载的日志参数:

    $ xfs_info /data
    meta-data=/dev/nvme0n1           isize=512    agcount=32, agsize=30517961 blks
             =                       sectsz=4096  attr=2, projid32bit=1
             =                       crc=1        finobt=1, sparse=1, rmapbt=0
             =                       reflink=1    bigtime=0 inobtcount=0
    data     =                       bsize=4096   blocks=976574768, imaxpct=25
             =                       sunit=0      swidth=0 blks
    naming   =version 2              bsize=4096   ascii-ci=0, ftype=1
    log      =internal log           bsize=4096   blocks=476843, version=2
             =                       sectsz=4096  sunit=1 blks, lazy-count=1
    realtime =none                   extsz=4096   blocks=0, rtextents=0
    

    要缓解日志锁竞争,必须放大缓冲,降低刷盘频率。

    深入块设备层:blk-mq 调度器的额外损耗

    除了文件系统,底层的 IO 调度器也在这里扮演了“反面角色”。 通过检查 NVMe 设备的调度器配置:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    系统默认使用了 mq-deadline。这是针对传统 SATA/SAS SSD 优化的多队列调度器,它试图在软件层对 IO 请求进行合并(Merge)和排序,以保证请求不会饿死。

    但在纯 NVMe 环境下,NVMe 硬件控制器本身已经具备了极深的金字塔形硬件队列(通常有 64K 个队列,每个队列深 64K)。在极高的并发下,mq-deadline 在内核态维护软件队列的自旋锁开销,反而成了多核 CPU 下的严重瓶颈。

    我们通过 perf 抓取内核热点:

    $ perf top -F 99 -e cpu-clock
    

    可以看到 blk_mq_sched_insert_requestssbitmap_get 占据了大量的 CPU 周期,这全是调度器无谓的锁开销。

    解决方案与性能对撞

    既然定位到了两个层面的阻塞,解法就非常明确了:降低 XFS 元数据刷盘频率,卸载块设备的软件调度开销。

    1. 调整 XFS 挂载参数

    将日志缓冲区大小直接拉到最大限制 256KB,并增加缓冲数量到 8 个(默认 8 个,无需显式改但推荐确认)。开启 noatime 避免读取时引发元数据更新。

    # 在 /etc/fstab 中修改挂载参数,或在线 remount
    $ mount -o remount,noatime,logbsize=256k,logbufs=8 /data
    

    2. 更改 NVMe 调度器为 none

    彻底旁路 IO 调度器,将请求直接打入 NVMe 硬件队列:

    $ echo none > /sys/block/nvme0n1/queue/scheduler
    

    注:为了持久化,建议写入 udev rule,例如 /etc/udev/rules.d/60-io-scheduler.rules ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"

    优化效果对比

    应用上述两步操作后,业务请求无缝恢复,再次观察 iostat

    Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme0n1           0.00     0.00    0.00 42500.00    0.00   285.20    13.74     1.50    0.03    0.00    0.03   0.01  38.50
    
    • IOPS 直接冲上 4.2万(业务并发量完全释放)。

    • 吞吐量达到 285 MB/s。

    • await 从 14.5ms 暴降至 0.03ms

    • %util 回落到 38.5% 的健康水位。 D 状态进程彻底消失。

    常见问题

    Q1:ext4 是否存在类似的日志瓶颈?如何排查? 存在。ext4 的日志由 jbd2 内核线程负责。在高并发小IO下,若经常看到 jbd2/nvme0n1-8 占用 100% 单核 CPU,或者大量进程阻塞在 wait_transaction_locked,即为典型的 ext4 日志瓶颈。可通过挂载参数 data=writeback 降低日志开销(牺牲部分数据安全性),或将日志放在独立的外部极速设备上(mke2fs -O journal_dev)。

    Q2:如果业务已经在使用 io_uring,还会被 XFS 的日志锁阻塞吗? 会。io_uring 解决的是系统调用(Syscall)开销和 Block 层的异步投递问题,但文件系统层的元数据操作(尤其是文件大小扩展、分配新块)如果在内核中必须走同步的 Log Force,io_uring 也会回退到慢速路径(Worker Thread)。为了让 io_uring 彻底发挥性能,建议使用预分配(fallocate)锁定空间,使后续的写操作变为纯粹的覆写(Overwrite),从而彻底避开元数据更新。

    Q3:如何动态观测 XFS 日志的写入频率和延迟? 可以使用 BCC (eBPF) 工具集中的 xfsdistxfsslower 工具。 执行 xfsslower 1,如果屏幕上疯狂打印出 xfs_log_force 的调用堆栈且延迟 > 1ms,就足以说明当前系统的 IO 瓶颈不在介质本身,而在于文件系统的日志同步机制。

  • 深入 SCHED_FIFO 调度死锁排查:RT 优先级滥用引发的 RCU Stall 与 Node 假死实战

    某次线上核心交易集群出现诡异的 Node 批量假死现象。故障发生时,API 响应瞬间超时(p99 从 2ms 飙升至 10s 以上),K8S 节点状态接连翻转为 NotReady,SSH 彻底失去响应。监控面板在断点前最后上报的数据显示:系统 Load Average 飙升至 800+,某几个单核 CPU 突然被打至 100%。

    最终结论:研发为了追求极致的低延迟,在业务代码中强行将关键线程的调度策略修改为 SCHED_FIFO(实时调度,优先级 99),并且在这个线程里写了一个没有退让机制的死循环 Spinlock。这导致对应的 CPU 核被完全霸占,内核态的 RCU (Read-Copy Update) 宽限期线程、ksoftirqd 等关键内核线程被彻底饿死,最终触发 rcu_sched stall,引发系统 Soft Lockup 进而 Panic。

    现场还原与死亡日志

    排查这种彻底死机的节点,常规的 topstrace 根本来不及敲,只能依赖保留的 kdump 或者重启后的 dmesg 遗言。在 messages 日志中,我提取到了最核心的崩溃栈:

    [ 1345.678901] watchdog: BUG: soft lockup - CPU#4 stuck for 22s! [bidding_worker:45092]
    [ 1345.678915] INFO: rcu_sched self-detected stall on CPU
    [ 1345.678920]  4-....: (59999 ticks this GP) idle=001/1/0x4000000000000000 softirq=45123/45123 fqs=14998
    [ 1345.678925]  (t=60000 jiffies g=123456 q=8901)
    [ 1345.678931] NMI backtrace for cpu 4
    [ 1345.678950] RIP: 0033:0x00007f8a9b2c3d4e (用户态死循环地址)
    

    日志非常清晰:CPU 4 卡死了 22 秒,RCU 机制检测到宽限期(Grace Period)已经 60000 个 jiffies 没有推进了。卡死 CPU 的进程是 bidding_worker,且 RIP 寄存器停留在用户态 (0033)。

    这意味着,不是内核出了 Bug,而是用户态进程死死咬住 CPU 不放,连内核的中断下半部和调度器都抢不回控制权。

    为什么用户态能干掉内核态?

    我拉取了该进程的调度配置,破案了:

    $ chrt -p 45092
    pid 45092's current scheduling policy: SCHED_FIFO
    pid 45092's current scheduling priority: 99
    

    这正是让人血压升高的操作。在 Linux 调度器(CFS,Completely Fair Scheduler)面前,所有普通进程(SCHED_OTHER)都要乖乖按时间片和 vruntime 来排队。但 SCHED_FIFOSCHED_RR 属于 Real-Time(实时)调度类,它的权限是凌驾于 CFS 之上的。

    SCHED_FIFO 的语义是:只要该线程不主动睡眠(sleep/wait)、不主动让出 CPU(sched_yield),或者不被更高优先级的 RT 线程抢占,它就会永远霸占当前的 CPU 核。

    业务代码里是怎么写的?

    // 致命的自旋锁逻辑
    while (!atomic_load(&task_ready)) {
        // 没有任何 sleep,没有 cpu_relax(),没有 sched_yield()
        // 纯纯的死循环烧 CPU
    }
    

    当优先级设定为 99 的 SCHED_FIFO 线程进入上述死循环时,灾难开始了:

    1. 抢占屏蔽:内核的普通调度器完全无法剥夺它的执行权。

    2. RCU 饿死:Linux 内核广泛使用 RCU 机制来管理无锁数据结构。RCU 需要等待所有 CPU 经过一次上下文切换(Quiescent State)来清理旧数据。CPU 4 被死循环霸占,永远不发生上下文切换,RCU 宽限期无限延长,内存无法释放,最终触发 RCU Stall Panic。

    3. 软中断阻塞:网卡收发包依赖的 ksoftirqd 默认也是普通优先级。CPU 4 上的网络包无法被处理,导致网络层面的“假死”。

    消失的最后一道防线

    按理说,Linux 内核对这种 RT 调度器的滥用是有防御机制的。kernel.sched_rt_runtime_uskernel.sched_rt_period_us 这两个 sysctl 参数,默认配置为每 1000000 微秒(1秒)内,RT 进程最多只能运行 950000 微秒(0.95秒)。剩下的 0.05 秒会强制留给普通进程(比如内核线程)续命。

    为什么没生效? 排查发现,某位前任 SRE 为了追求极限的内核调优,在初始化的 Ansible 剧本里加了一行: sysctl -w kernel.sched_rt_runtime_us=-1

    将该值设为 -1,意味着彻底关闭了 RT 进程的运行时间限制,扒光了内核的最后底裤。两者结合,精准击穿了系统的可用性防线。

    极低延迟的正确实现姿势

    如果业务真的需要旁路内核、避免调度开销来实现极低延迟(如 DPDK/SPDK 架构),绝不是简单粗暴地改个 SCHED_FIFO 就能搞定的。正确的系统级架构应该走CPU 隔离与绑核的路线:

    1. 内核启动参数隔离:通过 Grub 传入 isolcpus=4-7 nohz_full=4-7 rcu_nocbs=4-7。这会让操作系统把 CPU 4 到 7 隔离开,不往上面扔普通的定时器中断,不调度普通进程,并将 RCU 回调转移到其他核。

    2. 中断亲和性调整:配置 irqbalance 或手动修改 /proc/irq/xxx/smp_affinity,确保网卡和磁盘的硬件中断不要打到被隔离的核上。

    3. 应用侧精准绑核:业务进程启动后,使用 tasksetpthread_setaffinity_np,将死循环 Polling 的线程严格绑定在上述隔离核上。

    这时候,你用死循环轮询没有任何问题,因为这几个核本来就是买来专门给你“烧”的,不会影响系统大盘。

    排查清单与同类问题速查

    1. RCU Stall 定位确认 通过 dmesg -T | grep -i rcu_sched 查看是否有 self-detected stall。如果有,必须顺着堆栈找到是哪个 PID 卡死了特定的 CPU,并且留意 ticks this GP 是否异常庞大。

    2. 全网进程调度策略巡检 排查系统中是否有越权的调度策略:ps -eo pid,tid,class,rtprio,ni,pri,psr,pcpu,comm | awk '$3 != "TS" && $3 != "-" {print $0}' (TS 代表 SCHED_OTHER,重点关注 FF/RR 类型的进程)。

    3. 内核防御机制校验 检查 /proc/sys/kernel/sched_rt_runtime_us 是否被手贱修改为 -1。在非专用的隔离物理机上,强烈建议保持默认值 950000

    4. 自旋锁的防御性编程 在任何 C/C++/Rust 的用户态 Spinlock 实现中,循环体内必须加入 cpu_relax()(在 x86 下编译为 PAUSE 指令),这不仅能降低 CPU 流水线功耗,也能缓解总线争用。

  • 深入 K8S veth pair 丢包排查:高 PPS 触发的 SoftIRQ 单核瓶颈与 macvlan 卸载实战

    在 K8S 容器网络中,高并发(PPS > 30万)场景下 veth pair 极易因单队列架构触发宿主机单核 SoftIRQ (NET_RX) 100% 饱和,导致严重丢包与网络抖动。临时止血方案需在宿主机端开启 RPS(Receive Packet Steering)将软中断打散;而彻底解决该类 I/O 密集型业务瓶颈,应引入 macvlan 或 SR-IOV 进行网络栈卸载,直接旁路宿主机内核的复杂转发路径。

    故障现场:Redis 容器的神秘丢包与 99 线飙升

    近期排查了一起 K8S 集群内 Redis 响应毛刺问题。环境基础信息如下:

    • OS: Ubuntu 22.04 (Kernel 5.15.0-76-generic)

    • K8S 版本: v1.25.9

    • CNI: Calico v3.25.0 (BGP 路由模式)

    • 业务表现: 压测期间 Redis 实例的 QPS 达到 8 万时,p99 延迟从 2ms 突变至 150ms 以上,客户端频繁报 Read timed out

    首先登入 Redis 所在宿主机,直接通过 mpstat 查看中断分布:

    # 每秒输出所有 CPU 核状态
    mpstat -P ALL 1
    
    09:41:01 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
    09:41:02 AM  all    8.23    0.00    6.11    0.00    0.00   12.15    0.00    0.00    0.00   73.51
    09:41:02 AM    0    4.00    0.00    3.00    0.00    0.00    1.00    0.00    0.00    0.00   92.00
    ...
    09:41:02 AM   12    2.00    0.00   10.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
    

    如上所示,CPU 12 的 %soft 已经被彻底打满(100%)。进一步通过 /proc/softirqs 定位具体的中断类型:

    watch -d -n 1 "cat /proc/softirqs | grep NET_RX"
    

    确认是 NET_RX 软中断风暴。接着查看容器对应的宿主机端 veth 网卡(假设为 cali9a3b2c1)的丢包统计:

    # 确认网卡 rx_dropped 指标疯狂上涨
    ip -s link show cali9a3b2c1
    

    现象明确:宿主机单核处理软中断能力达到极限,导致网卡接收队列(Backlog)溢出,底层协议栈开始大面积丢弃数据包。

    为什么 veth pair 会成为高吞吐场景的性能毒药?

    要搞清楚这个问题,必须深入 veth pair 在内核中的数据流转机制。

    veth pair 是一对虚拟以太网设备。在 Calico 网络下,数据包从物理网卡(如 eth0)进入宿主机,经过内核路由判决后,发往对应的宿主机端 veth 设备(caliXXX),然后再进入容器的网络命名空间。

    对于物理网卡,现代网卡均支持多队列(RSS, Receive Side Scaling),可以通过 Hash 算法将不同数据流的硬件中断(HardIRQ)分发到多个 CPU 核上,进而触发多核并发处理 NET_RX 软中断。

    但 veth pair 是纯软件模拟的虚拟网卡,默认只有单队列(rx-0/tx-0)。 当数据包从物理网卡路由到 caliXXX 时,内核调用 dev_forward_skb,最终触发 netif_rxskb(套接字缓冲区)压入特定 CPU 的 softnet_data->input_pkt_queue 中。 由于 veth 没有硬件多队列支撑,所有发往该容器的数据包,其软中断处理逻辑通常只能由单核(通常是触发调用的源 CPU,或者被网卡中断绑定的固定 CPU)串行执行。当流量达到几十万 PPS 时,这个单 CPU 很快就会触及 100% 的瓶颈,导致后续包因为 Backlog 队满而被丢弃。

    实战破局:从软件调优到硬件卸载

    针对上述瓶颈,我们在实战中通常采用两个阶段的方案:快速止血与架构重构。

    第一阶段:软件层面开启 RPS 打散软中断

    RPS(Receive Packet Steering)是 RSS 的软件实现。它能在 netif_rx 接收到包后,利用四元组 Hash 软计算,将包投递到其他 CPU 的积压队列中,强制触发跨核的软中断处理。

    找到 Redis 对应的宿主机网卡 cali9a3b2c1,为其配置 RPS(假设宿主机为 16 核,我们将掩码设为 ffff,允许打散到所有核):

    # 将 16 进制掩码写入对应接收队列的 rps_cpus 中
    echo ffff > /sys/class/net/cali9a3b2c1/queues/rx-0/rps_cpus
    
    # 同步调大内核层面的 backlog 队列深度,防止缓冲击穿
    sysctl -w net.core.netdev_max_backlog=10000
    

    开启后,再次观察 mpstat,CPU 12 的 %soft 迅速下降至 30% 左右,其他 CPU 的 %soft 开始均衡上升,Redis 响应延迟立刻恢复到 2ms 的水平。

    注意: 这种方案有代价。RPS 带来了额外的 CPU 周期消耗(计算 Hash、跨核 Cache Miss),整体 CPU 负载(Load Average)会显著升高。这是典型的“空间换时间”策略。

    第二阶段:引入 macvlan / SR-IOV 卸载网络栈

    对于此类极致 I/O 的业务,经过多次踩坑,最终的防线必须是绕过复杂的宿主机网络栈。通过 Multus CNI 引入 macvlanSR-IOV,是当前主流的解法。

    macvlan 桥接模式为例,它的底层原理是直接在宿主机物理网卡(eth0)上虚拟出一个具有独立 MAC 地址的子接口。数据包到达物理网卡后,底层驱动通过匹配 MAC 地址,直接将包送入容器的 Network Namespace,彻底跳过了宿主机内核的路由查找、Netfilter (iptables/IPVS) 过滤以及 veth pair 的设备中转。 且 macvlan 继承了物理主网卡的 RSS 特性,天然支持多核并发接收。

    在 K8S 中配置 Multus 与 Macvlan 混合网络示例 (NetworkAttachmentDefinition):

    apiVersion: "k8s.cni.cncf.io/v1"
    kind: NetworkAttachmentDefinition
    metadata:
      name: macvlan-conf
      namespace: default
    spec:
      config: '{
          "cniVersion": "0.3.1",
          "type": "macvlan",
          "master": "eth0",
          "mode": "bridge",
          "ipam": {
            "type": "host-local",
            "subnet": "192.168.100.0/24",
            "rangeStart": "192.168.100.100",
            "rangeEnd": "192.168.100.200",
            "routes": [
              { "dst": "0.0.0.0/0" }
            ],
            "gateway": "192.168.100.1"
          }
        }'
    

    随后在 Redis Pod 中声明注解:

    metadata:
      annotations:
        k8s.v1.cni.cncf.io/networks: macvlan-conf
    

    改造后,Redis Pod 获得了直通物理网络的 eth1 网卡,单机压测极限 PPS 提升了近 3 倍,且宿主机的 CPU sys/soft 占用极低。

    常见问题 (FAQ)

    Q1:为什么使用 macvlan (bridge 模式) 后,宿主机反而 ping 不通该容器了? 这是 macvlan 驱动设计的经典防线。macvlan 拦截了进出物理网卡的流量,但根据 802.1q 规范,从物理网卡发出的包默认不会回流到自己。宿主机发送的报文直接从底层网卡出去了,无法通过 MAC 匹配路由回该网卡上的 macvlan 子接口。 解法: 在宿主机上再创建一个同网段的 macvlan 接口(例如叫 macvlan-host),将宿主机对该网段的路由指向 macvlan-host,利用 bridge 模式下的内部交换机制实现通信。

    Q2:SR-IOV 与 macvlan 相比优势在哪里,什么时候必须上 SR-IOV? macvlan 仍经过宿主机的物理网卡驱动和内核协议栈底层;而 SR-IOV(Single Root I/O Virtualization)是 PCIe 硬件级别的虚拟化。它通过 PF(Physical Function)虚拟出多个 VF(Virtual Function),VF 直接映射给容器。 如果是搞 DPDK 等用户态网络协议栈,或者极低延迟(微秒级)的 HFT (高频交易) 场景,必须用 SR-IOV 彻底 Bypass 内核。普通的高性能 Redis/MySQL,macvlan 已经足够。

    Q3:开启了 RPS,但有些网卡的 rps_cpus 修改后提示 “Permission denied” 或无效? 如果是针对容器内的 veth 设备修改,受限于 NetNS 权限,需在宿主机端的对端网卡(如 Calico 的 calixxx、Flannel 的 vethxxx)操作。另外,务必确保宿主机系统服务(如 irqbalance)不要与你手动的 RPS 掩码逻辑发生冲突,排查过程中发现两者打架是常态,针对极端优化的节点,通常建议关闭 irqbalance 并手动绑核。

  • 深入 NVMe 队列阻塞排查:blk-mq 调度器误用引发的 XFS 元数据锁雪崩与 sys CPU 饱和实战

    高并发写入场景下,NVMe 盘配合 XFS 极易触发 sys CPU 满载与 IO 夯死。核心原因是 NVMe 误用了 mq-deadline 调度器,导致 blk-mq 软件队列自旋锁争用,进而引发 XFS 分配元数据时在 xfs_log_commit_cil 处发生锁雪崩。直接结论:NVMe 设备的 IO 调度器必须设为 none,同时对于高并发盘,需在格式化时调大 XFS 的 agcount 以打散锁粒度。

    故障现场:数据库写入 p99 突增与 sys CPU 飙升

    某次排查过程中,一套承载核心业务的 PostgreSQL 集群(内核版本 5.10.134-el8,底层存储为裸金属物理机的 PCIe Gen4 NVMe SSD)在高并发 COPY 导入数据时,QPS 出现周期性断崖式下跌。

    通过 top 观察,CPU sys 态长期飙升至 70% 以上,iowait 反而在 10% 左右波动。这极不寻常——对于一块标称 100万 IOPS 的 NVMe 盘,IO 没有跑满,CPU 却在内核态被榨干。

    抓取当时的 iostat -x 1 核心指标:

    Device:         r/s     w/s     rkB/s     wkB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
    nvme0n1        12.0 42351.0     192.0  680512.0     0.0     0.0    0.0    0.0    0.15   18.42   12.5   16.00    16.06   0.02  85.40
    

    注意 w_await 达到了惊人的 18.42ms,对于 NVMe 来说,这个延迟意味着底层已经严重阻塞。但 %util 只有 85%,设备并未完全饱和。

    使用 perf top -U 直接看内核态热点,现场如下:

      18.45%  [kernel]  [k] queued_spin_lock_slowpath
      12.31%  [kernel]  [k] dd_insert_requests
       8.52%  [kernel]  [k] xfs_log_commit_cil
       6.14%  [kernel]  [k] blk_mq_submit_bio
       5.33%  [kernel]  [k] _raw_spin_lock_irqsave
    

    热点非常集中:dd_insert_requestsxfs_log_commit_cil。这表明系统同时在块设备调度层和文件系统日志提交层发生了严重的锁争用。

    为什么 NVMe 设备使用 mq-deadline 会导致 IO 栈雪崩?

    问题出在 Linux blk-mq(Block Multi-Queue)架构的调度器选择上。

    在传统的单队列(Single Queue)时代,所有 IO 请求进入一个全局队列,需要 CFQ 或 Deadline 这种电梯算法(Elevator)进行合并和排序,以减少机械硬盘的磁头寻道。

    到了 NVMe 时代,硬件支持多达 64K 个提交/完成队列。Linux 为此重构了 blk-mq 架构,分为软件队列(Software Staging Queues,通常每个 CPU 核心一个)和硬件分发队列(Hardware Dispatch Queues)。

    排查发现,该服务器的 NVMe 被默认配置了 mq-deadline 调度器:

    $ cat /sys/block/nvme0n1/queue/scheduler
    [mq-deadline] kyber bfq none
    

    底层阻塞原理: 当调度器设置为 mq-deadline(甚至 bfq)时,IO 请求在进入硬件队列前,必须先挂载到电梯算法的软件队列中。dd_insert_requests 就是 mq-deadline 插入请求的内核函数。由于高并发下成千上万个线程试图向这个软件队列提交 BIO(Block I/O),这就不可避免地触发了自旋锁(queued_spin_lock_slowpath)。 NVMe 的纳秒级响应速度完全被软件队列的自旋锁开销抹平,导致 CPU 在 sys 态空转,IO 提交路径被硬生生卡住。

    剥茧抽丝:XFS 延迟分配与 AIL/CIL 阻塞

    块设备的延迟飙升,迅速引发了文件系统层的连锁反应,这也是为什么 perf 中出现了大量 xfs_log_commit_cil

    XFS 是一种强依赖 Allocation Group(AG)并发设计的日志文件系统(当前版本 V5)。当数据库执行大量写入时,XFS 会利用延迟分配(Delayed Allocation)机制,在内存中缓存数据,直到刷盘时才真正分配物理 Block 并更新元数据。

    1. CIL(Committed Item List)雪崩:元数据变更首先写入内存中的 CIL。当底层 NVMe 因为 mq-deadline 阻塞时,后台刷脏线程(xfsaild)将 AIL(Active Item List)刷入磁盘的速度骤降。

    2. AG 锁争用:CIL 空间被占满,前端业务线程在调用 xfs_alloc_vextent 申请新的空间块时,必须等待日志空间释放。大量 PostgreSQL 线程被迫在同一个 AG 的元数据锁上排队。

    3. 全局夯死:IO 栈的阻塞放大了 XFS 的锁临界区时间,最终导致原本并行的 IO 瀑布般退化为串行等待,形成死锁态势的雪崩。

    解决方案与防御性配置

    解决该问题不需要修改业务代码,纯属系统级架构调优,分为治标和治本两步。

    1. 立即剥离软件调度器(实时恢复)

    将 NVMe 设备的调度器强行切换为 none,绕过所有电梯算法,让 BIO 请求直接从软件多队列打入硬件队列。

    echo none > /sys/block/nvme0n1/queue/scheduler
    

    执行瞬间,sys CPU 从 70% 骤降至 8%,PostgreSQL QPS 恢复正常,w_await 回落至 0.05ms。

    为了防止重启失效,通过 udev 固化防御策略:

    # vim /etc/udev/rules.d/60-io-scheduler.rules
    ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="none"
    

    2. XFS AG 并发度调优(底层重构)

    默认情况下,mkfs.xfs 根据磁盘大小自动计算 agcount(通常是 4 或 8)。对于大容量、极高吞吐的 NVMe 盘和数据库场景,默认 AG 数量太少,容易发生并发分配碰撞。 在节点下线重装阶段,调整格式化参数,人为扩大 AG 数量打散锁粒度:

    # 格式化 XFS:强制启用 32 个 AG,对齐 512M 日志大小
    mkfs.xfs -f -K -d agcount=32 -l size=512m,version=2,su=256k /dev/nvme0n1
    

    注:agcount 并非越大越好,过大会增加 mount 时间和内存开销,通常 16-32 针对高端 NVMe 是甜点区间。

    常见问题

    Q1: io_uring 在遇到这种 XFS 锁争用时,会退化成同步阻塞吗? 会。这是很多人使用 io_uring 踩坑的地方。虽然 io_uring 是异步 IO,但如果在文件系统层发生 metadata 锁争用(比如 XFS 分配 block),底层的 IORING_OP_WRITE 且带有 RWF_NOWAIT 标志位时,内核会直接返回 -EAGAIN。随后 io_uring 只能将这个 IO 任务推入后台的 io_worker 线程池进行同步阻塞处理,纯异步链路被击穿,高并发下依然会导致线程池耗尽。

    Q2: 调度器设置为 none 后,系统还有 IO 合并能力吗? 有,但发生在不同层级。none 确实禁用了电梯算法层的合并,但 blk-mq 在软件队列层(Software Staging Queue)和块设备硬件驱动层依然会利用 scatter-gather list 进行有限的相邻物理段合并。对于 NVMe 而言,本身 4K 随机 IO 的性能极高,强行进行复杂的 IO 合并排序带来的 CPU 锁开销远大于其收益。

    Q3: 如何在生产环境无损监控 XFS 的 AG 锁争用情况? 极力推荐使用 eBPF/bpftrace 而不是 SystemTap。可以通过挂载 tracepoint 实时监控 CIL 提交延迟:

    bpftrace -e 'tracepoint:xfs:xfs_log_commit_cil { @start[tid] = nsecs; } tracepoint:xfs:xfs_log_commit_cil_wait { if(@start[tid]) { @usecs = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); } }'
    

    如果输出的直方图显示大量调用耗时超过 1000 微秒(1ms),说明文件系统日志提交已出现严重积压,需立即排查底层块设备延迟。

  • 深入 Apache Pulsar 写入雪崩排查:Journal/Ledger 磁盘混用引发的 IO 饱和与 Bookie 假死实战

    某次接手一个号称“完全按照官方最佳实践”部署的 Pulsar 集群,业务方反馈高并发场景下大量 Producer 频繁抛出 PulsarClientException$TimeoutException,P99 写入延迟从常态的 5ms 瞬间飙升至 8000ms+,集群吞吐呈断崖式下跌。直接抛出排查结论:这是典型的底层存储架构无知导致的惨案。部署人员将 BookKeeper 的 journalDirectories(写前日志)和 ledgerDirectories(数据与索引)挂载到了同一块物理磁盘(甚至是同一块云盘)。当 Ledger 触发后台垃圾回收(Garbage Collection)或 RocksDB 刷盘时,海量随机 IO 直接榨干了磁盘 IOPS,导致 Journal 的顺序 fsync 严重阻塞。Bookie 内部线程池大面积挂起,最终因 ZK 心跳超时被踢出集群,引发 NotEnoughBookiesException 全局写入雪崩。

    Pulsar 最大的卖点就是“计算与存储分离”(Broker 与 Bookie 分离),但很多人只停留在节点级别的隔离,完全无视了 BookKeeper 内部极其苛刻的 IO 路径分离要求。

    BookKeeper 的写入模型极其严谨且保守:一条消息到达 Bookie 后,必须强制 fsync 落盘到 Journal(类似 MySQL 的 Redo Log),才会向 Broker 返回 ACK。同时,消息会被写入内存(MemTable),随后异步批量刷入 Ledger 磁盘,并更新 RocksDB 中的索引。 这套设计的初衷非常明确:用 Journal 的极速顺序写保证低延迟和数据可靠性,用 Ledger 的大容量存储应对历史数据读取和高吞吐。

    把 Journal 和 Ledger 混在一块盘上,无异于在高速公路上摆地摊。

    排查期间,登陆故障 Bookie 节点,一条极其普通的 iostat 命令就让问题原形毕露:

    # iostat -dx 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    nvme1n1           0.00     0.00  850.00 1200.00 10240.00 45000.00    53.89   145.20   70.83   90.50   56.90   0.49 100.00
    

    磁盘 %util 死死钉在 100%,avgqu-sz(请求队列长度)高达 145,await 飙到 70ms 以上(对于 NVMe 来说,超过 5ms 就已经是不及格了)。

    去翻看 Bookie 的 Prometheus 监控,核心指标 bookkeeper_journal_JOURNAL_SYNC_99_per(Journal 落盘 99 线)与磁盘 IO 延迟高度吻合,出现了巨幅毛刺。此时,Broker 的日志里已经尸横遍野:

    org.apache.bookkeeper.client.BKException$BKNotEnoughBookiesException: Not enough non-faulty bookies available
        at org.apache.bookkeeper.client.LedgerCreateOp.initiate(LedgerCreateOp.java:142)
        ...
    

    为什么会突然爆发?因为 BookKeeper 并非只有简单的追加写。当 Ledger 中的 EntryLog 文件里被删除(或过期)的数据达到一定比例时,Bookie 会触发后台 GC 线程(Minor/Major Compaction)。GC 的动作是读取旧文件、过滤有效数据、重写到新文件。这是一个极其暴力的重度随机读 + 顺序写过程。 如果 Journal 和 Ledger 共享物理 IO 设备,GC 产生的海量 IO 请求会瞬间塞满 OS 的 Block Layer 队列,Journal 线程哪怕只是想追加写入几 KB 数据并调用一次 fsync,也只能在队列里绝望地排队。

    不仅如此,由于 Journal 同步阻塞,Bookie 的 Netty Worker 线程被耗尽,导致 Bookie 连发往 ZooKeeper 的心跳都无法及时响应。ZK 判定 Bookie 宕机,Broker 发现 Ensemble 可用节点不足(例如配置了 3 副本,只剩下 2 个健康节点),直接拒绝写入。由于集群是均衡负载的,随着 GC 在各个节点轮番上演,整个 Pulsar 集群如同多米诺骨牌般倒塌。

    解决这种问题,不要去迷信什么神奇的 JVM 调优参数,核心就是尊重物理拓扑

    修复手段与防御性配置:

    1. 物理级别的 IO 隔离(最关键) 修改 bookkeeper.conf,强制分离 Journal 和 Ledger 目录到不同的物理磁盘。Journal 给一块极小但极快的高性能 NVMe SSD(几十G即可,写满会自动清理),Ledger 给大容量的普通 SSD 甚至 HDD。

    # 高速 NVMe 挂载点
    journalDirectories=/mnt/nvme_journal/bookkeeper/journal
    # 大容量 SSD/HDD 挂载点
    ledgerDirectories=/mnt/ssd_ledger/bookkeeper/ledgers
    

    2. 对后台 GC 进行冷酷的资源限流 不要让 GC 跑起来像脱缰的野马。在 bookkeeper.conf 中开启 GC 限速,严格控制其对磁盘带宽的占用:

    # 开启按字节限流
    isThrottleByBytes=true
    # 限制 Compaction 最大速率为 50MB/s (根据底层磁盘能力调整)
    compactionRateByBytes=52428800
    # 避免在高峰期触发 Major Compaction
    minorCompactionThreshold=0.2
    majorCompactionThreshold=0.8
    

    3. RocksDB 索引刷盘的平滑处理 Ledger 中的索引默认由 RocksDB 管理,RocksDB 的 MemTable Flush 同样会带来 IO 尖峰。确保配置了合理的 Write Buffer 和并发度:

    dbStorage_rockdb_writeBufferSizeMB=64
    dbStorage_rockdb_numLevels=6
    

    架构设计不是画几个方块就完事了。Pulsar 这种分布式中间件的性能底座,其实都建立在底层 Linux IO 调度和文件系统特性的基础之上。不理解数据的生命周期流转,不看磁盘的 IOPS 和延迟分布,一键部署出来的集群,最终都会在晚高峰教你做人。

    排查清单:BookKeeper IO 阻塞与假死速查

    1. 磁盘物理拓扑核对:执行 df -hlsblk,严格对照 bookkeeper.conf 中的 journalDirectoriesledgerDirectories,确认两者绝未落在同一块物理盘、同一个 LVM 卷或同一个共享云盘组上。

    2. Journal Sync 延迟监控:紧盯 bookkeeper_journal_JOURNAL_SYNC 的 P99 和 P999 指标,一旦常态超过 10ms,立刻排查底层的 IO 争抢或硬件寿命衰减问题。

    3. ZooKeeper 会话抖动排查:排查 Bookie 侧日志是否有 Expired session,以及 ZK 侧是否有 Closed socket connection for client。如果是 IO 夯死导致的 CPU 调度迟滞,考虑适当调大 zkTimeout(默认通常为 10s-30s),但治本仍在 IO 治理。

    4. GC 日志与速率审查:搜索 Bookie 日志中的 GarbageCollectorThread 关键字,观察 Compaction 触发频率和耗时。确认 isThrottleByBytes 是否开启并配置了合理的阈值,防止后台合并打挂前台写入。

    5. Direct Memory 泄漏挤压 OS Cache:检查 dbStorage_directIO_entryLogger 是否未正确分配,导致 Bookie OOM 或严重依赖 PageCache。确保为 Bookie 预留充足的 Direct Memory 给 RocksDB Block Cache 和 ReadAhead Cache。

  • 深入 NUMA 内存失衡排查:zone_reclaim_mode 引发的 THP 压缩阻塞与局域 OOM 击穿实战

    结论先行。针对 Elasticsearch/Kafka 等重度依赖 mmap 和 Page Cache 的应用,彻底关闭 THP(never)、设置 vm.zone_reclaim_mode=0 并强制 numactl --interleave=all 是规避 NUMA 局域 OOM 的铁律。跨 NUMA 访问的纳秒级延迟惩罚,远低于本地 Node 深度回收(Direct Reclaim)与大页压缩(Compaction)带来的秒级 I/O 夯死。

    现场还原:Load 飙升与诡异的毛刺

    某次排查中,业务反馈一个基于 Elasticsearch 7.17(底层系统为 Ubuntu 20.04,Kernel 5.4.0)的日志集群 P99 写入延迟出现极规律的剧烈抖动。平时延迟在 10ms 左右,但每隔几小时就会突发飙升至 2000ms+,伴随 Load Average 瞬间冲高到 80 以上。

    登录机器初步勘查,物理内存 256GB,JVM Heap 配置为 31GB(为了利用指针压缩),理论上剩余的 200GB+ 都会被 OS 用于 Page Cache 加速 mmap 读写。通过 free -g 查看,系统整体还有近 80GB 的 available 内存。

    然而,在查阅 /var/log/syslog 时,却发现了明确的 OOM Killer 介入日志:

    [51234.567890] java invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [51234.567895] CPU: 12 PID: 14532 Comm: java Tainted: G        W         5.4.0-122-generic #138-Ubuntu
    [51234.567901] Node 0 Normal free:45056kB min:45056kB low:56320kB high:67584kB
    [51234.567902] Node 0 Normal: 452*4kB (UME) 310*8kB (UME) ...
    [51234.567905] Node 1 Normal free: 83886080kB min:45056kB low:56320kB high:67584kB
    

    注意看日志里的致命细节:Node 0 的 free 内存已经触底(约 45MB,达到了 min watermark),而 Node 1 竟然还有 80GB 的空闲内存!

    性能观测:找出幕后黑手

    为了弄清为什么系统宁愿 OOM 也不用 Node 1 的内存,我拉起了常规的观测工具链。

    通过 numastat -m 查看 NUMA 节点的内存分布:

    $ numastat -m
                                 Node 0          Node 1           Total
                     --------------  --------------  --------------
    MemTotal                 128000          128000          256000
    MemFree                      43           81920           81963
    MemUsed                  127957           46080          174037
    Active                   110540           20480          131020
    Inactive                  12400           22500           34900
    

    Node 0 已经被彻底榨干。在延迟飙升期间,使用 perf top -p 抓取内核态调用栈,发现 CPU 极度密集地消耗在以下几个函数上:

    1. compaction_alloc

    2. isolate_freepages

    3. shrink_page_list

    同时,通过 /proc/vmstat 观察系统计数器,发现 compact_stallthp_fault_fallback 两个指标在毛刺期间呈现出几乎垂直的增长。

    为什么整体内存充足,却依然触发了局域 OOM 与 mmap 阻塞?

    这是一个典型的由 NUMA 架构默认分配策略、zone_reclaim_mode 回收机制以及 THP(透明大页)碎片整理共同酿成的惨剧。我们层层剖析。

    1. NUMA 的 Local Allocation 陷阱

    现代多路服务器默认开启 NUMA(Non-Uniform Memory Access)。Linux 内核默认的内存分配策略是 default,即优先在当前进程运行所在的 NUMA 节点上分配内存。 Elasticsearch 的主进程启动后,如果被调度器主要分配在 Node 0 的 CPU 上执行,它产生的大量 mmap 缺页中断(Page Faults)会疯狂吃掉 Node 0 的内存构建 Page Cache。最终,Node 0 被填满,而 Node 1 在旁边“看戏”。

    2. zone_reclaim_mode 引发的 Direct Reclaim 阻塞

    当 Node 0 的内存达到 low 水位线时,内核有两种选择:

    • A: 去 Node 1 借用空闲内存。

    • B: 强行在 Node 0 本地进行内存回收(驱逐 Page Cache 或 Swap)。

    内核如何决策?取决于 vm.zone_reclaim_mode 的值(以及节点间的距离 node_distance)。 在部分发行版或 BIOS 设置下,当 NUMA 节点距离较远时,系统倾向于在本地强行回收。此时如果业务正在高并发地写入,后台的 kswapd0 回收速度跟不上分配速度,内核就会挂起当前申请内存的用户态线程,进入Direct Reclaim(直接回收)路径。 shrink_page_list 就是在疯狂扫描和驱逐 Node 0 上的 Page Cache。这对于极度依赖 mmap 的 ES 和 Kafka 来说,相当于把热数据从内存里生生挖掉,下一次访问直接产生严重的磁盘 I/O 停顿。

    3. THP(Transparent Huge Pages)的致命一击

    如果只是缺内存,驱逐 Page Cache 最多带来 I/O 延迟。但 perf top 中的 compaction_alloc 揭示了更严重的问题:透明大页(THP)正在进行内存碎片压缩。 内核默认开启了 THP(madvisealways),试图为进程分配 2MB 的连续物理大页以减少 TLB Miss。当 Node 0 内存碎片化严重,没有连续的 2MB 空间时,内核的 khugepaged 或者触发 Direct Compaction 的线程会强行移动内存页面,试图“拼凑”出 2MB 的连续空间。 这个过程需要获取 Zone 级别的锁,会完全阻塞该 NUMA 节点上的其他内存分配请求。此时,业务看到的现象就是:机器负载瞬间飙到 80+,所有的写请求全部卡死(Hang),直到压缩超时或失败回退(thp_fault_fallback),随后由于 Node 0 实在挤不出哪怕 4KB 的内存,触发 OOM Killer 杀掉进程。

    核心调优实战与防御性配置

    不要迷信 OS 的默认配置,对于高吞吐的 DB/存储类应用,以下三步是必须落地的防御性基线:

    1. 强制 NUMA 内存交错分配(Interleave)

    通过 numactl 覆盖默认的本地分配策略,让应用在所有 NUMA 节点上均匀分配内存,彻底打散 Page Cache。 修改 ES 或 Kafka 的 systemd service 文件:

    [Service]
    # 将原来的 ExecStart 替换为带 numactl 的版本
    ExecStart=/usr/bin/numactl --interleave=all /usr/share/elasticsearch/bin/elasticsearch
    

    注:很多老鸟会担心 Interleave 带来的跨节点访问延迟(约增加 10~20 纳秒)。但在存储类系统中,因为局域内存耗尽引发的磁盘 I/O 阻塞(毫秒级甚至秒级),其代价是纳秒级跨节点延迟的 100,000 倍以上。

    2. 关闭 THP 与调整 zone_reclaim_mode

    透明大页对于 Redis/ES/Kafka 这类内存访问极度随机、频繁分配释放的应用,百害而无一利。必须在内核层彻底关闭,同时禁止本地激进回收。

    写入 /etc/sysctl.d/99-sysctl.conf

    # 优先去其他 Node 借用内存,绝不强行在本地发起深度回收
    vm.zone_reclaim_mode = 0
    # 降低 Swap 倾向,保护 Page Cache
    vm.swappiness = 1
    # 预留总内存的 1%-2% 给内核态,防止网络突发包导致网卡/内核分配内存失败触发直接回收
    # 256G 内存建议设置为 2G (2097152) 到 4G
    vm.min_free_kbytes = 2097152
    

    关闭 THP(不要只改 sysfs,建议写到 grub 引导参数里彻底干掉): 编辑 /etc/default/grub,在 GRUB_CMDLINE_LINUX 中追加: transparent_hugepage=never 执行 update-grub 并重启系统。若不重启,可通过以下命令即时生效:

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

    3. OOM Score 防御性保护

    对于关键存储进程,适当调低其 OOM Score,防止在极端情况下被内核误杀。可以在启动脚本中注入:

    echo -500 > /proc/$$/oom_score_adj
    

    常见问题 (FAQ)

    Q1:如何判断我现在的系统有没有受到 THP 的性能毒害? 查看 /proc/vmstat 中的关键计数器增量。执行 watch -n 1 "grep -e compact_stall -e thp_fault_fallback -e pgmigrate_success /proc/vmstat"。如果在你的业务高峰期,这几个指标在疯狂跳动,说明系统正在花费大量 CPU 周期进行内存整理,你的 P99 延迟绝对已经出问题了。

    Q2:vm.min_free_kbytes 设置得越大越好吗? 绝对不是。如果设置得太大(例如超过总内存的 5%),会导致系统提前触碰 low 甚至 high 水位线,触发后台 kswapd 极其频繁地唤醒,一直在做无用的 Page Cache 回收,反而降低了内存利用率并推高 CPU sys 使用率。一般 256G 内存给 2G~4G 足矣。

    Q3:除了 numactl --interleave=all,修改 BIOS 里的 Node Interleaving 有什么区别? BIOS 级别的 Node Interleaving 是从硬件层把 NUMA 给屏蔽掉(UMA 模式),OS 看到的只有一个大的 NUMA 节点。这种方式虽然简单粗暴,但所有进程都被迫交错访问。而使用 numactl 可以在 OS 保留 NUMA 感知的前提下,仅针对特定的吃内存大户(如 JVM / DB)进行交错,其他对 CPU 缓存敏感的轻量级计算进程(如 Nginx/Envoy)依然可以享受 NUMA 的本地访问加速,后者更加精细和灵活。

  • 深入 GitLab CI 阻塞排查:全局 Cache 滥用引发的 Runner IO 饱和与多级构建穿透实战

    某次代码合入高峰期,核心业务的 GitLab CI Pipeline 出现大面积排队,单次构建从 3 分钟恶化至 40 分钟。核心原因是全局 cache 滥用导致 Runner 节点磁盘 IO 打满(iowait > 65%),且 Docker 构建层缓存(BuildKit)被错误穿透。通过将缓存后端迁移至 MinIO S3、引入 BuildKit 挂载缓存,并严格分离 Cache 与 Artifacts,最终将 P99 构建耗时稳压在 2 分钟内。

    案发现场:Pipeline 假死与 IO 风暴

    排查过程中,研发反馈提交 PR 后 Pipeline 迟迟不执行。登录 GitLab Runner 宿主机(4C16G,普通 SSD),直接看系统负载:

    $ uptime
     14:22:10 up 45 days, 10:13,  2 users,  load average: 32.41, 28.14, 15.02
    
    $ iostat -dxz 1
    Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
    sda               0.00    12.40  145.00  380.00 45210.00 125044.0 648.59   14.20   25.30   15.10   29.20   1.90 100.00
    

    %util 持续 100%,load average 飙到 32。查看 Runner 容器的执行日志,发现大量 Job 卡在拉取和解压缓存阶段:

    Checking cache for default-3...
    Downloading cache.zip from https://storage.googleapis.com/gitlab-com-runners-cache/...
    WARNING: Downloaded cache is 3.2 GB
    Extracting cache is taking 12m 45s...
    

    一个包含前端 node_modules 和后端 .go/pkg/mod 的巨型 Cache 被挂载到了全局 default 阶段。当 10 个 Job 并发启动时,单台 Runner 需要瞬间下载并解压超过 30GB 的碎文件,直接把磁盘 IO 打穿。

    为什么全局 Cache 滥用会引发 Runner IO 雪崩?

    很多开发写 .gitlab-ci.yml 时,图省事喜欢把所有依赖丢进全局 cache,并且配置 paths 覆盖整个项目根目录。这在底层机制上是个灾难。

    GitLab Runner 处理 Cache 的默认逻辑是:基于 ZIP 压缩,在 Job 开始前下载解压,在 Job 结束后压缩上传。 如果配置不当(例如未配置分布式缓存,使用本地文件系统或远端低速 OSS),会产生以下连环爆炸:

    1. 网络与 CPU 双重瓶颈:每次 3GB 碎文件的 ZIP 压缩/解压,吃光 Runner 节点的 CPU 资源。

    2. 多 Job 踩踏:在同一个 Pipeline 中,buildtestlint 三个 Job 并发执行,会产生 3 次冗余的 3GB Cache 下载动作。

    3. 缓存穿透:只要有一个文件发生变化,缓存的 Hash Key 就会变动(或者使用 fallback key),导致全量重新上传。

    此外,Docker 构建环节也存在严重的缓存穿透。传统 docker build 遇到 COPY . . 时,只要源码树里任何一个无关文件(如 README.md)改动,其后的所有构建层(包括耗时的 go mod download)缓存全部失效。

    防御性流水线重构与多级缓存落地

    针对上述架构缺陷,实施分层缓存防御改造。环境基于 GitLab Runner 16.3 和 Docker 24.0.5。

    1. 阻断本地 IO 踩踏:引入 MinIO S3 分布式缓存

    放弃 Runner 本地文件缓存,在内网独立部署 MinIO 集群承接 Cache 流量,避免 Runner 磁盘成为瓶颈。修改 /etc/gitlab-runner/config.toml

    [[runners]]
      name = "high-perf-runner"
      url = "https://gitlab.example.com/"
      token = "glrt-xxxxxxxxxx"
      executor = "docker"
      [runners.docker]
        tls_verify = false
        image = "alpine:latest"
        privileged = true
        disable_entrypoint_overwrite = false
        oom_kill_disable = false
        disable_cache = false
        volumes = ["/cache"]
        shm_size = 0
      [runners.cache]
        Type = "s3"
        Path = "gitlab-runner-cache"
        Shared = true
        [runners.cache.s3]
          ServerAddress = "minio.internal.lan:9000"
          AccessKey = "admin"
          SecretKey = "StrongSecret123!"
          BucketName = "ci-cache"
          Insecure = true
    

    2. 精准外科手术:拆分 Cache 与 Artifacts,按需声明

    .gitlab-ci.yml 中,严禁使用全局 Cache。Cache 用于加速依赖下载,Artifacts 用于阶段间传递制品。

    stages:
      - deps
      - build
      - test
    
    # 只在依赖拉取阶段更新 Cache
    go-deps:
      stage: deps
      image: golang:1.21-alpine
      cache:
        key:
          files:
            - go.sum
        paths:
          - .go/pkg/mod/
        policy: pull-push # 唯一允许 push 的 Job
      script:
        - go mod download -x
    
    # 编译阶段:只读 Cache,通过 Artifacts 传递二进制
    go-build:
      stage: build
      image: golang:1.21-alpine
      cache:
        key:
          files:
            - go.sum
        paths:
          - .go/pkg/mod/
        policy: pull # 阻断重复压缩上传
      script:
        - go build -o myapp ./cmd/main.go
      artifacts:
        paths:
          - myapp
        expire_in: 1 hour # 防御制品磁盘打满
    

    3. 终极杀器:BuildKit 挂载缓存与内联镜像缓存

    针对 Docker 镜像构建,废弃旧版 docker build,全面启用 BuildKit (v0.12+) 的挂载缓存机制,将编译时的 .cache 独立持久化到 Runner 宿主机。

    修改 Dockerfile:

    # syntax=docker/dockerfile:1.4
    FROM golang:1.21-alpine AS builder
    WORKDIR /app
    COPY go.mod go.sum ./
    # 利用 BuildKit 挂载远端/本地模块缓存,避免每次下载
    RUN --mount=type=cache,target=/go/pkg/mod \
        go mod download
    
    COPY . .
    # 挂载构建缓存
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        go build -ldflags="-s -w" -o server .
    
    FROM alpine:3.18
    COPY --from=builder /app/server /server
    CMD ["/server"]
    

    配合 CI 脚本中的内联缓存(Inline Cache),实现多分支复用:

    docker-pack:
      stage: build
      image: docker:24.0.5-dind
      variables:
        DOCKER_BUILDKIT: 1
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        - >
          docker build
          --build-arg BUILDKIT_INLINE_CACHE=1
          --cache-from $CI_REGISTRY_IMAGE/cache:latest
          -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
          .
    

    常见问题

    Q1: Artifacts 和 Cache 到底怎么选?边界在哪? Cache 没有绝对保证,Runner 可能会清理它,它是为了速度(如 npm, go mod)。 Artifacts 是为了 Job 间的数据完整传递(如 build 产出的 .jar 传给 deploy)。绝对不要用 Cache 来传递编译产物,否则遇到并发 Pipeline 或者 Fallback Key 命中失败,会导致后续 Job 找不到文件而直接报错退出。

    Q2: DIND (Docker-in-Docker) 模式下,怎么复用宿主机的镜像层缓存? DIND 每次启动都是独立的守护进程,默认不共享宿主机 /var/lib/docker。如果非要复用,可以通过 Runner 的 volumes = ["/var/run/docker.sock:/var/run/docker.sock"] 改为 Docker-out-of-Docker (DOOD) 模式。但注意,这会带来严重的并发污染问题(多个 Job 同时强删镜像)。更推荐的做法是坚持 DIND,但引入 Registry Cache (--cache-from--cache-to=type=registry),将缓存推送到私有 Harbor。

    Q3: GitLab CI 并发太高导致 MinIO S3 节点网络打满怎么限流? 首先检查 Cache 包是否过大,剔除不必要的文件。其次,在 GitLab Runner 配置中使用 [runners.limit] 限制单 Runner 的并发 Job 数。最后,如果是特定大项目引发的,在 gitlab-ci.yml 里使用 rules 控制无关代码提交(如 Markdown 修改)不触发构建,或者引入 needs: (DAG 依赖) 取代按 Stage 批量阻塞,错开 Cache 拉取的时间峰值。

  • 深入 OpenTelemetry 追踪雪崩排查:全量采样引发的 Collector OOM 与无用 Span 泛洪实战

    某次微服务全链路追踪(OpenTelemetry)大范围铺开后,核心集群的 OTel Collector DaemonSet 出现大面积 OOMKilled,后端 Jaeger 和 ClickHouse 写入 QPS 瞬间跌零。业务线研发在排查线上故障时,发现应用日志与 TraceID 彻底脱节,排查陷入盲人摸象。最终结论:研发在 SDK 端开启了 100% 盲目全量采样,且未对 Kubelet 探针与 Redis 心跳做任何过滤;更有甚者,将动辄几百 KB 的 Base64 图片 Payload 强塞进 Span Attribute。由于 Collector 端未配置防御性的 memory_limiter 与背压处理,积压的脏数据瞬间打爆内存。

    解决思路很明确:SDK 端收敛采样率、Collector 端强行过滤高频无价值 Path 并截断超长 Attribute,同时修复异步线程池丢失 OTel Context 导致的 Trace-Log 关联断裂。

    案发现场与指标异常

    排查过程中,监控大盘发出刺耳的告警:otel-collector 的 Pod 频繁重启。登录节点查看系统日志,死因极其经典:

    $ dmesg -T | grep -i oom
    [xxx] Memory cgroup out of memory: Killed process 12345 (otelcol-contrib) total-vm:4250112kB, anon-rss:2097152kB
    

    此时去看 OTel Collector 本身的暴露指标(:8888/metrics),在每次 Pod 阵亡前,以下两个指标呈现出垂直拉升的态势:

    • otelcol_receiver_refused_spans{transport="grpc"}:暴增,说明 Receiver 端已经拒绝接收数据。

    • otelcol_processor_dropped_spans:激增,Processor 队列被打满。

    为了查清楚到底是什么垃圾数据塞满了 Collector,我临时开了一个 Debug Exporter 拦截了部分流量写入本地文件。打开一看,简直是灾难:

    1. 70% 的 Span 是无效心跳/healthz/metrics 以及海量的 Redis PING/PONG。一个 500 规模的 Pod 集群,每 10 秒一次的 Readiness/Liveness 探针,加上无脑的全量采样,一分钟就能造出几十万个毫无追踪价值的孤儿 Span。

    2. 把 Trace 当对象存储用:某几个核心业务的 Span 里面,http.request.body 居然包含了完整的 HTTP POST 数据,其中夹带了大量未经压缩的 JSON Array,单 Span 大小突破了 500KB。

    为什么说这是愚蠢的配置?

    全链路追踪的核心价值是系统拓扑呈现与分布式请求的瓶颈定位(Control Flow),绝不是用来存明细业务数据甚至二进制文件的。 把 500KB 的 Payload 塞进 Span,不仅极大消耗了应用的 CPU(序列化开销),还把网络带宽和 Collector 的内存当成了免费午餐。此外,对 /healthz 进行全量 Trace,相当于你在高速公路上给每一道测速探头拍个特写,除了把磁盘撑爆,没有任何排障意义。

    另外,排查业务日志时发现另一个低级失误:大量核心报错日志里没有 trace_id。查阅业务代码发现,研发在处理高并发请求时使用了 Java 的 @Async 或自建的 ThreadPoolExecutor,但完全没有做 MDC(Mapped Diagnostic Context)的跨线程传递。主线程的 OTel Context 一到子线程就丢失了,导致 Trace 和 Log 在最关键的异步执行环节强行脱钩。

    落地实战:防御性配置与采样重构

    针对这种乱象,必须在 Collector 层面实施强硬的“防御性编程”策略,不能指望所有业务线都能自觉写好 SDK 配置。

    1. Collector 内存兜底与背压(Memory Limiter)

    绝对不要在生产环境裸奔 Collector,必须配置 memory_limiter processor。它会在内存接近上限时主动拒绝新数据(触发背压限制),并强制执行 GC,宁可丢弃(Drop)部分监控数据,也不能让 Collector OOM 导致 DaemonSet 崩溃影响宿主机。

    processors:
      memory_limiter:
        check_interval: 1s
        limit_mib: 1500        # 硬限制,视 Pod Limit (如 2G) 预留 20%
        spike_limit_mib: 300   # 软限制阈值,超过 limit - spike 时开始 drop 数据
    

    2. 强行清洗无价值垃圾 Span (Filter Processor)

    使用 filter processor,在 Collector 入口处直接把探针和心跳干掉。

    processors:
      filter/drop_health:
        error_mode: ignore
        traces:
          span:
            - 'attributes["http.target"] == "/healthz"'
            - 'attributes["http.target"] == "/metrics"'
            - 'name == "PING"' # Redis Ping
    

    3. 截断超大 Attribute (Transform Processor)

    对于研发乱塞 Payload 的行为,使用 OTTL (OpenTelemetry Transformation Language) 强制截断,超过 2048 字节直接截断,保护后端存储引擎。

    processors:
      transform/truncate_payload:
        trace_statements:
          - context: span
            statements:
              - set(attributes["http.request.body"], substring(attributes["http.request.body"], 0, 2048)) where IsString(attributes["http.request.body"])
    

    4. 尾部采样(Tail-based Sampling)替换全量盲采

    在 SDK 端仅保留基础的 ParentBased(TraceIdRatioBased) 头采样策略(如 5%),将重头戏交给 Collector 层面的 Tail Sampling。对于状态码为 5xx 或延迟大于 2 秒的 Trace 实施 100% 抓取,而对正常的 200 OK 请求执行极低概率的采样。

    processors:
      tail_sampling:
        decision_wait: 10s # 等待 Trace 组装的时间
        num_traces: 50000
        policies:
          - name: always-sample-errors
            type: status_code
            status_code: {status_codes: [ERROR]}
          - name: slow-traces
            type: latency
            latency: {threshold_ms: 2000}
          - name: probabilistic-normal
            type: probabilistic
            probabilistic: {sampling_percentage: 5}
    

    5. 解决跨线程 Trace-Log 上下文丢失

    在业务代码中,严禁裸调线程池。如果是 Java + Spring Boot,推荐直接使用 OTel Java Agent,它对常见的线程池(ForkJoinPool, ThreadPoolExecutor)做了底层字节码增强。如果是手动传递,必须使用 Context.current().makeCurrent() 或包装 Runnable

    // 错误写法:Context 丢失
    executor.submit(() -> doSomething());
    
    // 正确写法:Context 传播
    Runnable instrumentedRunnable = Context.current().wrap(() -> doSomething());
    executor.submit(instrumentedRunnable);
    

    排查清单与同类问题速查

    1. Collector 频繁重启 / OOMKilled:第一时间检查 memory_limiter 是否配置正确。limit_mib 必须小于 K8S Pod 的 Memory Limit,通常预留 20%~25% 给非 Go 运行时开销。

    2. 存储后端(Elasticsearch/ClickHouse)CPU打满 / IO 瓶颈:大概率是 Span Payload 过大。通过 Collector 的 debug exporter 抽样排查 http.request.bodydb.statement 字段是否包含异常的巨型文本。

    3. Trace 找得到,但报错日志搜不到 TraceID:检查应用是否发生了跨线程/跨协程的异步调用,重点排查 OTel Context propagator 是否在线程切换时被正确传递。

    4. 无用 Span 泛洪排查:查询后端存储中 Span Name 排名前 10 的列表,如果 /health/pingSELECT 1 占据了大部分比例,立即在 Collector 侧追加 filter processor。

  • 深入 io_uring 延迟雪崩排查:O_DIRECT 缺失引发的 io-wq 线程池打满与 XFS 阻塞实战

    io_uring 并非异步 IO 银弹。在缺失 O_DIRECT 或执行 Append 写时,XFS 元数据锁会迫使 io_uring 降级至内核 io-wq 线程池。一旦线程池耗尽,主提交线程将陷入 D 状态,p99 延迟暴涨。核心解法:强制 Direct IO 并结合 fallocate 预分配文件块,彻底绕过元数据锁争用。

    某次排查一个基于 io_uring 重构的高并发存储网关(C++ 编写,运行于 Ubuntu 22.04,Kernel 5.15.0-82-generic,底层为 XFS v5 挂载)。该网关在压测阶段初期表现极佳,但当并发写入量达到 5000 QPS 时,系统 Load Average 瞬间飙升至 200+,p99 延迟从 2ms 劣化至惊人的 800ms。

    现场取证与指标异动

    首先看基础 IO 指标。通过 iostat 观察,磁盘的 %util 达到了 100%,但实际写入吞吐量(wMB/s)仅有可怜的 40MB/s,远未达到 NVMe SSD 的瓶颈。

    # iostat -x -d 1
    Device            r/s     w/s     rMB/s     wMB/s   rrqm/s   wrqm/s  %rrqm  %wrqm r_await w_await aqu-sz rareq-sz wareq-sz  svctm  %util
    nvme1n1          0.00 4820.00      0.00     38.50     0.00     0.00   0.00   0.00    0.00  182.50 142.10     0.00     8.18   0.21 100.00
    

    接着查看 CPU 状态,发现 iowait 极高,且存在大量的上下文切换(CS)。直接拉取进程状态,发现核心网关进程及其派生的内核线程大面积处于 D 状态(Uninterruptible Sleep)。

    # 查看 D 状态进程堆栈
    $ for pid in $(ps -eo pid,state | awk '$2=="D"{print $1}'); do echo "PID: $pid"; cat /proc/$pid/stack; done
    
    PID: 14205 (网关主线程)
    [<0>] io_sq_thread+0x28a/0x560
    [<0>] ret_from_fork+0x22/0x30
    
    PID: 14221 (io_wqe_worker_0)
    [<0>] xfs_ilock+0x105/0x220
    [<0>] xfs_file_buffered_aio_write+0x142/0x3a0
    [<0>] xfs_file_write_iter+0x7b/0xc0
    [<0>] io_write+0xe4/0x310
    [<0>] io_issue_sqe+0x39a/0x1e30
    [<0>] io_wq_submit_work+0x12d/0x3b0
    [<0>] io_worker_handle_work+0x153/0x290
    [<0>] io_wqe_worker+0x2cd/0x350
    [<0>] ret_from_fork+0x22/0x30
    

    内核堆栈直接暴露了致命问题:大量的 io_wqe_worker 线程阻塞在 xfs_ilock 上,且调用链明确显示走的是 xfs_file_buffered_aio_write(Buffered IO 路径)。

    为什么 io_uring 会在这个场景下退化为同步阻塞?

    很多研发对 io_uring 有个致命的误解,认为只要把 IO 丢进 SQE(Submission Queue Entry),内核就会纯异步处理。然而在 Linux VFS/文件系统层,真正的“非阻塞”是非常严苛的。

    io_uring 提交一个写请求时,它会默认带上 IOCB_NOWAIT 标志尝试“内联”(Inline)执行。

    1. 如果是 Direct IO (O_DIRECT) 且不改变文件大小(已分配块):XFS 能够无锁直接下发 BIO,请求立即返回 EIOCBQUEUED,这是最完美的 Fast Path。

    2. 如果是 Buffered IO 或者需要改变文件大小(Append 写)

    3. Buffered IO 需要分配 Page Cache,甚至触发内存回收,这在内核中是无法完全非阻塞的。
    4. Append 写需要分配新的磁盘 Block 并更新 Inode metadata,XFS 必须获取独占的 IOLOCK_EXCL 锁(即堆栈中的 xfs_ilock)。

    如果 XFS 发现无法 NOWAIT 完成,会向 io_uring 返回 -EAGAINio_uring 捕获到 -EAGAIN 后,会将这个 IO 任务打包,丢进内核后台的 io-wq 线程池(Slow Path)。

    在我们的高并发网关中,由于未设置 O_DIRECT,且业务在不断 Append 写新日志文件,导致:

    1. 所有的写请求都在 Fast Path 返回 -EAGAIN

    2. io_uring 疯狂创建 io_wqe_worker 线程来接管任务。

    3. 这些 Worker 线程在执行 XFS 元数据更新时,由于抢占同一个 Inode 的 xfs_ilock,发生严重的锁排队。

    4. 内核 io-wq 线程池有并发上限(受限于 RLIMIT_NPROC 和内部调度机制),当线程池被打满后,io_uring 的主提交线程(如果启用了 SQPOLL,则是 io_sq_thread,否则是用户态的 io_uring_enter 系统调用)也会被迫阻塞。

    这就形成了经典的雪崩链路:Buffered IO/元数据写 -> EAGAIN -> io-wq 线程池爆炸 -> XFS 锁争用打满 IO 栈 -> 核心线程 D 状态阻塞。

    源码级溯源:XFS 与 io-wq 的死亡缠绕

    翻开 Kernel 5.15 的源码,我们可以清晰地看到这个降级逻辑:

    // fs/io_uring.c
    static int io_issue_sqe(struct io_kiocb *req, unsigned int issue_flags)
    {
        // ...
        // 尝试执行写操作,带有 IOCB_NOWAIT
        ret = io_write(req, issue_flags); 
    
        // 如果底层文件系统返回 -EAGAIN,说明无法非阻塞完成
        if (ret == -EAGAIN && !(req->flags & REQ_F_NOWAIT)) {
            // 降级:将任务丢入 io-wq 后台线程池
            return io_queue_async_work(req, NULL);
        }
        // ...
    }
    

    而在 XFS 层:

    // fs/xfs/xfs_file.c
    STATIC ssize_t
    xfs_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
    {
        // 如果是 NOWAIT 且需要更新元数据/加锁失败,直接返回 -EAGAIN
        if (iocb->ki_flags & IOCB_NOWAIT) {
            if (!xfs_ilock_nowait(ip, XFS_IOLOCK_EXCL))
                return -EAGAIN;
        }
        // ...
    }
    

    破局之道:防御性 IO 架构改造

    要让 io_uring 发挥真正的 100K+ IOPS 威力,必须严防死守 Slow Path 降级。针对该网关,我们实施了以下三板斧改造:

    1. 强制启用 O_DIRECT 并对齐内存

    修改文件打开标志,彻底绕过 Page Cache。注意,使用 O_DIRECT 要求用户态 buffer 的内存地址和写入长度必须是块设备逻辑扇区(通常是 512 或 4096 字节)的整数倍。可以使用 posix_memalign 分配内存。

    // 改造前
    int fd = open("data.log", O_WRONLY | O_CREAT | O_APPEND, 0644);
    
    // 改造后 (移除 O_APPEND,加入 O_DIRECT)
    int fd = open("data.log", O_WRONLY | O_CREAT | O_DIRECT, 0644);
    

    2. 利用 fallocate 预分配击穿 XFS 元数据锁

    由于去掉了 O_APPEND,我们要自己维护写入 Offset。更重要的是,为了避免每次写入都触发 XFS 的块分配(Block Allocation)导致获取 IOLOCK_EXCL,必须在文件创建时预分配足够大的空间。

    // 预分配 1GB 空间,保持文件 size 不变 (FALLOC_FL_KEEP_SIZE)
    // 这样后续的 IO 都是纯数据覆写 (Overwrite),XFS 只需要 IOLOCK_SHARED 甚至无锁下发
    if (fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 1024 * 1024 * 1024) != 0) {
        perror("fallocate failed");
    }
    

    3. 约束 io_uring 的退化行为 (非必须,但推荐)

    在初始化 io_uring 时,可以在 SQE 中显式设置 IOSQE_ASYNC,但这会强制走 io-wq,并非我们想要的。正确做法是依赖系统的默认行为,但通过上述 1 和 2 的改造,确保所有的 IO 都能在 Fast Path 成功,彻底饿死 io_wqe_worker 线程。

    改造后再次压测,5000 QPS 下 Load Average 降至 2.5,iowait 趋近于 0,p99 延迟稳定在 1.2ms,通过 ps 命令再也看不到海量的 io_wqe_worker 线程,系统恢复丝滑。

    常见问题

    Q1:除了 XFS,在 ext4 上使用 io_uring 也会遇到这个问题吗? 会。无论 ext4 还是 btrfs,只要是 Buffered IO,或者涉及到文件 Append 写、打洞(Punch hole)、文件扩容,VFS 层都会面临元数据更新的锁保护。io_uring 遇到无法立即拿到的锁,统统会返回 -EAGAIN 并回退到 io-wq 线程池。这也是为什么高性能存储引擎(如 SPDK, ScyllaDB)坚决只用 O_DIRECT | O_DSYNC + AIO/io_uring 的原因。

    Q2:如何监控系统中 io_uring 的 io-wq 线程数量及退化情况? 可以通过 eBPF 挂载内核探测点。一个简单的 bpftrace 脚本可以统计每秒降级到 io-wq 的请求数:

    bpftrace -e 'kprobe:io_queue_async_work { @[comm] = count(); } interval:s:1 { time("%H:%M:%S\n"); print(@); clear(@); }'
    

    如果看到你的核心业务进程疯狂触发该探针,说明你的 IO 栈配置存在严重问题,正在大量退化。

    Q3:我使用了 O_DIRECT,为什么 io_uring 的 p99 延迟偶尔还是会抖动? 即使是 Direct IO,如果底层 NVMe 硬件队列打满,或者发生了 PCIe 链路层重传,依然会导致延迟上升。此外,XFS 默认开启了 speculative preallocation(推测性预分配),在某些碎片化严重的文件系统上,即便是对齐的覆写,也可能偶尔触发元数据刷新(Journaling),可以通过挂载参数 allocsize 进行微调,或者定期进行 xfs_fsr 碎片整理。

    Q4:启用 IORING_SETUP_SQPOLL 轮询模式能解决阻塞问题吗? 不能。SQPOLL 只是内核启动一个专门的 io_sq_thread 去轮询你的 SQ 队列,省去了你发起 io_uring_enter 系统调用的开销(减少 syscall 上下文切换)。但如果底层的 XFS 依然因为锁争用返回 -EAGAINio_sq_thread 同样会将任务丢给 io-wq,甚至如果 io-wq 阻塞,io_sq_thread 自身也会陷入 D 状态,导致整个提交队列停摆。架构设计不能用并发去掩盖底层的串行锁。

  • 深入 Zabbix 队列雪崩排查:Housekeeper 锁表引发的 MySQL IO 饱和与 History Syncer 堵塞实战

    排查某次监控系统大面积告警延迟事故,Zabbix Dashboard 显示待处理队列(Queue)堆积突破 20 万,告警通知 P99 延迟达到夸张的 2 小时。最终定位:业务团队滥用自定义模板,通过 log[] 键值将大段 Java 报错栈直接写入 Zabbix,导致 history_strhistory_text 表数据暴增;同时 Zabbix 原生 Housekeeper 清理过期数据时触发海量 DELETE 操作,彻底打爆 MySQL InnoDB 的 IOPS,引发 History Syncer 进程全部夯死。解决方案极其粗暴且有效:彻底关闭 Zabbix 原生 Housekeeper,将所有历史与趋势表改造成 MySQL 按天/按月分区表(Table Partitioning),用 DROP PARTITION 替代 DELETE

    故障现场极具讽刺意味:监控系统本身成了最需要被监控的系统。登录 Zabbix Server 节点,系统负载(Load Average)出奇的低,但查看 zabbix_server.log,满屏的红色告警:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server trapper processes more than 75% busy
    [Z3005] query failed: [1205] Lock wait timeout exceeded; try restarting transaction [delete from history_text where itemid=19283 and clock<1698710400]
    

    History Syncer 是 Zabbix 将内存缓存数据刷入数据库的核心进程,它被堵死,意味着前端 Poller 和 Trapper 收集到的数据全憋在 Server 的 Shared Memory 里,最终导致采集停滞、队列爆炸。

    切到 MySQL 数据库节点,罪魁祸首立刻浮出水面。执行 iostat -dx 2 观察磁盘,数据盘的 %util 死死钉在 100%,w/s (每秒写 IO)达到磁盘物理极限,await 延迟飙升到 800ms 以上。 进入 MySQL 终端敲下 SHOW FULL PROCESSLIST;,看到几十个处于 updatingLocked 状态的 SQL 线程,全部是类似这样的语句:

    DELETE FROM history_str WHERE itemid=40281 AND clock < 1698710400 LIMIT 5000;
    DELETE FROM history_text WHERE itemid=40282 AND clock < 1698710400 LIMIT 5000;
    

    技术逻辑其实非常清晰。把关系型数据库当做时序数据库(TSDB)来用,本身就是一种架构妥协。在 InnoDB 引擎中,执行 DELETE 语句删除几百万行历史数据,简直是运维自杀。DELETE 并不是简单地抹掉磁盘空间,而是会产生海量的 Undo Log(用于回滚)和 Redo Log(用于崩溃恢复),同时需要更新 B+ 树索引,引发大量的数据页分裂与 Buffer Pool 内存淘汰(Churn)。 当这种重度 IO 操作遇上业务团队滥用 Zabbix 收集文本日志(把 Zabbix 当 ELK 用),history_text 表的一行数据可能高达几 KB。Housekeeper 一启动,瞬间的随机写 IO 洪峰直接把底层存储击穿。

    为什么说这种配置不可原谅?因为在超过万级 NVPS(每秒处理新值数)的中大型 Zabbix 架构中,依赖原生 Housekeeper 清理数据是标准的新手雷区。Zabbix 官方手册虽然提过分区表的替代方案,但默认安装依然开启 Housekeeper,这坑了无数没有经历过数据量毒打的运维。

    止血与根治方案:

    第一步,紧急止损。立刻修改 zabbix_server.conf,将 Housekeeping 的频率设置为 0,切断 IO 洪峰的源头,并重启 Zabbix Server,等待队列慢慢消化。

    # zabbix_server.conf
    HousekeepingFrequency=0
    

    第二步,架构重构,实施 MySQL 表分区(Table Partitioning)。 原理很简单:将时间序列数据按时间(clock 字段)分散到不同的物理文件中。当需要删除过期数据时,直接 ALTER TABLE ... DROP PARTITION。在文件系统层面,这等同于直接 rm -f 一个物理文件,时间复杂度为 O(1),瞬间释放空间,彻底零 IO 负担,不会产生任何 Undo Log。

    针对 Zabbix 历史表的改造核心 SQL 如下(以 history_uint 为例):

    -- 确保表结构没有外键,且时钟字段是主键/唯一键的一部分
    ALTER TABLE history_uint PARTITION BY RANGE (clock) (
        PARTITION p20231101 VALUES LESS THAN (UNIX_TIMESTAMP('2023-11-02 00:00:00')),
        PARTITION p20231102 VALUES LESS THAN (UNIX_TIMESTAMP('2023-11-03 00:00:00')),
        -- 预先建好未来的分区
        PARTITION p_future VALUES LESS THAN MAXVALUE
    );
    

    配合一个定时执行的 Shell 或 Python 脚本(业内常用 zabbix-mysql-partitioning.pl 脚本),每天零点自动检查并 DROP 掉过期的历史分区,同时 ADD 未来的新分区。

    最后,强烈建议剥离 Zabbix 的文本日志收集职能。Zabbix 核心是数值型时序监控(float/uint),日志型(str/text)数据一律丢给 Filebeat + Elasticsearch 或 Promtail + Loki 去处理。在监控架构里,强行让一个工具做所有事,最后通常是什么都做不好。

    同类问题排查清单(Zabbix 性能雪崩速查)

    1. Zabbix 内部队列指标:在 Zabbix Frontend 检查 Administration -> Queue。如果延迟集中在 10 分钟以上,且大部分是 Zabbix agent (active) 或 Trapper,大概率是 Server 性能瓶颈而非网络问题。

    2. 底层数据库 IOPS 饱和度:通过 iostat -dx 1 或 Node Exporter 的 node_disk_io_time_seconds_total 指标,排查底层数据盘 %util 是否长期处于 90%+。如果是,立即停止 Zabbix Server 进程以保护 DB。

    3. Housekeeper 配置确认:检查 Zabbix Server 配置中的 HousekeepingFrequencyMaxHousekeeperDelete。大规模场景下必须置 0 关闭,改用 DB 原生表分区或直接使用 TimescaleDB/ClickHouse 作为后端。

    4. 滥用监控项审查:在 MySQL 执行 SELECT itemid, COUNT(*) FROM history_text GROUP BY itemid ORDER BY COUNT(*) DESC LIMIT 10;,揪出产生大量文本型历史数据的 Top 10 监控项(Items),并在 Zabbix 页面中直接 Disable,切断污染源。

    5. 缓存击穿指标:查看 Dashboard 中的 Zabbix cache usage, % free。如果 History index cacheValue cache 经常跌破 5%,说明 CacheSizeHistoryCacheSize 配置过小,或者存在大量低频的长周期聚合查询在刷缓存。