把订单号、用户 ID 或原始 URL 放进指标标签,会让 Java 服务不断创建新的时间序列。本文从 Micrometer 的指标身份、Prometheus 的序列数量和对象保留关系入手,说明如何定位高基数标签,并用有界路由、结果分类和注册保护控制监控成本。

问题背景:监控本身也会消耗资源

Java 服务上线一项接口耗时监控后,请求量没有明显变化,堆内存却逐渐上涨。堆转储中能看到大量计时器、指标标识和字符串;与此同时,Prometheus 的抓取响应越来越大,监控查询也开始变慢。

这时容易把注意力放在业务缓存上,但另一条排查路径是:指标是否把每次请求中的动态值保存了下来?

例如下面的写法,看起来可以按订单查询耗时,实际上会不断创建指标:

Timer.builder("order.lookup")
    .tag("orderId", orderId)
    .register(registry)
    .record(() -> lookup(orderId));

只要新的订单号不断出现,新的指标身份就会不断出现。注册表通常会保留这些指标,普通的垃圾回收无法替你释放仍被引用的对象。

本文讨论 Micrometer 与 Prometheus 场景。示例使用 Java 17 和 Micrometer Core 1.13.6,原理同样适用于许多基于标签组织指标的系统。

核心原理:标签组合决定指标数量

在 Micrometer 中,指标名称和标签集合共同参与指标身份的确定。同一个名称,只要某个标签值不同,就可能对应另一个指标对象。

Prometheus 则以指标名称及完整标签集区分时间序列。需要注意,一个 Micrometer 指标不一定只导出一条序列。例如计时器可能导出累计次数、累计耗时;启用直方图后,还会产生多个桶序列。

“基数”可以理解为某个标签实际出现了多少种不同的值。接口模板通常只有有限几种,用户 ID、订单号、请求 ID 却可能持续增长。

假设某项指标包含 20 种路由、5 种请求方法和3种结果,理论标签组合上限是:

20 × 5 × 3 = 300

这是组合上界,实际数量取决于哪些组合真正出现。如果再加入几百万种用户 ID,增长空间就完全不同了。实例标签、导出的统计项和直方图桶还会进一步扩大服务端保存的序列数量。

因此,问题通常不在于标签名字多,而在于标签值没有明确边界。

排查路径:先找增长维度,再找注册位置

先检查监控输出中同一个指标的标签值。优先关注 uri、userId、orderId、exception 等字段:

  • uri 是否包含真实订单号、查询参数或随机路径?
  • exception 是否记录了完整异常消息?
  • 某个标签是否直接取自请求头或用户输入?
  • 应用是否为每个租户、任务或文件创建独立指标?

在 Prometheus 中,可以查询某个计数序列的规模:

count(order_lookup_seconds_count)

也可以按路由查看每种值对应多少条序列:

count by (route) (order_lookup_seconds_count)

这里的名称只是示例,使用前应以实际抓取输出为准。如果查询结果已经非常大,优先缩小到具体任务或实例,避免一次查询给监控系统增加额外负担。

随后检查 Java 堆中的保留关系。大量指标对象本身还不足以证明问题;需要确认它们由注册表持有,而且标识中的动态标签与请求输入对应。把这个证据与代码里的 tag(...) 注册位置联系起来,才能形成定位闭环。

排查抓取接口时,也不要反复把完整响应输出到终端。可以保存一次样本,围绕目标指标检查;响应较大时,先在单个实例上分析。

代码实践:让标签来自有限集合

下面的示例把请求路径映射成有限的业务路由,并把结果限制为成功和失败。订单号可以参与业务处理,但不进入指标标签。

这是一个可独立运行的示例。Maven 项目添加依赖,并将编译版本设为 Java 17:

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-core</artifactId>
    <version>1.13.6</version>
</dependency>
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;

import java.util.Objects;

public final class BoundedMetricsDemo {
    enum Route {
        ORDER_DETAIL("/orders/{id}"),
        UNKNOWN("unknown");

        final String tagValue;

        Route(String tagValue) {
            this.tagValue = tagValue;
        }
    }

    static Route classify(String path) {
        if (path != null && path.matches("/orders/[0-9]+")) {
            return Route.ORDER_DETAIL;
        }
        return Route.UNKNOWN;
    }

    static final class OrderHandler {
        private final MeterRegistry registry;

        OrderHandler(MeterRegistry registry) {
            this.registry = Objects.requireNonNull(registry);
        }

        String handle(String path) {
            Route route = classify(path);
            Timer.Sample sample = Timer.start(registry);
            boolean success = false;
            try {
                if (route == Route.UNKNOWN) {
                    throw new IllegalArgumentException("Unsupported path");
                }
                String result = lookup(path);
                success = true;
                return result;
            } finally {
                sample.stop(Timer.builder("order.lookup")
                    .tag("route", route.tagValue)
                    .tag("outcome", success ? "success" : "error")
                    .register(registry));
            }
        }

        private String lookup(String path) {
            return "order:" + path.substring("/orders/".length());
        }
    }

    public static void main(String[] args) {
        try (SimpleMeterRegistry registry = new SimpleMeterRegistry()) {
            OrderHandler handler = new OrderHandler(registry);
            handler.handle("/orders/1001");
            handler.handle("/orders/1002");
            try {
                handler.handle("/missing");
            } catch (IllegalArgumentException expected) {
                // 演示失败请求也会被计时。
            }
            registry.getMeters().forEach(meter ->
                System.out.println(meter.getId()));
        }
    }
}

两次不同订单的成功请求会复用同一个计时器,失败请求对应另一个计时器。本次运行会形成两个指标身份,而不是为三个路径分别建指标。

finally 保证同步业务抛出异常时也记录耗时;结果标签由固定字符串决定,不使用异常消息。这里衡量的是同步方法执行时间。若方法返回 CompletableFuture,计时结束点应放在异步完成回调中,否则记录到的只是提交任务的时间。

示例通过简单规则识别路由。实际 Web 应用应优先使用框架已经匹配出的路由模板,例如 /orders/{id}。未匹配路径统一归入 unknown,可以避免扫描请求或任意路径制造新标签。不要仅删除查询参数就认为路径已经稳定,路径中的资源 ID 仍然会造成增长。

注册保护:把上限作为最后一道防线

对于仍可能被扩展的标签,可以在应用开始注册相关指标之前添加保护:

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

registry.config().meterFilter(
    MeterFilter.maximumAllowableTags(
        "order.lookup",
        "route",
        32,
        MeterFilter.deny()
    )
);

这会限制匹配名称前缀的指标中,route 标签可接受的不同值数量,超出的注册会被拒绝。它不会自动删除已经创建的指标,也不会为其他标签提供同样的限制。

拒绝注册意味着一部分数据无法被记录。因此,上限应该依据业务路由规模制定,并配合检查抓取输出、注册规模或额外的诊断信号。不能看到内存稳定,就默认监控仍然完整。

保护措施用于兜底,真正的边界仍应体现在标签生成逻辑里。

常见坑:减少一侧的数据,不代表整个链路都安全

只在 Prometheus 抓取阶段过滤指标。 这可以减少服务端接收和保存的数据,但应用内的指标对象可能已经创建,Java 堆里的增长并不会因此消失。

把异常消息当成错误类型。 异常消息常包含参数、文件名和远端响应,同一种错误也可能产生大量不同值。适合标签的是受控错误码或有限类别;详细堆栈和请求标识应进入日志,再通过链路标识关联。

给所有接口统一启用大量直方图桶。 直方图适合计算聚合分位数,但每个标签组合都会增加桶序列。应根据延迟目标和聚合需求配置,而不是默认所有指标都需要相同精度。

靠反复移除指标解决无界标签。 动态移除涉及并发访问、指标引用和监控连续性,还可能让同一身份被反复创建。通常更可靠的做法是从源头去掉动态维度。

实践建议与总结

增加一个标签之前,先回答两个问题:它最多会出现多少种值,这个维度是否真正参与聚合分析?如果只是为了找到某一笔订单,把它放在日志或链路中通常更合适。

上线后持续观察目标指标的序列数量、抓取耗时、响应体规模和应用堆占用。发布前也可以用大量不同资源 ID 请求同一个路由,检查指标数量是否保持稳定。这种验证比只检查指标是否出现,更能发现设计问题。

修复后,应用内已有指标可能需要通过受控重启清理;Prometheus 中旧序列也不会立即从历史存储消失,应结合保留周期判断效果。

指标的价值在于把大量请求压缩成可比较的统计结果。给标签设置明确边界,才能让监控在业务规模增长时,继续帮助定位问题,而不是成为新的资源压力来源。

最后修改:2026 年 10 月 11 日
如果觉得我的文章对你有用,请随意赞赏