最近在给一个订单类微服务搭监控,发现一个很普遍的问题:大家 Spring Boot 项目里 Actuator 端点都开了,Prometheus 数据也能出,但真正要查某个渠道在周末晚上的失败量有没有变高时,翻遍所有指标也找不到答案。原因就是默认那套指标只有总量,没有维度。所谓 Spring Boot + Micrometer 实现多维度指标统计,本质就是把一维计数变成几张可以交叉筛选的表。这篇文章我会从 Spring Boot 自带的 Actuator 能力接上 Micrometer,结合 Prometheus 和 Grafana,把依赖引入、指标设计、标签拆分、抓取查询的完整链路走一遍,适合后端开发、负责可观测性的同学,也适合那些刚把监控从“能看”做到“能定位”的团队。
1. 多维度指标统计:为什么默认监控总是不够用
1.1 一维指标只能回答“有多少”,回答不了“在哪里”
先想清楚一个问题:你做监控,是为了看到数字在跳,还是为了出问题时能快速定位?大多数项目默认的 Actuator 指标,能回答的只是前一句。比如 tomcat.threads.busy 告诉你当前忙线程数,jvm.memory.used 告诉你堆内存用了多少,这些指标当然有用,但它们都不带业务维度。等线上突然出现告警,你只能知道“有问题”,下一步还是得靠日志去猜是哪个接口、哪个调用方、哪个错误码引起的。
多维度指标统计要解决的就是这件事。它的做法很简单:给一个数值指标挂上若干标签。同样一条订单量数据,既可以说“今天有 10 万单”,也可以拆成“华东 APP 成功 4 万单、华东 APP 失败 200 单、华北小程序成功 1 万单……”,这里的区域、渠道、结果就是维度。有了这些维度,你才可能从总曲线里看到局部异常,而不是每天守着一条平缓的“平均数”曲线。
1.2 维度、标签和时间序列:监控数据模型的核心三角
在 Micrometer 的数据模型里,有三层概念需要分清:指标名、维度(Tag)、时间序列。我习惯用一个生活类比:指标是一张销售报表的表头,比如“订单量”;维度是表头下面的筛选项,比如“城市”“商品分类”“支付方式”;每选择一组筛选条件,得到的不是一张总表,而是一条独立的统计线。同样是“订单量”指标,加上 city="上海"、sku="A"、pay="微信" 三个标签,和 city="北京"、sku="B"、pay="支付宝",在监控系统里就是两条完全不同的序列。
之所以强调这个区别,是因为后期写查询时,很多人直接在 PromQL 里用指标名聚合,却忽略了标签条件才是核心。比如 rate() 和 sum by () 组合得不好,可能把完全不相干的时间序列加在一起。理解了“指标名 + 标签集合 = 唯一时间序列”这个公式,多维度统计就通了一半。
1.3 一个真实场景:订单量从“能看”到“能定位”
举一个我实际处理过的订单服务例子。最初只有一个计数器 total_order,出问题的时候,监控面板上一片绿,但业务方说某个渠道的订单失败率突然升高了。因为没有渠道维度的数据,我只能把日志拉出来,用 grep 按渠道筛,花了一个多小时才定位到问题。这不是工具的问题,是当初的指标设计缺陷。
改造后,我把 total 指标拆成带 region、channel、result 三个标签的计数器。同样再遇到类似异常,一条 PromQL 就能秒级看到:sum by (region, channel) (rate(order_create_total{result="fail"}[5m]))。如果再发现某个渠道的曲线异常,直接在 Grafana 下拉切换 region、channel 就能锁定范围。整套改造并不复杂,核心就是写报告时多传几个标签。
2. 依赖引入与端点暴露:最小接入也会掉进的坑
2.1 最小依赖:Spring Boot 2.x 与 3.x 的配置差异
先说最小依赖。以 Maven 为例,如果你的项目是 Spring Boot 2.x,需要加两个东西:一是 spring-boot-starter-actuator,它负责暴露端点,并自带 Micrometer 核心;二是 micrometer-registry-prometheus,它负责把 Micrometer 的指标翻译成 Prometheus 需要的文本格式。Spring Boot 3.x 同样是这两个依赖,只是内部 Micrometer 版本升级了,API 基本兼容。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>Gradle 对应写法就是把 dependencies 换成 implementation。需要注意一点:不要自己额外引 io.micrometer:micrometer-core,除非你想高于 Boot BOM 管理版本自己控制,否则重复引入会造成注册表行为不一致。
为什么必须加 registry?因为 Micrometer 本身是门面接口,它不知道自己要把数据送到哪里。Prometheus registry、Influx registry、Graphite registry 这些都是具体输出实现。只有引入 registry 并触发自动配置,Spring 才会创建 PrometheusMeterRegistry 类型的 Bean,并把 /actuator/prometheus 端点绑定到它身上。很多新手只加了 actuator,发现端点 404,原因就出在这里。
2.2 Actuator 端点暴露:prometheus 指标为什么点了没反应
application.yml 里的配置这样写:
management: endpoints: web: exposure: include: health,info,prometheus endpoint: health: show-details: always这里有个容易踩的细节:Spring Boot 默认只暴露 health 和 info 两个 web 端点。你即使加了 Micrometer 依赖,如果不把它加进 include 列表,/actuator/prometheus 依然返回 404。我之前就见过同事把依赖加了一堆,curl 端点一直空串,半天没反应过来是 include 没写。
另一个细节是 health 端点默认不需要登录,但如果你的应用有全局鉴权拦截器(比如 Spring Security),/actuator/prometheus 也会被拦截。遇到抓取超时,先看一下是不是认证过不去。我通常会给 actuator 路径配置放行规则,或者在 Prometheus 抓取配置里带 Basic Auth 凭据。
如果你用的是低版本 Spring Boot 2.0 / 2.1,部分版本里 management.metrics.export.prometheus 还有额外开关需要手动打开。建议优先用 2.5+ 版本,配置项更统一。
2.3 版本冲突与依赖收敛的排查思路
版本冲突最典型的场景是:在 pom 里手动指定了 micrometer-registry-prometheus 的较新版本,而 Spring Boot BOM 管理的还是旧版。编译没问题,运行后某些方法报 NoSuchMethodError,或者指标类型对不上。原因很简单:自动配置注入的 micrometer-core 还是 BOM 里那一份,而 registry 里调用了新版本才有的方法。
我的建议是不要手动写版本号,让 Spring Boot 父 POM 统一管理。如果确实要升级 Micrometer,就整体升级 Spring Boot 到对应支持的版本,或者把 micrometer-core 和 registry 一起用 dependencyManagement 锁到同一版本。排查时用 mvn dependency:tree 检查依赖树,重点看是否同时存在两个不同版本的 micrometer-core。
还有一个隐藏点:spring-boot-starter-actuator 内部已经依赖 micrometer-core,如果你又手动引了一次,可能出现两个传递版本。表面看着没问题,但 Prometheus registry 导出指标时,某些类型会缺失。稳妥做法是永远只依赖 starter,不要往 pom 里手动塞 micrometer-core。
3. 三类核心指标:HTTP、JVM 与自定义业务指标
3.1 http.server.requests:请求指标的完整链路与常见误解
http.server.requests 是 Spring Boot 自动注册的 HTTP 请求指标,几乎每个 Spring MVC 应用启动后都会有。它本质是一个 Timer,导出到 Prometheus 后你会看到三组:xxx_count(请求次数)、xxx_sum(累计耗时)、xxx_max(滑动窗口最大耗时),以及开启百分比直方图后的 xxx_bucket。
默认标签主要包括 uri、method、status、outcome、exception。这里的 uri 用的是 Spring MVC 路径模板而不是具体路径,比如 /users/{id},这个处理很友好,因为能避免动态路径把基数撑爆。不过也有例外:如果异常在 filter 层就抛掉了,uri 可能变成 UNKNOWN 这种兜底值,查询时要注意过滤。
很多人有个误解:认为看到这个指标就能直接拿 histogram_quantile 算出 P99。其实默认情况下 Timer 只发布 count、sum、max,如果不手动开启直方图,是没有 bucket 序列的,histogram_quantile 算出来会是空。要开启直方图,在配置里加一行:
management: metrics: distribution: percentiles-histogram: http.server.requests: true开启后会有大量 bucket 序列,存储成本上升,但对排查长尾问题很有价值。如果不想全局开启,也可以对某个具体指标名单独开启,格式是一样的。另外,如果你要自定义额外的 HTTP 维度,我建议不要直接改这个指标,而是用后面的 WebMvcTagsContributor 方案。
3.2 JVM 指标:开箱即用背后的自动配置
JVM 这组指标基本不需要写任何代码。Spring Boot 自动配置会把 JvmMemoryMetrics、JvmGcMetrics、JvmThreadMetrics、JvmClassLoaderMetrics 等绑定到全局 MeterRegistry,你只要访问端点就能看到。常见的有 jvm_memory_used_bytes、jvm_memory_committed_bytes、jvm_memory_max_bytes,它们都带 heap/non-heap 和内存区域两个标签,堆内堆外和 Metaspace 都能分开看。
jvm_gc_pause 这个指标尤其值得关注。它记录了 GC 停顿耗时,带 action、cause 两个标签。用sum by (cause) (rate(jvm_gc_pause_seconds_count[5m]))能看到 GC 次数,用rate(jvm_gc_pause_seconds_sum[5m])能看到停顿时间占比。有一次线上服务偶尔出现几秒卡顿,我靠这个指标快速定位到大对象分配频繁,然后通过调堆和优化缓存解决了问题,比翻 GC 日志省力很多。
需要提醒一句:JVM 指标虽然是自动绑定的,但如果你依赖的注册表不是 Spring 管理的全局 registry,比如自己在某个类里 new 了一个 PrometheusMeterRegistry 并设置成全局,自动配置可能绑定到别处去,端点页面里就没有 JVM 指标。这类问题我放在第 6 章的排查链路里详细说。
3.3 自定义业务指标:Counter、Gauge、Timer、DistributionSummary 的选型逻辑
自定义业务指标,先判断数值类型,再选 Meter 类型,别一上来就无脑用 Counter。Micrometer 的模型里,Counter 是只增不减的计数器,适合订单量、访问次数、失败次数;Gauge 是当前瞬时值,可增可减,适合队列长度、活跃连接数、缓存命中数;Timer 针对耗时,内部包含计数和总耗时,还可以生成分位数;DistributionSummary 面向“大小”,比如请求包体大小、结算金额。
Counter 的代码非常简单:
Counter.builder("order.create") .tags("channel", channel, "region", region, "result", result) .register(registry) .increment();Gauge 我建议绑定实时值来源,而不是维护一个“手动 set 的值”然后宣称它是 Gauge。一个常见姿势是配合 AtomicInteger:
AtomicInteger pendingCount = new AtomicInteger(0); Gauge.builder("order.pending.count", pendingCount, AtomicInteger::get) .register(registry); // 业务侧只需要 pendingCount.incrementAndGet(); pendingCount.decrementAndGet();Timer 有两个 API 风格。一是手动启停 Sample:
Timer.Sample sample = Timer.start(registry); try { // 执行业务 } finally { sample.stop(Timer.builder("order.process.time") .publishPercentiles(0.5, 0.95, 0.99) .register(registry)); }二是使用 @Timed 注解,但它要生效,通常需要注册 TimedAspect Bean,而且注解方式不方便加业务标签,动态标签还是要用后面的自定义方案。选型逻辑总结成一句话:会重置的就别用 Counter,瞬时值优先 Gauge,带耗时的必须上 Timer,只关心分布不关心耗时的选 Summary。
4. 多维度标签设计实操:决定监控系统上限的关键步骤
4.1 Tag、Label 与高基数的关系:概念先厘清
Micrometer 里叫 Tag,Prometheus 里叫 Label,Grafana 面板上经常直接显示为标签。叫法不同,本质相同:都是 (key, value) 对。你在代码里写 .tag("channel", "app"),导出到 Prometheus 就是 channel="app" 这个 label。
高基数风险是我最想强调的一件事。所谓基数,就是一个标签可能出现多少个不同取值。channel 只有 3 个值是低基数;userId 有百万级取值就是高基数。高基数标签会让时间序列数量成倍膨胀:一个 Counter 本来只有一条序列,挂上 100 万用户的 user_id 标签后,就变成 100 万条序列。Prometheus 抓取一次数据量暴涨,查询时如果不用精确维度过滤,聚合也会变得很慢。
看标签设计是否合理,可以问三个问题:这个标签的取值在未来三年内是否可枚举?如果不可枚举,是否真的必须靠它做报警或聚合?是否可以用日志或链路追踪代替?如果答案都是否,赶紧换掉。还有一个容易被忽略的点:同一个指标上的多个标签是乘法关系。3 个取值 A、2 个取值 B、4 个取值 C,相乘就是 24 条序列。标签数量也不推荐贪多,控制在 5 个以内最稳。
4.2 订单指标的多维拆分:一份可以直接抄的示例
用一个订单创建场景的完整示例来说明。假设需要按渠道(channel:app、mini、h5)、区域(region:east、north、south)、结果(result:success、fail)三个维度统计:
@Service public class OrderMetrics { private final MeterRegistry registry; public OrderMetrics(MeterRegistry registry) { this.registry = registry; } public void recordCreated(String channel, String region, boolean success) { Counter.builder("order.create") .tag("channel", normalize(channel)) .tag("region", normalize(region)) .tag("result", success ? "success" : "fail") .register(registry) .increment(); } private String normalize(String value) { return value == null ? "unknown" : value.trim().toLowerCase(); } }注意几个细节。第一,registry 通过构造器注入,不要每次 new 一个,否则指标会注册到不同的 registry 上,端点里看不到。第二,normalize 方法是为了保证标签值统一,“APP”和“app”在 Prometheus 里会被当成完全不同的两个取值,这一条很容易踩。第三,result 这个维度可以拆成两个独立 Counter,但拆开后想算总订单数就得把两个指标再相加,不如一个指标带标签方便。
顺手整理一张维度设计对照表:
| 场景 | 推荐标签 | 不推荐标签 |
|---|---|---|
| 接口调用统计 | method、status、outcome、uri模板 | 具体路径 /auth/12345 |
| 订单业务 | channel、region、result、payment | userId、orderId |
| 消息消费 | topic、consumerGroup、status | messageId、offset |
| 定时任务 | jobName、executor、status | 单次执行ID |
4.3 动态标签注入:从请求上下文里提取业务维度
如果指标不是业务代码本身创建的,比如想给 http.server.requests 增加额外的渠道维度,Spring Boot 提供了 WebMvcTagsContributor 扩展点。实现接口后,Spring 会把它自动贡献到 HTTP 指标的默认标签上。
@Component public class BizTagsContributor implements WebMvcTagsContributor { @Override public Iterable<Tag> httpRequestTags(WebRequest request, HttpServletRequest httpRequest, Object handler) { return Tags.of("channel", resolveChannel(httpRequest)); } @Override public Iterable<Tag> httpLongRequestTags(WebRequest request, HttpServletRequest httpRequest, Object handler) { return httpRequestTags(request, httpRequest, handler); } private String resolveChannel(HttpServletRequest request) { String channel = request.getHeader("X-Channel"); return channel != null ? channel : "unknown"; } }但我实际用下来,对全局注入非常谨慎。header 里的值不可控,如果网关没做白名单,某个客户端传了随机 channel,标签基数就会失控。我自己的偏好是:HTTP 层指标保留系统默认标签,业务维度的数据在核心业务代码里记录专门的 Counter/Timer,这样业务逻辑重构后指标归属依然清晰。如果你们的网关规范做得很严格,渠道取值固定,用 contributor 确实方便,看团队技术栈取舍。
另外,如果使用 @Timed 注解写接口耗时,需要额外标签时可以用 extraTags 字段,但它是静态的。更灵活的动态标签可以借助 MeterFilter 的 commonTags 机制,给某类指标统一加一组 appName、env 这样的静态标签,非常适合多环境部署。
4.4 标签命名建议与反模式清单
命名方面,Prometheus 官方推荐指标名用小写字母加下划线,但 Micrometer 里习惯用点号写多层级,比如 order.create.duration,导出后自动转成下划线。为了避免自动追加 _total 带来的双重后缀,我建议自定义 Counter 的名字不要以 total 结尾,让 Micrometer 去加。
常见反模式我列一下:标签值包含用户输入、标签名使用驼峰、把不同量纲的东西塞进一个指标、标签多了不加约束、单位不统一。比如有个项目把错误码和耗时放进同一个标签,结果想统计耗时分布时根本没法用,标签的职责要单一。
还有一个经验:多维度指标虽然查询灵活,但 Grafana 面板不会自动把所有维度展示出来,建议在指标设计阶段就把“指标字典”写进文档,记录每个标签的枚举值和含义。三个月后再回来加面板,会感谢当时的这个决定。
5. 采集与查询:把指标接进 Prometheus,再落到 Grafana
5.1 Prometheus 抓取配置:从 endpoint 到 scrape job
Prometheus 侧的配置本身不复杂。假设应用跑在本机 8080 端口,prometheus.yml 里加一个抓取任务:
scrape_configs: - job_name: 'order-service' metrics_path: '/actuator/prometheus' static_configs: - targets: ['localhost:8080']如果你的 Spring Boot 应用设置了 context-path,metrics_path 要带上上下文路径,比如 /myapp/actuator/prometheus。这一步很容易忽略,导致抓取 404。
多实例场景下,我推荐用服务发现代替静态列表。Kubernetes 环境里 Prometheus 官方服务发现机制可以自动找到 Pod;非 K8s 环境用文件发现或 Consul 也行。关键点一致:不要写死 instance,否则多实例扩容后很难按实例维度拆解。
最后说一点安全:actuator 端点务必确认不要暴露到公网。如果一定要开,在网关或安全框架里加鉴权,或者在 Prometheus 侧配置 Basic Auth,否则会泄露内部接口清单和运行数据。
5.2 我常用的几条 PromQL:总量、错误率、分位数、业务下钻
先把几个最常用的 PromQL 列出来,都是我在 Grafana 面板里验证过的写法。
总错误率:
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m]))按状态码和接口分组的 QPS:
sum by (status, uri) (rate(http_server_requests_seconds_count[5m]))接口耗时 P95,前提是开启了直方图:
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket[5m])) by (le, uri))业务维度下钻:
sum by (region, channel) (rate(order_create_total{result="fail"}[5m]))强调一个调参常识:查询里的 [5m] 是 rate 的窗口大小。窗口太短会抖动,太长会掩盖瞬时异常。面板里通常给 1m、5m、15m 几个选项,让查看者自己切换。
看到 _total 后缀的 Counter,rate() 会把单位统一成每秒,这样才是速率;直接用 Counter 原始值画曲线,长周期下看不出毛刺。不要用原始值画累计曲线,排障价值不大。
5.3 Grafana 面板搭建:模板变量与标签下钻的实操技巧
Grafana 侧我建议先建两个基础面板:一个 JVM 内存,一个 HTTP QPS 和错误率。JVM 内存面板用 sum by (area) (jvm_memory_used_bytes),一眼能看到堆内外分布;HTTP 面板用 rate() 带开头的写法。建好后把常用查询做成模板变量,比每次都手写快很多。
模板变量是 Grafana 里很实用的功能。我通常会建一个 $region 变量,查询语句写成 label_values(order_create_total, region),这样面板顶部出现下拉框,点击不同区域,下面的图表自动过滤。同理建 $channel、$result,等于把标签变成了交互筛选器,多维度统计的直观价值就出来了。
还有一个很多人没注意的细节:Grafana 面板的 legend 默认会显示整个标签集,标签过多会把图例刷屏。我只在 legend 里保留想看的维度,比如 {{region}} - {{channel}},配合格式化设置,图例清晰很多。这一个细节能让面板观感完全不同。
6. 实测中遇到的坑与优化建议
6.1 指标“消失”的排查链路:从代码到抓取端
这里分享一次真实排查过程。现象:自定义指标 order_create_total 在 Prometheus 里能看到,但 JVM 指标一直空。排查链路是这样走的:先 curl /actuator/prometheus 检查是否有 jvm_memory_used_bytes 前缀,发现没有;然后看应用日志有没有 Micrometer 相关报错;接着查依赖树,确认是否存在重复 micrometer-core;最后打开源码,找到自动配置里的 CompositeMeterRegistry 相关逻辑。
最终发现:项目里因为历史原因引入了另一套 metrics 库,它把 Prometheus 注册表替换成了自定义注册表,JVM 指标绑定到了 CompositeMeterRegistry 的另一个委托上,而 /actuator/prometheus 端点绑定的是当前激活的那个 PrometheusMeterRegistry,两边不是同一个注册表。解决方法是统一用一个全局注册表,并清理掉多余的 metrics 依赖。
这个案例想表达的核心是:指标“消失”十有八九不是写错代码,而是注册表对象不一致。你手动 new 一个 PrometheusMeterRegistry,又从 Spring 容器里拿到另一个,代码里无论怎么 register 都不会出现在端点上。排查时一定优先确认 Spring 容器里的 MeterRegistry Bean 和你代码里用的是不是同一个。
同理,Gauge 没有出现时,还要检查绑定的实时函数是否存在异常。如果函数抛异常或者返回 null,指标也可能直接不输出。
6.2 指标采集对性能的影响:实测感受与配置取舍
性能开销方面,我自己做过一个小实验:在一个高频接口里加一个 Counter,每秒调用几千次,CPU 增加几乎可以忽略。Micrometer 的计数器本质是原子计数器和 map 查找,Prometheus 抓取时也只是快照,不会反复消耗业务线程。
真正有明显开销的是直方图分位数。如果用 publishPercentiles 让 Micrometer 在服务端计算 P50/P95/P99,每个标签组合都会维护一组预分布分位数,内存占用会随标签组合数量线性上涨。所以我的建议是:生产环境优先用 percentiles-histogram,让 Prometheus 端 histogram_quantile 计算,不要在应用内做分位数聚合。
另一个开销点是标签数量对抓取的影响。一次抓取数据量如果超过几 MB,业务接口可能被 scrape 拖慢。必要时可以把抓取间隔从 15 秒调到 30 秒或 60 秒,前提是告警也能接受这个延迟。
6.3 后续扩展方向:多实例聚合、链路指标与告警
到这一步,你的监控已经能从“有没有问题”进化到“哪里有问题”。再往后可以扩展几层:一是把 job、instance 标签纳入聚合,支持按实例维度对比,定位单点故障;二是把业务指标和日志、链路追踪打通,比如在 Trace 里带同一个订单号,遇到指标异常时直接跳到对应日志;三是基于多维度指标设计告警规则,比如“某渠道失败率 5 分钟内超过 1%”,依靠标签精准命中,而不是全局笼统告警。
技术选型上,如果团队已经在用 OpenTelemetry,可以考虑把 Micrometer 作为 OTel Metric SDK 的输入源,统一协议后再走 Prometheus 或云厂商托管时序库。不过这是后话,不建议在项目初期就把链路铺得太复杂,先把本服务的指标体系和维度设计做好。
最后再分享一个让我印象很深的细节。我们曾经在复盘一个线上问题时,发现指标曲线在事发前 20 分钟就出现了一个非常细微的异常抖动,当时如果有人盯着按渠道拆分的失败率曲线,完全能提前预警。真正把我们救下来的,还是多维度这套体系,而它当初只花了一个下午就接完。希望这篇实战记录也能帮你少走弯路,把监控做成真正能定位问题的工具,而不是只有一张总览图。