标签: OOM

  • 深入 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 的本地访问加速,后者更加精细和灵活。

  • 深入 Prometheus OOM 雪崩排查:动态 Label 滥用引发的高基数风暴与 TSDB WAL 夯死实战

    某次生产核心监控集群突然全线熔断,Prometheus 节点 Load Average 飙升至 100+,Pod 陷入持续的 OOMKilled 死亡循环。排查确认,业务研发在一项 HTTP 统计指标中错误注入了 trace_iduser_id 作为 Label,导致时间序列(Time Series)基数瞬间暴增千万级别。最终通过介入 metric_relabel_configs 强制丢弃高基数 Label,并物理清理内存映射的 Head 块与臃肿的 WAL(Write-Ahead Log)才得以恢复。

    结论先放在这里:监控指标(Metrics)绝对不是日志(Logs),把无边界的动态变量作为 Label 写入 Prometheus,是对 TSDB 存储引擎最无知的谋杀。

    案发现场:失控的 OOM 与堵死的 IO

    排查过程中,告警通道首先报出 Prometheus target 掉线,紧接着是 Kubernetes 节点资源耗尽告警。登录宿主机,dmesg 日志非常直白:

    [52143.123456] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=-998
    [52143.123458] Memory cgroup out of memory: Killed process 10245 (prometheus) total-vm:42949672960kB, anon-rss:34359738368kB, file-rss:0kB, shmem-rss:0kB
    

    32GB 内存的 Pod 被硬生生撑爆。由于 Pod 重启策略为 Always,Prometheus 尝试重新启动,但在恢复 WAL 阶段再次卡死,磁盘 IOPS 被打满,日志停留在:

    level=info ts=... caller=head.go:760 component=tsdb msg="Replaying WAL, this may take a while"
    level=info ts=... caller=head.go:812 component=tsdb msg="WAL segment loaded" segment=1023 maxSegment=1045
    

    重放速度极慢,且内存水位在重放过程中成级数增长,最终在启动完成前再次 OOM。这属于典型的高基数(High Cardinality)雪崩

    罪魁祸首:TSDB 的倒排索引与高基数之殇

    在处理这种无法启动的僵尸实例时,直接查 PromQL 是行不通的。直接把挂载的 PV 临时挂给一个 Debug 容器,掏出 promtool 对 TSDB 数据目录进行离线分析:

    promtool tsdb analyze /prometheus/data/
    

    输出结果直接锁定了元凶:

    Block ID: ...
    ...
    Label names with highest number of values:
    1. trace_id: 12045678
    2. user_id: 8543210
    ...
    Metrics with highest number of series:
    1. http_requests_total: 12045678
    

    一个原本只有几十个 endpoint 和 method 组合的 http_requests_total 指标,因为加上了 trace_id,硬生生裂变出了 1200 万个时间序列。

    为什么加个 Label 能把 32G 内存干爆?这要从 Prometheus TSDB 的底层机制说起:

    Prometheus TSDB 的设计前提是 Label 的组合是有限且收敛的。当前正在写入的数据存放在内存中的 Head Block,Head Block 默认保留最多 3 小时的数据。为了实现快速的多维查询,TSDB 维护了倒排索引(Inverted Index)。 每一条唯一的 Label 组合(例如 http_requests_total{method="GET", trace_id="abc"})都会被当作一个全新的 Series。

    1. 内存放大:在 Head 块中,每个活跃的 Series 都会占用几百字节到几 KB 不等的内存(包括结构体、索引缓存、Chunk 引用等)。一千万个 Series,光是基础的结构体开销就能轻易吃掉十几 GB 内存。

    2. WAL 风暴:每次出现一个新的 Series,TSDB 必须在 WAL 中写入一条 Series Record 以保证宕机不丢失。高基数意味着海量的新 Series 不断产生,WAL 写入量呈指数级上升,直接将磁盘 IO 打到饱和。

    3. Compaction 瘫痪:当 Head 块数据落盘生成持久化 Block 时,后台的 Compaction 机制需要对成千万的 Series 进行合并和索引重构,这会耗尽 CPU,并导致 Compaction 积压。

    业务将 trace_id 塞进 Label,等于把 O(N) 复杂度的存储系统当成了 O(1) 的 Key-Value 库在用。

    止血与修复实战

    既然抓到了凶手,修复逻辑就是:阻断毒流量输入,清理已中毒的数据。

    第一步:通过 relabel 丢弃高基数 Label 在不改动业务代码(或业务还没来得及回滚)的情况下,运维必须在 Prometheus 抓取阶段直接阉割掉这个恶意的 Label。在 prometheus.yml 中修改对应 Job 的配置:

    scrape_configs:
      - job_name: 'business_app'
        # 注意:必须使用 metric_relabel_configs,这作用于抓取后、落盘前的阶段
        metric_relabel_configs:
          - source_labels: [trace_id]
            regex: '.*'
            action: labeldrop
          - source_labels: [user_id]
            regex: '.*'
            action: labeldrop
    

    注:如果是客户端直接暴露了几千万行的 /metrics,那应用本身大概率也会因为构建 metrics 字符串而 OOM。此时需要业务立即回滚。

    第二步:处理无法启动的 TSDB 此时由于旧的脏数据还卡在 WAL 里,Prometheus 依然起不来。最粗暴有效的方法是放弃最近几小时的 Head 块数据(监控容忍短暂的断点,但不容忍系统不可用)。

    进入数据目录,直接清理 WAL 和 chunk_head:

    cd /prometheus/data/
    # 备份后删除(如果在乎现场的话)
    rm -rf wal/*
    rm -rf chunks_head/*
    

    清理后拉起 Prometheus,内存占用瞬间回落到正常的几 GB 水平,Load Average 恢复正常,集群起死回生。

    排查清单与同类问题速查

    1. 内存/OOM 快速定位
    2. 永远不要猜测,直接用 promtool tsdb analyze 分析本地数据块,查看 Metrics with highest number of series 排名。

    3. 区分 Relabel 阶段

    4. relabel_configs:作用于 Target 发现阶段,用于过滤抓取目标(改 IP、改端口、丢弃整个 Endpoint)。
    5. metric_relabel_configs:作用于抓取后、写入 TSDB 前,用于修改或过滤具体的 Metrics 和 Label(丢弃高基数 Label 必用)。

    6. 监控自身的监控

    7. 必须为 Prometheus 配置 prometheus_tsdb_head_seriesprometheus_target_scrapes_exceeded_sample_limit_total 的告警。当 Head 序列数突增时,能在 OOM 发生前拦截。

    8. 高基数需求替代方案

    9. 业务确实需要通过 Metrics 关联 TraceID 怎么办?使用 OpenMetrics 标准的 Exemplars。Exemplars 附着在具体的观测值上,不会被纳入倒排索引,不影响基数,完美解决 Metrics 到 Trace 的联动诉求。

    10. 防御性配置限制

    11. scrape_configs 中强制加上 sample_limitlabel_limitlabel_value_length_limit。宁可让超过阈值的抓取失败(报错 sample limit exceeded),也绝不让垃圾数据撑爆整个集群。
  • 深入 K8S Operator 内存雪崩排查:全局缓存滥用引发的 Informer OOM 与按需过滤实战

    排查发现,大量 Operator OOMKilled 并非代码内存泄漏,而是直接 Watch corev1.Secret 等基础资源时,Controller-Runtime 默认拉取全集群数据并构建本地 Indexer 缓存引发的。解决核心是在 Manager 初始化时引入 cache.Options.ByObject(v0.15.0+),在 Reflector 建立 Watch Stream 侧实施 Label 过滤,阻断无关数据进入 DeltaFIFO,将内存从 4GB 压降至 50MB。

    案发现场:一次常规 Watch 引发的血案

    某次排查线上自研 DB Operator 时,发现该组件的 Pod 频繁重启,Exit Code 137,监控面板显示内存使用率呈标准的锯齿状(直奔 4GB Limit 后被内核 OOM Killer 击溃)。同时,集群 kube-apiserver 的 CPU 出现周期性异常飙升,网络出带宽被打满。

    直接拉取 Operator 的 pprof heap 剖析文件:

    go tool pprof -http=:8080 http://localhost:8081/debug/pprof/heap
    

    Top 资源消耗显示,90% 以上的内存被 client-go 的底层缓存结构吃掉:

    (pprof) top
    Showing nodes accounting for 3.6GB, 92% of 3.9GB total
    Dropped 120 nodes (cum <= 0.02GB)
          flat  flat%   sum%        cum   cum%
         2.1GB 53.84% 53.84%      2.1GB 53.84%  k8s.io/client-go/tools/cache.(*ThreadSafeStore).Add
         1.0GB 25.64% 79.48%      1.0GB 25.64%  k8s.io/apimachinery/pkg/apis/meta/v1/unstructured.(*Unstructured).DeepCopy
         0.5GB 12.82% 92.30%      3.6GB 92.30%  k8s.io/client-go/tools/cache.(*sharedIndexInformer).HandleDeltas
    

    检查业务代码,开发人员在 Setup 阶段写了如下逻辑,目的是为了监听 Operator 自身下发给 DB 实例的 Secret(用于 TLS 证书轮转):

    // 埋雷代码
    err = ctrl.NewControllerManagedBy(mgr).
        For(&dbv1.MyDatabase{}).
        Watches(&corev1.Secret{}, &handler.EnqueueRequestForOwner{
            OwnerType:    &dbv1.MyDatabase{},
            IsController: true,
        }).
        Complete(r)
    

    为什么一个简单的 Watch 会打爆整个 Indexer 缓存?

    很多新人对 Controller-Runtime 的 Cache 机制存在误解,以为在 Reconcile 里过滤或者用 EnqueueRequestForOwner 就能限制内存使用。这是典型的防御边界后置

    在 Controller-Runtime 的底层架构中,所有通过 Client 读取的操作(非直接指定 client.Reader)和 Watch 操作,都会由全局共享的 Cache 组件(Backed by Informer)接管。

    1. 无差别 LIST/WATCH: 当你调用 Watches(&corev1.Secret{}) 时,Manager 会检查对应的 Informer 是否启动。如果没有,它会创建一个针对 corev1.Secret 的全局 Informer。

    2. 全量同步: Reflector 会向 API Server 发送一个 不带任何过滤条件LIST /api/v1/secrets 请求。如果你的集群里有 10 万个 Secret(比如 Helm Release 的历史记录、各种 ServiceAccount Token),这 10 万个对象会被全量拉取。

    3. 入库 Indexer: 拉取到的数据经过 DeltaFIFO,最终塞进本地的 ThreadSafeStore(一个带有读写锁的 map)。

    4. 爆炸: Kubernetes 的 Secret 对象通常包含巨大的 Base64 payload(如证书、配置)。将 10 万个 Secret 全量缓存在 Operator 的进程内存中,不仅当场撑爆 4GB,还会导致 API Server 在建立 Watch stream 时耗尽缓冲区。

    EnqueueRequestForOwner 只是限制了事件入队 (WorkQueue) 的范围,并没有限制 Informer 缓存 (Cache) 的范围。内存早就被底层 map 吃干抹净了。

    破局:在 Watch Stream 侧实施按需过滤

    解决这个问题的关键在于:将过滤逻辑前置推送到 API Server 端

    sigs.k8s.io/controller-runtime v0.15.0 开始,Cache 配置选项被重新设计,我们可以利用 cache.Options.ByObject 为特定资源指定 LabelSelector 或 FieldSelector。这样,底层 Reflector 在构造 ListWatch 请求时,就会带上相应的查询参数。

    重构 Manager 初始化逻辑:

    import (
        "k8s.io/apimachinery/pkg/labels"
        "sigs.k8s.io/controller-runtime/pkg/cache"
        "sigs.k8s.io/controller-runtime/pkg/client"
        ctrl "sigs.k8s.io/controller-runtime"
    )
    
    func main() {
        // 1. 定义我们只关心带有特定 Label 的 Secret
        secretLabelSelector := labels.SelectorFromSet(labels.Set{
            "app.kubernetes.io/managed-by": "my-db-operator",
        })
    
        // 2. 配置 Cache 策略
        mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
            Scheme: scheme,
            Cache: cache.Options{
                ByObject: map[client.Object]cache.ByObject{
                    &corev1.Secret{}: {
                        Label: secretLabelSelector,
                        // 如果只需要监听特定 NS,也可以配合 FieldSelector
                        // Field: fields.SelectorFromSet(fields.Set{"metadata.namespace": "db-system"}),
                    },
                },
            },
        })
    
        if err != nil {
            setupLog.Error(err, "unable to start manager")
            os.Exit(1)
        }
        // ... 后续启动逻辑
    }
    

    修改后重启 Operator,API Server 侧接收到的请求变为: LIST /api/v1/secrets?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dmy-db-operator

    仅拉取和缓存 Operator 真正管理的几十个 Secret,内存消耗瞬间从 4GB 暴跌至 50MB 左右,GC 压力释放,CPU 利用率趋于一条平滑的直线。

    常见问题 (FAQ)

    Q1:为了绕过缓存导致 OOM,我直接使用 mgr.GetAPIReader() 替代 mgr.GetClient() 来读取 Secret 可以吗? 不可以滥用 APIReader。APIReader 会直接穿透缓存向 API Server 发起实时查询。如果在高频的 Reconcile 循环中对基础资源使用 APIReader,会产生可怕的 Read QPS,极易触发 API Server 的流控(RateLimiting),甚至拖垮主节点 etcd。基础资源读取必须走缓存,关键在于“控缓存规模”而非“弃用缓存”。

    Q2:如果我需要监听多个特定 Namespace 下的资源,而不是依赖 Label,该怎么配置? 可以通过 cache.Options.DefaultNamespaces 来限制全局缓存的命名空间范围。如果你需要监听 ns-ans-b

    Cache: cache.Options{
        DefaultNamespaces: map[string]cache.Config{
            "ns-a": {},
            "ns-b": {},
        },
    }
    

    这在底层会实例化 MultiNamespacedCache,对每个指定 NS 启动独立的 Watcher。

    Q3:我在 Cache 中配置了 LabelSelector,这会影响 Garbage Collection (GC) 和 OwnerReference 的级联删除吗? 会产生直接影响。如果父对象 A 创建了子对象 B,且通过 cache.ByObject 过滤了子对象 B(比如子对象 B 没有打上对应的 Label),那么当 B 发生状态变化或被意外删除时,Operator 的 Informer 将无法收到事件,也就无法触发针对父对象 A 的 Reconcile,导致级联恢复机制失效。 最佳实践: 只要是你 Operator 创建并需要跟踪生命周期的附属资源,必须在创建时强行注入统一的管控 Label,并在 Manager Cache 配置中对齐该 Label。

  • 深入 RocketMQ 顺序消息雪崩排查:无限重试引发的队列阻塞与 CommitLog PageCache 抖动惨案

    近期处理了一起由边缘业务引发的全局 RocketMQ 集群雪崩事故。故障现象非常典型:核心链路的 Producer 突然出现大量 [TIMEOUT_CLEAN_QUEUE]broker busysystem busy 报错,消息发送 P99 延迟从平时的 2ms 飙升到 3000ms 以上,最终触发限流降级,核心业务受损。

    直接抛出结论: 这不是集群容量不足的问题,而是一次典型的“业务代码低级失误 + 底层机制连锁反应”引发的惨案。某业务团队滥用 MessageListenerOrderly(顺序消费),且在 Listener 中未做全局异常捕获。一条“毒药消息”(Poison Pill)触发空指针异常,导致该 MessageQueue 无限重试并被死锁。 随着积压加剧,Consumer 触发冷读(Cold Read),疯狂从磁盘拉取历史数据,引发底层 PageCache 颠簸(Thrashing)。这直接导致 Broker 写 CommitLog 时发生严重的 Major Page Fault(缺页中断),写入线程被阻塞,集群为了自我保护触发了 BrokerFastFailure 机制,全盘拒绝了所有 Producer 的写入请求。

    解决这种问题,光靠扩容 Broker 是没用的,必须从业务消费逻辑兜底和 Broker 存储层防御两端同时下刀。

    故障现场与排查推演

    排查过程中,我们首先查阅了核心 Producer 的报错日志,满屏都是这个极其刺眼的异常:

    MQBrokerException: CODE: 2 DESC: [TIMEOUT_CLEAN_QUEUE]broker busy, start flow control for a while, period in queue: 205ms, size of queue: 876
        at org.apache.rocketmq.client.impl.MQClientAPIImpl.processSendResponse(MQClientAPIImpl.java:682)
    

    看到 TIMEOUT_CLEAN_QUEUE,有经验的架构师脑子里应该立刻条件反射出它的触发机制:RocketMQ 的 BrokerFastFailure 后台线程会定时清理发送队列,如果发现请求在队列中等待处理的时间超过 200ms(默认值),就会直接丢弃该请求并返回 broker busy。

    为什么会等待超过 200ms?说明 Broker 处理写请求的线程池卡住了。 我立即登录主 Broker 节点,用 vmstat 1iostat -xz 1 扫了一眼,Load Average 飙到了 80+,CPU 使用率并不高,但 %wa (IO Wait) 高达 60%,磁盘 util 长时间顶在 100%。

    查看 Broker 的 store.log,果不其然,刷盘耗时严重超标:

    WARN flush disk log [CommitLog] cost: 450 ms
    WARN flush commit log cost: 455 ms
    

    RocketMQ 是基于 mmap 实现的高效顺序写,CommitLog 直接写入 PageCache,通常在微秒级。这种几百毫秒的延迟,说明 PageCache 被污染了,触发了严重的缺页中断,导致同步等待磁盘 I/O

    顺藤摸瓜,查看监控大盘的 Consumer Lag 指标,发现某非核心服务的滞后量达到了数百万条。 登录该业务的 Pod 抓取线程栈(jstack),发现大量的 ConsumeMessageThread 处于阻塞状态。

    愚蠢的 Root Cause

    翻看该业务的代码,血压直接飙升。他们为了保证所谓的“严格顺序”,使用了 MessageListenerOrderly,代码如下:

    consumer.registerMessageListener(new MessageListenerOrderly() {
        @Override
        public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
            // 没有任何 try-catch 兜底逻辑
            String payload = new String(msgs.get(0).getBody());
            processStrictly(payload); // 这里抛出了 NullPointerException
            return ConsumeOrderlyStatus.SUCCESS;
        }
    });
    

    为什么这在普通消费中不是致命问题,但在顺序消费中却是灾难?

    在普通并发消费(MessageListenerConcurrently)中,如果抛出异常或返回 RECONSUME_LATER,RocketMQ 会将消息发往 %RETRY%Group 的重试队列,并带有阶梯重试间隔,重试 16 次后进入死信队列(DLQ),当前队列会继续消费下一条消息。

    但在顺序消费(MessageListenerOrderly)中,底层逻辑是严格保序的。为了防止乱序,如果 Listener 抛出异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT,RocketMQ 会认为这条消息没处理完,绝对不会跳过它。它会将当前 MessageQueue 挂起,默认等待 1 秒后,再次投递这条一模一样的消息,陷入死循环(无限重试)。

    在这个场景下:

    1. 队列被锁死:毒药消息无限重试,后续几万条正常消息全部被阻塞在该队列后面。

    2. K8S 重启风暴:业务方发现积压,习惯性地去删 Pod 重启。Pod 的频繁上下线导致 Consumer Group 疯狂触发 Rebalance。在顺序消费模式下,Rebalance 需要向 Broker 申请分布式锁,频繁的锁争抢进一步增加了 Broker 的 CPU 压力。

    3. 冷读触发雪崩:因为消息积压时间太长,这些数据早就从 OS PageCache 中淘汰。当积压的队列试图拉取消息时,触发了大量的磁盘随机读取(冷读)。这些大量的冷读数据挤占了宝贵的 PageCache,导致 CommitLog 写入时找不到空闲页,触发 Major Fault 落盘,最终阻塞了全局的发送请求。

    一段没有写 try-catch 的几十行边缘代码,直接干翻了整个大集群,这就是缺乏防御性编程意识的代价。

    修复与底层防御加固

    对于这种问题,必须实施双端改造。

    1. 业务侧:顺序消费的防御性兜底

    严禁在 MessageListenerOrderly 中裸奔。必须全局捕获异常,并设定自定义的最大重试次数(利用 Message 的 ReconsumeTimes 属性)。当重试超过阈值时,手工将其告警并写入本地死信表或旁路处理,强制返回 SUCCESS 让位给后续消息。

    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
        MessageExt msg = msgs.get(0);
        try {
            process(msg);
            return ConsumeOrderlyStatus.SUCCESS;
        } catch (Exception e) {
            log.error("Consume orderly error, msgId: {}", msg.getMsgId(), e);
            // 防御性编程:判断重试次数,避免无限阻塞队列
            if (msg.getReconsumeTimes() >= 3) {
                moveToCustomDLQ(msg); // 降级处理
                return ConsumeOrderlyStatus.SUCCESS; // 强行放行
            }
            return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
        }
    }
    

    2. Broker 侧:隔离冷热读写,保护 PageCache

    即使业务再拉胯,基础设施也必须坚挺。调整 OS 和 Broker 配置以提升抗雪崩能力。

    • OS 层内核参数调优: 调整 vm.extra_free_kbytesvm.min_free_kbytes,强制内核保留一定的空闲内存用于应对突发的 IO 请求分配,避免 Page Reclaim 引发阻塞。 bash sysctl -w vm.zone_reclaim_mode=0 sysctl -w vm.swappiness=1

    • Broker 存储层调优: 强制开启预热和 mmap 内存锁定。 “`properties # 强制将 mmap 映射的内存锁定在物理内存中,避免被 Swap 出去 (mlockall) warmMapedFileEnable=true

      开启异步刷盘下额外的堆外内存池。

      写请求先写入 DirectByteBuffer,再异步 commit 到 PageCache。

      极大地缓冲了 PageCache 抖动对 Producer 写入请求的影响。

      transientStorePoolEnable=true “`

    • 开启冷热分离(RocketMQ 5.x 推荐,或 4.x SSD+HDD 架构): 如果磁盘条件允许,将 CommitLog 和 ConsumeQueue 部署在高性能 NVMe 上,或者利用 RocketMQ 的 Cold Data 机制,将长期积压的数据下沉,确保热点读取完全命中内存。

    排查清单 (同类问题速查)

    1. [TIMEOUT_CLEAN_QUEUE] broker busy 报错:意味着 Broker 处理写入请求的耗时超过 200ms。不要急于怀疑网络,第一优先级检查 Broker 磁盘 %wastore.log 中的 Flush Cost,大概率是 PageCache 抖动导致 mmap 写入缺页阻塞。

    2. 顺序消费死锁陷阱MessageListenerOrderly 不受最大重试 16 次的限制。Listener 抛出未捕获异常或返回 SUSPEND_CURRENT_QUEUE_A_MOMENT 会导致该队列无限重试。必须由业务层判断 ReconsumeTimes 进行主动放行。

    3. 冷读风暴污染内存:Consumer 拉取长时间积压的历史消息(冷读),会将磁盘文件重新加载到 PageCache,直接挤占 CommitLog 的内存页空间。可通过启用 transientStorePoolEnable=true 彻底解耦业务冷读对热点发送写入的直接冲击。

    4. K8S Rebalance 抖动:顺序消费依赖向 Broker 侧申请全局锁。Pod 的频繁起停会导致 Consumer 假死,引发长时间的 Rebalance 等待(锁续期与超时机制),表现为队列有堆积但没有消费速率。

  • 深入容器供应链安全:Trivy SCA 扫描 OOM 引发流水线假死与 Cosign Keyless 验签失败排查实战

    某次核心业务发布大面积卡死,根本原因是 Trivy 生成 SBOM 时对超过 2GB 的 Fat-JAR 进行深层解包触发 OOM-Kill,同时 CI/CD 中 Cosign Keyless 签名由于 OIDC Token 失效导致签名无效,引发 Kyverno 准入控制器验签超时拦截。本文直接给出针对超大镜像的 SCA 调优方案,并剖析基于 Fulcio/Rekor 的 Cosign 无密钥验签底层原理与拦截策略配置。

    现场还原:OOM 与 API Server 抖动

    排查过程中发现两处异常: 第一,GitLab CI 流水线在执行 SCA(软件成分分析)和 SBOM(软件物料清单)生成节点时大面积挂起,查看 Runner 所在节点的系统日志,满屏的 OOM-Kill

    $ dmesg -T | grep -i oom
    [Tue Oct  x xx:xx:xx xxxx] trivy invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [Tue Oct  x xx:xx:xx xxxx] Out of memory: Killed process 14582 (trivy) total-vm:4194304kB, anon-rss:2048576kB, file-rss:0kB, shmem-rss:0kB
    

    第二,部分侥幸通过 CI 的镜像在推送到生产 K8S 集群时,Pod 处于 CreateContainerConfigError,Event 提示 Kyverno 准入控制 Webhook 拦截:

    Error creating: admission webhook "check-image-signature.kyverno.svc" denied the request: 
    image index.docker.io/mycorp/payment-svc:v1.2.3 failed signature verification: 
    verify signature failed: getting transparency log entry: context deadline exceeded
    

    同时,K8S API Server 的 P99 延迟从平时 20ms 飙升到了 3000ms 以上。

    为什么 Trivy 在生成 SBOM 时会触发 OOM?

    在供应链安全体系中,SCA 扫描不仅要比对 OS 级别的漏洞(如 dpkg, rpm),更要解析应用依赖(如 Maven, npm)。涉事业务线打包了一个极其臃肿的 Java 镜像(超过 2.5GB),内部嵌套了大量的胖 JAR 包(Fat-JAR)。

    当使用 Trivy (版本 v0.49.1) 生成 CycloneDX 格式的 SBOM 时:

    trivy image --format cyclonedx --output sbom.json mycorp/payment-svc:v1.2.3
    

    底层原理是:Trivy 默认会分析镜像内所有的压缩文件(包括 .jar, .war, .tar.gz)。为了提取内部的 pom.xmlgo.mod 确认组件版本,Trivy 需要将这些归档文件加载到内存并解压到 /tmp 目录。 当遇到嵌套深度高、单体文件极大的 JAR 包时,Trivy 的 Goroutine 会并发解压,导致堆内存暴涨。若容器限制了 2GB RAM,必然被底层 Cgroup 对应的 OOM Killer 猎杀。

    解决与优化方案:

    1. 限制并发与文件类型:对大体积镜像屏蔽不必要的深层扫描,关闭并行解压。

    2. 挂载缓存与临时目录:将 Trivy 的临时解压目录映射到宿主机的高速 NVMe 磁盘上,而不是吃容器内存(tmpfs)。

    3. 调整命令参数

    # 增加临时目录环境变量,并跳过测试类或特定大型数据目录
    export TMPDIR=/mnt/host-disk/trivy-tmp
    trivy image \
      --format cyclonedx \
      --output sbom.json \
      --skip-dirs "/app/data" \
      --parallel 1 \
      --offline-scan \
      mycorp/payment-svc:v1.2.3
    

    Cosign Keyless 验签超时与 Kyverno 雪崩阻断

    解决了 CI 端的 SBOM 生成问题后,来看 K8S 端的拦截。 目前业界推崇 Sigstore 体系下的 Cosign Keyless(无密钥)签名。它不依赖静态私钥,而是依靠 OIDC 身份认证 -> Fulcio(颁发短期证书)-> Rekor(不可篡改的透明日志)这一闭环。

    在 CI 环境中(Cosign v2.2.3),签名的底层工作流是:

    cosign sign --yes index.docker.io/mycorp/payment-svc:v1.2.3
    

    Cosign 向 Rekor 提交签名记录。

    当 K8S 集群内的 Kyverno(v1.11.1)拦截到 Pod 创建请求时,它需要校验镜像签名。配置的 ClusterPolicy 如下:

    apiVersion: kyverno.io/v1
    kind: ClusterPolicy
    metadata:
      name: verify-image-signature
    spec:
      validationFailureAction: Enforce # 严格阻断
      webhookTimeoutSeconds: 5         # Webhook 超时 5 秒
      rules:
        - name: verify-signature
          match:
            any:
            - resources:
                kinds:
                  - Pod
          verifyImages:
          - imageReferences:
            - "index.docker.io/mycorp/*"
            attestors:
            - entries:
              - keyless:
                  subject: "https://gitlab.mycorp.com/*"
                  issuer: "https://gitlab.mycorp.com"
    

    超时雪崩的底层根因: 为了验证 Keyless 签名,Kyverno 必须向公网的 Rekor 服务器(rekor.sigstore.dev)发起出站 HTTP 请求,检索 transparency log。 由于生产环境所在的 VPC 进行了严格的公网出站限制(NAT 网关 ACL 变更),导致 Kyverno 请求 Rekor 的 TCP 建连一直卡在 SYN_SENT 状态,直到 5 秒超时。 由于设置了 validationFailureAction: Enforce 并且 K8S API Server 持续等待 Webhook 返回,大量发版请求同时卡住,直接导致 API Server 对应处理线程池耗尽,P99 延迟飙升。

    防御性重构: 基础设施的安全校验决不能成为系统可用性的单点瓶颈(SPOF)。

    1. 网络放行与私有化部署:在 NAT 网关显式放行 Sigstore 相关的域名(rekor.sigstore.dev, fulcio.sigstore.dev),长期方案是部署私有化的 Rekor/Fulcio 实例。

    2. Kyverno 容错配置:在未实现本地缓存时,将 failurePolicy 设为 IgnoreFail 是个需要权衡的问题。对于非金融核心链路,建议启用缓存并调整 Webhook 拦截策略:

    # 在 webhook 配置中启用缓存策略,并在极端网络断开时降级
    spec:
      failurePolicy: Ignore # 网络故障时不阻断 K8S 调度,改为告警
      webhookTimeoutSeconds: 3
      # Kyverno 1.11+ 支持使用 ttl 缓存验签结果,避免每次 Pod 扩容都请求公网
    

    常见问题

    Q1:Syft 和 Trivy 生成的 SBOM 格式 (SPDX/CycloneDX) 在后续消费时有何区别? SPDX 出身于 Linux 基金会,侧重于开源软件的许可证(License)合规性跟踪;CycloneDX 由 OWASP 驱动,原生地更侧重于漏洞管理(Vulnerability)和依赖路径分析。如果在 CI/CD 管道中重点是做 SCA 漏洞拦截并结合 Dependency-Track,建议统一输出为 CycloneDX 格式。

    Q2:Cosign 生成的 .sig 签名文件是如何与原镜像绑定的?删除原镜像标签会影响验签吗? Cosign 在 OCI 注册表(如 Harbor, Docker Hub)中并不直接修改原镜像,而是根据原镜像的 sha256 摘要创建一个附着对象(Attachment)。例如镜像 sha256 为 sha256:1234...,Cosign 会生成一个 tag 为 sha256-1234....sig 的新镜像层来存储签名内容。验签底层依赖的是 Digest 散列值,因此单纯删除或修改原镜像的 Tag,只要镜像文件的 Hash 未变,验签依然能够通过。

    Q3:遇到高度受限的离线环境 (Air-gapped) 怎么做 SCA 漏洞库更新和 Cosign 验签? 离线环境是供应链安全的痛点。针对 Trivy,需要在有网环境使用 trivy image --download-db-only 提取 trivy.dbtrivy-java.db,然后打包并通过内网推送到离线机器的 ~/.cache/trivy/ 目录;针对 Cosign 验签,必须放弃强依赖外网的 Keyless 方案,改用传统的基于 KMS 或本地静态公私钥对(cosign generate-key-pair)的签名模式,将公钥内置于 K8S 准入控制器中,实现完全内网闭环校验。

  • TiDB 集群 P99 暴涨至 5000ms:一个 2 亿行大事务 DELETE 引发的 Percolator 惨案

    某次排查过程中,核心交易集群的 TiDB 节点发生大面积 OOM,集群 P99 延迟从日常的 10ms 直接飙升到 5000ms 以上,TiKV 节点接连抛出 Server is busy 拒绝服务。先说最终结论: 某位研发在后台归档任务中,执行了一条没有任何 LIMIT 和分批的 DELETE 语句,企图在一个事务内删掉 2 亿行历史数据。由于对底层 Percolator 分布式事务模型一无所知,这个超级大事务不仅瞬间抽干了 TiDB Server 的内存,残留在 TiKV 的海量锁和 MVCC 墓碑(Tombstone)更是直接引发了读写雪崩。

    案发现场:从 OOM 到全局雪崩

    监控面板上,故障的爆发几乎是垂直的:

    1. tidb_server_memory_usage 指标在 3 分钟内从 4GB 飙升到 64GB(容器 Limit),随后节点被内核 OOMKilled

    2. TiKV 的 Raft apply duration P99 飙到秒级,Coprocessor CPU 打满。

    3. 应用端出现大量 java.sql.SQLException: Lock wait timeout exceeded; try restarting transactionRegion is unavailable

    切到机器上抓一下 dmesg,典型得不能再典型的 OOM:

    [123456.789] Memory cgroup out of memory: Kill process 5678 (tidb-server) score 1980 or sacrifice child
    [123456.790] Killed process 5678 (tidb-server) total-vm:85934028kB, anon-vm:67108864kB, file-vm:0kB, shmem-vm:0kB
    

    翻看 INFORMATION_SCHEMA.SLOW_QUERY 和存活节点的 TiDB 日志,抓到了罪魁祸首:

    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    就是这么一句平平无奇的 SQL,命中了近 2 亿条数据。

    当我拿着这条 SQL 去找对应业务线的开发时,得到的答复是:“我们用的是分布式数据库啊,底层不是无限水平扩展的吗?删个历史数据怎么就挂了?”

    这种把分布式数据库当成魔法、完全无视底层物理定律的想法,是导致大多数生产灾难的根源。分布式 != 无底洞。

    刨根问底:为什么分布式数据库最怕“大事务”?

    在单机 MySQL (InnoDB) 中,大事务会撑爆 Undo Log,导致长事务阻塞和主从延迟。而在 TiDB 这类基于 Percolator 模型的分布式 HTAP 数据库中,大事务的杀伤力是指数级的。

    1. OOM 的元凶:两阶段提交(2PC)的内存缓冲

    TiDB 处理事务使用的是 Percolator 模型的变种。在事务提交(Commit)之前,客户端(即 TiDB Server)会把所有修改的数据缓存在自己的内存中。 当执行这句 2 亿行的 DELETE 时,TiDB Server 需要将这 2 亿个 Key 的修改操作(在底层,DELETE 也是一种写入,即写入包含 Tombstone 标记的 KV)装进内存。 算一笔最简单的账:单行数据的 Key + Value 加上事务元数据假设为 200 Byte。 200,000,000 * 200 Byte ≈ 40 GB。 更要命的是,Go 语言在处理如此庞大的对象分配时,GC 会产生巨大的开销,内存碎片加上堆栈扩展,轻轻松松就能把 64GB 的容器内存干爆。

    2. “掩耳盗铃”的配置修改

    其实 TiDB 为了防止这种惨案,出厂设置是有保护机制的:txn-total-size-limit 默认通常为 100MB。 理论上,这个事务早就该报 Transaction too large 失败了。但我查阅配置变更历史时发现,前段时间该业务线抱怨过几次批量更新报错,某位缺乏敬畏之心的运维,直接将全网的 txn-total-size-limit 改成了 10GB! 放开这种硬性防御阈值,等于拆掉了保险丝。TiDB 成功绕过了配置限制,然后死在了物理内存耗尽上。

    3. 锁残留与 Resolve Lock 风暴

    TiDB Server OOM 崩溃后,灾难并没有结束。 在 Percolator 2PC 的 Prewrite 阶段,TiDB 会在 TiKV 端写入大量的 Primary Lock 和 Secondary Lock。TiDB Server 进程猝死,导致这些锁变成了“孤儿锁”。 此时,正常的业务请求如果读取到了这些被锁住的 Key,就会发现事务处于 Pending 状态。为了保证 ACID,读请求必须触发锁清理机制(Resolve Lock)。 几十万个并发查询撞上几千万个残留锁,瞬间引发了海量的 RPC 交互:

    [WARN] [endpoint.go:612] ["error response"] [err="Key is locked (primary)"] 
    [WARN] [resolve.go:128] ["resolve lock timeout"] [txn=43981293847123984]
    

    TiKV 的 RPC 线程池直接打满,Raftstore 处理缓慢,最终导致大面积的 Region unavailable,连正常的小事务也无法提交。

    终极解法与避坑指南

    对于分布式数据库的批量数据清理,绝对不能用传统的“大事务一波流”。如果你需要删几亿条数据,请把“防御性编程”刻在脑子里。

    正规的落地姿势有三种:

    方案 A:非事务 DML(Non-transactional DML) 新版 TiDB 提供了原生的批处理语法,直接在内部完成分批提交,不保证事务的原子性(反正删历史数据也不需要原子性),彻底绕过大事务限制:

    BATCH ON id LIMIT 10000 
    DELETE FROM trade_orders WHERE create_time < '2023-01-01 00:00:00';
    

    方案 B:按时间分区的 Drop Partition 对于日志流、流水表,建表时就应该规划好时间分区(Partition By Range)。清理历史数据只需一条 ALTER TABLE trade_orders DROP PARTITION p2022;。这在底层仅仅是元数据的解绑,瞬间完成,没有 MVCC,没有锁冲突。

    方案 C:TiDB TTL (Time to Live) 机制 如果业务特性允许,直接在表结构上加上 TTL 属性:

    ALTER TABLE trade_orders TTL = `create_time` + INTERVAL 1 YEAR;
    

    交由 TiDB 后台按 Region 慢慢清理,对前台业务透明。

    排查清单:同类大事务问题速查 (Troubleshooting Checklist)

    1. 核对 OOM 与系统日志 立刻在 TiDB 节点执行 dmesg -T | grep -i oom,如果命中 tidb-server,说明发生过严重的内存挤兑,大概率是大事务或者无索引的巨型 JOIN。

    2. 定位元凶 SQL 检索 INFORMATION_SCHEMA.SLOW_QUERY,重点关注 Mem_maxTxn_start_tsQuery_time 极大的语句: SELECT query, mem_max, process_time FROM information_schema.slow_query ORDER BY mem_max DESC LIMIT 5;

    3. 检查全局限制配置 不要盲目调大保护参数。检查 tidb_mem_quota_query(单条 SQL 内存限制)和 txn-total-size-limit(总事务大小限制),恢复到合理阈值(推荐单事务不要超过 1GB)。

    4. 清理遗留的悲观锁/乐观锁 如果 OOM 后集群持续卡顿,观察 Grafana 中的 TiKV-Details -> Locks 面板。必要时可通过临时调低 resolve-lock 的 backoff 时间来加速孤儿锁清理,或联系官方辅助清理陈旧的 MVCC tombstone 触发手动 Compaction。

    分布式架构给了你海量存储的错觉,但底层的内存、网络 IO 和锁机制依然遵循着严密的物理约束。在生产环境敲下回车之前,想想底层要付出多大的代价。