把订单号、用户 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 中旧序列也不会立即从历史存储消失,应结合保留周期判断效果。
指标的价值在于把大量请求压缩成可比较的统计结果。给标签设置明确边界,才能让监控在业务规模增长时,继续帮助定位问题,而不是成为新的资源压力来源。