《Spring Boot 高级》10.1 Micrometer 指标模型

从 MeterRegistry 注册表与 Meter.Id 的身份语义讲起,对比 Counter、Gauge、Timer、DistributionSummary 的取值差异,拆解 Observation 用低/高基数键值统一指标与追踪,说明 MeterFilter 改名、加标签、限基数的介入点,并落到 Prometheus 导出与 percentiles-histogram 对 P99 的影响。

本节目标:把 MeterRegistry 的注册表结构、Meter.Id 的身份语义、四类 Meter 的取值差异、Observation 如何同时喂给指标与追踪,以及 MeterFilter 的介入位置一次讲清;读完你能自己判断「这条曲线该用 Counter 还是 Gauge」「P99 该在客户端算还是在服务端算」。
适用版本:Spring Boot 4.1.x(Java 21)

10.1 Micrometer 指标模型

实战卷解决的是「怎么配 Actuator、怎么暴露端点」,本节解决「注册表里到底存了什么、指标名字与标签是怎么拼出来的、为什么同样的代码在 Prometheus 里查不到 P99」。全程围绕一个借阅服务:LoanService.borrow() 每次借书要更新计数、记录耗时、并暴露当前在借数量。

本章的 MeterRegistry 与 Observation 类名、方法签名,均从本机 micrometer-core-1.17.1.jar 与 micrometer-observation-1.17.1.jar 用 javap 核实。

10.1.1 MeterRegistry:注册表与工厂是同一个对象

io.micrometer.core.instrument.MeterRegistry 是一个抽象类(不是接口),它同时承担两个角色:指标注册表与具体 Meter 的工厂。它把「造一个 Counter」抽象成受保护方法,交给后端实现去决定怎么存:

protected abstract Counter  newCounter(Meter.Id id);
protected abstract Timer    newTimer(Meter.Id id, DistributionStatisticConfig config, PauseDetector pauseDetector);
protected abstract <T> Gauge newGauge(Meter.Id id, T obj, ToDoubleFunction<T> f);
protected abstract DistributionSummary newDistributionSummary(Meter.Id id, DistributionStatisticConfig config, double scale);

也就是说,PrometheusMeterRegistry、SimpleMeterRegistry、CompositeMeterRegistry 各自实现这几个 newXxx,而查重、命名约定、过滤器这些跨后端逻辑全部收在抽象基类里。公共 API 是 counter(String, Iterable<Tag>)、timer(...)、gauge(...) 等重载——它们先在注册表里按 Meter.Id 查,查不到才调 newCounter。

MeterRegistry 里几个值得记住的成员(javap 核实):

成员类型作用
getMeters()List<Meter>取出全部已注册 Meter,写测试断言时最常用
find(String)Search按名字前缀检索,find("loan").tag("...", "...")
remove(Meter)Meter摘掉一个 Meter(动态标签回收时用)
config()MeterRegistry.Config拿配置面板,注册过滤器与公共标签
more()MeterRegistry.More造 FunctionCounter / FunctionTimer / TimeGauge 这些「由外部函数驱动」的 Meter

MeterRegistry.Config 是后端的配置入口,本机核实的方法有 meterFilter(MeterFilter)、commonTags(Iterable<Tag>)、namingConvention(NamingConvention)、pauseDetector(PauseDetector),以及 4.x 新增的 withHighCardinalityTagsDetector(...)。注意它是可变配置:过滤器按注册顺序依次生效,顺序写反了结果会不一样(见 10.1.5)。

10.1.2 Meter.Id:名字 + 标签才是身份

Meter 接口只有一个身份来源:getId(),返回 io.micrometer.core.instrument.Meter.Id。Meter.Id 由五部分组成(javap 核实):名字 name、标签 tags、基准单位 baseUnit、描述 description、类型 type。

关键点:注册表用 Meter.Id 去重,而不是用名字。loan.borrow 带 result=success 与 result=failure 是两个 Meter,而不是一个 Meter 的两个值。这解释了为什么标签组合会直接影响内存:每个不同的标签组合都是一条独立的 Meter,注册表里就是一个 ConcurrentHashMap 条目。基数爆炸(cardinality explosion)说的就是这件事。

Meter.Id 是不可变的,withName / withTag / withTags / replaceTags / withBaseUnit 都返回新对象。MeterFilter.map(Meter.Id) 正是利用这一点做改名与加标签——它拿到旧 Id,返回一个新 Id。

10.1.3 四类 Meter 的语义差异

同样是「记录一件事」,四种 Meter 记录的东西完全不同。对照表如下:

Meter语义关键方法(本机核实)典型用途
Counter单调递增的累计值increment() / increment(double) / count()请求数、错误数、借出次数
Gauge某一刻的瞬时值value()当前在借数、队列深度、连接池占用
Timer一组耗时的分布record(long, TimeUnit) / record(Runnable) / count() / totalTime(TimeUnit) / max(TimeUnit)接口耗时、SQL 耗时
DistributionSummary一组非时间量的分布record(double) / count() / totalAmount() / max()响应体大小、批处理条数

Counter 与 Gauge 的本质区别在数据从哪里来:Counter 由你的代码主动 increment,是「推」;Gauge 注册时绑定一个对象和一个 ToDoubleFunction<T>,由采集器在每次 scrape 时拉——所以 Gauge 的值永远是「采集那一刻算出来的」。

这里有一个反复坑人的细节,本机 javap -p io.micrometer.core.instrument.internal.DefaultGauge 可以看到它的字段:

private final java.lang.ref.WeakReference<T> ref;
private final java.util.function.ToDoubleFunction<T> value;

Gauge 对目标对象持弱引用。 如果你写 registry.gauge("loan.active", new AtomicInteger(), AtomicInteger::get),那个 AtomicInteger 是临时对象、注册完就被回收,Gauge 会在下一次采集时报出 0 或 NaN。正确做法是把被观测对象做成 bean 字段或集合元素,保证它生命周期长于 Gauge。

Timer 与 DistributionSummary 都实现了 HistogramSupport,因此都支持 percentile(double, TimeUnit)(客户端算分位)与 histogramCountAtValue(long)(读桶计数)。区别只是量纲:Timer 内部按纳秒记时、对外用 baseTimeUnit() 换算,Summary 直接记你给的 double。

10.1.4 Observation:一条 API 同时喂指标与追踪

io.micrometer.observation.ObservationRegistry 与 Observation 是 1.10 引入的统一抽象。它的设计意图是:业务代码只描述「发生了一次借书」,至于这次观察是变成 Timer 还是变成 Span,由注册表上挂的 handler 决定。

核心 API(本机核实):

ObservationRegistry registry = ObservationRegistry.create();

Observation.createNotStarted("loan.borrow", registry)
    .lowCardinalityKeyValue("book.type", "paper")
    .highCardinalityKeyValue("loan.id", loanId)
    .observe(() -> loanService.borrow(loanId));

两个键值方法的分工是整个设计的关键:

方法会进哪里基数要求
lowCardinalityKeyValue(String, String)指标标签 和 span 标签低基数(类型、结果、租户等级)
highCardinalityKeyValue(String, String)只进 span,不进指标高基数(订单号、用户 id)

这条规则直接回答了实践中最常见的问题:为什么 loan.id 不该做成指标标签——它是高基数键值,只能进 span。把高基数维度写进低基数槽位,就是基数爆炸。

Observation 的生命周期由一串 ObservationHandler<T> 驱动。ObservationHandler 定义 onStart / onStop / onError / onEvent / onScopeOpened / onScopeClosed(本机核实)。指标侧的实现是 io.micrometer.core.instrument.observation.DefaultMeterObservationHandler:

public DefaultMeterObservationHandler(MeterRegistry registry)
public void onStart(Observation.Context context)
public void onStop(Observation.Context context)
public void onEvent(Observation.Event event, Observation.Context context)

Spring Boot 在 classpath 上有 micrometer-core 时会自动把 DefaultMeterObservationHandler 注册到自动配置的 ObservationRegistry 上(官方 3.0 Release Notes 明确写出),因此每次 Observation 停下都会产出一个 Timer。追踪侧的 handler 见 10.2。

10.1.5 MeterFilter:在注册表入口做拦截

MeterFilter 是 io.micrometer.core.instrument.config.MeterFilter,三个回调分别对应三个介入点(本机核实):

MeterFilterReply accept(Meter.Id id);                                   // 这个 Meter 要不要注册
Meter.Id        map(Meter.Id id);                                       // 改名 / 加标签
DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config); // 改分位/桶

它有一批静态工厂,覆盖了绝大多数治理需求:

需求工厂方法(本机核实)
给所有 Meter 加公共标签commonTags(Iterable<Tag>)
重命名某个标签键renameTag(String name, String from, String to)
丢弃某些标签键ignoreTags(String...)
替换标签值replaceTagValues(String, Function<String,String>, String...)
只保留白名单denyUnless(Predicate<Meter.Id>) / accept(Predicate<Meter.Id>)
限制总 Meter 数maximumAllowableMetrics(int)
限制某标签的取值数maximumAllowableTags(String, String, int, MeterFilter)
按名字前缀拒绝denyNameStartsWith(String)
给某指标设期望上界(影响直方图桶)maxExpected(String, Duration) / minExpected(String, Duration)

把基数保护写进注册表,比事后在 Grafana 里发现「多了十万条序列」要早得多。一个典型的租户隔离过滤器:

@Bean
MeterFilter tenantCardinalityGuard() {
    return MeterFilter.maximumAllowableTags(
        "loan.borrow", "tenant", 200,
        MeterFilter.deny());   // 超过 200 个租户取值后,新取值直接拒绝注册
}

注意顺序:Config.meterFilter(...) 按调用先后执行,denyUnless 放太前会把后面要用的 Meter 也拦掉。建议顺序是「改名/加标签 → 限基数 → 白/黑名单」。

10.1.6 @Timed / @Counted 与 @Observed 的取舍

三套注解的定位不同,本机 javap 核实的成员如下:

注解关键成员产出
@Timedvalue / extraTags / longTask / percentiles / histogram / serviceLevelObjectives / description仅 Timer 指标
@Countedvalue / recordFailuresOnly / extraTags / description仅 Counter 指标
@Observedname / contextualName / lowCardinalityKeyValues指标 + span(经 ObservedAspect)

@Timed / @Counted 是 Micrometer 的老 API,需要额外注册 TimedAspect / CountedAspect(且依赖 AspectJ 织入),它们只能产出指标,不会产生 span。@Observed 走的是 io.micrometer.observation.aop.ObservedAspect,把方法调用包成一个 Observation,从而同时进指标与追踪。

新代码的建议是明确的:能用 @Observed 就用 @Observed,需要自定义指标名或 SLO 桶时再退到 @Timed。@Observed 生效需要打开注解支持(management.observations.annotations.enabled),并在容器里注册 ObservedAspect bean:

@Configuration
class ObservationConfig {
    @Bean
    ObservedAspect observedAspect(ObservationRegistry registry) {
        return new ObservedAspect(registry);
    }
}

10.1.7 Prometheus 导出与 P99 的算法选择

micrometer-registry-prometheus 1.17.1 的实现类是 io.micrometer.prometheusmetrics.PrometheusMeterRegistry。这里有一个容易踩的包名变化:类从旧包 io.micrometer.prometheus 迁到了 io.micrometer.prometheusmetrics(本机 unzip -l 核实,整个 io/micrometer/prometheusmetrics/ 目录下才有 PrometheusMeterRegistry / PrometheusConfig / PrometheusNamingConvention)。照着旧文章 import io.micrometer.prometheus.PrometheusMeterRegistry 会直接编译失败。

PrometheusMeterRegistry 暴露 scrape() 返回文本、scrape(String contentType)、scrape(OutputStream)(本机核实),Actuator 的 /actuator/prometheus 端点就是把 scrape() 的结果直接写出去。它的构造函数接收 io.prometheus.metrics.model.registry.PrometheusRegistry——即新版 Prometheus client_java 1.x 的模型,不再是旧的 simpleclient。

P99 到底在哪儿算,取决于两个配置,它们对应 DistributionStatisticConfig 的两个开关(本机核实 isPercentileHistogram() / isPublishingPercentiles() / isPublishingHistogram()):

配置生成的指标P99 在哪算能否跨实例聚合
management.metrics.distribution.percentiles.*=0.99xxx_seconds{quantile="0.99"}客户端预先算好不能,多实例平均无意义
management.metrics.distribution.percentiles-histogram.*=truexxx_seconds_bucket{le="..."} 累积桶服务端用 histogram_quantile() 算能,桶可累加

结论:要跨实例看 P99,必须开 percentiles-histogram,让 Prometheus 侧用 histogram_quantile(0.99, ...) 聚合桶;percentiles 只在单实例、且你清楚它不可聚合时才用。桶的边界还可以用 management.metrics.distribution.minimum-expected-value / maximum-expected-value 或 MeterFilter.maxExpected / minExpected 收窄,桶太稀会让 histogram_quantile 的插值误差变大。

由于本机没有 Prometheus 后端,下面这段抓取结果是示例输出:

# 示例输出(本机未部署 Prometheus,非实测)
# TYPE loan_borrow_seconds histogram
loan_borrow_seconds_bucket{result="success",le="0.01"} 812.0
loan_borrow_seconds_bucket{result="success",le="0.05"} 1993.0
loan_borrow_seconds_bucket{result="success",le="0.1"} 2011.0
loan_borrow_seconds_bucket{result="success",le="+Inf"} 2014.0
loan_borrow_seconds_count{result="success"} 2014.0
loan_borrow_seconds_sum{result="success"} 27.418

10.1.8 命名约定与公共标签的拼装顺序

一个指标最终叫什么,是「业务给的名字」经过一串加工后的结果,顺序固定:

原始 name/tags
  → MeterFilter.map(Meter.Id)          // 你的改名、加标签
  → MeterRegistry.Config.commonTags    // 全局公共标签(如 application、region)
  → NamingConvention.name/tagKey       // 后端命名约定(Prometheus 把点换成下划线)
  → 后端存储

两个常被忽略的点:

  1. 公共标签既是「维度」也是「成本」。 management.metrics.tags.region=cn-east 会给每一条序列都加一个 region 标签。它不会让序列数翻倍,但会让每个样本多一个键值对——真正放大序列数的是高基数的公共标签,比如把 instance 设成 Pod 名。
  2. 命名约定在后端侧,不在业务侧。 Micrometer 里名字可以带点(loan.borrow),Prometheus 的 PrometheusNamingConvention 会把它转成 loan_borrow,并对 Timer 追加 _seconds 单位后缀。所以「为什么 Grafana 里查不到 loan.borrow」的答案往往是:查的时候要用下划线名,且 Timer 要带单位后缀。

10.1.9 指标契约的常见坑清单

现象根因处理
指标值是 0 或 NaNGauge 观测对象被 GC把被观测对象做成 bean 字段/集合元素
序列数突然暴涨高基数维度进了低基数标签迁到 highCardinalityKeyValue 或加 maximumAllowableTags
编译找不到 PrometheusMeterRegistry用了旧包名新包是 io.micrometer.prometheusmetrics
多实例 P99 明显失真用了客户端 percentiles改开 percentiles-histogram 走服务端聚合
某注解不生效@Timed/@Counted 没注册 Aspect,或 @Observed 没注册 ObservedAspect补 bean,或改用 @Observed

10.1.10 知道之后能做什么

自己列一遍注册表。 在测试里注入 MeterRegistry,跑一遍借书流程后断言 registry.getMeters() 里的名字与标签,把「指标契约」写成测试——比事后在监控里发现标签拼错要早得多。

用 MeterFilter 兜住基数。 给多租户指标加 maximumAllowableTags(..., deny()),并配 maximumAllowableMetrics(...) 做总量上限,防止一个坏版本把注册表撑爆。

验证 Gauge 的弱引用。 故意把被观测对象做成局部变量,跑两次采集,观察 value() 从正常值变成 0——亲眼看到弱引用被回收,比记结论更牢。

在 PrometheusMeterRegistry.scrape() 上下断点。 断点里能直接看到文本输出是怎么由 Meter.Id 经 PrometheusNamingConvention 拼出来的(下划线替换、单位后缀、_total / _bucket 规则)。

小结

  • MeterRegistry 同时是注册表与工厂;查重、命名、过滤在抽象基类,newCounter / newTimer / newGauge 交给后端。
  • Meter.Id 由「名字 + 标签 + 单位 + 描述 + 类型」组成,标签组合不同即不同 Meter,这是基数问题的根源。
  • Counter 是推、Gauge 是拉且持弱引用;Timer / DistributionSummary 支持分位与桶,量纲不同。
  • Observation 用低基数键值进指标、高基数键值只进 span,一条 API 同时喂指标与追踪。
  • MeterFilter 在注册表入口做改名、加标签、限基数,顺序敏感。
  • Prometheus 里 P99 要跨实例聚合,必须用 percentiles-histogram 产桶,而非客户端 percentiles。

下一节把 Observation 的追踪侧接上:span 从哪来、traceId 怎么进日志、采样与传播格式怎么选。

阅读导航:上一节:9.3 原生镜像的构建与权衡 · 下一节:10.2 分布式追踪实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计