标签: OOM排查

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

  • 深入 K8S Operator 状态更新雪崩排查:Generation 机制失效引发的无限 Reconcile 死循环与 Informer 内存打爆实战

    结论先行:在基于 controller-runtime (如 v0.15.0) 开发 Operator 时,若未对 CRD 开启 /status 子资源隔离,且缺失基于 GenerationChangedPredicate 的事件过滤,每次状态回写都会引发 ResourceVersion 变更,进而被 Informer 重新推入 Workqueue,形成无限 Reconcile 死循环。这会瞬间打爆 API Server 的 QPS,并导致 Controller 因 DeltaFIFO 积压而 OOM。核心解法:强制开启 Status Subresource,应用 Generation 过滤机制,并在逻辑闭环中严格校验 ObservedGeneration

    案发现场:API Server 限流与 Controller OOM

    某次线上巡检排查过程中,监控大盘突然亮起红灯:K8s 集群 (v1.28.2) 的 API Server 出现大量 HTTP 429 (Too Many Requests) 限流报错。 排查发现,某个自研的 Operator 所在的 Pod 内存持续飙升,触发了 OOMKilled,且在 CrashLoopBackOff 期间,集群的 Load Average 显著下降,一旦重启立马复现。

    拉取 Operator 的 Prometheus Metrics 暴露端点,抓取到的关键指标如下:

    • workqueue_adds_total{name="mycrd-controller"} 每秒暴增 5000+。

    • workqueue_depth 长期维持在 10 万以上的极高水位。

    • controller_runtime_reconcile_total 速率呈指数级上升。

    这显然是一个典型的“死循环”特征。提取 OOM 前的 pprof heap 快照分析,内存几乎全量消耗在 k8s.io/client-go/tools/cache.(*DeltaFIFO).Queue 中。换句话说,Informer 的底层事件队列被彻底塞满了。

    查看该 Operator 对应控制器的核心代码片段:

    func (r *MyCRDReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        var instance myv1.MyCRD
        if err := r.Get(ctx, req.NamespacedName, &instance); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // 核心业务逻辑:比如创建底层的 Deployment 或执行一些远程 API 调用
        err := r.DoSomeHeavyLogic(ctx, &instance)
        if err != nil {
            return ctrl.Result{}, err
        }
    
        // 更新状态
        instance.Status.Phase = "Running"
        instance.Status.Message = "Reconcile successful"
        // 致命缺陷点
        if err := r.Client.Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    
        return ctrl.Result{}, nil
    }
    

    为什么一次简单的 Status 更新会引发全局雪崩?

    要理解这个死循环的根源,必须剖析 K8s 内部的资源版本控制与 Informer Watch 机制。

    在 Kubernetes 中,所有的资源对象都有两个关键的元数据字段:

    1. metadata.generation:由 API Server 维护。只有当资源的 Spec 发生变化时,该值才会递增。

    2. metadata.resourceVersion:K8s 底层 Etcd MVCC 机制的映射。任何对该资源的修改(包括加 Label、改 Annotation、更新 Status),都会导致 resourceVersion 改变。

    在上述出问题的代码逻辑中,发生了如下的“死亡飞轮”:

    1. 用户创建 CRD (Generation = 1, ResourceVersion = 100)。

    2. Informer 监听到创建事件,推入 Workqueue。

    3. Controller 触发 Reconcile,执行业务逻辑。

    4. Controller 修改 CRD 状态,并调用 r.Client.Update 回写到 API Server。

    5. API Server 接受更新,因为没有分离 /status 子资源,这是对整个对象的全量更新,ResourceVersion 变为 101。

    6. 灾难发生:Informer 的 Reflector 通过 Watch 机制感知到了 ResourceVersion 从 100 变到了 101,认为对象发生了变化(UpdateEvent),将其重新包装并扔进 DeltaFIFO。

    7. Controller 再次拿到该对象的请求,重新触发 Reconcile。

    8. 再次覆盖 Status,ResourceVersion 变为 102,再次触发 Watch…

    由于 DoSomeHeavyLogic 包含耗时操作,高频的 Update 直接让队列积压,内存爆炸。同时,API Server 在短时间内承受了海量的无效写请求,导致全局延迟抖动。

    架构级重构与防御性加固

    解决此类问题不能仅靠打补丁,需要遵循 Operator 开发的防御性最佳实践进行系统性修复。

    1. 强制启用 Status Subresource

    K8s 提供了 Subresource 机制,将业务期望(Spec)与实际状态(Status)在 API 层面隔离。 在 CRD 的 Go 结构体上方,必须声明 kubebuilder 注解:

    //+kubebuilder:object:root=true
    //+kubebuilder:subresource:status
    //+kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
    //+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
    
    type MyCRD struct {
        metav1.TypeMeta   `json:",inline"`
        metav1.ObjectMeta `json:"metadata,omitempty"`
    
        Spec   MyCRDSpec   `json:"spec,omitempty"`
        Status MyCRDStatus `json:"status,omitempty"`
    }
    

    重新执行 make manifests,这会在生成的 CRD YAML 中添加 status 子资源。 在 Reconcile 代码中,必须使用专用的 Status 客户端:

    // 错误写法:会全量覆盖,极易产生并发冲突
    // r.Client.Update(ctx, &instance)
    
    // 正确写法:仅更新 Status 子资源
    if err := r.Status().Update(ctx, &instance); err != nil {
        return ctrl.Result{}, err
    }
    

    2. 注入 GenerationChangedPredicate 拦截器

    虽然启用了 Status Subresource,但其他 Controller 或人工修改 Label/Annotation 依然会改变 ResourceVersion 触发 Reconcile。如果业务逻辑无需关心元数据变更,应当在 Controller 注册时进行拦截。

    controller-runtime 提供了强大的 Event Filters (Predicates):

    import "sigs.k8s.io/controller-runtime/pkg/predicate"
    
    func (r *MyCRDReconciler) SetupWithManager(mgr ctrl.Manager) error {
        return ctrl.NewControllerManagedBy(mgr).
            For(&myv1.MyCRD{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
            Complete(r)
    }
    

    深挖一下 GenerationChangedPredicate 的源码逻辑: 它在处理 UpdateEvent 时,严格对比旧对象和新对象的 Generation

    // 源码片段摘录 k8s.io/controller-runtime/pkg/predicate/predicate.go
    func (GenerationChangedPredicate) Update(e event.UpdateEvent) bool {
        if e.ObjectOld == nil || e.ObjectNew == nil {
            return false
        }
        // 只有当 Spec 发生实质性改变时,才允许进入 Workqueue
        return e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration()
    }
    

    3. 实现 ObservedGeneration 闭环校验

    作为高可用的极致追求,Status 设计中应当包含 ObservedGeneration 字段。这能让观察者(包括人类和上层系统)一眼判断出当前 Status 是否已经反映了最新的 Spec。

    type MyCRDStatus struct {
        Phase              string `json:"phase,omitempty"`
        ObservedGeneration int64  `json:"observedGeneration,omitempty"` // 记录已处理完毕的 Generation
    }
    

    Reconcile 中的闭环处理逻辑:

    func (r *MyCRDReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        // 1. 获取对象...
    
        // 2. 防御性判断:如果当前 Status 已经处理过当前的 Spec,直接 Return
        if instance.Status.ObservedGeneration == instance.Generation {
            // 说明没有新的业务需要处理
            return ctrl.Result{}, nil
        }
    
        // 3. 核心业务逻辑执行...
    
        // 4. 更新状态与 Generation 快照
        instance.Status.Phase = "Running"
        instance.Status.ObservedGeneration = instance.Generation // 推进位点
        if err := r.Status().Update(ctx, &instance); err != nil {
            return ctrl.Result{}, err
        }
    
        return ctrl.Result{}, nil
    }
    

    这种设计是标准的水平触发(Level-Triggered)机制的体现:我们只关心期望状态(Generation)与实际状态(ObservedGeneration)是否一致,一切流转都以此为依据。

    常见问题 (FAQ)

    Q1: 使用了 GenerationChangedPredicate 后,为什么 CRD 实例删除时,配置好的 Finalizer 没有被触发? 在使用 GenerationChangedPredicate 时,开发者经常误以为它会拦截 Delete 事件。实际上查看源码可知,它默认是放行 DeleteEvent 的。如果 Finalizer 卡住,通常是因为在 Reconcile 入口处使用了 client.IgnoreNotFound(err) 吞掉了错误,或者在拦截器配置中手写了覆盖逻辑(如自定义的 Predicate 组合丢失了 Delete 接口的实现)。删除动作不会改变 Generation,但会设置 DeletionTimestamp,必须确保这部分逻辑不被过滤。

    Q2: Reconcile 里面高频调用 r.Get() 会不会压垮 API Server? 不会。controller-runtime 默认注入的 Client 是一个 SplitClient。它的 GetList 操作默认命中 Informer 在本地内存中维护的 Indexer 缓存,而非直接发起 HTTP 请求给 API Server。但需要注意:不要在缓存未 Ready 前调用,也不要对无权限 Watch 的资源(如 Secret 全局 List)滥用,否则会 fallback 回 API Server 或直接抛错。

    Q3: 在更新 Status 时,Update 经常报 the object has been modified; please apply your changes to the latest version and try again,如何优雅解决? 这是典型的乐观锁冲突(Conflict)。在并发极高或者 Informer 缓存延迟时,你拿到的 ResourceVersion 已经落后于 API Server 里的版本。 推荐的方案是弃用 Update,改用 Patch(优先使用 ServerSideApply 策略)。

    patch := client.MergeFrom(instance.DeepCopy())
    instance.Status.Phase = "Running"
    err := r.Status().Patch(ctx, &instance, patch)
    

    Patch 操作只需要提交增量修改,极大降低了由于 ResourceVersion 冲突导致的频繁重试率,从底层释放了队列压力。

  • 深入 TiDB 读延迟雪崩排查:长事务阻塞 GC 引发 MVCC 堆积与 TiKV Coprocessor OOM 惨案

    某次排查过程中,业务反馈核心交易链路上游频繁报超时(Timeout),监控显示整个 TiDB 集群的查询 P99 延迟从平时的 8ms 暴涨至 6000ms 以上,紧接着监控告警触发:多个 TiKV 节点相继 OOM 重启,集群陷入雪崩状态。

    不绕弯子,直接抛出排查结论:某业务研发绕过数据平台,使用客户端直连线上核心 OLTP 集群,开启了一个长事务执行极其复杂的分析型查询,且中途因客户端崩溃导致连接处于“Sleep”挂起状态长达 14 个小时未提交。

    根据 TiDB 基于 Percolator 模型的 MVCC 原理,为了保证该长事务的可重复读(Repeatable Read),全局 GC(Garbage Collection)的 Safepoint 被强行锁死在此事务的 start_ts,无法向前推进。导致的结果是:核心交易表产生的数千万次 Update/Delete 产生海量的历史版本(Tombstone)无法被清理。正常的单行主键查询被下推到 TiKV Coprocessor 后,底层的 RocksDB Iterator 被迫扫描成千上万个废弃版本数据才能找到最新记录,读放大呈指数级飙升,直接打满 Coprocessor 线程池并耗尽了 TiKV 的物理内存。

    案发现场与暴力干预

    当时接手排查时,现象非常诡异。慢查询日志里并没有突发的大流量,所有的正常交易 SQL(哪怕是主键 SELECT)都慢得令人发指。登录故障所在的 TiKV 宿主机查看现场:

    # dmesg -T | grep -i oom
    [xxx] Out of memory: Killed process 12345 (tikv-server) total-vm:42949672960kB, anon-rss:32145678kB, file-rss:0kB, shmem-rss:0kB
    

    TiKV 已经被内核 OOM-Killer 献祭。查看 Grafana 监控 TiKV-Details -> Coprocessor Detail -> Total Ops Details,发现底层的 ScanNext 操作次数飙升了近万倍;同时 TiKV-Details -> Thread CPU -> Coprocessor CPU 直接画了一条顶格的直线。

    经验直觉告诉我,这不是 SQL 索引没建好,而是底层存储引擎在“负重前行”。立即查看 GC 状态:

    SELECT * FROM mysql.tidb WHERE variable_name IN ('tikv_gc_safe_point', 'tikv_gc_last_run_time');
    

    果然,tikv_gc_safe_point 的时间戳停留在十几个小时前。

    找出罪魁祸首的命令很简单,拉取全集群执行时间超过 1 小时的长事务:

    SELECT INSTANCE, ID, USER, HOST, DB, COMMAND, TIME, STATE, INFO 
    FROM INFORMATION_SCHEMA.CLUSTER_PROCESSLIST 
    WHERE TIME > 3600 ORDER BY TIME DESC;
    

    抓到一个 TIME 高达 50000+ 秒的 Sleep 连接。没有任何犹豫,直接 KILL TIDB 斩断该连接。

    大约等待了 5 分钟(GC 重新计算 Safepoint 并开始后台清理),TiKV 的 CPU 使用率断崖式下跌,P99 延迟回归 10ms 以内,报警全部解除。

    底层原理解析:为什么一个挂起的连接能搞挂整个集群?

    很多人把 TiDB 当作单机 MySQL 来用,缺乏对分布式 MVCC 机制的敬畏。在 Percolator 事务模型中,任何数据的更新(Update)和删除(Delete)本质上都是写入一条带有新时间戳(commit_ts)的记录,而非就地修改。

    为了防止磁盘被无尽的历史版本撑爆,TiDB 后台有一个 GC Leader 节点,定期(默认 10 分钟)推进 Safepoint,并通知 TiKV 清理掉 Safepoint 之前的旧版本。

    但这里有一个极其致命的硬性约束:Safepoint 的推进绝对不能超过集群中当前正在运行的最老事务的 start_ts。如果不加这个限制,长事务在执行中途,其依赖的老版本数据被 GC 提前清理掉了,就会报出著名的 GC life time is shorter than transaction duration 错误。

    当出现一个几小时不提交的僵尸事务时,GC Safepoint 被迫停滞。我们看看底层的读放大是怎么产生的:

    在 TiKV 侧,数据存储在 RocksDB 中。当你执行 SELECT * FROM table WHERE id = 1 时,Coprocessor 会构造一个 RocksDB Iterator 并在该键值区间进行 Seek,然后不断调用 Next() 往下扫。 正常情况下,扫到最新的有效记录就返回了。但由于 GC 停滞,该行数据如果经历了 10 万次高频更新,RocksDB 里就会存在 10 万个带有不同版本号的旧数据。Iterator 必须强行越过(遍历)这 10 万个逻辑删除标识(Tombstone),最终把数据拼装返回。

    这就导致了:

    1. CPU 爆炸:无休止的 Next() 调用榨干了 Coprocessor CPU。

    2. OOM 惨案:读取海量垃圾版本导致 Block Cache 被频繁换入换出(Thrashing),内存中驻留了大量无用的多版本数据结构,直至突破 memory-usage-limit 防线引发 OOM。

    防御性配置与避坑指南

    把这种“一粒老鼠屎坏了一锅汤”的风险暴露在默认配置下,是极度危险的运维架构。要想在生产环境中活得久,必须在服务端建立防御机制。

    1. 全局只读事务超时熔断 严格限制单个查询的最长执行时间,超过阈值由服务端主动掐断。

    -- 设置全局 SQL 超时时间为 30 分钟(毫秒计算)
    SET GLOBAL max_execution_time = 1800000;
    

    2. OOM 防御:单次查询内存硬限 防止垃圾 SQL 或者深层无索引 JOIN 直接撑爆 TiDB 节点的内存。

    -- 限制单条 SQL 占用最大内存为 4GB
    SET GLOBAL tidb_mem_quota_query = 4294967296;
    -- 配置超过配额时的行为为 CANCEL(直接熔断报错)
    SET GLOBAL tidb_oom_action = 'CANCEL';
    

    3. 长时间空闲连接杀手(Idle Timeout) 对于文中这种事务开启后客户端挂死导致的 Sleep 状态,必须通过空闲超时来兜底:

    -- 断开空闲时间超过 3600 秒的交互式连接
    SET GLOBAL interactive_timeout = 3600;
    SET GLOBAL wait_timeout = 3600;
    

    4. 架构隔离:HTAP 的正确打开方式 永远不要在 OLTP 的存储节点(TiKV)上跑重度分析型查询。如果业务确实需要拉取全表进行长周期聚合分析,必须通过 TiFlash 列存引擎进行物理隔离。利用 set @@session.tidb_isolation_read_engines = "tiflash"; 强行将耗时分析路由到 TiFlash,保护核心交易链路。

    排查清单 (Troubleshooting Checklist)

    1. 读延迟剧增且 CPU 打满:如果整体 QPS 平稳但 P99 飙升,首查 Grafana TiKV-Details -> Coprocessor -> Total Ops DetailsNext 调用次数是否异常放大。

    2. 确认 GC 状态:查询 mysql.tidb 表中的 tikv_gc_safe_point,对比当前系统时间,若滞后超过 1 小时,必有长事务或死锁阻塞。

    3. 定位僵尸事务:使用 SELECT * FROM INFORMATION_SCHEMA.CLUSTER_PROCESSLIST WHERE TIME > N 定位超长事务,必要时立刻 KILL TIDB

    4. 验证 MVCC 版本堆积度:通过 pd-ctl 或者慢查询日志中 Total_keysProcess_keys 的比值来判断读放大比例,若 Total_keys 远大于 Process_keys,说明扫描了大量废弃历史版本。

  • 深入 Apache Pulsar 雪崩排查:大负载滥用引发的 Bookie OOM 与 Zookeeper Ledger 元数据风暴

    某次核心业务线的 Pulsar 集群突发雪崩,生产端 99 线写入延迟从 5ms 瞬间飙升到 5000ms+,紧接着出现大面积 ProducerFencedExceptionTimeoutException。先抛结论:这又是一起典型的“把 MQ 当网盘用”引发的血案。业务方将单条动辄 5MB 到 10MB 的非结构化 JSON 直接怼进 Pulsar,且未开启消息分块(Chunking)。大负载瞬间打爆了 Bookie 的 Direct Memory 导致节点 OOM 宕机;Bookie 下线后触发了 Broker 的 Ledger Ensemble 切换风暴,海量的新 Ledger 创建请求最终将底层的 ZooKeeper 彻底打瘫,集群随之全局假死。

    如果你也遇到了 Pulsar 写不进去,但 Broker 负载看着很低的情况,先去查底层的 BookKeeper 和 Zookeeper,Pulsar 存储计算分离的本质决定了:Broker 只是无状态的网关,真正的血肉之躯在下层。

    案发现场与指标崩盘

    排查初期,监控面板上的数据极其诡异:

    1. Broker 层:CPU 负载平稳,甚至有点闲置,但 pulsar_storage_write_latency_le 指标直接断崖式破表。

    2. Bookie 层:集群中某一台 Bookie 节点离奇掉线,剩余存活节点的 bookkeeper_journal_JOURNAL_SYNC_latency_99 从微秒级涨到了惊人的 3-5 秒。

    3. Zookeeper 层Outstanding Requests 飙升至数万,znode_count 在短短十分钟内激增了几十万。

    登入那台掉线的 Bookie 节点,dmesg -T 没有看到 OS OOM Killer 的痕迹,但翻看 Bookie 的 bookkeeper.log,满屏的猩红:

    ERROR org.apache.bookkeeper.bookie.Bookie - Error on writing ledger
    java.lang.OutOfMemoryError: Direct buffer memory
        at java.nio.Bits.reserveMemory(Bits.java:694)
        at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
        at io.netty.buffer.PoolArena$DirectArena.allocateDirect(PoolArena.java:754)
        at io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:331)
    ...
    

    很明显,Bookie 进程因为 Netty 直接内存(Direct Memory)耗尽挂了。

    底层原理解析:大消息为何引发全局雪崩?

    在 Pulsar 的架构中,消息持久化由 BookKeeper 负责。为了追求高吞吐,Bookie 高度依赖 Netty 的池化直接内存来处理读写 IO,避免 JVM 堆内存的垃圾回收停顿(GC Pauses)。

    第一米多米诺骨牌:Direct Memory 爆炸 业务侧高并发写入 5MB+ 的大消息时,Bookie 的 Write Cache(由 dbStorage_writeCacheMaxSizeMb 控制,默认占用分配直接内存的 25%)被迅速填满。同时,由于单条 Payload 过大,Netty 在分配和回收 Direct Buffer 时出现碎片化和频繁的扩容操作,最终直接顶破了 MaxDirectMemorySize 的上限。

    第二米多米诺骨牌:Ledger 切换风暴 Pulsar 的写高可用依赖于 Bookie 的 Ensemble 机制。假设配置了 E=3, W=3, A=2(使用3个Bookie节点,写3份,2份Ack即成功)。当上述那台 Bookie OOM 宕机后,Broker 在等待 Ack 时发生超时,此时 Broker 会果断执行防御性动作:

    1. 将当前正在写入的 Ledger 标记为关闭(Fenced)。

    2. 从存活的 Bookie 列表中挑选新的节点,组成新的 Ensemble,并在 Zookeeper 中创建一个全新的 Ledger。

    灾难点在于:业务侧的重试风暴没有停止,大消息还在疯狂涌入。新 Ledger 刚创建,新的 Bookie 又被大消息塞得 IO 夯死或网络延迟,Broker 再次超时,再次 Fence Ledger,再次请求 ZK 创建新 Ledger。

    第三米多米诺骨牌:Zookeeper 瘫痪pulsar-admin topics stats-internal 输出中,平常一个 Topic 只有寥寥几个 Ledger,此时却看到了几千个碎片化的 Ledger ID:

    "ledgers": [
        {"ledgerId": 104523, "entries": 5, "size": 25600000},
        {"ledgerId": 104524, "entries": 2, "size": 10240000},
        {"ledgerId": 104525, "entries": 1, "size": 5120000}
    ]
    

    每一个 Ledger 的创建、状态变更,都需要强一致性地写入 Zookeeper。Zookeeper 本身就不擅长处理高频写,在这场疯狂的切换风暴中,ZK 的事务日志盘被彻底压爆,连接队列堆满。最终,Broker 抛出 MetadataStoreException: KeeperErrorCode = ConnectionLoss,全员罢工。

    与此同时,BookKeeper 内部的 AutoRecovery 检测到副本数不足,开始后台搬运数据,这让仅存的几台 Bookie 的磁盘 IOPS 和带宽更是雪上加霜,Journal 盘彻底失去响应(Sync 卡死)。

    现场恢复与架构调整

    要让这套系统活过来,重启是没用的,必须阻断恶性循环。

    1. 阻断生产洪峰:临时在 Broker 的 broker.conf 中动态下调 maxMessageSize(比如降回 1MB),硬性拦截业务侧的大负载写入,强制生产端抛错。

    2. 扩容与隔离:调大 Zookeeper 的 JVM 堆内存,增加 maxClientCnxns;重启 OOM 的 Bookie,并在启动参数 bkenv.sh 中将其 XX:MaxDirectMemorySize 翻倍。

    3. 禁用自动恢复:紧急执行 bookkeeper shell autorecovery -disable,防止数据重建任务抢占正常读写的 IO 资源,等凌晨低峰期再开启。

    长期避坑建议与加固方案:

    不要指望业务开发能完全遵守规范,运维和架构的底线就是通过配置和架构隔离来兜底。

    • 强制启用生产端 Chunking 或外置对象存储:对于大负载,如果非要用 MQ,生产端必须配置 ProducerBuilder.enableChunking(true),将大消息切片后发送,消费端再重组;或者将原始负载丢入 S3/MinIO,Pulsar 里只流转 Object URL。

    • 硬件层级冷热分离:BookKeeper 必须严格区分 Journal 盘和 Ledger 盘。Journal 盘用于顺序写 WAL,必须上 NVMe SSD;Ledger 盘用于批量落盘和随机读,可以使用大容量 SATA SSD 甚至 HDD。如果混用在一块盘上,fsync 延迟必然被大消息拉爆。

    • 精细化 Bookie 内存与缓存控制: 在 bookkeeper.conf 中,明确指定 DbLedgerStorage 的内存分配比例,防止 Direct Memory 失控: ini # 读缓存与写缓存的分配比例(默认 25/25,推荐读多时调高读,写多调高写) dbStorage_readAheadCacheMaxSizeMb=... dbStorage_writeCacheMaxSizeMb=... # 控制直接内存用于 Netty 接收缓存的比例 allocatorPoolingPolicy=PooledDirect

    排查清单:Pulsar 写入雪崩同类问题速查

    1. 查看 Broker 底层延迟指标:重点监控 bookkeeper_journal_JOURNAL_SYNC_latency_99。如果该指标突破 50ms 甚至达到秒级,说明 Bookie 磁盘 IO 已成瓶颈,检查是否触发了 AutoRecovery 或存在大消息滥用。

    2. 排查 Zookeeper 压力:如果 Broker 日志频繁出现 ConnectionLossSessionExpired,检查 ZK 的 Outstanding Requests 指标。大概率是 Broker 频繁更换 Ledger 导致的元数据风暴。

    3. 检查 Topic 碎片化:使用 pulsar-admin topics stats-internal 查看 ledgers 列表。如果单个 Topic 存在大量仅包含几个 Entry 的碎片化 Ledger,说明 Bookie 状态极不稳定,触发了频繁的 Ensemble 容错切换。

    4. Bookie OOM 溯源:检查 dmesg 排除系统级 OOM 后,直接看 Bookie 进程日志搜索 OutOfMemoryError。若为堆外内存溢出,需结合 bkenv.sh 中的 MaxDirectMemorySize 以及业务消息 Size 综合评估。

  • 深入 K8S Operator 内存 OOM 排查:缺失 FieldIndexer 引发的 Informer Cache 爆炸与 Finalizer 死锁实战

    controller-runtime (基于 v0.15.0) 的 Operator 开发中,最隐蔽的 OOM 与性能杀手往往源于开发者在 Reconcile 循环中滥用全局 client.List 进行内存级过滤,而非向 Manager 注册 FieldIndexer。这种反模式会强制 Informer 监听并缓存集群全量资源,直接撑爆本地 ThreadSafeStore。当 Operator 因 OOM 陷入 CrashLoopBackOff 时,又会产生连锁反应:拦截了删除事件的 Finalizer 无法执行清理逻辑,导致海量 CR(Custom Resource)和关联 Namespace 陷入永久 Terminating 死锁。解决此问题的核心在于:利用 FieldIndexer 下推查询条件到索引层,并严格遵循安全的 Finalizer 状态机编排。

    故障现场:Operator 频繁 OOM 与僵尸 CR 风暴

    排查某次生产环境问题时,监控系统发出严重告警:

    1. Operator Pod OOMKilled:内存使用量频繁突破 2Gi 的 Limit 阈值。

    2. Reconcile 延迟剧增:P99 Reconcile 时延从毫秒级劣化至 15 秒以上。

    3. 僵尸对象堆积:大量自定义资源 DataJob 及其所在的 Namespace 处于 Terminating 状态无法回收,集群 API Server 的 Watch 流连接数激增。

    拉取 Operator 的 Go pprof heap dump 进行现场剖析:

    go tool pprof -top http://operator-svc:8081/debug/pprof/heap
    

    输出结果极为刺眼,超过 85% 的内存消耗集中在 k8s.io/client-go/tools/cache.(*threadSafeMap).Updatek8s.io/apimachinery/pkg/apis/meta/v1/unstructured。这说明本地 Informer Cache 中囤积了极其庞大的对象数据。

    审查业务侧代码,在 DataJob 的 Reconcile 主逻辑中发现了这坨致命的“全表扫描”代码:

    // 致命的反模式代码
    podList := &corev1.PodList{}
    // 直接 List 全局 Pod,未指定 Namespace 或 Label/Field Selector
    if err := r.Client.List(ctx, podList); err != nil {
        return ctrl.Result{}, err
    }
    
    var ownedPods []corev1.Pod
    for _, pod := range podList.Items {
        // 在内存中暴力遍历过滤 owner
        for _, owner := range pod.OwnerReferences {
            if owner.Name == dataJob.Name {
                ownedPods = append(ownedPods, pod)
            }
        }
    }
    

    为什么滥用 client.List 会导致 Informer Cache 撑爆?

    在回答这个问题之前,必须理解 controller-runtime 的读写分离哲学与 Informer 底层运行机制。

    默认情况下,mgr.GetClient() 注入给 Reconciler 的 Client 是一个 Split Client(读写分离客户端)。

    • 写操作(Create/Update/Delete/Patch):直接透传给 APIServer。

    • 读操作(Get/List):默认全部被拦截并路由到本地 Informer Cache(CacheReader)。

    当你调用 r.Client.List(ctx, podList) 时,底层发生了什么?

    1. controller-runtime 发现你要 List Pod 资源。

    2. 如果此前没有针对 Pod 初始化过 Informer,Manager 会动态启动一个全量 Pod Informer。

    3. 该 Informer 通过 Reflector 向 APIServer 发起 ListAndWatch 请求。

    4. APIServer 将集群中所有的 Pod(假设有 50,000 个)推送到本地。

    5. DeltaFIFO 接收数据,经过处理后全量灌入 ThreadSafeStore(基于 Go map 实现的内存缓存)。

    灾难的根源:虽然缓存避免了频繁请求 APIServer,但 Pod 是一个极其臃肿的结构体(包含大段的 Annotations、Env、Volume 挂载信息)。50,000 个 Pod 在 Go 内存中反序列化后,轻易就能吃掉 1GB~2GB 内存。为了过滤区区几个属于特定 CR 的 Pod,把全集群的 Pod 搬进内存,典型的“为了吃一小口肉,把整个养猪场买下来”。

    实战解法:注入 FieldIndexer 下推索引

    要消除这种全表扫描引发的 OOM,必须利用 FieldIndexer。它的原理是在 Informer 同步数据到 ThreadSafeStore 时,根据你定义的提取函数,提前构建好倒排索引。

    1. 注册索引 (SetupWithManager)

    在 Operator 启动时,将 metadata.ownerReferences 注册为可检索的字段索引:

    const jobOwnerKey = ".metadata.controller"
    
    func (r *DataJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
        // 建立基于 OwnerReference 的倒排索引
        if err := mgr.GetFieldIndexer().IndexField(context.Background(), &corev1.Pod{}, jobOwnerKey, func(rawObj client.Object) []string {
            pod := rawObj.(*corev1.Pod)
            owner := metav1.GetControllerOf(pod)
            if owner == nil {
                return nil
            }
            // 确保 Owner 是当前 GVK
            if owner.APIVersion == apiGVStr && owner.Kind == "DataJob" {
                return []string{owner.Name}
            }
            return nil
        }); err != nil {
            return err
        }
    
        return ctrl.NewControllerManagedBy(mgr).
            For(&batchv1.DataJob{}).
            Owns(&corev1.Pod{}).
            Complete(r)
    }
    

    2. 重构 Reconcile 逻辑

    将内存遍历替换为按字段匹配(client.MatchingFields):

    podList := &corev1.PodList{}
    // 此时只会从 Cache 的索引桶中精准捞取对应 name 的对象
    err := r.List(ctx, podList, client.InNamespace(req.Namespace), client.MatchingFields{jobOwnerKey: dataJob.Name})
    if err != nil {
        return ctrl.Result{}, err
    }
    

    通过这种方式,Informer 依然会在后台维护缓存,但由于限定了 Namespace(通过 RBAC 和 Manager 启动参数 Cache 限制监听范围),以及规避了无效的大切片拷贝操作,Operator 的内存消耗被严格压制在百兆级别。

    打破 Finalizer 级联死锁

    回到故障现场的第三个问题:为什么大量资源卡在 Terminating? 原因在于 Operator 由于上述 OOM 问题不断 Crash,导致资源删除事件无法被正常消费。而这些 CR 注入了 Finalizer。

    在 K8S 中,只要对象的 metadata.finalizers 列表不为空,APIServer 就只会将对象的 DeletionTimestamp 赋值,而不会真正从 Etcd 中物理删除该记录。若 Operator 宕机,Finalizer 迟迟不被移除,资源就会僵死。

    防御性 Finalizer 编排范式

    处理 Finalizer 必须极其谨慎,严禁在网络抖动或外部 API 调用失败时强行移除 Finalizer,否则会导致依赖的云端或集群外部资源泄露。标准的安全状态机如下:

    const dataJobFinalizer = "batch.example.com/finalizer"
    
    func (r *DataJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
        dataJob := &batchv1.DataJob{}
        if err := r.Get(ctx, req.NamespacedName, dataJob); err != nil {
            return ctrl.Result{}, client.IgnoreNotFound(err)
        }
    
        // 检查资源是否正在被删除
        if dataJob.ObjectMeta.DeletionTimestamp.IsZero() {
            // 未被删除,检查是否需要注入 Finalizer
            if !controllerutil.ContainsFinalizer(dataJob, dataJobFinalizer) {
                controllerutil.AddFinalizer(dataJob, dataJobFinalizer)
                if err := r.Update(ctx, dataJob); err != nil {
                    return ctrl.Result{}, err
                }
            }
        } else {
            // 资源处于 Terminating 状态,执行清理逻辑
            if controllerutil.ContainsFinalizer(dataJob, dataJobFinalizer) {
                // 1. 执行自定义清理逻辑 (必须幂等,并处理超时/失败)
                if err := r.cleanUpExternalResources(dataJob); err != nil {
                    // 清理失败,返回 err 触发重试,绝对不能移除 Finalizer
                    return ctrl.Result{}, err
                }
    
                // 2. 清理成功,安全移除 Finalizer
                controllerutil.RemoveFinalizer(dataJob, dataJobFinalizer)
                if err := r.Update(ctx, dataJob); err != nil {
                    return ctrl.Result{}, err
                }
            }
            // 允许终止 Reconcile
            return ctrl.Result{}, nil
        }
    
        // 正常的业务 Reconcile 逻辑...
        return ctrl.Result{}, nil
    }
    

    避坑指南:在 Update Finalizer 状态时,极易遭遇 Conflict (HTTP 409) 错误。这是因为在处理清理逻辑的几秒钟内,对象的 ResourceVersion 可能已经被其他 Controller 改变。controller-runtime 会自动在下一个 Reconcile 循环重试,因此你的 cleanUpExternalResources 必须是严格幂等的

    常见问题 (Q&A)

    Q1:什么时候应该绕过 Informer Cache 直接读取 APIServer? 极少数情况。当你需要强一致性读取(例如处理极度敏感的锁机制或鉴权),不能容忍毫秒级的 Cache 同步延迟时。在 controller-runtime 中,可以通过注入 client.Reader 并使用 client.NewAPIReader(mgr.GetClient()) 获取直连 APIServer 的对象。但严禁在频繁的 Reconcile 循环中对全量列表使用直读,否则立刻引发 APIServer QPS 告警。

    Q2:如果我只需要获取资源的 metadata,不想缓存庞大的 spec/status 怎么办? 在较新的 controller-runtime 中(配合 Kubernetes 1.27+),你可以启用 MetadataOnly Client。它基于 APIServer 的 PartialObjectMetadata API,Informer 在本地仅缓存对象的 ObjectMeta 结构体,这能将数百 MB 的 Cache OOM 风险直接降维到几 MB。

    Q3:为什么我加上了 FieldIndexer,Operator 启动时还是对 APIServer 造成了 Watch 风暴? 检查你启动 Manager 时的 Options.Cache 配置。默认行为是全局监控(Watch All Namespaces)。如果你是一个 Namespace-scoped 的 Operator,务必在 Cache 配置中指定 DefaultNamespaces 列表。否则,每个 GVK 的 Informer 启动时依然会触发集群全量 Resync。

  • 突破 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 依然存在,生成的索引内存消耗是一模一样的。