• 凌晨三点的 ksoftirqd 飙升:基于 XDP 与 eBPF 映射的无锁网卡级丢包实战

    传统 Netfilter/iptables 在应对千万级 PPS 小包洪泛时,会因大量 sk_buff 内存分配耗尽 CPU,引发严重软中断风暴。本文给出终极解法:基于 Kernel 5.15 编写 XDP 程序,在网卡驱动层实现零拷贝的 XDP_DROP,结合 eBPF Per-CPU Map 实现无锁统计,将单核拦截性能从 150 万 PPS 直接拉升至 800 万 PPS 以上。

    凌晨三点,监控大盘上的 API 网关 P99 延迟突然飙升到 5 秒以上,随之而来的是节点 Load Average 破百的刺耳警报。登录机器,敲下 top,一个老生常谈的惨烈现场:

    # top - 03:15:22 up 124 days,  4:12,  1 user,  load average: 102.14, 88.55, 48.23
    %Cpu0  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
    %Cpu1  :  0.0 us,  0.0 sy,  0.0 ni,  0.0 id,  0.0 wa,  0.0 hi, 100.0 si,  0.0 st
    ...
      PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
        9 root      20   0       0      0      0 R  99.9   0.0   1245:10 ksoftirqd/0
       16 root      20   0       0      0      0 R  99.9   0.0   1244:52 ksoftirqd/1
    

    ksoftirqd 满载,典型的软中断风暴。切到 sar -n DEV 1 看一眼网卡流量,eth0 的 RX PPS(每秒接收包数)冲到了 450 万,但 RX kB/s 却只有区区 200MB/s。很明显,典型的 64 字节 TCP SYN 洪泛或 UDP 盲打。

    之前配置的 iptables -t raw -A PREROUTING -p tcp --syn -j DROP 规则明明在生效,为什么 CPU 还是被打死了?

    为什么常规 Netfilter 拦不住线速小包攻击?

    要理解这个问题,必须剥开 Linux 内核接收网络包的底层链路(RX Data Path)。

    当网卡接收到物理信号并转化为数据帧后,整个处理流水线如下:

    1. DMA 拷贝:网卡将数据帧写入内存的 Ring Buffer。

    2. 硬中断(Hard IRQ):网卡通知 CPU 数据已到达。

    3. 软中断(Soft IRQ):内核唤醒 ksoftirqd,通过 NAPI 机制轮询拉取数据。

    4. 内存分配(核心痛点):内核为每个数据包调用 build_skb() / alloc_skb() 分配核心数据结构 struct sk_buff,并进行元数据初始化。

    5. 协议栈与 Netfilter:包进入 GRO 处理,经过 TC 子系统,最终到达 Netfilter 的 PREROUTING 链(即 iptables 规则生效的地方)。

    在千万级 PPS 的小包场景下,步骤 4 的 alloc_skb 内存分配与释放开销占据了 CPU 超过 70% 的周期。哪怕你的 iptables 规则再精简,当包到达 Netfilter 时,内核已经把最耗时的脏活全干完了。这也就是为什么“墙内丢包”依然会导致系统雪崩。

    我们需要把防线前推,推到 sk_buff 分配之前。这就是 XDP(eXpress Data Path)大显身手的地方。

    XDP 实战:在 DMA 缓冲区直接执行裁决

    XDP 程序作为一个 eBPF 钩子,直接挂载在网卡驱动层(Native XDP)。包刚进内存,还没来得及包装成 sk_buff,我们的代码就可以直接读取裸数据(Raw Packet),并返回 XDP_DROP 让驱动原地丢弃。

    1. 编写防御性 eBPF C 代码

    防御性编程在 eBPF 中是强行规定的:BPF Verifier(校验器)会极其严苛地审查指针越界。如果不做边界检查,代码根本无法注入内核。

    以下代码实现了一个过滤特定端口(如 80 端口)恶意 SYN 包,并通过 Per-CPU Map 记录丢包数的 XDP 程序:

    // xdp_drop.c
    #include <linux/bpf.h>
    #include <linux/if_ether.h>
    #include <linux/ip.h>
    #include <linux/tcp.h>
    #include <linux/in.h>
    #include <bpf/bpf_helpers.h>
    
    // 定义 Per-CPU Array Map,用于无锁高频计数
    struct {
        __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
        __type(key, __u32);
        __type(value, __u64);
        __uint(max_entries, 1);
    } drop_cnt SEC(".maps");
    
    SEC("xdp_syn_drop")
    int xdp_prog_main(struct xdp_md *ctx) {
        // 获取包在内存中的起始与结束地址
        void *data_end = (void *)(long)ctx->data_end;
        void *data = (void *)(long)ctx->data;
    
        // 1. 解析以太网头
        struct ethhdr *eth = data;
        if ((void *)(eth + 1) > data_end) 
            return XDP_PASS; // 校验器要求:必须做边界检查
    
        if (eth->h_proto != __constant_htons(ETH_P_IP))
            return XDP_PASS;
    
        // 2. 解析 IP 头
        struct iphdr *ip = (void *)(eth + 1);
        if ((void *)(ip + 1) > data_end)
            return XDP_PASS;
    
        if (ip->protocol != IPPROTO_TCP)
            return XDP_PASS;
    
        // 3. 解析 TCP 头
        struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
        if ((void *)(tcp + 1) > data_end)
            return XDP_PASS;
    
        // 4. 拦截逻辑:如果是目标端口为 80 的 SYN 包
        if (tcp->dest == __constant_htons(80) && tcp->syn && !tcp->ack) {
            __u32 key = 0;
            __u64 *value = bpf_map_lookup_elem(&drop_cnt, &key);
            if (value) {
                *value += 1; // Per-CPU 更新,无需原子操作锁
            }
            return XDP_DROP; // 在网卡驱动层直接丢弃,不分配 sk_buff
        }
    
        return XDP_PASS;
    }
    
    char _license[] SEC("license") = "GPL";
    

    2. 编译与挂载

    确保系统安装了 clangllvm 以及内核开发包(当前环境 Kernel 5.15.x)。

    # 编译为 BPF 字节码
    clang -O2 -g -Wall -target bpf -c xdp_drop.c -o xdp_drop.o
    
    # 将 XDP 程序挂载到 eth0 网卡
    # 这里默认使用 native 模式,如果网卡驱动不支持,可加 force 强制使用 generic 模式(但性能退化)
    ip link set dev eth0 xdp obj xdp_drop.o sec xdp_syn_drop
    

    挂载瞬间,监控图表上的 ksoftirqd CPU 使用率从 100% 垂直断崖下跌到 5% 以内。Load Average 开始快速回落。

    闭环:基于 eBPF Map 的可观测性

    没有监控的运维就是在裸奔。包是丢了,丢了多少?我们需要读出 eBPF Map 里的数据。

    我们之前在代码里定义了 BPF_MAP_TYPE_PERCPU_ARRAY。为什么不用普通的 ARRAY?因为在多核网卡多队列场景下,多个 CPU 同时执行 XDP_DROP 并累加同一个内存变量,会引发严重的 Cache-line bouncing(缓存行伪共享)和原子锁竞争,反向拖垮性能。Per-CPU Map 为每个 CPU 核心分配独立的内存区域,完全无锁。

    使用 bpftool 工具读取数据:

    # 1. 找到我们的 map id
    bpftool map list
    # 输出示例:
    # 105: percpu_array  name drop_cnt  flags 0x0
    #      key 4B  value 8B  max_entries 1  memlock 4096B
    
    # 2. 导出特定 ID 的 Map 数据
    bpftool map dump id 105
    # 输出示例:
    [{
            "key": 0,
            "values": [{
                    "cpu": 0,
                    "value": 1548291
                },{
                    "cpu": 1,
                    "value": 1692831
                },
                ...
            ]
    }]
    

    你可以编写一个简单的 Python BCC 脚本或 Go 程序(基于 cilium/ebpf 库),定期拉取这个 Map 数据,聚合后暴露出 Prometheus Metrics,一套极轻量级的防 DDoS 可观测闭环就建立起来了。

    卸载 XDP 程序的命令也极其简单:

    ip link set dev eth0 xdp off
    

    常见问题 (FAQ)

    Q1:挂载时报错 RTNETLINK answers: Operation not supported 是怎么回事? 通常是因为你的网卡驱动不支持 Native XDP(例如某些老旧的虚拟化网卡或特定的老版本驱动)。解决办法是改用 generic 模式:ip link set dev eth0 xdp generic obj xdp_drop.o sec xdp_syn_drop。注意,Generic XDP 运行在 sk_buff 分配之后,性能收益大打折扣,但可用于测试逻辑。

    Q2:代码编译没问题,挂载时被 Verifier 拒绝,提示 invalid memory access eBPF 的 Verifier 极度保守。你不仅需要检查 ethhdr 的越界,任何通过偏移量访问内存的操作(比如通过 IP 头长度推导 TCP 头位置 struct tcphdr *tcp = (void *)ip + (ip->ihl * 4))之后,都必须紧跟边界检查 if ((void *)(tcp + 1) > data_end)。少写一行检查,Verifier 就会判定有越界风险而拒绝加载。

    Q3:XDP 把包在网卡层丢了,排查问题时我用 tcpdump 还能抓到这些包吗? 抓不到。tcpdump 基于 AF_PACKET 套接字,工作在内核协议栈层面。XDP 的介入时机早于它。如果你必须对这些丢弃的包进行采样分析,需要在 XDP 代码中利用 bpf_perf_event_output 将特定包的 Header 异步推送到用户空间(类似 AF_XDP 机制),但这会引入额外的上下文切换开销,建议通过开关或概率采样来控制。

  • 深度剖析:跨机房 Federation 链路高延迟引发的 RabbitMQ 内存雪崩与路由风暴

    结论先行:跨机房部署 RabbitMQ Federation 时,高延迟 WAN 链路配合过大的 prefetch-count 会触发 Erlang VM 内存雪崩。解决方案:将 Upstream 的 prefetch-count 下调至 100-500,调优底层 TCP 发送窗口,并强制配置 max-hops=1 彻底阻断 AMQP 路由环路。以下是故障现场复盘。

    凌晨两点半,告警群被 P99 投递延迟报警刷屏。生产环境一组基于 RabbitMQ 3.11.15 (Erlang 25.3) 构建的双活集群由于跨机房专线拥塞,引发了连锁反应:上游集群触发 vm_memory_high_watermark 导致全量生产者被 Connection.Blocked 阻塞,核心交易链路短时瘫痪。

    为什么高延迟WAN链路会击穿 Federation 的内存防线?

    排障的第一步永远是看现场指标。通过 rabbitmq-diagnostics memory_breakdown,我发现上游集群的内存消耗并非由于 Queue 中积压了大量 Ready 消息,而是 connection_readersconnection_writers 占用了接近 6GB 内存。

    本质上,RabbitMQ Federation 插件是一个运行在下游(Downstream)集群内部的 AMQP 客户端。它会在上游(Upstream)声明一个内部队列(通常命名为 federation: exchange_name -> target),然后通过 AMQP 协议的 basic.consume 不断拉取消息。

    当 WAN 链路出现 50ms 以上的延迟波动时,灾难的种子就埋下了:

    1. 默认无限制的信道窗口:如果不显式指定,Federation 链路会使用默认较大的 prefetch_count(或者受限于网络吞吐)。

    2. Erlang 的异步发送机制:上游的 Channel 进程在收到 ACK 之前,会将 In-flight(飞行中)的消息保存在 Erlang 进程字典和底层 TCP Socket 缓冲区中。

    3. 内存急剧膨胀:延迟飙升导致下游 ACK 返回极慢。上游积压了大量 Unacked 消息,Erlang VM 为了维持吞吐,不断分配 Binary Heap。当总内存触及 vm_memory_high_watermark.relative = 0.4 的警戒线时,RabbitMQ 启动自保,触发全局内存告警,挂起所有发送消息的 TCP 连接。

    抓取底层网络包也能印证这一点:

    # 查看堆积在 TCP Send Buffer 里的数据量
    ss -tnpi | grep -A 1 5672
    

    你会看到 wmem_alloccwnd 极大,数据卡在内核态发不出去,上层 Erlang 进程不断重试分配内存。

    隐藏在 Binding 下的无限反射:路由风暴溯源

    在控制住了内存水位(临时调大 watermark 阈值放行流量)后,我发现上游的 TPS 曲线呈现出不自然的周期性锯齿。查阅日志,发现了大量重复的 x-received-from Headers。

    这就是跨机房双活的第二个大坑:AMQP 路由风暴

    在双向同步(Active-Active)架构中,A 机房的 Exchange 同步给 B 机房,B 机房的 Exchange 又配置了 Federation 同步给 A 机房。如果路由控制不当,一条消息会在 A 和 B 之间像乒乓球一样无限反射。

    Federation 防止环路的核心机制是附加 AMQP Header:

    • 消息离开 A 机房时,被打上 x-received-from: A-node-name

    • 消息到达 B 机房,B 尝试转发回 A 时,检查 Header 发现 A 已经存在,则丢弃。

    但坑在于:如果你使用的是 HAProxy 等四层负载均衡连接 Upstream,或者节点重启导致 Node Name 发生变化,Header 的防环检测就会失效。此时 max-hops 参数就成了最后一道防线。如果没配,消息默认会跳跃多次,导致内部网络带宽被无效的 AMQP Framing 完全榨干。

    核心调优与防御性配置落地

    废话不多说,直接上修复方案和最终配置。我们要从应用层协议栈到底层内核参数进行全面限制。

    1. 收紧 Federation 链路的 QoS

    重置 Upstream 参数,严格控制 prefetch-countmax-hops

    # RabbitMQ 控制台执行,动态更新 Federation Upstream
    rabbitmqctl set_parameter federation-upstream my-cross-dc-upstream \
    '{"uri":"amqp://sync_user:password@remote-haproxy:5672", 
      "prefetch-count": 200, 
      "max-hops": 1,
      "reconnect-delay": 5,
      "ack-mode": "on-confirm"}'
    

    注:prefetch-count: 200 是经过网络带宽延迟乘积(BDP)计算的折中值,既保证了基本吞吐,又避免了延迟突发时的内存爆仓。ack-mode: on-confirm 确保消息在落盘后再回执,防止脑裂丢数据。

    2. 底层 TCP 缓冲区调优

    rabbitmq.conf 中调整与 WAN 链路适配的 TCP 缓存参数,防止底层协议栈吃光内存后反压至 Erlang 层。

    # /etc/rabbitmq/rabbitmq.conf
    ## 针对高延迟网络调优 TCP Write/Read Buffer
    tcp_listen_options.sndbuf  = 131072
    tcp_listen_options.recbuf  = 131072
    tcp_listen_options.backlog = 1024
    tcp_listen_options.nodelay = true
    
    ## 开启信用流控告警
    vm_memory_high_watermark_paging_ratio = 0.75
    

    3. 清理残留的无效 Binding

    路由风暴往往伴随着错误的内部绑定。使用以下命令排查并清理:

    # 过滤查看内部的 federation 绑定关系
    rabbitmqctl list_bindings -p / | grep 'federation:'
    

    如果发现某些已废弃机房的临时 Queue 还在,坚决通过 rabbitmqadmin delete queue name='...' 干掉,防止死信不断积压。

    常见问题

    Q1:跨机房同步,Shovel 和 Federation 到底该怎么选? Federation 是基于 Exchange 拓扑的声明式同步,适合大面积的“状态复制”(如配置广播、多活全量同步),但其隐藏了内部队列,出故障时排查成本高。Shovel 是明确的点对点队列搬运工,属于典型的“硬连接”,结构简单且极度可控。如果是核心交易数据的跨机房灾备,我强烈建议使用 Shovel;如果是常规业务的多活路由,再考虑 Federation。

    Q2:Federation 链路状态显示 running,但消息就是不同步怎么排查? 大概率是网络半连接(Half-Open)或者 AMQP 协议层的死锁。直接看下游节点的内部 Queue 堆积情况。使用 rabbitmqctl list_queues name messages_unacknowledged 过滤 federation: 开头的队列。如果 unacknowledged 居高不下,说明网络回包被丢弃。结合 tcpkill 或重启 Federation link 插件即可快速恢复。

    Q3:如何精准监控 Federation 的积压情况? 不要只盯上游业务队列。必须监控下游针对上游自动生成的内部队列积压。建议在 Prometheus Exporter 中增加正则匹配: rabbitmq_queue_messages_ready{queue=~"federation:.*"}。只要这个指标突破 1000,立刻触发 P2 级告警检查专线质量,否则等待你的就是全线上游节点的熔断。

  • 深入解析 GitLab CI 制品分层缓存:基于 BuildKit 与外部后端的镜像构建优化实战

    镜像构建耗时往往是 CI 流水线的核心瓶颈。通过在 GitLab Runner (v16.3) 中引入 Docker BuildKit 的 LLB 状态树,结合 --mount=type=cache 挂载与 Registry/S3 分布式缓存后端,我将核心业务的 Go/Node.js 镜像构建耗时从 15 分钟压缩至 90 秒,彻底解决了高并发构建时的 CPU 与网络 IO 争用问题。

    上午巡检时看了一眼 CI 大盘,近期研发提交的高峰期,流水线的排队等待率显著下降。回想起几个月前,每天一到发版窗口,K8S 集群里的 CI 节点负载就被打满,本质原因其实是没有建立起工程化的制品缓存体系。今天正好梳理一下这套缓存落地的底层逻辑。

    为什么传统的 Docker 分层缓存在分布式 CI 环境中总是失效?

    很多研发在本地跑 docker build 觉得很快,但一上 CI 就慢得令人发指。这是因为本地环境是有状态的,Docker Daemon 的 /var/lib/docker/overlay2 目录完整保留了之前构建的每一层哈希值和物理文件。

    而在现代的分布式 CI 架构中(例如使用 GitLab Kubernetes Executor),为了保证构建的纯洁性和资源的弹性伸缩,Runner Pod 通常是无状态阅后即焚的(Ephemeral)。 当你在流水线里执行标准的 docker build 时,面临两个致命缺陷:

    1. 宿主机缓存漂移:上一次构建在 Node A,下一次调度到了 Node B。由于 Daemon 环境隔离,历史 layer 完全丢失,必须从 FROM 语句开始重新拉取基础镜像、重新下载全量依赖。

    2. CI Cache 机制不匹配:即便你使用了 GitLab 官方的 cache: 关键字,它也只是在流水线步骤之间通过 S3 或 MinIO 打包、解包工作目录下的文件(ZIP 压缩/解压)。这种机制对应用层的代码有效,但根本无法介入 Docker Daemon 的底层构建图(Build Graph)中。

    结果就是,每次构建都在重复执行 go mod downloadnpm install,白白浪费海量的网络带宽与磁盘 IO。

    核心解法:引入 BuildKit LLB 状态树与分布式缓存后端

    从 Docker 18.09 开始引入的 BuildKit(默认在较新版本中启用)彻底重构了构建引擎。它不再是逐行执行 Dockerfile,而是将其编译为低级构建器(LLB,Low-Level Builder)的无环有向图(DAG)。

    基于 DAG,BuildKit 不仅能并发执行互不依赖的构建阶段(Multi-stage),更重要的是它支持了外部缓存后端(Cache Backends)。这意味着我们可以将构建树的元数据和文件层推送到远端 Registry 或 S3 中,下一次无论 Runner 被调度到哪台物理机,都能通过拉取元数据直接恢复缓存图。

    下面是我们目前正在使用的生产级 Dockerfile 改造方案,以 Go 1.21 项目为例:

    # 必须显式声明开启 BuildKit 的高级语法支持
    # syntax=docker/dockerfile:1.4
    FROM golang:1.21-alpine AS builder
    
    WORKDIR /app
    
    # 优化点 1:只复制依赖描述文件,防止业务代码变动导致依赖层的 cache 被 invalid
    COPY go.mod go.sum ./
    
    # 优化点 2:挂载外部构建缓存到容器内的标准包路径
    # 即使容器被销毁,/go/pkg/mod 下的缓存依然能在 BuildKit Daemon 中持久化或通过后端恢复
    RUN --mount=type=cache,target=/go/pkg/mod \
        go mod download
    
    COPY . .
    
    # 优化点 3:不仅缓存依赖库,同时缓存 Go 的编译中间文件(GOCACHE)
    RUN --mount=type=cache,target=/go/pkg/mod \
        --mount=type=cache,target=/root/.cache/go-build \
        go build -ldflags="-w -s" -o /app/server ./cmd/main.go
    
    FROM alpine:3.18
    WORKDIR /app
    COPY --from=builder /app/server /app/server
    CMD ["/app/server"]
    

    生产级流水线配置与指令重构

    在 GitLab CI 中,仅仅改写 Dockerfile 是不够的。我们需要通过 docker buildx 工具链对接远端 Registry 作为缓存载体。以下是具体的 .gitlab-ci.yml 核心片段(基于 GitLab Runner v16.3 + Docker 24.0.5):

    variables:
      DOCKER_DRIVER: overlay2
      DOCKER_BUILDKIT: 1
      # 定义缓存镜像库的地址
      CACHE_IMAGE: $CI_REGISTRY_IMAGE/buildcache
    
    build-image:
      stage: build
      image: docker:24.0.5-cli
      services:
        - docker:24.0.5-dind
      script:
        - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
        # 初始化一个支持多种特性的 buildx 实例
        - docker buildx create --use --name multi-arch-builder --driver docker-container
        - docker buildx build 
            --push 
            --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA 
            --cache-from type=registry,ref=$CACHE_IMAGE:latest 
            --cache-to type=registry,ref=$CACHE_IMAGE:latest,mode=max 
            .
    

    原理解析:

    1. --cache-from type=registry,ref=...:在构建开始时,BuildKit 不会马上拉取完整的镜像 Blob,而是先拉取 Cache 的 JSON 元数据。在 DAG 计算时,只有命中的层级才会被按需下载(Lazy-pulling)。

    2. --cache-to ... mode=max:这里有一个极易踩坑的点。默认的 mode=min 只会缓存最终输出镜像所涉及的层。但我们在多阶段构建(Multi-stage)中,往往 builder 阶段的依赖耗时最长。必须设置为 mode=max,告诉 BuildKit 将所有中间阶段的层也一并推送到远端缓存库。

    常见问题 (FAQ)

    我在推进这套流水线重构时,遇到过几个非常典型的现场问题,这里一并总结:

    Q1:多条流水线并发执行时,--mount=type=cache 会发生写冲突或文件锁死吗? A:会。默认情况下,BuildKit 对于同一挂载点的并发访问策略是 sharing=shared,这意味着多个构建容器可以同时读写该目录。对于 go mod 这种具备并发安全设计的工具没问题,但如果你在使用 npm install 且涉及到旧版 SQLite 绑定等操作,极易引发锁损坏。 解决办法是在挂载指令中显式指定锁机制:--mount=type=cache,target=/root/.npm,sharing=locked。这样当并发流水线调度到同一个 BuildKit 实例时,后续的构建会等待前一个构建释放目录锁。

    Q2:使用 Registry 缓存后,为什么有时候网络拉取缓存的耗时比重新编译还要长? A:这是一个非常经典的 I/O 与 CPU 的博弈问题。go build 在高配 CPU 节点上可能只需要 10 秒,但对应的 Cache 层打包压缩、上传到远端、再通过网络下载解压可能需要 30 秒。 遇到这种情况,建议检查 CI 节点与 Registry 之间的网络是否属于同 AZ 的内网。如果带宽受限,可以在 cache-to 中添加参数关闭压缩:--cache-to type=registry,ref=...,compression=uncompressed。虽然存储空间占用更大,但在千兆/万兆内网下,未经压缩的元数据传输耗时几乎为零。

    Q3:我的基础镜像(Base Image)更新了,如何确保缓存失效而不是继续使用存在安全漏洞的旧依赖? A:BuildKit 的缓存键(Cache Key)是通过指令内容和上下文文件的哈希共同决定的。如果你的 FROM alpine:3.18 发生了底层补丁更新(镜像 SHA256 改变),但 Dockerfile 文本没变,BuildKit 默认可能会复用基于旧镜像生成的 LLB 节点。 为了确保绝对的安全更新,可以在 CI 触发配置中设置定期(如每周一次)传入一个无缓存的构建指令 docker buildx build --no-cache ...,强制刷新一版带有最新底层系统的 $CACHE_IMAGE,为后续的构建提供新的健康基线。

  • 深夜的软中断风暴:当 nf_conntrack 扩容变成一场对哈希链表的谋杀

    凌晨两点半,监控系统的连环告警把我从浅睡眠中直接砸醒。某核心业务集群的几台入口网关节点全部飘红,告警内容很直接:网络吞吐断崖式下跌,且伴随着极其严重的丢包。

    我登进机器,习惯性地扫了一眼系统负载。CPU 使用率并没有达到 100%,但 si(软中断)的指标高得吓人,个别核心的软中断几乎被吃满。dmesg -T 敲下去,满屏的红色日志触目惊心: nf_conntrack: table full, dropping packet

    这是一个极其经典的连接跟踪表爆满问题。但让我眉头一皱的不是这个报错本身,而是接下来的发现。

    为了确认当前的连接数限制,我跑了一下 sysctl:

    sysctl net.netfilter.nf_conntrack_max
    net.netfilter.nf_conntrack_max = 4194304
    

    看到这个数字,我大概猜到了前面排查问题的人干了什么蠢事。 四百多万的上限?这机器的内存确实不小,但把 nf_conntrack_max 闭着眼睛调大,真的是解决丢包的万灵药吗?

    我顺手查了一下连接跟踪表底层的哈希桶大小:

    cat /sys/module/nf_conntrack/parameters/hashsize
    65536
    

    对着这两个数字,我揉了揉太阳穴,有点想笑,但更多的是对这种低级操作的无语。

    在这个点上犯错,是对内核基础数据结构的完全无知。nf_conntrack 在内核中维护的是一个哈希表结构。每一个连接记录(tuple)通过哈希算法映射到一个具体的桶(bucket)里。当发生哈希冲突时,内核会用双向链表把这些记录串起来。

    如果你把最大连接数 max 设为 4194304,但哈希桶的数量 hashsize 依然保持默认的 65536,这意味着什么? 这意味着在极端负载下,每个哈希桶里平均要挂载 4194304 / 65536 = 64 个节点。更要命的是,这个数字只是平均值。在真实的流量洪峰里,由于哈希分布不均,某些长链表的长度可能会达到几百。

    当一个数据包进入 Netfilter 协议栈,走到 PREROUTING 链的 conntrack 钩子时,内核函数 __nf_conntrack_find_get 会被调用。为了找到这个包对应的连接状态,CPU 必须拿到自旋锁(spinlock),然后顺着那条长达几十上百个节点的链表,逐一对比五元组(源IP、目的IP、源端口、目的端口、协议)。

    每秒几十万的 PPS,每个包都要在软中断上下文里锁住哈希桶去遍历长链表。这种级别的锁竞争和 Cache Line 颠簸,不把 CPU 软中断打爆才怪。这就像是你建了一个能容纳四百万辆车的大型停车场,却只留了 6 万个收费站,当晚高峰到来时,整个交通系统的瘫痪是必然的。

    我快速敲下命令,先把哈希桶的大小提上来,给软中断止血:

    echo 1048576 > /sys/module/nf_conntrack/parameters/hashsize
    

    (注:调整 hashsize 必须通过写 /sys/module/nf_conntrack/parameters/hashsize,它会自动按比例重置 nf_conntrack_max,所以随后需要重新设置 max,且桶大小建议设为 max 的 1/4 或 1/2,取决于内存余量。)

    系统负载眼看着在十几秒内降了下来,丢包停止,流量曲线重新爬升。

    但排查到这里并没有结束。一个网关节点,为什么会在半夜突然产生几百万的连接?

    我导出了当前的连接跟踪状态表,做了一个简单的聚合:

    conntrack -L 2>/dev/null | awk '{print $4, $5, $6}' | sort | uniq -c | sort -nr | head -n 10
    

    结果令人啼笑皆非。霸榜的根本不是什么外部用户的突发访问,而是全部指向本机的 Redis 集群同步端口(6379)以及一个内部的日志收集 Agent(UDP 8125)。开发团队在凌晨跑了一个全量数据的重算批处理任务,大量的本地短连接把 conntrack 表瞬间塞满。

    这就引出了另一个极其愚蠢的逻辑:为什么要对纯内网、甚至是 Loopback/同子网的流量进行连接跟踪?

    iptables/netfilter 的 conntrack 机制是为了配合 NAT 和复杂的状态防火墙(Stateful Firewall)而生的。但对于同节点内部的组件通信,或者确信不需要进行 NAT 转换的高频内网 RPC,走完整的 conntrack 状态机纯粹是在交智商税。

    在 Netfilter 的报文流转图里,raw 表的优先级是最高的。它挂载在 NF_INET_PRE_ROUTINGNF_INET_LOCAL_OUT 钩子上,比 conntrack 的执行时机还要早。

    对于这种毫无疑问的内部高频流量,最优雅的解法是直接在 raw 表中将其标记为 NOTRACK,彻底绕过连接跟踪引擎。

    我在凌晨的终端里敲下这两组规则:

    # 对于进入本机的 Redis 高频流量免追踪
    iptables -t raw -I PREROUTING -p tcp --dport 6379 -j NOTRACK
    iptables -t raw -I PREROUTING -p tcp --sport 6379 -j NOTRACK
    
    # 对于本机发出的流量免追踪
    iptables -t raw -I OUTPUT -p tcp --dport 6379 -j NOTRACK
    iptables -t raw -I OUTPUT -p tcp --sport 6379 -j NOTRACK
    

    (注意:如果在 filter 表中有类似 -m state --state ESTABLISHED,RELATED -j ACCEPT 的规则,需要补充针对 UNTRACKED 状态的放行规则,否则被 NOTRACK 的包会被默认策略 Drop 掉:)

    iptables -I INPUT -m state --state UNTRACKED -j ACCEPT
    iptables -I OUTPUT -m state --state UNTRACKED -j ACCEPT
    

    规则生效后,conntrack 表的条目数像泄了气的皮球一样,直接从两百多万掉到了不到十万的常态水位。软中断 CPU 使用率彻底恢复到了个位数。

    运维体系里的很多组件就像是一把把精密的瑞士军刀。Netfilter 极其强大,但如果你对它底层的链表结构、钩子顺序(Hooks)和优先级缺乏最基本的敬畏心,遇到问题只知道去百度搜索“conntrack table full 怎么解决”,然后闭着眼睛去改一个孤立的系统参数,那么下一次业务雪崩,就是在为你当初的草率买单。

    不要试图用空间去掩盖架构逻辑上的瑕疵。把 max 调到四百万不是优化,那叫挖坑。真正懂包过滤的人,懂得在 raw 表里让不该被追踪的流量安静地溜走。

  • 突破数万 NVPS 的监控积压:Zabbix Proxy 架构解耦与底层数据库 IO 重构

    刚把监控大盘上的 Zabbix Queue 积压量从 50 万硬生生压回 0,顺手把跑满的数据库主库切断了重连。拿起手边的茶杯,茶水已经冷透了,窗外是凌晨三点的夜色。

    在过去的四个小时里,整个机房的告警系统处于半瘫痪状态。大量宿主机的 CPU、内存告警出现长达数小时的延迟,甚至发生了“主机已宕机,告警还在报 CPU 负载高”的时空错乱感。

    表象很容易看清:Zabbix Server 的 History Syncer 进程长时间 100% busy,导致 History Cache 被打满。紧接着,各地分布的 Zabbix Proxy 无法将采集数据上报给 Server,Proxy 本地的数据库开始急速膨胀,最终导致全部监控链路阻塞。

    但这只是表象。高并发监控系统崩溃的尽头,往往都是存储的底层挣扎。

    1. 拆解 IO 风暴:Housekeeper 的无差别屠杀

    当监控项规模达到几十万,NVPS(每秒处理的新值数量)突破两三万时,Zabbix 原生的架构设计会暴露出一个致命缺陷:Housekeeper 清理机制

    Zabbix 默认依赖内部的 Housekeeper 进程去定期删除过期历史数据。其本质是执行类似这样的 SQL:

    DELETE FROM history_uint WHERE clock < 1698765432;
    

    在 MySQL (InnoDB) 引擎下,对一张高达 TB 级别的超级大表执行海量 DELETE 操作,简直是一场灾难。 首先,它会导致严重的写放大。InnoDB 需要为每一行被删除的数据记录 Undo Log 以支持 MVCC 回滚;其次,这些操作会把 Buffer Pool 中大量热点业务数据挤出,导致缓存命中率暴跌;最后,删除后的空间并不会立刻释放,而是留下大量数据空洞(Fragment),引发不可预测的页分裂和合并,让底层的随机 IOPS 直接拉满。

    我的处理动作很直接:停掉 Housekeeper,用 MySQL 的表分区(Table Partitioning)降维打击。

    zabbix_server.conf 中直接斩断历史数据的清理动作:

    # 禁用历史数据和趋势数据的原生清理
    HousekeepingFrequency=0
    MaxHousekeeperDelete=0
    

    随后在数据库端,对 historyhistory_uinttrends 等核心大表实施按天/按月的分区策略。改写后的表结构如下(以 history_uint 为例):

    ALTER TABLE history_uint PARTITION BY RANGE (clock) (
        PARTITION p20231024 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-25 00:00:00')),
        PARTITION p20231025 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-26 00:00:00')),
        PARTITION p20231026 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-27 00:00:00')),
        ...
        PARTITION p_max VALUES LESS THAN MAXVALUE
    );
    

    用定时脚本或者存储过程,每天凌晨执行 ALTER TABLE history_uint DROP PARTITION p20231024;。在文件系统层面,这等同于直接 unlink 删除了一个 .ibd 物理文件,这是一个 $O(1)$ 的顺序 IO 操作。原本需要锁表死磕几个小时的清理动作,现在几十毫秒就能完成,数据库 IO 瞬间回归平稳。

    2. 重塑 Proxy 架构缓冲:打破内存与连接的死锁

    数据库的 IO 瓶颈解除后,Zabbix Server 的写入速度恢复,但 Proxy 端的积压并没有立刻消化。

    看了一眼 Zabbix Server 的内部状态:

    zabbix_server -R diaginfo
    

    输出显示 HistoryCacheSize 的可用空间在剧烈震荡。Zabbix Proxy 的运行逻辑是:如果 Server 的接收缓冲满了,Trapper 进程会拒绝 Proxy 的批量推送。Proxy 被拒后,只能把数据继续积压在自己本地的 SQLite/MySQL 中。随着本地数据越攒越多,Proxy 的 Poller 进程会被拖慢,引发更大范围的采集延迟。

    为了加速存量几百万积压数据的消化,我调整了 Server 端负责衔接 Proxy 的关键内存参数,并大幅增加了同步器并发:

    # 扩大历史数据缓存,防止 Proxy 突发大流量将 Cache 击穿
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    
    # 扩大底层同步进程数(对应写入 MySQL 的并发数)
    StartHistoryPollers=30
    
    # 增加 Trapper 进程以接收大批量的 Proxy 连接
    StartTrappers=50
    

    注意,StartHistoryPollers 并不是越大越好。如果这个数值超过了 MySQL 能承载的最大并发写入线程数,反而会导致 InnoDB Row Lock Contention(行锁争用)。在做了分区表的基础上,我将并发控制在 30 左右,既能保证写入吞吐,又不会引发严重的锁竞争。

    3. 从源头止损:自定义模板与预处理(Preprocessing)的重构

    当数据洪峰终于退去,系统负载降下来后,我开始查根源:为什么今天的 NVPS 会突然飙升到平时的三倍?

    排查 Zabbix 的 items 表发现,近期业务组导入了一套自定义的“全栈监控模板”。这套模板存在两个极其外行的设计:

    第一,滥用被动模式(Passive Check)。 模板里包含了几百个针对端口存活、TCP 状态的监控项,且全部配置为 Zabbix agent(被动模式),采集周期设为 10 秒。 在被动模式下,Zabbix Proxy 或 Server 的 Poller 进程需要主动发起 TCP 连接去拉取数据。面对上千台机器,成千上万的短连接频繁建立和销毁,直接耗尽了 Proxy 本地的临时端口号(TIME_WAIT 飙升),Poller 进程全被网络 IO 阻塞。 我立刻用 SQL 批量将这部分监控项全部修改为 Zabbix agent (active)。主动模式下,Agent 会自己在本地汇总数据,然后在一个长连接中批量推给 Proxy,彻底释放了 Proxy 的并发调度压力。

    第二,大量采集无意义的静态冗余数据。 比如“系统内核版本”、“网卡 MAC 地址”、“挂载点配置”,这些数据几个月都不会变一次,模板却丧心病狂地设置了每分钟采集一次,并且全部原样存入数据库。 这是对存储资源的极大浪费。我直接在自定义模板的监控项中,加入了 Zabbix 原生的 Preprocessing(预处理) 逻辑: 使用了 Discard unchanged with heartbeat(丢弃未更改的心跳数据),心跳周期设置为 1d(一天)。

    // 在监控项预处理步骤中添加
    {
      "type": "DISCARD_UNCHANGED_HEARTBEAT",
      "params": "1d"
    }
    

    这行简单的配置在底层起到了奇效:当 Agent 将数据推送到 Server 时,Server 的 Preprocessing Manager 进程会在内存里比对上一次的值。如果内核版本还是 3.10.0-1160,直接在内存中丢弃这条数据,不进入 History Cache,更不发起任何数据库 INSERT 操作。仅此一项改动,全局 NVPS 瞬间断崖式下降了 40%,系统终于迎来了真正的平静。

    监控系统的本质,是处理海量时间序列数据的流式计算与存储架构。很多人习惯把 Zabbix 当成一个无脑的黑盒工具,堆机器、加内存。但当架构演进到真正的深水区,决定系统生死存亡的,往往是对一条 SQL 锁范围的精确评估,是对 TCP 队列状态的底层感知,是对每一字节数据生命周期的严苛控制。

    问题解决了。收拾完手头的脚本,该去补个觉了。

  • 跨越 Veth Pair 的性能鸿沟:高并发场景下 IPVLAN 与 SR-IOV 的底层抉择

    上午的流量早高峰刚过,监控大屏上的多条告警逐渐恢复平静。趁着喝口水的功夫,我把刚结束的复盘会内容整理一下。

    事情起因是业务线新上线了一个高频交易网关,部署在 K8S 集群中。QPS 刚切过来 30%,节点上几个 CPU 核心的 si(软中断)使用率就直接飙到了 100%,随之而来的是 P99 延迟剧烈抖动,部分请求出现几十毫秒的网络排队延迟。业务研发跑过来问是不是宿主机网卡跑满了,我瞥了一眼监控面板,千兆网卡的带宽连一半都没用到,但这台机器的网络 PPS(每秒包数)已经突破了 40 万。

    很明显,这不是带宽瓶颈,而是经典的 Linux 网络协议栈软中断瓶颈。更准确地说,是容器网络默认的 veth pair 在高并发下的底层机制拖垮了 CPU。

    Veth Pair 的隐性代价:上下文切换与软中断风暴

    目前绝大多数 K8S 的 CNI 插件(如 Flannel、Calico)默认都采用 veth pair + Bridge/路由 的模式。veth pair 本质上是一对虚拟网卡,连接着容器的 Network Namespace 和宿主机的 Root Namespace。

    为了定位当时的 CPU 开销,我在物理机上抓了一把 perf top -C <对应核>

      12.45%  [kernel]       [k] veth_xmit
      10.21%  [kernel]       [k] __netif_receive_skb_core
       8.32%  [kernel]       [k] net_rx_action
       7.14%  [kernel]       [k] br_handle_frame
       6.55%  [kernel]       [k] ipt_do_table
       4.10%  [kernel]       [k] ip_forward
    

    注意看 veth_xmit 这个函数。当网关 Pod 处理完请求向外发包时,数据包从容器内的 eth0(veth一端)发出,经过内核协议栈,最终调用 dev_queue_xmit() 发送到虚拟网卡,触发 veth_xmit

    veth pair 的底层逻辑是:在发包端调用 veth_xmit 时,实际上是在向对端(宿主机上的 veth 接口)发包。它会调用 netif_rx()(或者更高版本内核中的 netif_rx_ni / netif_rx_internal),把 sk_buff 挂到目标 CPU 的 softnet_data 队列上,然后触发一个 NET_RX_SOFTIRQ 软中断。

    这意味着什么?一个数据包从容器到物理网卡,要在内核态经历至少两次完整的网络协议栈处理(容器内一次,宿主机一次),并触发额外的软中断上下文切换。 还要经过宿主机上的 Bridge (br_handle_frame) 和 Netfilter/iptables (ipt_do_table)。在 40万 PPS 的冲击下,这种“纯软件模拟”的转发路径不仅带来了巨大的 CPU 消耗,更是延迟抖动的罪魁祸首。

    绕过宿主机协议栈:Macvlan 的局限与 IPVLAN 的突围

    既然宿主机协议栈太重,那能不能让容器直接和物理网卡对话?

    方案无非是 Macvlan 和 IPVLAN。

    Macvlan 的原理是基于物理网卡虚拟出多个带有独立 MAC 地址的子网卡。容器直接使用这些子网卡,数据包在物理网卡的 rx_handler 阶段就被直接截获并分发到对应的容器 Namespace,完全绕过宿主机的 Bridge 和 iptables。

    但在企业级网络架构中,Macvlan 有一个致命缺陷:交换机 MAC 表爆炸。 如果一个集群有 100 台宿主机,每台跑 50 个 Pod,物理交换机上就需要学习 5000 个 MAC 地址。多数接入层交换机的 CAM 表容量是有限的(通常 4K-8K),一旦溢出,交换机会降级为广播行为,引发未知的单播泛洪(Unknown Unicast Flooding),这在生产环境是不可接受的灾难。另外,部分公有云环境出于安全考虑,甚至会在底层 vSwitch 直接丢弃非宿主机 MAC 的包。

    因此,我们当时毫不犹豫地将网关节点的网络模型切换到了 IPVLAN (L2 Mode)

    IPVLAN 最大的特点是:所有虚拟接口共享物理网卡的 MAC 地址,但在 IP 层(L3)进行流量多路复用。

    下面是当时我们在测试环境用原生命令验证 IPVLAN 拓扑的配置片段:

    # 1. 在物理网卡 eth0 上创建 ipvlan 子接口,模式为 L2
    ip link add link eth0 name ipv1 type ipvlan mode l2
    ip link add link eth0 name ipv2 type ipvlan mode l2
    
    # 2. 将子接口移入独立的 Network Namespace 模拟容器
    ip netns add ns1
    ip link set dev ipv1 netns ns1
    ip -n ns1 link set ipv1 up
    ip -n ns1 addr add 192.168.1.100/24 dev ipv1
    ip -n ns1 route add default dev ipv1
    
    # 宿主机上无需配置任何桥接或路由策略即可完成容器对外通信
    

    在内核源码中,当使用 IPVLAN 时,物理网卡 eth0 注册了 ipvlan_handle_frame 作为接收钩子(rx_handler)。当带有宿主机 MAC 的数据包到达时:

    1. 网卡驱动收包。

    2. 触发 ipvlan_handle_frame

    3. IPVLAN 模块解析以太网帧头后面的 IP 头。

    4. 基于目标 IP 地址,通过内部的 Hash 表直接查找到对应的子接口(即某个容器的网卡),并将 sk_buff 直接交过去。

    效果立竿见影: 网关集群切换到支持 IPVLAN 的 CNI(配合 multus-cni 使用)后,PPS 承载能力提升了接近一倍,宿主机 CPU 的软中断开销暴降了 60% 以上,网络延迟彻底压平。

    极致性能的终局:SR-IOV 硬件直通

    如果仅仅是网关,IPVLAN 已经能应付绝大多数场景。但早上的复盘会还讨论了另一个极端的场景:如果未来把核心数据库(比如高频写入的 Redis 集群或者 TiKV)做容器化,百万级 PPS 下,IPVLAN 还能扛住吗?

    答案是:勉强,但不够优雅。因为 IPVLAN 依然依赖宿主机 CPU 来处理中断和执行拆包/分发逻辑。要想彻底解放 CPU,只能依靠硬件,这就必须引入 SR-IOV (Single Root I/O Virtualization)

    SR-IOV 的本质是让支持该特性的物理网卡(如 Mellanox ConnectX 系列或 Intel X710)在 PCIe 硬件层面“裂变”出多个虚拟功能(VF, Virtual Function)。

    相比于 IPVLAN 的纯软件多路复用,SR-IOV 的架构是降维打击:

    1. 每个 VF 拥有独立的 PCI 寄存器、独立的硬件收发队列(TX/RX Queues)、独立的 MAC 和 VLAN 过滤表。

    2. 将 VF 直接通过 VFIO/IOMMU 映射给容器使用。

    3. 数据包到达网卡后,物理网卡的内置交换芯片(eSwitch)直接基于硬件规则将包放入对应 VF 的队列,并通过 DMA 机制直接拷贝到容器分配的内存页中。

    我们在验证 SR-IOV 时,常用的排查和分配手段如下:

    # 查看物理网卡是否支持及当前配置的 VF 数量
    cat /sys/class/net/eth0/device/sriov_numvfs
    
    # 开启 4 个 VF
    echo 4 > /sys/class/net/eth0/device/sriov_numvfs
    
    # 使用 lspci 可以看到新生成的 Virtual Function 硬件设备
    lspci | grep Ethernet
    # 04:00.0 Ethernet controller: Intel Corporation Ethernet Controller X710 for 10GbE SFP+ (PF)
    # 04:02.0 Ethernet controller: Intel Corporation Ethernet Virtual Function 700 Series (VF 0)
    # 04:02.1 Ethernet controller: Intel Corporation Ethernet Virtual Function 700 Series (VF 1)
    

    当容器配置了 SR-IOV CNI 后,在容器内部看到的就是一块真真切切的硬件网卡。整个发包路径直接从容器内的 Socket 通过驱动写到 PCIe 设备的硬件队列,宿主机的内核网络栈在这个过程中完全是被架空的,没有任何 CPU 会因为这个容器的网络 I/O 被软中断打断

    结语

    技术架构里从来没有银弹。 对于 90% 的普通微服务,veth pair 配合 eBPF(比如 Cilium 的 sockops 绕过)或者单纯的 iptables/IPVS 已经足够;对于高吞吐的 API 网关或者视频流媒体,切换到 IPVLAN 可以用极小的架构变动换取巨大的性能红利,并且避开交换机 MAC 限制;而对于追求极致微秒级延迟和极高 PPS 的核心存储与交易撮合引擎,SR-IOV 才是最终归宿。

    认清瓶颈在内核还是在硬件,在上下文切换还是在队列排队,远比盲目修改内核参数要有效得多。剩下的时间,我得去查查为什么今天那台边缘节点的 kubelet PLEG 会出现轻微卡顿了。

  • 深度还原:PageCache 抖动引发的 CommitLog 写入雪崩——从 RocketMQ 事务消息回查机制说起

    凌晨两点一刻,刚把监控大盘从主屏切到后台,准备过一遍明天的变更脚本,手机和值班群同时炸了。

    告警内容很直接:订单核心链路的 RocketMQ 集群大面积报 Producer Send Timeout,发送 RT 的 P99 从平时的 2ms 飙升到了 3000ms 以上。

    连上 VPN,切到生产环境跳板机。这套集群支撑着交易核心,不容闪失。我扫了一眼 Grafana,Broker 的网络入流量并没有明显突增,但系统负载(Load Average)却异常起飞,特别是 CPU 的 iowait 比例出现剧烈锯齿。

    迅速敲击命令进入故障 Broker 节点,开始现场勘探。

    现场勘探:是谁锁死了 CommitLog?

    进入 Broker 日志目录,tail -f broker.log,满屏都是刺眼的 [OS_PAGECACHE_BUSY][TIMEOUT_CLEAN_QUEUE]

    2023-10-27 02:18:05 INFO SendMessageThread_1 - [OS_PAGECACHE_BUSY] broker busy, start flow control for a while
    2023-10-27 02:18:05 WARN SendMessageThread_2 - [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 876
    

    RocketMQ 抛出 OS_PAGECACHE_BUSY 的条件很明确:当往 CommitLog 写入消息时,获取锁的时间超过了设定的阈值(osPageCacheBusyTimeOutMills,默认 1000ms)。

    我检查了 Broker 的核心配置:

    flushDiskType=ASYNC_FLUSH
    transientStorePoolEnable=false
    useReentrantLockWhenPutMessage=false
    

    这里使用的是异步刷盘,而且 useReentrantLockWhenPutMessagefalse,意味着 RocketMQ 内部使用的是自旋锁(SpinLock)来保证 CommitLog 写入的串行化。

    这就解释了为什么 Load 会飙高:当某个线程因为某种原因在 putMessage 阶段被卡住超过 1 秒,持有自旋锁不释放,后续成百上千的 Producer 线程会在 CPU 上疯狂自旋等待,直接把系统 Load 打爆。

    但这只是表象。写入是在内存(PageCache)中进行的,按理说只有微秒级的延迟,为什么会被卡住这么久?

    看看系统的 I/O 状态:

    $ 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 4500.23  120.45   180.50    15.20    85.45     8.50   18.20   20.10    2.50   2.10  98.50%
    

    磁盘 %util 接近 100%,但诡异的是,wMB/s(写吞吐)只有 15MB/s,而 rMB/s(读吞吐)居然高达 180MB/s。

    一个核心的消息接收节点,为什么会有这么大的随机读?

    抽丝剥茧:事务消息回查引发的冷读风暴

    pidstat -d -t 1 抓一下具体的线程 I/O:

    $ pidstat -d -t 1 -p $(jps | grep BrokerStartup | awk '{print $1}')
    ...
    15:20:01      UID      TGID       TID   kB_rd/s   kB_wr/s kB_ccwr/s iodelay  Command
    15:20:02     1001         -     10245 185200.00      0.00      0.00      45  TransactionMess
    ...
    

    破案的第一步:罪魁祸首是 TransactionMessageCheckService 线程。

    这是 RocketMQ 处理事务消息回查的后台线程。结合业务背景,凌晨 1 点左右,下游的一个账户服务做过一次平滑重启,期间网络出现了短暂的闪断,导致订单系统发出了大量的 Half 消息(预备事务消息),但没有收到最终的 Commit/Rollback。

    在 RocketMQ 的内核实现中,事务消息的底层逻辑是这样的:

    1. 真实的 Topic 和 QueueId 被隐藏,消息先被写入名为 RMQ_SYS_TRANS_HALF_TOPIC 的系统 Topic。

    2. 业务执行本地事务,向 Broker 发送二阶段确认。如果是 Commit,Broker 会把消息恢复成真实的 Topic 并投递;如果是 Rollback,则废弃。

    3. 如果二阶段确认丢失,TransactionMessageCheckService 会定期扫描 Half Topic。

    问题就出在回查上。

    当回查线程扫描到未决的 Half 消息时,它需要读取原始消息的内容来构造回查请求发送给 Producer。如果这些 Half 消息是几小时前的,或者由于量大导致已经被刷入磁盘的冷区,回查操作就会触发大量的磁盘随机读

    RocketMQ 是基于 Mmap 映射物理文件的。当读取冷数据时,会触发缺页中断(Page Fault),内核必须将磁盘数据加载到 PageCache 中。高达 180MB/s 的冷读,瞬间污染了系统大量的 PageCache。

    雪崩的闭环:PageCache 颠簸与 Mlock 阻塞

    仅仅是冷读,为什么会阻塞新消息的写入(写 CommitLog)?这就涉及 Linux 内核的内存管理与 RocketMQ AllocateMappedFileService 的底层博弈了。

    RocketMQ 为了避免 CommitLog 在写入时动态分配物理内存导致延迟,采用了一套预分配机制:提前通过 Mmap 映射好下一个 1GB 的文件,并调用 madvisemlock 将内存锁定,防止被 Swap 出去。这个过程被称为 warmMappedFile

    源码片段如下(摘自 MappedFile.java):

    public void warmMappedFile(int pages, int flushDiskType) {
        long beginTime = System.currentTimeMillis();
        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
        int flush = 0;
        long time = System.currentTimeMillis();
        for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
            byteBuffer.put(i, (byte) 0); // 触发 Page Fault,强制分配物理内存
            // force flush when flush disk type is sync
            if (type == FlushDiskType.SYNC_FLUSH) { ... }
        }
        // ...
        this.mlock(); // 调用 libc 的 mlock 锁定内存
    }
    

    今晚的灾难是一场精妙的巧合拼图:

    1. 事务回查引发读风暴:大量的冷数据读取,导致 OS PageCache 被迅速耗尽。

    2. 内核触发直接内存回收(Direct Reclaim):系统内存吃紧,kswapd 疯狂运作,甚至触发了直接回收,导致所有的 I/O 操作变慢。

    3. 预分配线程被卡死AllocateMappedFileService 在执行 byteBuffer.put(i, (byte) 0) 试图热身下一个 1GB MappedFile 时,由于内存紧缺和磁盘 I/O 极度繁忙,缺页中断陷入深度阻塞。

    4. 当前 CommitLog 写满,Producer 等待:当老的 CommitLog 写满,写入线程向预分配服务索要新的 MappedFile 时,拿不到文件,只能等待。

    5. 自旋锁耗尽 CPU:持有 putMessageLock 的线程在等待,后续发来消息的 Producer 线程在自旋锁上死循环(while (!this.putMessageLock.tryLock())),瞬间打满 CPU。

    最终,等待超过 1000ms,Broker 抛出 [OS_PAGECACHE_BUSY],集群彻底雪崩。

    现场止血与底层根治

    时间到了凌晨 2:35,原因清楚了,止血是第一要务。

    1. 紧急压制回查风暴

    不能让 TransactionMessageCheckService 继续肆虐了。我直接通过 mqadmin 命令动态调整该 Broker 的事务回查参数:

    # 降低回查频率和单次回查的规模
    sh mqadmin updateBrokerConfig -b 10.x.x.x:10911 -k transactionCheckInterval -v 60000
    sh mqadmin updateBrokerConfig -b 10.x.x.x:10911 -k transactionCheckMax -v 100
    

    参数下发后不到一分钟,磁盘读吞吐降到了 5MB/s 以内,I/O await 断崖式下降,[OS_PAGECACHE_BUSY] 报错随之停止,生产端的 Send Timeout 告警清零。

    2. 架构层面的根治:启用 TransientStorePool

    事后复盘,虽然回查风暴是导火索,但 RocketMQ 本身的部署配置也有优化的空间。为了彻底隔离 PageCache 抖动对主写链路的冲击,我们在后续的变更中开启了 transientStorePoolEnable 机制。

    修改 Broker 配置:

    transientStorePoolEnable=true
    transientStorePoolSize=5
    

    开启堆外内存池后,RocketMQ 的写入路径发生了本质变化:

    • 未开启前:Producer 发送消息 -> 追加到 MappedByteBuffer (PageCache) -> Flush 线程异步刷盘。这种模式下,写入直接依赖内核对 PageCache 的分配,极易受缺页中断和系统脏页回写的影响。

    • 开启后:RocketMQ 启动时,直接调用 posix_memalign 在内核中锁定一批 DirectByteBuffer 作为内存池。Producer 的消息直接写入这些已经锁定物理内存的 Direct IO 缓冲中(纯内存操作,无 Page Fault),然后由 CommitRealTimeService 线程异步将 ByteBuffer 里的数据 commit 到 MappedFile (PageCache),最后再异步 flush 到磁盘。

    写入路径与刷盘路径实现了物理内存级别的隔离。哪怕后台 PageCache 抖动成麻花,只要 DirectByteBuffer 池没有耗尽,前端 Producer 的写链路就是丝般顺滑的。

    当然,启用 transientStorePoolEnable 的代价是进程重启或机器宕机会丢失还没 commit 到 PageCache 的极少量消息(因为存在于堆外内存中)。但对于强依赖吞吐稳定性的业务,配合 RocketMQ 的多副本机制(如 DLedger 或 Controller 模式的主备),这个 trade-off 是完全值得的。

    处理完所有善后工作,确认集群各项指标平滑如镜。看了看时间,已经是凌晨四点。合上电脑终端,下半夜终于可以消停了。运维架构的底色,往往就藏在这一行行内存映射与内核调度的博弈之中。

  • 当跨机房同步遇上存储分离:一次 Pulsar BookKeeper WriteCache 背压与雪崩的底层剖析

    凌晨一点半,办公室只剩敲击键盘的白噪音。监控大屏突然闪红,Pulsar 集群的 Producer P99 延迟监控曲线像被猛抽了一鞭子,从平稳的 5ms 直接飙升到了 3000ms+,部分高频写入业务开始报 ProducerSendError

    切到终端,快速拉取 Broker 的指标,发现并不是 Broker 层的 GC 或网络拥塞,而是底层存储层(Bookie)的 pulsar_storage_write_latency_le 出现了严重的长尾。

    在 Pulsar 的计算存储分离架构中,Broker 是无状态的路由与分发层,真正的脏活累活都在 BookKeeper。当 Bookie 的写入延迟飙升,通常意味着磁盘 IO 或者内存管线被彻底堵死了。

    现场排查:冰火两重天的 IO 状态

    我挑了一台延迟最高的 Bookie 节点 SSH 上去,习惯性地打出一套组合拳:

    # 观察磁盘 IO 状态
    iostat -x 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
    nvme0n1 (Journal) 0.00     0.00    0.00   85.00     0.00  1205.00    28.35     0.01    0.15    0.00    0.15   0.12   1.02%
    sdb     (Ledger)  0.00     0.00 8540.00   12.00 546560.00 4500.00   128.00    32.50   18.50   18.60   12.40   0.11 100.00%
    

    在标准的 BookKeeper 部署最佳实践中,我们将 Journal(类似 MySQL 的 Redo Log)独立部署在高吞吐低延迟的 NVMe 盘上(nvme0n1),而把 Ledger(实际的数据文件和 RocksDB 索引)放在普通 SSD 上(sdb)。

    目前的现象是:Journal 盘闲得发慌(%util 1%),但 Ledger 盘的读 IO 已经被彻底打满(r/s 飙到 8500+,%util 100%)。

    Journal 盘负责处理实时的写入请求(fdatasync),理论上只要 Journal 盘没满,写入就不该阻塞。那为什么 Broker 端感受到了巨大的写入延迟?

    顺藤摸瓜:是谁在疯狂读取?

    Ledger 盘被海量的读请求击穿,只有两种可能:一是某个消费者在大量回溯历史消息(Catch-up Read);二是集群在做数据均衡或者副本修复。

    通过 pulsar-admin 查看集群整体状态,我锁定了罪魁祸首:

    pulsar-admin topics stats-internal tenant-a/namespace-1/topic-x
    

    在一堆 JSON 输出中,我看到了 replication 节点的异常:

    "replication" : {
      "cluster-b" : {
        "msgThroughputOut" : 52428800.0,
        "msgRateOut" : 12500.0,
        "replicationBacklog" : 15800450,
        "connected" : true,
        "replicationDelayInSeconds" : 3600
      }
    }
    

    真相浮出水面:租户 A 配置了 Geo-Replication,将数据跨地域异步复制到 cluster-b。一小时前,跨机房的专线出现了短暂的物理网络抖动,导致复制链路断开。网络恢复后,Broker 侧的 Replication Cursor 开始疯狂地向后追平这 1500 万条积压数据。

    这股突发的冷数据读取洪流,直接打穿了 Bookie 的 ReadAheadCache,穿透到了底层的 Ledger 磁盘。

    深度解析:Ledger 读风暴如何引发 Journal 写阻塞?

    这似乎是个多租户隔离失效的经典案例:一个租户的跨机房冷读,影响了全局的实时热写。但在计算分离架构下,Journal 和 Ledger 盘是物理隔离的,读写究竟在哪一层发生了交叉碰撞?

    这就必须深入到 BookKeeper 的 DbLedgerStorage 底层管线。

    在 Bookie 中,一条 Message 的写入路径(AddEntry)如下:

    1. 请求进入 Bookie 的 Netty 线程,分发给 SyncThread

    2. 写 WAL:追加到 Journal 内存队列,由单独的 Journal 线程 fdatasync 到 NVMe 盘。

    3. 写缓存:同时将数据插入到内存中的 WriteCache

    4. Journal 落盘且 WriteCache 插入成功后,向 Broker 返回 ACK。

    这里的关键在第 3 步和后续的异步刷盘机制。

    内存中的 WriteCache 是有容量上限的(由 dbStorage_writeCacheMaxSizeMb 控制,默认通常是系统内存的 1/4)。当 WriteCache 写满时,会触发 Flush 动作:

    • 将数据序列化并追加到 Ledger 磁盘的 EntryLog 文件中。

    • 将 Entry 的位置索引信息(LedgerId, EntryId -> EntryLogId, Offset)写入 RocksDB。

    如果此时 Ledger 磁盘正在被 Geo-Replication 的海量随机读(追冷数据)严重占用,IOPS 饱和,导致 Flush 操作极其缓慢。

    那么连锁反应来了:

    1. Flush 变慢,WriteCache 内存无法及时释放。

    2. 前端高频的热点写入继续涌入,瞬间填满剩余的 WriteCache

    3. WriteCache 一旦满了,新的 AddEntry 请求在尝试插入 WriteCache 时,就会被同步阻塞(Backpressure)。

    4. Netty 工作线程被挂起,无法处理新的网络请求,最终导致请求在队列中超时,抛出 Transaction timeout 异常,Broker 端观察到的 P99 延迟直接原地起飞。

    这就是为什么 Journal 盘空闲,但写入依然被卡死的根本原因。资源在物理磁盘上是隔离的,但在内存管线(WriteCache)和存储引擎(DbLedgerStorage)上却存在强耦合。

    现场止血与架构调优

    既然找到了症结在于 Geo-Replication 产生的读取洪流没有被限流,破坏了底层存储的 IO 节奏,止血方案就非常明确了。

    1. 动态下发 Dispatch 限流策略(止血)

    Pulsar 提供了灵活的多租户资源隔离能力,我立即通过 pulsar-admin 对租户 A 的特定 Namespace 下发了流量 Dispatch 限制,掐断它的读取速率,给磁盘留出喘息的空间。

    # 限制该 namespace 的 Dispatch 速率为 50MB/s,或者 10000 msg/s
    pulsar-admin namespaces set-dispatch-rate tenant-a/namespace-1 \
      --byte-dispatch-rate 52428800 \
      --dispatch-rate 10000
    
    # 限制跨机房 Replication 的读取速率(关键配置)
    pulsar-admin namespaces set-replicator-dispatch-rate tenant-a/namespace-1 \
      --byte-dispatch-rate 20971520 \
      --dispatch-rate 5000
    

    指令下发后大约 10 秒,iostatsdb%util 开始回落到 60% 左右。Bookie 的 WriteCache 终于能够顺畅地 Flush 到 Ledger 盘,积压的 AddEntry 队列迅速清空,集群 P99 延迟恢复到 5ms。

    2. 底层 BookKeeper 参数调优(治本)

    为了防止未来其他租户再次触发这种边缘场景引发雪崩,需要对 BookKeeper 的底层配置进行更严谨的调校。

    调整 WriteCache 与 ReadAheadCache 的配比 默认情况下,读写缓存的分配可能并不适合重度冷读积压的场景。在 bookkeeper.conf 中显式隔离并调整内存屏障:

    # 强制开启 DbLedgerStorage
    ledgerStorageClass=org.apache.bookkeeper.bookie.storage.ldb.DbLedgerStorage
    
    # 增加 WriteCache 的比例,提供更大的缓冲池来吸收底层的抖动
    dbStorage_writeCacheMaxSizeMb=4096
    
    # 限制 ReadAhead 的内存使用,防止冷数据污染导致 OOM 或频繁的 GC
    dbStorage_readAheadCacheMaxSizeMb=2048
    
    # 分离 RocksDB 的读写 BlockCache
    dbStorage_rocksDB_blockCacheSize=1073741824
    

    操作系统层面的 IO 提示优化 Geo-Replication 回溯历史数据时,本质上是对 Ledger 文件的顺序读。我们可以通过调整内核的 Read-Ahead 大小,减少底层的 IO 次数,提升吞吐:

    # 将 Ledger 盘 sdb 的预读设置为 4096 个扇区 (2MB)
    blockdev --setra 4096 /dev/sdb
    

    3. 隔离 Read/Write 线程池

    BookKeeper 中可以通过配置将读和写的处理线程池完全拆开,避免冷读耗尽处理线程资源导致心跳或写入响应不及时:

    # 开启独立的读线程池
    numReadWorkerThreads=16
    numAddWorkerThreads=16
    numHighPriorityWorkerThreads=8
    

    总结

    计算存储分离的架构(如 Pulsar + BookKeeper)在理论上提供了极好的扩展性,但在实际的运维战场上,资源的边界往往比想象中更加模糊。

    在这个场景中,跨地域高延迟网络抖动触发了 Broker 端的异步补偿(Geo-Replication Catch-up),补偿机制转化为海量的吞吐导致底层的存储读引擎击穿,进而在 WriteCache 内存模型处形成了反向的写背压(Backpressure),最终导致了全局写入的雪崩。

    多租户与存储分离,不是把服务拆开部署就万事大吉了。真正的能力体现在从计算侧的配额下发、网络层的隔离、到存储引擎侧 IO 管线的严格切分。只有把 QOS(服务质量限制)像烙印一样打在每一个数据流转的节点上,才能在复杂的生产环境中睡个安稳觉。

  • 深度剖析:Checkpoint Age 激增引发的雪崩——当 Redo Log 阻塞遇上间隙锁

    凌晨两点半,机房的 VPN 刚断开。屏幕上的 Threads_running 指标终于从刺眼的 800 多回落到了个位数。

    这原本是一个再平淡不过的深夜,直到告警短信把我叫醒:核心交易库 TPS 突然掉底,连接池被打满。初看现象,这是一起典型的数据库死锁或锁等待超时(Lock Wait Timeout),但顺着线索往下挖,底层却是一场由 Buffer Pool 刷脏机制和 Redo Log 容量引发,最终通过间隙锁(Gap Lock)放大导致的全盘雪崩。

    这个问题很有代表性,它把 InnoDB 的内存管理、日志机制和并发控制完美地串联在了一起。趁着现在毫无睡意,把排查过程和底层逻辑梳理一下。

    1. 现场:诡异的锁等待

    登录数据库,习惯性地先看当前运行的事务和锁状态:

    SELECT
      r.trx_id waiting_trx_id,
      r.trx_mysql_thread_id waiting_thread,
      r.trx_query waiting_query,
      b.trx_id blocking_trx_id,
      b.trx_mysql_thread_id blocking_thread,
      b.trx_query blocking_query
    FROM performance_schema.data_lock_waits w
    INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
    INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;
    

    结果显示,大量的单条 INSERT 语句被阻塞。顺藤摸瓜找到源头(blocking_query),是一个定时清理历史数据的批处理 SQL:

    DELETE FROM trade_orders 
    WHERE status = 'CLOSED' AND updated_at < '2023-09-01 00:00:00';
    

    updated_at 字段上有二级索引。在默认的 REPEATABLE READ 隔离级别下,InnoDB 为了防止幻读,会在扫描二级索引时加上 Next-Key Lock(Record Lock + Gap Lock)。由于这是一个范围删除,它不可避免地锁住了大段的索引间隙,导致落入这些间隙的新订单 INSERT 被阻塞。

    到这一步,看似问题已经找到了:大批量 DELETE 导致的间隙锁阻塞。

    但逻辑上说不通。这条清理语句每次只限制删除 1000 条数据,平时执行耗时通常在 50ms 以内。为什么今晚这个事务执行了十几秒还没提交?事务不提交,锁就不会释放。

    是什么拖慢了这 1000 条数据的删除?

    2. 下沉:被无视的 Checkpoint Age

    既然不是死锁,也没有其他事务阻塞这个 DELETE,那瓶颈必然在系统资源或 InnoDB 引擎内部。扫了一眼系统监控,CPU 负载不高,但磁盘 I/O 的 %util 接近 100%,大量的写操作排队。

    立刻切到引擎层,查看 InnoDB 状态:

    mysql> SHOW ENGINE INNODB STATUS\G
    ...
    ---
    LOG
    ---
    Log sequence number          14589320145
    Log flushed up to            14589319800
    Pages flushed up to          14080120000
    Last checkpoint at           14080119800
    ...
    

    这段输出里的四个数字,是解开谜团的钥匙。我们来算一笔账:

    Checkpoint Age = Log sequence number (当前 LSN) – Last checkpoint at (上一次检查点 LSN) = 14589320145 – 14080119800 = 509,200,345 Bytes (约 485 MB)

    再看一下线上 Redo Log 的配置: innodb_log_file_size = 256M innodb_log_files_in_group = 2

    总 Redo Log 容量是 512MB。 由于 Redo Log 是循环写入的,为了防止覆写还未刷入磁盘的脏页日志,InnoDB 定义了两个水位线:

    • Async Watermark (异步刷脏水位):通常是总容量的 75%(约 384MB)。

    • Sync Watermark (同步刷脏水位):通常是总容量的 90%(约 460MB)。

    当前的 Checkpoint Age(485MB)已经无情地突破了 Sync Watermark!

    3. 底层机制:单线程刷脏的绝望

    当 Checkpoint Age 突破 90% 时,InnoDB 会发生什么?

    在正常情况下,Buffer Pool 中的脏页是由后台线程(Page Cleaner Thread)异步刷入磁盘的。不管前台有多少高并发的增删改,只要后台刷得够快,Redo Log 就有足够的空间推进,前台线程只管写内存和顺序写 Redo Log 即可,速度极快。

    但这批夜间跑批任务包含了大量密集的 UPDATEDELETE,短时间内生成了海量的 Redo Log。256M * 2 的 Redo Log 空间被迅速填满。后台异步刷脏的速度(受限于 innodb_io_capacity 参数)远远赶不上 Redo Log 产生的速度。

    当 LSN 推进到 Sync Watermark 时,InnoDB 的保护机制被触发:所有产生 Redo Log 的用户线程(User Threads)被强制挂起,必须参与同步刷脏(Sync Flush)。

    这就解释了那个诡异的现象:

    1. DELETE 事务在执行过程中,遇到了 Redo Log 空间不足。

    2. 该事务的执行线程被 InnoDB 引擎强行拽去干苦力——等待甚至参与将 Buffer Pool 里的脏页刷回磁盘(通过推进 Checkpoint 来释放 Redo 空间)。

    3. 这个过程是随机 I/O,且极其耗时。导致原本 50ms 就能完成的 DELETE,被拖长到了十几秒。

    4. 雪崩的闭环

    现在,整个雪崩的逻辑链条完全闭合了:

    1. 导火索: 跑批任务触发密集写操作,产生大量 Redo Log。

    2. 容量瓶颈: innodb_log_file_size 过小,Checkpoint Age 迅速突破 Sync Watermark。

    3. I/O 阻塞: 引擎进入同步刷脏模式,用户线程被阻塞,等待脏页落盘。

    4. 锁放大: 正在执行 DELETE 的线程被挂起,但它持有的间隙锁(Gap Lock)并不会释放

    5. 雪崩: 大量正常业务的 INSERT 请求命中被锁定的索引间隙,进入 Lock Wait 状态。连接池迅速被堆积的挂起线程耗尽,引发全盘宕机。

    5. 破局与参数调优

    知道了症结,解决起来就不复杂。这种问题,单靠优化 SQL 治标不治本,核心是要调整 InnoDB 的内存与日志 I/O 策略,让存储层能扛住瞬间的吞吐。

    第一步:扩容 Redo Log

    256M 的单文件大小放在现代的高并发业务中犹如儿戏。直接将其扩容:

    # my.cnf
    innodb_log_file_size = 2G
    innodb_log_files_in_group = 3
    

    注:在 MySQL 8.0.30 之后,这两个参数被废弃,统一使用 innodb_redo_log_capacity。这里由于线上还是 5.7 版本,依然采用老参数。这样总容量达到 6G,给予后台线程充足的缓冲时间来刷脏。

    第二步:释放底层 I/O 潜力

    既然底层是纯 SSD 阵列,没必要让 InnoDB 表现得像个老旧的机械硬盘。调整后台刷脏的 I/O 能力:

    # 告诉 InnoDB 底层存储每秒能处理的 IOPS
    innodb_io_capacity = 3000
    # 遇到脏页堆积或 Checkpoint 追尾时,最高可以飙到的 IOPS
    innodb_io_capacity_max = 6000
    
    # 针对 SSD 关闭相邻脏页合并刷盘特性(该特性只对机械硬盘有意义,SSD 上反而增加开销)
    innodb_flush_neighbors = 0
    

    第三步:规避大范围间隙锁

    从业务侧,把这种依赖二级索引范围扫描的 DELETE 改造掉。先通过主键查出需要删除的 ID,然后做主键删除,将 Next-Key Lock 降级为精准的 Record Lock,彻底解除对其他正常 INSERT 业务的间隙阻塞:

    -- 改造前
    DELETE FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...';
    
    -- 改造后,分批执行
    SELECT id FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...' LIMIT 1000;
    DELETE FROM trade_orders WHERE id IN (...);
    

    6. 尾声

    很多人在排查数据库阻塞时,一看到锁等待,就死磕业务逻辑和事务隔离级别。但实际上,数据库是一个极其精密的机械体。内存、日志、I/O 以及并发控制锁,是互相咬合的齿轮。一个看起来微不足道的 Redo Log 尺寸配置,在特定的业务波峰下,就能通过间隙锁将阻塞效应放大千百倍,最终酿成灾难。

    运维架构的深度,往往就藏在这些基础组件的边界摩擦里。合上电脑,该补个觉了。

  • 当 io_uring 遭遇 XFS 元数据锁:高并发 Direct IO 阻塞的底层机制解析

    凌晨两点半,VPN 还在挂着。刚刚把压测环境的一组存储节点退下来,顺手把排查过程理一理。

    事情的起因是存储研发团队在重构底层写引擎,从传统的 AIO 迁移到了 io_uring。理论上,配合 NVMe SSD 和 XFS的 Direct IO,吞吐量应该能实现数量级的跃升。但在进行大并发压测时,现象却让人大跌眼镜:当并发写入请求急剧增加时,磁盘的 IO util 连 30% 都没跑到,IOPS 却出现了断崖式下跌,同时机器的 sys CPU 飙升到了 85% 以上。

    引入新技术时,把一切理所当然当成常态,往往就是要交学费的时候。

    现场还原与初步定位

    登录压测机,第一感觉是系统响应变慢了,但 iostat 显示磁盘毫无压力。 用 top 看了一眼,几个压测进程的 CPU 占用并不高,反而是内核的 kworker 线程和名为 io_wq_manager 的线程把 sys 态 CPU 给吃干抹净了。

    遇到内核态 CPU 飙升,最直接的手段就是抓热点。直接跑一把 perf

    perf top -U -F 99 -g
    

    抓了几十秒,展开调用栈,看到了一个非常刺眼的调用链路:

    - 81.24% io_wqe_worker
       - 79.12% io_issue_sqe
          - 78.05% io_write
             - 77.81% xfs_file_write_iter
                - 75.32% xfs_ilock
                   - 74.90% down_write
                      - rwsem_down_write_slowpath
                         - osq_lock
    

    这说明绝大部分 CPU 周期耗在了自旋锁上(osq_lock 是 qspinlock 的一部分),而锁的源头是 XFS 的 inode lock(xfs_ilock),触发点居然是 io_wqe_worker

    按照 io_uring 的设计理念,它应该是一个极度轻量级的环形队列交互,为什么会突然涌出大量的内核 worker 线程,而且还在疯狂抢夺 XFS 的文件系统锁?

    剖析 io_uring 的 NOWAIT 语义退化

    要理清这个问题,得深入到 Linux IO 栈的提交流程里。

    当我们通过 io_uring 提交一个异步写请求时,如果不做特殊设置,内核底层在解析这个 SQE(Submission Queue Entry)时,会默认给这个 IO 加上 IOCB_NOWAIT 标志。这个标志的含义是告诉底层的文件系统和块设备:这个 IO 必须是非阻塞的,如果你发现当前操作需要睡眠等待(比如等锁、等内存分配),请立刻返回 -EAGAIN,不要阻塞我的提交线程。

    我们再来看看 XFS 这一层。压测工具模拟的业务场景是多线程并发对同一个文件进行 Append Write(追加写)

    在 XFS 的实现中,进入 xfs_file_write_iter 时,如果是普通的覆盖写(Overwrite),且不需要分配新的数据块,XFS 只需要获取 inode 的共享锁(XFS_IOLOCK_SHARED),这种情况下并发写入毫无压力。

    但是,追加写(Append)需要修改文件的 EOF(End of File),这涉及到了文件大小元数据的变更,甚至可能需要向 Allocation Group(AG)申请分配新的 Block。在这个过程中,XFS 必须获取该文件 inode 的独占锁(排他锁):

    // 截取自 fs/xfs/xfs_file.c
    STATIC ssize_t
    xfs_file_dio_write_aligned(...)
    {
        ...
        if (iocb->ki_flags & IOCB_APPEND) {
            // 需要独占锁
            iolock = XFS_IOLOCK_EXCL;
        } else {
            iolock = XFS_IOLOCK_SHARED;
        }
    
        // 如果带了 NOWAIT 标志,尝试非阻塞获取锁
        if ((iocb->ki_flags & IOCB_NOWAIT) && !xfs_ilock_nowait(ip, iolock))
            return -EAGAIN;
        ...
    }
    

    看上面这段逻辑就很清晰了。高并发下,第一个线程拿到了 inode 的独占锁开始处理追加写,后续的并发请求到达时,XFS 发现带有 IOCB_NOWAIT 并且无法立刻拿到锁,果断返回 -EAGAIN

    重点来了,io_uring 收到这个 -EAGAIN 后会怎么做?

    它当然不能把错误直接抛给应用层(那就破坏了异步 IO 的语意)。io_uring 的内部机制是:既然你在当前的提交上下文无法非阻塞完成,那我就把你扔到后端的 io-wq(内核异步工作队列)里慢慢跑。

    于是,原本应该在高速环形队列里完成的极速 IO 提交,变成了一场灾难:

    1. 提交线程不断遇到 -EAGAIN

    2. 任务被海量丢入 io-wq

    3. io-wq 线程池迅速扩容(生成大量 io_wqe_worker 线程)。

    4. 这些 worker 线程剥离了 NOWAIT 标志,再次向 XFS 发起写请求,开始硬扛着去抢那个文件的独占锁(阻塞等待)。

    5. 几千个内核线程抢一把读写锁的写锁,触发严重的 osq_lock 竞争,内核态上下文切换风暴爆发,sys CPU 直接打满,吞吐量断崖式下降。

    解决方案与最佳实践

    弄懂了底层逻辑,修复方案就不能单纯在 io_uring 层面调参了,必须从文件系统和 IO 模型的结合点入手。

    1. 空间预分配(fallocate),化 Append 为 Overwrite

    既然罪魁祸首是 Append Write 导致的排他锁和元数据更新,那么最有效的手段就是打破这个条件。通过 posix_fallocate 或者 Linux 特有的 fallocate 系统调用,提前为文件分配好足够的物理空间。

    在应用层的逻辑中:

    • 先预分配一段空间(比如 1GB)。

    • 各个线程不要再用 O_APPEND 标志,而是自己维护一个全局递增的 offset(可以用原子操作 atomic_fetch_add)。

    • 每次构建 sqe 时,明确指定写入的 offset

    这样,XFS 在处理这些 io_uring 请求时,发现空间已经分配,不需要修改 EOF,只需要 XFS_IOLOCK_SHARED 即可。非阻塞拿共享锁几乎不会失败,NOWAIT 语义得以保持,io-wq 的退化灾难直接消失。

    // 伪代码示例
    int fd = open("test.dat", O_RDWR | O_DIRECT | O_CREAT, 0644);
    // 预分配 1G 空间,注意 XFS 建议使用 fallocate 以保持物理连续性
    fallocate(fd, 0, 0, 1024 * 1024 * 1024);
    
    // ... 在提交 sqe 时
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_write(sqe, fd, buffer, size, current_offset);
    // 更新 offset
    atomic_fetch_add(&global_offset, size);
    

    2. XFS Extent Size Hint 的调优

    即使做了预分配,如果是针对多个不同文件的大并发写入,依然可能在 XFS 的 AG(Allocation Group)锁上发生竞争。可以通过给目录或文件设置 extsize,让 XFS 在分配数据块时按更大的粒度进行(比如 1MB 到 4MB),减少底层 B+ 树分裂和分配元数据修改的频率。

    # 查看当前的 extent size
    xfs_io -c "extsize" /data
    # 设置当前目录的默认 extent size 为 2MB
    xfs_io -c "extsize 2m" /data
    

    3. 谨慎使用 io_uring 的高级特性

    很多人觉得配置了 IORING_SETUP_SQPOLL(让内核线程去轮询提交队列)就能解决一切阻塞问题。实际上,SQPOLL 仅仅是把用户态的 io_uring_enter 甚至 syscall 的开销省了,它依然绕不开底层 XFS 的锁机制。如果底层退化成同步抢锁,SQPOLL 的轮询线程一样会被卡死,甚至会导致单核 CPU 100% 的死锁假象。

    总结

    Linux IO 栈从来不是几个新鲜名词的简单拼凑。io_uring 确实提供了当前 Linux 下最高效的异步原语,但它的性能上限依然受限于底层的块设备调度与文件系统的具体实现。

    当你在高频低延迟或者极高并发的场景下使用 Direct IO 时,必须对文件系统(无论是 XFS 还是 ext4)在特定操作(追加、稀疏文件填充、跨 AG 分配)下的锁粒度有绝对的把握。仅仅关注上层调用,只会被深埋在内核态的上下文切换和自旋锁里反复摩擦。