1. 为什么需要 Micrometer
在微服务和云原生时代,应用的运行状态已经不能只靠「服务是否在线」来判断。接口有没有变慢、线程池是否被打满、缓存命中率是否下降、GC 是否频繁、下游依赖是否抖动,这些问题都需要通过可量化的指标来持续观测。传统的日志只能告诉我们「发生了什么」,而度量指标能告诉我们「发生了什么变化,以及变化的趋势是什么」。
问题在于,度量生态非常碎片化。Prometheus、Datadog、Graphite、InfluxDB、CloudWatch、New Relic 等监控后端各自拥有不同的数据模型、命名习惯和推送拉取协议。如果开发者直接把某种监控系统的客户端 SDK 嵌入业务代码,未来切换监控后端时就要改动大量埋点代码。Micrometer 的出现正是为了解决这种厂商锁定问题。
Micrometer 是 JVM 平台上一个供应商中立的指标门面(Metrics Facade),它提供了一套统一的应用度量 API。开发者只依赖 Micrometer 这一套 API 进行埋点,通过切换不同的 Registry 实现,就能把同一份指标输出到 Prometheus、Datadog、Elastic、Graphite 等不同的监控系统。它的定位类似于日志领域的 SLF4J:一个统一的抽象层,而不是具体的实现。
在 Spring Boot 2.0 之后,Micrometer 成为 Spring Boot 官方的默认度量方案,全面取代了此前的 Dropwizard Metrics。Actuator 暴露的众多指标,例如 JVM 内存、GC、HTTP 请求、数据库连接池等,底层都是通过 Micrometer 采集和聚合的。因此,对于任何基于 Spring Boot 的 JVM 应用来说,理解 Micrometer 是构建可观测性体系绕不开的一环。
一句话概括:Micrometer 让业务代码与监控后端解耦,让我们一次埋点、多处输出,是整个 JVM 可观测性体系的度量层基石。
2. Micrometer 的核心概念与整体架构
要想用好 Micrometer,首先要理解它的几个核心抽象。Micrometer 的模型并不复杂,围绕「指标」「标签」「注册表」和「过滤器」四个概念展开。
2.1 Meter:指标
Meter 是 Micrometer 中最基础的度量单元,表示一个可以被采样的数值。它可能是一个不断累加的计数器,也可能是一个实时波动的量规。Micrometer 内置了多种 Meter 类型,每种类型都有清晰的语义:
Counter:单调递增的计数器,只能增加,不能减少。
Gauge:反映某个瞬时值,可以上升也可以下降,例如集合大小、队列长度。
Timer:用于统计耗时和调用次数,适合记录接口响应时间。
DistributionSummary:用于统计数值型数据的分布,例如请求体大小、消息字节数。
LongTaskTimer:用于统计仍在进行中的长任务的持续时间和活跃数量。
FunctionCounter / FunctionTimer:基于函数回调的计数器与计时器,适合监控已有对象中的可观测字段。
2.2 Tag:标签
标签是由键值对组成的维度信息。通过标签,我们可以把同一个指标按照不同维度拆分。例如记录 HTTP 请求数时,可以用uri、method、status作为标签,从而分别统计「/order 接口的 200 请求数」「/payment 接口的 500 请求数」等。
标签是把一维指标扩展为多维指标的关键。但必须注意,标签的基数(Cardinality)不能无限膨胀,否则会造成时间序列爆炸和内存激增。标签的值应当是低基数、有限的,例如枚举状态的名称,而不应该是用户 ID、订单号、手机号这类高基数信息。
2.3 MeterRegistry:注册表
MeterRegistry 是 Meter 的容器,也是 Micrometer 的核心接口。每个 Meter 都会注册到一个 Registry 中,Registry 负责保存指标定义、维护指标的最新状态,并在监控后端拉取或者定时推送时把指标导出。
一个应用中通常会使用CompositeMeterRegistry来聚合多个后端注册表。这样同一个 Meter 可以同时发送到 Prometheus 和 Datadog,而不需要重复埋点。
2.4 MeterFilter:过滤器
MeterFilter 用于对注册的 Meter 进行筛选和加工。它可以在指标注册时拒绝某些不合格的 Meter,也可以统一改写标签、添加公共前缀、限制最大标签基数等。MeterFilter 是实现团队埋点规范的重要工具,例如通过过滤器把所有业务指标统一加上app和env标签。
2.5 整体执行链路
一次典型的指标记录过程如下:业务代码通过 Micrometer API 获取或创建一个 Meter;每次记录数据时,内部的聚合器会更新该 Meter 对应的统计值;监控系统在固定时间窗口内拉取数据时,Registry 会把所有 Meter 导出为对应后端需要的格式;MeterFilter 在注册和导出阶段介入,完成标签加工和指标过滤。
下面这张架构示意以 Mermaid 代码块形式给出,便于在支持 Mermaid 的文档中渲染:
3. 快速上手:第一个 Micrometer 示例
先从最小示例开始建立直观感受。在纯 Java 环境中,引入 Micrometer 核心依赖和某个 Registry 实现后,就可以创建一个计数器并输出指标。
以 Maven 为例,核心依赖如下。这里使用 Micrometer 1.x 版本,实际开发中建议与 Spring Boot 依赖管理的版本保持一致。
xml
<dependencies> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-core</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> </dependencies>
下面是使用 Prometheus Registry 记录一个计数器的完整代码:
java
import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; public class MicrometerFirstDemo { public static void main(String[] args) { MeterRegistry registry = new SimpleMeterRegistry(); Counter orders = Counter.builder("orders.created") .description("订单创建数量") .tag("channel", "app") .register(registry); orders.increment(); orders.increment(2); double count = registry.get("orders.created") .tag("channel", "app") .counter() .count(); System.out.println("当前累计订单创建数:" + count); } }这个示例展示了 Micrometer 最典型的用法:通过Counter.builder构建指标,通过tag增加维度,通过registry注册,最后通过registry.get查找指标。虽然这里用了SimpleMeterRegistry,但只要把它替换成PrometheusMeterRegistry或CompositeMeterRegistry,业务埋点代码完全不需要变化,这就是门面抽象的核心价值。
在真实项目中,我们很少手动创建 Registry,Spring Boot 会自动装配。手动构建 Registry 的写法更多出现在单元测试和脱离 Spring 容器的小工具中。了解底层 API 有助于理解 Spring Boot 自动配置背后发生的事情。
4. Meter 类型详解
Micrometer 的六类 Meter 覆盖了绝大多数度量场景。理解每种类型的使用时机和导出形态,是正确埋点的前提。
4.1 Counter:单调递增计数器
Counter 用于统计只增不减的事件总量,例如请求总数、错误总数、消息消费总数、任务完成总数。调用increment()增加 1,也可以传入数值增加指定数量,但数值必须是非负数。
Counter 只保留累加值本身,不关心事件的发生时间间隔。如果要计算单位时间内的速率,通常由监控后端基于相邻采样值的差值计算,例如 Prometheus 中的rate()函数。
推荐使用 Builder 风格创建 Counter,语义更清晰:
java
Counter failedLogins = Counter.builder("auth.login.failed") .description("登录失败次数") .baseUnit("times") .tags("region", "cn", "service", "user-service") .register(registry); // 业务逻辑中 if (!password.matches(hashed)) { failedLogins.increment(); }注意事项:不要把当前正在变化的总量用 Counter 表达。例如「当前在线用户数」可能增加也可能减少,应该用 Gauge;「累计注册用户数」只增不减,才适合用 Counter。
4.2 Gauge:瞬时值量规
Gauge 用于读取某个瞬时值,值可以上升也可以下降。经典应用场景包括:线程池当前活跃线程数、阻塞队列长度、JVM 内存使用量、缓存条目数量、连接池当前连接数。
Gauge 本身不维护值,它只是对一个被观测对象某个时刻的快照。创建 Gauge 时需要提供一个返回数字的函数或方法引用:
java
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; BlockingQueue<String> queue = new ArrayBlockingQueue<>(1000); queue.offer("task-1"); Gauge.builder("queue.size", queue, BlockingQueue::size) .description("阻塞队列当前长度") .tag("queue.name", "order-handler") .register(registry);上面的代码中,BlockingQueue::size会在每次采样时被调用,获取实时队列长度。这种绑定对象的写法非常常用,但要注意被观测对象必须存活足够长的时间。如果对象被 GC 回收,Gauge 将无法继续观测。
对于简单数值对象,Micrometer 也提供了Gauge.builder(name, supplier, valueFunction)的重载,以及对AtomicInteger、AtomicLong等标准类型的直接支持。
Gauge 不适合记录事件发生的次数,也不适合记录某个事件的耗时。如果我们发现同一个逻辑既需要「当前值」也需要「累计次数」,应该同时使用 Gauge 和 Counter 两个 Meter,而不是混用语义。
4.3 Timer:耗时统计的利器
Timer 是生产环境使用频率最高的 Meter 之一。它同时记录两个维度的信息:调用次数和总耗时。基于这两个值,Timer 可以提供平均值、最大值、百分位数以及由后端计算的速率。Timer 内部维护一个直方图,具体精度取决于 Registry 的实现。
Timer 的常见使用方式有三种。
第一种是手动记录耗时:
java
Timer requestTimer = Timer.builder("http.server.requests") .tag("uri", "/api/order") .tag("method", "POST") .publishPercentiles(0.5, 0.95, 0.99) .register(registry); long start = System.nanoTime(); try { handleOrder(request); } finally { requestTimer.record(System.nanoTime() - start, TimeUnit.NANOSECONDS); }第二种是使用record包裹 Runnable 或 Callable:
java
Timer taskTimer = registry.timer("task.execute.duration", "type", "export"); taskTimer.record(() -> exportData(data));第三种是使用Timer.Sample记录多个阶段耗时,适合统计一次请求中不同明细阶段所花费的时间:
java
Timer.Sample sample = Timer.start(registry); // 第一阶段:查询数据库 Order order = orderRepository.findById(orderId); // 第二阶段:调用外部服务 Coupon coupon = couponClient.query(order.getUserId()); // 结束采样并记录总耗时 sample.stop(Timer.builder("order.flow.duration") .tag("channel", "web") .register(registry));百分位数的计算成本不可忽视。像publishPercentiles这类特性在 Prometheus Registry 下并不意味着可以在任意时间窗口精确计算,Prometheus 更推荐通过histogram_quantile结合 bucket 进行计算。Spring Boot 默认开启的distributionStatisticExpiry和分位数发布参数也需要根据流量规模合理配置。
4.4 DistributionSummary:数值分布统计
DistributionSummary 与 Timer 非常相似,都维护计数、总和和分布信息。区别在于 Timer 记录的是耗时,单位是时间;DistributionSummary 记录的是任意数值,例如请求体字节数、消息大小、订单金额、批量任务条数。
java
DistributionSummary messageSizeSummary = DistributionSummary.builder("mq.message.size") .description("消息体大小分布") .baseUnit("bytes") .publishPercentiles(0.5, 0.95, 0.99) .register(registry); messageSizeSummary.record(message.getBytes().length);对于金额这类不需要百分位数、只关心总量的场景,有时直接用 Counter 记录累计值即可。DistributionSummary 的价值在于分析分布形态,适合回答「多数消息有多大、极端大消息的边界在哪里」这类问题。
4.5 LongTaskTimer:长任务计时器
普通 Timer 只有在任务结束后才会记录耗时,无法观测正在运行中的长任务。对于批处理任务、定时作业、大型报表生成等可能持续几分钟甚至几小时的场景,LongTaskTimer 可以在任务执行过程中持续暴露当前已运行时长和活跃任务数。
java
LongTaskTimer exportTimer = LongTaskTimer.builder("report.export.active") .tag("report.type", "sales") .register(registry); LongTaskTimer.Sample sample = exportTimer.start(); try { generateSalesReport(); } finally { sample.stop(); }输出到 Prometheus 时,LongTaskTimer 会生成report_export_active_seconds_active_count、_duration_sum、_duration_max等时间序列。活跃任务数量可以用于告警,比如「报表导出任务超过 10 个时通知值班同学」。而任务的平均耗时、最大值则用于性能分析。
4.6 FunctionCounter 与 FunctionTimer
很多被监控的资源不是我们自己维护的对象,例如第三方库的线程池、连接池。它们内部通常已经维护了某些统计字段,但没有直接暴露 Micrometer API。FunctionCounter 和 FunctionTimer 允许我们通过函数引用,把已有对象的字段映射成指标。
FunctionCounter 用于单调递增字段:
java
ThreadPoolExecutor executor = buildExecutor(); FunctionCounter.builder("worker.tasks.completed", executor, ThreadPoolExecutor::getCompletedTaskCount) .register(registry);FunctionTimer 用于包含「计数 + 总额」两个字段的对象,比如某些连接池同时维护了请求次数和总耗时:
java
PoolStats poolStats = dataSource.getPoolStats(); FunctionTimer.builder("druid.pool.query", poolStats, ps -> ps.getQueryCount(), ps -> ps.getQueryTotalMillis(), TimeUnit.MILLISECONDS) .register(registry);FunctionTimer 和 FunctionCounter 都是「读取型」指标,Micrometer 不会修改被观测对象的状态,只会在采样时调用传入的函数。这种解耦能力让它可以无缝监控各种非 Micrometer 原生资源。
5. 命名规范与标签设计
指标设计中最容易出错的地方往往不在 API 调用,而在于命名和标签。一个随意命名的指标会让团队在几个月后完全看不懂,一个高基数标签则可能直接把监控系统打爆。因此,在写代码之前就应该约定清楚规则。
5.1 指标命名规范
Micrometer 官方推荐使用「点号分隔的小写单词」作为指标名,单词之间用点连接,不允许使用空格、斜杠、下划线混用等方式。例如http.server.requests、jvm.memory.used、db.connection.active,读起来就是一条清晰的语义路径,从系统到模块再到具体含义。
命名时建议遵循以下规则:
使用小写单词组合,从大到小排列,例如先写
auth.login.failed,不要写成failedLoginAuth或auth_login_failed。尽量避免无意义的冗余词,例如
metrics.count、system.info.value这类命名没有提供额外信息。指标名要能回答「这个数字代表什么」,而不是描述实现细节。优先写
http.server.requests,而不是servlet.doGet.count。带上合适的单位语义,例如耗时类指标使用
duration、字节数使用bytes、次数使用total或count。
下面是一个命名比较完整、可读性较好的示例:
java
Counter loginFailedCounter = Counter.builder("auth.login.failed") .description("登录失败次数,按登录方式和区域拆分") .baseUnit("times") .tag("method", "password") .tag("region", "cn") .register(registry); Timer orderCreateTimer = Timer.builder("order.create.duration") .description("订单创建接口耗时") .tag("channel", "app") .publishPercentiles(0.5, 0.95, 0.99) .register(registry);需要注意的是,不同的监控后端会根据自己的命名规范对指标名做转换。例如 Prometheus 的NamingConvention会把点号替换为下划线,http.server.requests最终可能变成http_server_requests_seconds_count这类形态。我们不需要手动适配每种后端,但应知道指标名在后端展示时可能与 Java 代码中的名称不完全一致。
5.2 标签设计规范
标签的作用是给同一个指标补充维度,但维度不是越多越好。标签设计的核心原则是:只使用低基数、高价值的维度。低基数意味着标签取值的数量是有限的、可控的,例如环境、机房、服务名、状态码、业务类型。
高基数标签是监控系统最常见的事故来源之一。如果给每个请求都打上userId、orderId、requestId、phone这类标签,随着业务量增长,时间序列数量会急剧膨胀,最终拖垮 Prometheus、Datadog 等后端的存储和查询。
实际落标时可以参考以下规则:
可以做标签的:
service、env、region、method、status、channel、cache.name等枚举值。不要做标签的:
userId、orderNo、phone、ip、requestId等随请求无限增长的值。日志里的事情不要塞进指标:如果需要定位单个用户或单笔订单的详情,应该去查日志和链路追踪,而不是把明细维度打进指标系统。
对于 HTTP 请求这类场景,也不建议直接使用原始 URI 作为标签值,因为/order/12345这样的路径会把每个订单号都变成一个时间序列。正确做法是记录路径模板,例如/order/{id},让同一类接口共享同一组指标。
5.3 使用 MeterFilter 统一规范
规范不能只靠口头约定,最好通过MeterFilter把公共标签、命名约束和过滤策略落到代码中。这样即使团队成员在埋点时漏写了标签,Registry 也会在注册阶段自动补齐。
下面的配置统一给所有指标加上公共标签,并拒绝一些不需要暴露的内部指标:
java
registry.config() .commonTags("app", "order-service", "env", "production", "region", "cn") .meterFilter(MeterFilter.denyNameStartsWith("jvm.")) .meterFilter(MeterFilter.maximumAllowableTags("http.server.requests", "uri", 20));commonTags用于添加所有指标共享的维度,是减少重复代码、保证标签一致性的最有效手段。MeterFilter.denyNameStartsWith用于在注册阶段直接拒绝某个前缀的指标,避免无用的指标进入监控系统。MeterFilter.maximumAllowableTags可以给某个指标的最大标签数量设限,防止不小心引入过高基数的维度。
5.4 埋点前检查清单
在实际开发中,可以在提交埋点代码前用下面几条快速自检:
指标名是否小写、点号分隔、语义清晰?
指标类型是否匹配业务含义,Counter、Gauge、Timer 是否用对了?
标签值是否存在高基数风险,例如用户 ID、订单号、完整 URI?
公共标签是否已经通过
commonTags统一补充?是否给指标添加了必要的
description和baseUnit?