分类: 监控与可观测性

  • 深入 Zabbix 监控雪崩排查:自定义 LLD 滥用引发的 MySQL IO 饱和与 History Syncer 阻塞实战

    近期处理了一起极为惨烈的监控系统雪崩事故。故障表现为 Zabbix Server 队列疯狂飙升,延迟监控项(Queue over 10 minutes)达到数十万,所有告警延迟触发甚至丢失,DB 节点的 Load Average 飙到 80+,底层 NVMe 磁盘 %util 锁死在 100%。最终排查结论:业务研发在未经评审的情况下,引入了一个存在严重缺陷的自定义 LLD(Low-Level Discovery)模板,将大量高频更新的容器环境变量作为 Text 类型全量上报。这直接撑爆了 history_text 表,触发了 Zabbix 内部 Housekeeper 的疯狂 DELETE 清理操作,导致 MySQL InnoDB 产生海量随机 IO 与 Undo Log 积压,最终拖垮 History Syncer 进程,引发监控全局假死。

    把关系型数据库当成时序库甚至日志库来用,是监控系统运维中最不可原谅的低级错误。自定义监控脚本返回几百 KB 的冗余字符串,还要每隔 30 秒上报一次,这种粗暴的做法不仅毫无数据价值,更是对底层存储 IO 的公开处刑。

    排查过程的 Dashboard 是一片刺眼的红。首先看 Zabbix Server 的自身监控,核心指标 Zabbix server history syncer processes more than 75% busyZabbix server history cache, % used 均已触顶 100%。 这说明 Zabbix Server 接收到的数据已经塞满了内存中的 History Cache,但负责将 Cache 刷入数据库的 History Syncer 进程被严重阻塞,无法写入。

    顺藤摸瓜,直接登录 Zabbix 的 MySQL 后端节点,抓取现场指标:

    # 查看磁盘 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           0.00     0.00  120.50 8500.20  1928.00 136003.2  31.97    145.20   16.80   25.50   16.60   0.12 100.00
    
    # 查看 MySQL 进程状态
    $ mysql -e "SHOW FULL PROCESSLIST;" | grep -i "history_text"
    

    不出所料,SHOW PROCESSLIST 中堆积了大量处于 updating 状态的 SQL,其中最致命的是这条: DELETE FROM history_text WHERE itemid=105432 AND clock<1690000000

    此时再看 InnoDB 引擎状态:

    mysql> SHOW ENGINE INNODB STATUS\G
    ---TRANSACTION 4598234, ACTIVE 45 sec fetching rows
    mysql tables in use 1, locked 1
    100345 lock struct(s), heap size 8345120, 5345020 row lock(s), undo log entries 3450920
    

    Undo log entries 达到 300 万级别,History List Length 严重超标。到这里,整个雪崩的逻辑链条已经非常清晰了:

    1. 毒模板泛滥:业务层添加了自定义的 LLD 规则,自动发现并生成了近 50 万个 Text 类型的 Item。这些 Item 每 30 秒抓取一次,数据载荷大。

    2. 写放大与表膨胀history_text 表在短时间内急剧膨胀。对于 MySQL InnoDB 而言,大批量变长字符串的写入会引发严重的页分裂(Page Split)。

    3. Housekeeper 绞肉机:Zabbix 默认启用了内部的 Housekeeper 来清理过期历史数据。当它试图通过 DELETE 语句清理数亿条历史记录时,由于 clock 字段的索引效率在庞大的表体量前急剧下降,操作退化为缓慢的范围扫描。

    4. IO 饱和与雪崩:巨量 DELETE 产生了惊人的 Undo Log 和 Redo Log,将 NVMe 的写带宽榨干。History Syncer 进程在执行 INSERT 时等不到锁和 IO 资源,集体挂起。History Cache 随之满载,Poller 无法继续收集数据,监控队列彻底爆掉。

    解决这种系统级阻塞,第一步必须是止血,而不是优雅地等待 SQL 执行完毕。

    第一步:暴力阻断清理任务并清理毒模板 直接在 DB 端 Kill 掉所有 Housekeeper 相关的 DELETE 线程,同时在 Zabbix Server 配置中紧急禁用内部清理:

    # /etc/zabbix/zabbix_server.conf
    HousekeepingFrequency=0
    

    重启 zabbix-server 服务。随后在 Web 端找出那个罪魁祸首的 LLD 模板,强制 Unlink and clear,并批量禁用相关主机上的遗留 Item。

    第二步:DB 层面的历史表彻底改造(MySQL Partitioning) Zabbix 原生 Housekeeper 依赖 DELETE,这在千万级以上的监控规模中是绝对的性能毒药。防御性运维的最佳实践是:永远不要用 DELETE 清理 Zabbix 历史,全部改用表分区(Table Partitioning)并通过 DROP Partition 来回收空间。

    historyhistory_uinthistory_strhistory_text 以及 trends 系列表实施按天/月分区:

    -- 以 history_uint 为例,按天分区
    ALTER TABLE history_uint PARTITION BY RANGE (clock) (
        PARTITION p20231001 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-02 00:00:00')),
        PARTITION p20231002 VALUES LESS THAN (UNIX_TIMESTAMP('2023-10-03 00:00:00')),
        ...
    );
    

    配合 Crontab 每天执行存储过程,自动 ALTER TABLE history_uint DROP PARTITION p_old,耗时仅需几十毫秒,彻底根除 IO 灾难。

    第三步:Zabbix Server 核心参数调优 为了防止未来再出现单点突发流量打挂整个 Server 的情况,必须对 Cache 和 Syncer 进行硬性隔离和扩容:

    # 提高历史缓存,给 DB 抖动留出缓冲池 (根据 RAM 大小调整)
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    ValueCacheSize=1G
    
    # 增加 Syncer 进程数,但不要超过 CPU 核心数的一半
    StartDBSyncers=16
    
    # 限制自定义脚本超时时间,防止 Poller 假死
    Timeout=10
    

    经过上述改造,截断庞大的 history_text,重构分区表后,Zabbix Server 的 Queue 在 5 分钟内迅速清零,IO 恢复到个位数,系统平稳落地。

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

    1. 观察 History Cache 使用率Zabbix server history cache, % used 持续走高,说明后端 DB 已经成为瓶颈。切勿盲目增加 Zabbix Poller 数量,这只会加重 DB 负担,应优先排查 MySQL IO 及慢查询。

    2. 检查 Housekeeper 状态 查看 Zabbix Server 日志中 housekeeper 每次执行的耗时。如果耗时超过几十分钟甚至小时级,必须立即停用内部 Housekeeper,改用 MySQL 数据库表分区(Partitioning)策略。

    3. 排查 Unsupported Items 与高频 LLD 使用 zabbix_get 测试自定义脚本耗时。严格限制 LLD 的发现频率(通常建议 1 小时以上),并慎用 TextLog 类型监控项。对于单纯的日志采集,请移步 ELK 或 Loki,Zabbix 不是日志垃圾桶。

    4. MySQL InnoDB 参数防御 确保 innodb_buffer_pool_size 分配了机器物理内存的 60%-70%;innodb_flush_log_at_trx_commit 在极致性能要求且允许少量数据丢失时可设为 2innodb_io_capacity 需根据底层 SSD/NVMe 性能调高(如设为 10000+)。

  • 深入 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。

  • 深入 Zabbix 监控雪崩排查:LLD 发现风暴引发的 Proxy 缓存积压与 History Syncer 夯死实战

    近期处理了一起 Zabbix 6.0 LTS 集群雪崩事故。根因是某业务线引入劣质自定义 LLD 模板,单机生成逾万监控项,引发 Proxy 缓存打满与 History Syncer 进程 100% 繁忙,最终压垮后端 DB IO 导致全局断连。核心解法:阻断异常 LLD 发现、调优 Zabbix 核心缓存参数,并将底层存储彻底迁移至 PostgreSQL + TimescaleDB 解决写入墙问题。

    故障现场:Queue 积压与 Poller 满载

    排查过程中,监控大屏首先报警的是 Zabbix Queue 严重积压,延迟超过 10 分钟的 item 数量直线飙升破 5 万。登录 Zabbix Server 核心节点,top 命令显示 Load Average 飙升至 80+,系统 iowait 长期盘踞在 40% 以上。

    查看 Zabbix Server 日志 /var/log/zabbix/zabbix_server.log,满屏都是极其致命的告警:

    Zabbix server history syncer processes more than 75% busy
    Zabbix server history syncer processes more than 100% busy
    server is out of memory: Out of memory (data: 256M, index: 64M)
    cannot accept connection from proxy "cn-sh-proxy-01": max number of Trapper processes reached
    

    切到前端分布式 Proxy 节点 /var/log/zabbix/zabbix_proxy.log,同样处于崩溃边缘:

    cannot send proxy data to server at "10.0.0.10": Zabbix server connection failed
    history cache is full, sleeping for 1 second
    

    表象很清晰:数据写不进数据库,导致 Zabbix Server 的 History Syncer(负责将内存数据刷入 DB 的核心进程)全部夯死。Server 端 Trapper 进程耗尽,导致 Proxy 无法上报数据,Proxy 本地的 HistoryCache 被打爆,最终整个监控链路瘫痪。

    为什么一个简单的自定义模板能搞垮整个监控集群?

    很多开发在写 Zabbix 监控脚本时,缺乏“防御性编程”思维。抓取故障现场的 Proxy sqlite3 库(或本地临时文件),发现罪魁祸首是一个名为 Custom_K8s_Pod_Discovery 的 LLD (Low-Level Discovery) 脚本。

    该脚本通过 Python 遍历全量 Pod 状态,但没有做任何 Limit 限制和状态机过滤。单台 Kubernetes Node 上的脚本直接返回了近 5MB 的 JSON Array:

    {
      "data": [
        {"{#PODNAME}": "web-api-7b89f...", "{#NAMESPACE}": "prod", "{#CONTAINER}": "nginx"},
        // ... 往下还有 15000+ 个对象
      ]
    }
    

    Zabbix LLD 引擎在处理这个宏大 JSON 时,会为每一个 {#PODNAME} 动态生成 5 个 Item(CPU、内存、网络 IO 等)。 算一笔账:1 台机器抛出 15000 个实体 $\times$ 5 个 Item = 75000 个监控项。 如果是 100 台节点的集群,瞬间生成 750 万个新监控项

    这些海量监控项每 30 秒采集一次数据,疯狂涌入 Zabbix Proxy。 Proxy 的默认 HistoryCacheSize 仅有区区 16M,瞬间被打满。随后 Proxy 将庞大的 Payload 塞给 Zabbix Server,Server 端的 History Syncer 试图将这几百万条并发写入后端的 MySQL history_uint 表。MySQL InnoDB 面对这种毫无规律的极高频并发 Insert,B+ 树页分裂严重,NVMe 磁盘的 IOPS 直接打满,写延迟达到 500ms 以上,彻底堵死。

    架构级改造:从 MySQL 到 PG+TimescaleDB

    在千万级 Item 的企业监控场景下,MySQL 表分区脚本(如常用的 partitioning.sql 存储过程)不仅维护极其痛苦,且对历史数据的清理依然会产生锁争用。

    解决写入瓶颈的最终态方案,是利用原生时序数据库。Zabbix 从 5.0 开始深度支持 PostgreSQL + TimescaleDB 扩展,将 history 相关的表转化为 hypertable,实现按时间维度的透明 Chunk 分片。

    迁移与落地步骤:

    1. 部署 PostgreSQL 14 与 TimescaleDB 插件。

    2. 导入 Zabbix 基础 Schema 后,务必执行 TimescaleDB 转换脚本:

    # Zabbix 6.0 环境下开启 TimescaleDB 支持
    zcat /usr/share/doc/zabbix-sql-scripts/postgresql/timescaledb.sql | sudo -u zabbix psql zabbix
    
    1. 在 Zabbix Server 开启内部历史数据压缩(极大降低磁盘 IO 并节省 70% 空间):
    -- 连接到 zabbix 库
    UPDATE config SET db_extension='timescaledb', history_compression_status=1, history_compress_older='7d';
    

    切换到 TimescaleDB 后,Zabbix History Syncer 的写操作变成了针对内存中最新 Chunk 的顺序追加写(Append-only),避开了全表扫描和巨型 B-Tree 维护,单机轻松抗住 10万+ QPS 的监控项写入。

    调优与防御性配置落地

    底层存储问题解决后,必须对 Zabbix 核心配置进行防御性加固,防止类似 LLD 风暴再次冲垮服务。

    1. Zabbix Server 核心参数重调

    编辑 /etc/zabbix/zabbix_server.conf

    # 根据物理内存,大幅提高历史缓存,作为 DB 抖动时的缓冲池
    HistoryCacheSize=2G
    HistoryIndexCacheSize=256M
    ValueCacheSize=1G
    
    # 增加数据刷盘进程数(需结合 DB 最大连接数考量)
    StartHistorySyncers=30
    
    # 增加处理 Proxy 和 Agent 主动上报的 Trapper 进程
    StartTrappers=100
    
    # 禁用 Server 端轮询,强制全部走 Proxy 分布式采集
    StartPollers=0
    

    2. Zabbix Proxy 缓冲防御

    编辑 /etc/zabbix/zabbix_proxy.conf

    # 提高 Proxy 侧的缓存,容忍更长时间的 Server 端断连
    HistoryCacheSize=1G
    HistoryIndexCacheSize=128M
    
    # 严格控制外部脚本超时时间,防止进程卡死(默认3秒,最大不超过10秒)
    Timeout=10
    

    3. 数据预处理(Pre-processing)截流

    针对自定义监控项,强制要求在 Zabbix Web UI 的 Item Preprocessing 中配置以下规则:

    • Discard unchanged with heartbeat (心跳抑制): 如果监控值没有变化,直接在 Proxy/Server 端丢弃,只在达到 heartbeat(如 1 小时)时强制写入一次。这能削减 60% 以上的无用状态写入。

    • 正则表达式过滤: 对 LLD 发现的文本进行白名单截断,丢弃非核心进程的数据。

    常见问题

    Q1: Proxy 报错 “Zabbix server connection failed”,但网络 Ping 和 Telnet 都通,如何排查? 通常不是网络问题,而是 Zabbix Server 端的 Trapper 进程全忙。检查 Zabbix Server 监控大屏上的 Zabbix server trapper processes busy 指标是否达到 100%。若是,需调大 StartTrappers,或检查是否有超大 Payload 正在阻塞网络层解析。

    Q2: 监控项经常出现断点,日志提示 “first network error, wait for 15 seconds”,如何优化? 这是 Poller 进程在执行某些慢请求(如大文本抓取、远端 API 调用)时超时了。Zabbix 默认超时 Timeout=3 秒。建议将耗时任务改成 Agent 端的异步 Crontab 写入本地文件,Zabbix 只做简单的 vfs.file.contents 读取;或者将 Timeout 谨慎上调至 10。

    Q3: 迁移到 TimescaleDB 后,Zabbix 的 Housekeeper 还需要开启吗? 绝对不需要对历史表开启。开启 TimescaleDB 后,应在 Zabbix UI 的 “Administration -> General -> Housekeeping” 中,勾选 Override item history period 并启用内部机制。旧数据的清理会由 DB 原生的 drop_chunks() 函数瞬间完成,而不是 Housekeeper 一行行执行极度耗 IO 的 DELETE 语句。

    Q4: 怎样防止自定义 LLD 脚本再次引发灾难? 运维必须剥夺业务组直接创建 LLD Template 的权限。通过 CI/CD 管道扫描业务侧提交的脚本,限制 LLD 返回的 JSON 最大数组长度(如不超过 200)。此外,在 Zabbix 中利用 “LLD overrides” 功能,强制要求匹配特定正则的对象才能触发 Item 发现。

  • 突破 OOM 死亡循环:Prometheus 高基数指标引发的 TSDB 内存雪崩与底层结构解析实战

    结论先行:Prometheus 频繁 OOM 且 WAL 截断失败,99% 的根因是高基数(High Cardinality)标签打穿了 Head Block 的倒排索引。底层 Gorilla 压缩算法只能极大地优化时序“值”的存储(16字节压缩至约1.37字节),但救不了无限膨胀的 Label 组合。解决方案:通过 promtool tsdb analyze 定位基数元凶,用 metric_relabel_configs 在抓取阶段实行防御性清洗,并合理配置 TSDB 的 Block 压缩与落盘周期。

    某次排查过程中,我们线上一套监控几十个 K8S 集群的核心 Prometheus(v2.45.0)节点陷入了 CrashLoopBackOff 的死亡循环。告警静默,监控大屏一片空白。

    查看系统内核日志,死因极其明确——被 OOM Killer 制裁:

    $ dmesg -T | grep -i oom
    [xxx] prometheus invoked oom-killer: gfp_mask=0x100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=0
    [xxx] Memory cgroup out of memory: Killed process 12345 (prometheus) total-vm:85493200kB, anon-rss:67108864kB
    

    物理机分配了 64GB 内存给这个容器,居然在几分钟内被吃干抹净。这绝不是正常的指标写入量增长,而是典型的“高基数雪崩”。

    寻找雪崩元凶:拨开 WAL 与 Head Block 的迷雾

    Prometheus 的 TSDB 设计基于内存(Head Block)与磁盘(Persistent Block)的组合。最新采集的 2-3 小时数据全部驻留在内存中,并依靠 WAL(Write-Ahead Log)保证不丢数据。每次 Prometheus OOM 重启后,第一件事就是 Replaying WAL。如果导致 OOM 的高基数数据还在 WAL 里,重启过程必将再次吃满内存,形成死循环。

    为了强行中断这个循环,我们先将该节点的内存限制临时放大到 128GB 让其启动,随后立即使用官方神器 promtool 对本地数据目录进行离线解剖:

    $ promtool tsdb analyze /prometheus/data
    ...
    # Top 10 label names with high memory usage:
    1: trace_id
    2: client_ip
    3: pod_ip
    
    # Top 10 series count by metric names:
    1: 4501230  http_request_duration_seconds_bucket
    2: 2100450  http_requests_total
    ...
    

    破案了。某业务研发在 http_requests_total 和耗时直方图里,顺手加上了 trace_idclient_ip 作为 Label。

    为什么仅仅是多加了一个 Label,就会耗尽上百 GB 的内存?

    很多开发对时序数据库有误解,认为“Prometheus 压缩率很高,多加个字段无所谓”。这就必须深入 TSDB 的底层数据结构来解释。

    在 Prometheus 中,一条时间线(Series)由 Metric Name 和一组 Label 键值对唯一确定http_requests_total{method="GET", status="200", client_ip="192.168.1.10", trace_id="abc123xxx"}

    TSDB 处理数据分为两大核心路径:数据块(Chunks)倒排索引(Inverted Index)

    1. Chunks 的极致压缩(Gorilla 算法) 对于属于同一条时间线的连续样本数据 (Timestamp, Value),Prometheus 采用了类似 Facebook Gorilla 论文中的 XOR 增量压缩算法。因为时间戳通常是规律递增的(如 15s 一次),Value 往往变化极小。通过计算差值的差值(Delta-of-Delta),一对原本需要 16 Bytes(8 Byte int64 时间戳 + 8 Byte float64 值)的样本,能被压缩到平均 1.37 Bytes。这就是大家常说的“高压缩率”。

    2. 倒排索引的内存黑洞 Gorilla 压缩对 Label 完全无效。为了能让 PromQL 飞速查询,Prometheus 必须为每一个 Label 的 Name 和 Value 建立倒排索引映射: Label (client_ip="192.168.1.10") -> [Series ID 1, Series ID 205...] 当引入 trace_id 这种几乎每次请求都不同的 Label 时,Series 的数量等于所有 Label 基数的笛卡尔积。 百万级别的 trace_id 瞬间生成了数百万条独立的 Series。每条全新 Series 的诞生,都会在 Head Block 中分配新的字符串内存(Symbols table)、新的倒排索引指针(Postings list),以及独立的数据 Chunk。内存消耗呈现指数级爆炸,且完全无法被压缩。

    当这些海量的内存结构积压在 Head Block(默认驻留时间最多达 3 小时),内存自然会瞬间被打穿。

    落地实战:防御性清洗与架构调优

    对于这种毒瘤级的指标,我们绝不妥协,必须在网关侧/采集侧直接干掉,实施“防御性运维”。

    1. 采集端防御配置(metric_relabel_configs) 在 Prometheus 的 scrape_configs 中,利用 metric_relabel_configs 在指标进入 TSDB 引擎前将其截杀。注意,不要用 relabel_configs(作用于 target 发现阶段),必须用 metric_relabel_configs

    scrape_configs:
      - job_name: 'business-api'
        ...
        metric_relabel_configs:
          # 方案一:直接丢弃整个包含了违规 Label 的指标序列(下手最狠)
          - source_labels: [trace_id]
            regex: '.+'
            action: drop
    
          # 方案二:保留指标,但抹除高基数 Label(推荐,保证监控不丢失总并发量)
          - regex: '(trace_id|client_ip)'
            action: labeldrop
    

    加载配置 (curl -X POST http://localhost:9090/-/reload) 后,新的数据洪流被清洗干净。

    2. TSDB 块生命周期(Compaction)调优 为了让 Prometheus 尽快清理掉历史遗留的庞大 Head Block,我们需要理解 TSDB 的落盘(Compaction)机制。 Head Block 中的数据达到特定条件会切分成持久化的 Block 目录(包含 meta.json, index, chunks/, tombstones)。

    如果服务器内存吃紧,我们可以适当干预落盘周期(启动参数配置):

    # 默认 min-block-duration 为 2h,决定了 Head 块多久切片一次落盘。
    # 强制保持一致,避免块过大难以合并
    --storage.tsdb.min-block-duration=2h
    --storage.tsdb.max-block-duration=24h
    

    持久化到磁盘后的 Block 会被通过 mmap 的方式映射到虚拟内存空间(VIRT),此时只要不进行全量范围的 PromQL 查询,这部分数据对物理内存(RES)的占用将大幅度降低,由 Linux 内核的 PageCache 全权接管。

    3. 清理已存在的毒瘤数据(Tombstones 机制) 对于历史的脏数据,可以使用 Admin API 软删除:

    curl -X POST -g 'http://localhost:9090/api/v1/admin/tsdb/delete_series?match[]={trace_id=~".+"}'
    

    执行后,数据不会立刻从磁盘消失,而是写入到 Block 目录下的 tombstones 文件中。后续的 Compaction 过程会读取该文件并真正剔除无用数据。如果需要强制立即清理磁盘,可调用:

    curl -X POST http://localhost:9090/api/v1/admin/tsdb/clean_tombstones
    

    常见问题 (FAQ)

    Q1:为什么通过 metric_relabel_configs 删除了高基数 Label,Prometheus 的物理内存(RES)并没有立刻下降? A:这是符合预期的。由于 Prometheus TSDB Head Block 的机制,数据通常要在内存中攒满 2 小时(加上最长允许的 1 小时 overlap)才会执行落盘并释放内存。即使新抓取的数据不再含有高基数标签,旧的庞大倒排索引依然存活在内存里。你需要耐心等待下一次 Head 切片,或者干脆重启进程,配合之前放宽的内存上限让 WAL 重放完成后,内存自然回落。

    Q2:Prometheus 发生 OOM 重启后,启动特别慢,日志一直卡在 Replaying WAL 是什么情况? A:Prometheus 只有正常退出时,才会将 Head Block 里的内容做 Checkpoint 或者全量 Flush 落盘。OOM 属于非正常崩溃,内存数据丢失。重启后,它必须逐行读取 data/wal/ 目录下的日志以在内存中重建倒排索引和 Chunks。如果 WAL 高达几十个 GB,这个过程将极其漫长(且高度依赖磁盘 IOPS)。建议将 TSDB 部署在企业级 NVMe SSD 上,这是监控系统的底线。

    Q3:我可以通过降低抓取频率(将 scrape_interval 从 15s 调整为 60s)来缓解高基数导致的 OOM 吗? A:不能,这是经典误区。scrape_interval 影响的是同一条 Series 每分钟追加的样本点数量。这部分数据被 Gorilla XOR 算法高效压缩,占用极小。导致 OOM 的是 Series 的总数(基数规模)膨胀,进而撑爆了不可压缩的倒排索引表。无论是 15s 抓取一次还是 1 分钟抓取一次,只要这几百万个带唯一 trace_id 的 Label 依然存在,生成的索引内存消耗是一模一样的。