🤖
AI审核中

Prometheus高基数排障实战

原创 云原生与运维 19分钟 139浏览 1评论

内存上涨时,先检查指标的“身份”

监控系统自己开始内存告警时,除了检查业务流量,还应该问一个问题:

每次业务请求,究竟是在更新已有时间序列,还是在创建新的时间序列?

在 Prometheus 中,一条时间序列由指标名称和完整的标签集合共同标识。指标名称相同,只要某个标签值不同,就可能是另一条时间序列。因此,“项目里只有几十个指标”并不能说明监控成本很低。

以自定义指标 app_http_requests_total 为例,下面这些标签看起来都有排障价值,但增长方式完全不同:

标签 示例 增长方式
method GET、POST 通常可以限制为固定集合
route /api/orders/{id} 可以与接口定义数量保持一致
uri /api/orders/938271 随实际请求路径不断增加
user_id 938271 随用户数量增加
request_id 每次请求生成的标识 可能随请求次数持续增加

Prometheus 官方明确提醒,不要把用户编号、邮箱地址等高基数或无界集合直接作为标签。判断一个标签是否合适,不能只看“现在有多少个值”,还要看它的取值空间是否受控。

将用户编号转换为哈希值,也没有解决这个问题。只要不同用户仍然对应不同标签值,时间序列数量就没有实质减少。

高基数治理控制的是不同身份的数量,而不是字符串看起来有多短。

一个直方图,为什么能扩展出数万条序列

假设一个服务的 HTTP 延迟指标包含以下维度。这里使用的是容量推演数据,不是线上实测结果。

维度 取值数量
实例 6
路由模板 80
请求方法 4
结果分类 3

当这些维度可以形成全部组合时,标签组合数量为:

6 \times 80 \times 4 \times 3 = 5760

这只是标签组合,还没有计算指标类型带来的扩展。

经典 Histogram 通常会暴露多个 _bucket 序列,以及 _sum 和 _count。其中,+Inf 桶也占用一条序列。

假设配置了 10 个有限边界桶,那么每组标签会对应:

10 \text{ 个有限桶} + 1 \text{ 个正无穷桶} + 1 \text{ 个 sum} + 1 \text{ 个 count} = 13 \text{ 条序列}

总量就变成:

5760 \times 13 = 74880

这还没有计算客户端可能额外暴露的其他指标。

如果路由标签不再是 80 个模板,而是 80,000 个实际路径,在其他假设不变时,理论组合规模还会扩大 1,000 倍。

需要注意,乘法结果是“所有组合都出现时”的估算,不代表 Prometheus 会提前创建整个笛卡尔积。实际数量取决于应用真正暴露并被采集到的组合。

这个推演的意义是:审查一个 Histogram,不能只问“配置了几个桶”,还必须检查它与多少组业务标签、多少个实例相乘。

高基数与序列抖动,不是同一个问题

高基数描述的是存量:当前存在很多不同的时间序列。

序列抖动描述的是变化:不断有新序列出现,旧序列又停止更新。

例如,把容器标识、任务执行编号或请求编号放进标签,即使某个时刻看到的业务规模并不大,也可能持续制造新的序列身份。

对 Prometheus 来说,新增序列和向已有序列追加样本是不同的操作。其自身提供了 Head 中序列总数,以及创建、移除序列的累计计数器。

假设 Prometheus 自监控的 job 名称是 prometheus,可以分别查询:

prometheus_tsdb_head_series{job="prometheus"}
rate(
  prometheus_tsdb_head_series_created_total{job="prometheus"}[5m]
)

第一条观察 Head 中的序列存量,第二条观察最近五分钟的新增速度。它们都应该与发布、扩缩容和业务变化放在同一时间轴上分析。

仅看存量可能漏掉持续的创建与淘汰;仅看新增速度,也不能说明历史积累已经清理完毕。

使用 Remote Write 时,还需要留意额外成本。Prometheus 会维护序列编号与标签值之间的映射,序列抖动会增加相关内存压力,发送分片及其队列也需要内存。远端发送跟不上时,可以检查 prometheus_remote_storage_samples_pending。

在应用侧,Micrometer 的 Meter 同样以名称和标签维度区分。动态标签不仅影响 Prometheus,也可能先让应用注册出大量 Meter。

graph LR
    A["原始路径或业务编号进入标签"] --> B["应用产生大量 Meter"]
    B --> C["抓取样本与新增序列增加"]
    C --> D["Prometheus 存储与索引承压"]
    D --> E["查询成本上升"]
    C --> F["Remote Write 缓存与队列承压"]

因此,只给 Prometheus 增加内存,未必能消除整个链路的问题。

沿着目标、指标、标签逐层定位

下面假设被排查服务的 job 名称为 order-api。实际使用时,需要替换为自己的 job 和指标名称。

从抓取目标判断异常范围

先检查哪个实例暴露的样本最多:

topk(
  10,
  scrape_samples_scraped{job="order-api"}
)

再检查最近一次抓取新增序列较多的目标:

topk(
  10,
  scrape_series_added{job="order-api"}
)

这两个指标的含义不同:scrape_samples_scraped 表示目标暴露的样本数量,scrape_series_added 表示本次抓取新增序列数量的近似值。后者不是累计计数器,不应该直接对它使用 rate()。

判断时要结合上下文。新实例刚启动,第一次抓取出现大量新增序列并不意外;如果实例长期稳定运行,业务维度也没有扩张,却持续产生大量新序列,就值得检查动态标签。

用 TSDB 统计找出主要贡献者

Prometheus 的 TSDB 状态接口可以返回序列和标签基数统计:

set -o pipefail

curl -fsS \
  --connect-timeout 3 \
  --max-time 20 \
  'http://127.0.0.1:9090/api/v1/status/tsdb?limit=10' \
  | jq '.data | {
      headStats,
      seriesCountByMetricName,
      labelValueCountByLabelName,
      memoryInBytesByLabelName
    }'

其中,headStats 用于查看 Head 概况,seriesCountByMetricName 帮助定位序列数量较多的指标,labelValueCountByLabelName 帮助识别取值过多的标签。

memoryInBytesByLabelName 尤其容易被误读。它按标签值的长度进行统计,不是完整的内存归因报告,不能据此断言“删除这个标签,进程就会释放同等大小的内存”。

这个接口适合低频排查,不适合在已经过载的实例上反复刷新。limit=10 限制的是返回条目数量,不应被当作服务端计算成本的保证。

缩小范围后,再运行聚合查询

当 Prometheus 仍有足够查询余量时,可以在指定 job 内统计序列较多的指标:

topk(
  10,
  count by (__name__) (
    {job="order-api"}
  )
)

定位到具体指标后,再检查可疑标签的不同取值数量:

count(
  count by (uri) (
    app_http_requests_total{
      job="order-api",
      uri!=""
    }
  )
)

内层按 uri 分组,外层计算分组数量。这里统计的是查询时刻可见的不同 URI,而不是这个指标在全部历史中出现过多少个 URI。count_values() 则统计不同样本值,并不是统计标签取值的替代写法。

也不要认为套了一层 topk(10, ...),查询就只需要处理十条序列。聚合前仍可能需要读取大量输入数据;Prometheus 官方也提醒,输出很少的聚合查询依然可能产生较高负载。

诊断顺序应当是缩小目标、缩小指标,再检查标签,而不是一开始就在整个实例上执行宽范围聚合。

止血时,最容易犯的错误是直接删除标签

发现 user_id 导致高基数后,一个直觉做法是通过 labeldrop 删除它。

问题在于:删除标签不是聚合数据。

假设同一次抓取中存在以下两个样本,其他标签完全相同:

指标 user_id method 值
app_http_requests_total u1 GET 3
app_http_requests_total u2 GET 7

删除 user_id 后,这两个样本会拥有相同的序列身份,但 Prometheus 不会自动把它们相加为 10。

labeldrop 的实现只是移除匹配的标签,没有累计、合并或聚合计数的逻辑。对不同计数器直接这样处理,可能造成重复样本冲突或破坏数据语义。

同样的问题也会出现在抓取阶段的路径替换上:把 /api/orders/1 和 /api/orders/2 都改成 /api/orders/{id},不等于把两个独立 Counter 正确聚合成一个 Counter。

只有确认移除标签后,所有样本仍然保持正确且唯一的身份,才适合使用这类处理。

紧急措施应该明确承认数据损失

当某个自定义指标已经威胁监控系统稳定性时,可以在确认告警和看板依赖后,暂时停止摄入这个指标。

以下片段添加到已经定位的 scrape job 中,不是替换整个 Prometheus 配置:

metric_relabel_configs:
  - source_labels: [__name__]
    regex: "app_http_requests_total"
    action: drop

sample_limit: 30000

这个示例会丢弃整个 app_http_requests_total,不是降低其精度,也不是保留聚合结果。使用前需要明确受影响的观测能力,并为修复后的指标准备恢复路径。

metric_relabel_configs 在摄入前处理样本,所以它能够阻止这些样本进入存储,但不能消除应用生成指标、构造响应及传输响应的成本。

sample_limit: 30000 也不是“超过三万条以后,只丢弃多出来的部分”。它是每个目标、每次抓取在 metric relabeling 之后的样本数量限制;超限会让整次抓取被视为失败。这里的三万只是示例阈值,需要根据正常峰值与容量预算设置。

它还不能防止低速序列抖动:一个目标每次只暴露少量样本,但每次都换成全新的标签值,依然可能持续创建新序列。

修改配置后,检查并验证加载结果

可以先使用与运行环境版本匹配的 promtool 检查配置:

promtool check config /etc/prometheus/prometheus.yml

检查通过后,按部署方式触发配置重载。已经启用 --web.enable-lifecycle 的实例,可以使用:

curl -fsS \
  --max-time 10 \
  -X POST \
  http://127.0.0.1:9090/-/reload

HTTP 重载接口默认关闭;发送 SIGHUP 也是官方支持的重载方式。配置语法检查通过,只代表配置有效,仍需验证抓取状态和实际过滤效果。

在 Java 源头,让标签集合有上界

采集侧止血之后,真正的修复点应该回到埋点。

对于 HTTP 指标,可以制定一个明确约束:进入 MeterRegistry 的路由只能来自已知模板,请求方法只能来自固定集合,未知输入统一进入有限的兜底分类。

归一化应发生在注册和更新 Meter 之前。这样,不同请求会累加到同一个计数器,而不是先生成大量计数器,再尝试修改它们的名字。

下面是一份不依赖 Spring 的 Micrometer 示例:

import io.micrometer.core.instrument.MeterRegistry;

import java.util.Objects;
import java.util.Set;

public final class ApiRequestMetrics {

    public static final String METER_NAME =
            "app.http.request.results";

    private static final Set<String> ROUTES = Set.of(
            "/api/orders",
            "/api/orders/{id}",
            "/api/products/{id}"
    );

    private static final Set<String> METHODS = Set.of(
            "GET", "POST", "PUT", "PATCH",
            "DELETE", "HEAD", "OPTIONS"
    );

    private final MeterRegistry registry;

    public ApiRequestMetrics(MeterRegistry registry) {
        this.registry = Objects.requireNonNull(
                registry, "registry must not be null"
        );
    }

    public void record(
            String matchedRouteTemplate,
            String requestMethod,
            int statusCode
    ) {
        String route =
                matchedRouteTemplate != null
                        && ROUTES.contains(matchedRouteTemplate)
                        ? matchedRouteTemplate
                        : "UNMATCHED";

        String method =
                requestMethod != null
                        && METHODS.contains(requestMethod)
                        ? requestMethod
                        : "OTHER";

        String statusClass =
                statusCode >= 100 && statusCode < 600
                        ? (statusCode / 100) + "xx"
                        : "OTHER";

        registry.counter(
                METER_NAME,
                "route", route,
                "method", method,
                "status_class", statusClass
        ).increment();
    }
}

matchedRouteTemplate 应当来自框架已经匹配到的路由模板,而不是直接使用用户请求的完整路径。示例中的允许列表需要与实际接口定义同步。

即使调用方误传了大量不同的未知路径,也只会落入 UNMATCHED,不会按原始字符串继续扩展。

在没有额外动态公共标签的前提下,这段代码的标签组合上界为:

(3+1) \times (7+1) \times (5+1) = 192

这里分别包括路由、方法和状态分类的兜底值。实际注册量则取决于哪些组合被调用。

示例有意使用了新的指标名称。真实改造中,如果标签结构和统计语义已经发生变化,可以通过新旧指标迁移来更新看板与告警,避免让同一个名称在发布前后代表不同口径。

MeterFilter 是护栏,不是业务归一化的替代品

Micrometer 提供了 maximumAllowableTags,可以限制匹配指标某个标签的取值数量:

import io.micrometer.core.instrument.config.MeterFilter;

// 在相关业务 Meter 注册之前完成配置。
registry.config().meterFilter(
        MeterFilter.maximumAllowableTags(
                ApiRequestMetrics.METER_NAME,
                "route",
                128,
                MeterFilter.deny()
        )
);

这段配置限制的是匹配范围内 route 的取值数量,不是整个系统的时间序列数量。达到限制后使用 deny(),新 Meter 会被拒绝,并返回不记录数据的 NOOP Meter;它不会把超额数据自动合并进 OTHER。

因此,不能把“监控内存不涨了”直接视为修复成功。它也可能意味着一部分指标已经不再记录。

同时,不宜把这个过滤器当作严格的并发配额系统。其实现采用检查已见集合后再添加的方式;真正稳定的维度上界,应该由前面的允许列表与归一化逻辑保证。

如果增加拒绝计数或诊断日志,记录本身也应该使用固定维度,不能又把被拒绝的原始 URI 填进告警指标标签。

回归测试要验证“不会增长”,也要验证“没有漏记”

只检查接口返回正常,覆盖不到高基数问题。

测试需要不断输入新的业务数据,然后验证 Meter 数量是否稳定,以及预期计数是否完整。

下面的测试使用 JUnit Jupiter 和 Micrometer 的 SimpleMeterRegistry:

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ApiRequestMetricsTest {

    @Test
    void dynamicInputsMustNotCreateUnboundedMeters() {
        try (SimpleMeterRegistry registry =
                     new SimpleMeterRegistry()) {

            ApiRequestMetrics metrics =
                    new ApiRequestMetrics(registry);

            for (int i = 0; i < 10_000; i++) {
                metrics.record(
                        "/api/orders/{id}",
                        "GET",
                        200
                );

                metrics.record(
                        "/unknown/" + i,
                        "GET",
                        404
                );
            }

            assertEquals(
                    2,
                    registry.find(
                            ApiRequestMetrics.METER_NAME
                    ).counters().size()
            );

            double unmatchedCount = registry.get(
                            ApiRequestMetrics.METER_NAME
                    )
                    .tags(
                            "route", "UNMATCHED",
                            "method", "GET",
                            "status_class", "4xx"
                    )
                    .counter()
                    .count();

            assertEquals(
                    10_000.0,
                    unmatchedCount,
                    0.0
            );

            double total = registry.find(
                            ApiRequestMetrics.METER_NAME
                    )
                    .counters()
                    .stream()
                    .mapToDouble(Counter::count)
                    .sum();

            assertEquals(
                    20_000.0,
                    total,
                    0.0
            );
        }
    }
}

这组断言提出了两个要求:不同的未知路径只能进入有限的兜底维度;所有预期事件仍然被记录,不能仅靠拒绝 Meter 来维持数量稳定。

SimpleMeterRegistry 将 Meter 的值保存在内存中,不负责向 Prometheus 导出数据。因此,这个测试验证的是应用侧行为,不等于完整的采集链路测试。

集成测试仍应检查实际导出内容、Histogram 的扩展情况,以及 Prometheus 附加的目标标签。

降采样、预聚合和原生直方图,各有边界

延长抓取周期,减少的是样本频率

把抓取周期从 30 秒延长到 60 秒,可以减少单位时间内采集的样本,但如果目标持续暴露同样的标签组合,这些序列身份依然存在。

Prometheus 的存储文档也区分了减少序列数量和延长抓取间隔,并指出减少序列数量往往更有效。

因此,抓取周期适合根据指标变化速度和告警要求调整,不适合用来掩盖无界标签。

Recording Rule,不会替换已经摄入的原始序列

记录规则会预先计算表达式,并把结果保存成新的时间序列。这可以减少看板重复执行昂贵查询的成本,但不会自动停止采集、删除或替换原始高基数指标。

例如,把按用户区分的请求量聚合为按服务统计,可能让看板查询变快,却没有解决按用户序列仍在进入 TSDB 的问题。

查询成本与摄入成本,需要分别治理。

Native Histogram,不能消除动态业务标签

原生直方图把计数、总和及桶信息放在复合样本中,相比经典 Histogram 的多条浮点序列表示方式,可以减少桶展开带来的成本。

但不同用户、不同请求编号或不同原始路径,仍然是不同的标签组合。根据 Prometheus 的序列身份规则,换一种 Histogram 表示方式,并不会让这些组合自动消失。

原生直方图解决的是直方图表示与处理效率,标签治理解决的是维度空间。两者可以配合,不能互相替代。

验收标准不能只有“内存降了”

一次完整的高基数修复,至少需要同时观察增长、完整性和历史回收。

增长是否受控。 在正常预热和发布变化之外,业务不断产生新的订单或请求时,不应再按业务编号持续创建新的 Meter。Prometheus 的新增序列速度应当回到与接口数量、实例变化相匹配的水平。

采集是否完整。 检查 up 是否正常,并对比 scrape_samples_scraped 与 scrape_samples_post_metric_relabeling。如果只在抓取侧丢弃指标,前者可能仍然很高;如果修复已经进入应用源头,目标暴露的样本数量也应得到控制。

还要验证请求总数、失败数量和延迟指标仍符合预期,避免把触发 sample_limit、拒绝 Meter 或误删指标造成的数据缺失,当成资源优化成果。

历史是否逐步退出。 停止暴露旧序列,不代表它们立即从全部存储结构中消失。序列在查询中被标记为过期,与 Head、持久化块及相关数据的回收不是同一件事。进程重启也可能需要回放 WAL,因此不应把反复重启当作标签治理方案。

判断修复效果,应先确认新增来源已经被切断,再观察存量如何随系统正常运行逐步变化,而不是规定“配置重载后几分钟内,RSS 必须下降多少”。

业务编号可以不断增加,监控维度却应该有明确的规模预算与生命周期。让请求继续累加到有限、稳定、语义清楚的指标中,才是高基数治理真正要完成的工作。

1 条评论

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧

头像
  1 条评论
伴我   湖南省衡阳市