本节目标:把
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 核实的成员如下:
| 注解 | 关键成员 | 产出 |
|---|---|---|
@Timed | value / extraTags / longTask / percentiles / histogram / serviceLevelObjectives / description | 仅 Timer 指标 |
@Counted | value / recordFailuresOnly / extraTags / description | 仅 Counter 指标 |
@Observed | name / 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.99 | xxx_seconds{quantile="0.99"} | 客户端预先算好 | 不能,多实例平均无意义 |
management.metrics.distribution.percentiles-histogram.*=true | xxx_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 把点换成下划线)
→ 后端存储
两个常被忽略的点:
- 公共标签既是「维度」也是「成本」。
management.metrics.tags.region=cn-east会给每一条序列都加一个region标签。它不会让序列数翻倍,但会让每个样本多一个键值对——真正放大序列数的是高基数的公共标签,比如把instance设成 Pod 名。 - 命名约定在后端侧,不在业务侧。 Micrometer 里名字可以带点(
loan.borrow),Prometheus 的PrometheusNamingConvention会把它转成loan_borrow,并对 Timer 追加_seconds单位后缀。所以「为什么 Grafana 里查不到loan.borrow」的答案往往是:查的时候要用下划线名,且 Timer 要带单位后缀。
10.1.9 指标契约的常见坑清单
| 现象 | 根因 | 处理 |
|---|---|---|
| 指标值是 0 或 NaN | Gauge 观测对象被 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 分布式追踪实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。