标签: 性能调优

  • 凌晨三点的延迟突刺:生产环境残留 bpf_printk 引发的 XDP 吞吐断崖式坠落

    凌晨三点,网关集群告警狂飙,核心 API 的 P99 延迟从稳定的 2ms 瞬间击穿到 500ms,整体 QPS 从 8万掉到不足 1万,外部监控呈现大面积网络超时。结论先行:研发在灰度 eBPF/XDP 的防 DDoS 策略时,把带有 bpf_printk 的调试代码直接带上了生产线。在单机几十万 PPS 的网络快路径(Fast Path)里高频调用内核全局打印函数,导致网卡软中断处理被 trace_pipe 的自旋锁死死卡住。一句话:拿拖拉机的变速箱去匹配了 F1 的发动机。

    登入机器,Load Average 直接飙到 80+。第一反应是看系统瓶颈卡在哪。敲下 top,发现所有的 CPU 核心 si(软中断)和 sy(系统态)双双爆表,几乎没有 us(用户态)的占用。这说明 CPU 都在内核态原地打转,业务进程根本分不到时间片。

    看网络大盘,sar -n DEV 1 显示入向流量断崖式下跌,网卡层面的 rx_missed_errorsrx_dropped 正在以每秒上万的速度激增。网卡硬件队列被打满,上层拿不到包。

    是 XDP 程序里面的业务逻辑写了死循环,被 BPF 校验器(Verifier)漏放了吗?掏出 perf top 采样内核热点,破案了。霸榜的根本不是什么复杂的包解析逻辑,而是这几个刺眼的函数:

      38.45%  [kernel]  [k] __raw_spin_lock_irqsave
      25.12%  [kernel]  [k] bpf_trace_printk
      18.05%  [kernel]  [k] trace_event_buffer_commit
       5.20%  [kernel]  [k] bpf_prog_xxxxxxxxxxxx_xdp_drop_prog
    

    排名前三的符号占了超过 80% 的 CPU 周期,全在等锁和写 Trace 数据。看一眼研发提交的 BPF C 代码,果然在最核心的包头解析逻辑里藏着这么一行:

    SEC("xdp")
    int xdp_drop_prog(struct xdp_md *ctx) {
        void *data_end = (void *)(long)ctx->data_end;
        void *data = (void *)(long)ctx->data;
        struct ethhdr *eth = data;
    
        if (data + sizeof(*eth) > data_end) return XDP_PASS;
        if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
    
        struct iphdr *ip = data + sizeof(*eth);
        if ((void*)(ip + 1) > data_end) return XDP_PASS;
    
        // 就是这行要了命
        bpf_printk("Received IP packet from %x, proto: %d\n", ip->saddr, ip->protocol);
    
        // ... 后续 DDoS 防御匹配逻辑 ...
        return XDP_PASS;
    }
    

    这不仅是一个 Bug,这是对高并发系统常识的蔑视。

    XDP (eXpress Data Path) 为什么快?因为它在网卡驱动层、甚至在网卡硬件里(硬件卸载)就完成了数据包的处理。此时连 Linux 网络的灵魂核心 sk_buff 都还没来得及分配,主打的就是一个无锁、零拷贝、极速。

    bpf_printk 是个什么东西?它底层调用的是 bpf_trace_printk,这是一个纯粹为本地开发调试设计的辅助函数。它会将格式化后的字符串写入 /sys/kernel/debug/tracing/trace_pipe。这是一个全局的 Trace 缓冲区,意味着什么?意味着加锁

    在每秒数十万次的发包频率下,XDP 驱动着各个 CPU 核心高速运转,结果到了这一行代码,所有的 CPU 瞬间撞上一堵墙,为了抢夺 trace_pipe 的全局自旋锁(Spinlock)而疯狂互相踩踏。原本 O(1) 的无锁数据面,被硬生生降维改造成了串行处理的锁地狱。XDP 直接变成了系统性能的黑洞。

    止血方案毫无技术含量可言,直接卸载挂载在网卡上的 BPF 程序:

    # 查看当前挂载的 XDP 程序
    ip link show dev eth0
    # 剥离 XDP
    ip link set dev eth0 xdp off
    # 或者用 bpftool 精准干掉
    # bpftool net detach xdp dev eth0
    

    敲下回车的一瞬间,QPS 瞬间恢复 8万,P99 延迟掉回 2ms,CPU 软中断使用率平滑回落至正常水位。

    如果你真的需要在 eBPF 的 Fast Path 中向用户态传递大量事件、日志或抓包数据,永远不要用 Printk。唯一正确的姿势是使用 BPF_MAP_TYPE_RINGBUF(内核版本 5.8+)或者 BPF_MAP_TYPE_PERF_EVENT_ARRAY。前者是多个 CPU 共享的无锁环形缓冲区,支持按页进行 mmap 映射,后者是 Per-CPU 的事件数组,两者都可以实现内核态到用户态的异步、批量、高性能数据投递。

    eBPF/XDP 性能排查清单

    1. 快速定位异常 BPF 占用: 使用 bpftool prog show 列出当前加载的程序,观察 run_time_nsrun_cnt,如果单次运行时间异常偏高,说明程序逻辑存在阻塞或严重不合理的开销。

    2. 确认 Trace Pipe 拥塞: 直接查看 cat /sys/kernel/debug/tracing/trace_pipe,如果在生产环境发现大量日志疯狂刷屏,必须立即排查哪个 BPF 程序残留了调试代码。

    3. 软中断热点分析: 遇事不决 perf top -g -e cpu-clock -K。如果是网络 IO 导致的软中断风暴,抓取内核态调用栈能最快暴露出是驱动问题、协议栈锁争用,还是 BPF 钩子的锅。

    4. 验证底层硬件丢包: 网络层如果抓不到包,不要死盯 tcpdump。用 ethtool -S eth0 | grep -i -E "drop|miss|err" 确认是不是数据包在网卡 Ring Buffer 阶段就被静默丢弃了。

  • 凌晨三点的 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 级告警检查专线质量,否则等待你的就是全线上游节点的熔断。