Spring Boot Actuator + Micrometer 实战:自定义业务指标并接入 Prometheus 观测接口耗时

Spring Boot Actuator + Micrometer 实战:自定义业务指标并接入 Prometheus 观测接口耗时

摘要:很多 Spring Boot 项目接入 Actuator 后,只停留在 /actuator/health 和 JVM 默认指标,真正排查接口慢、异常升高、队列积压时仍然缺少业务视角。本文用一个最小 Spring Boot 3.x demo 演示如何通过 Micrometer 注册 CounterTimerGauge,再通过 Prometheus 抓取 /actuator/prometheus,用 PromQL 观察接口耗时、异常数和业务积压。适合 Java 后端、Spring Boot 开发者,以及准备补齐可观测性基础能力的团队参考。

验证状态:本文示例基于 Spring Boot Actuator、Micrometer Prometheus Registry、Prometheus scrape 配置的官方常见用法整理;代码是可本地复现的 demo 骨架。具体 Spring Boot / Micrometer 版本、Prometheus 部署方式和 Grafana 面板请以项目实际依赖基线为准。

1. 为什么只接 health 还不够

很多项目的 Actuator 接入非常简单:

复制代码
/actuator/health      看服务是否存活
/actuator/info        看应用基础信息
/actuator/metrics     看 JVM、HTTP、线程池等默认指标

这些指标有价值,但它们主要回答"服务活没活、资源有没有异常"。当接口变慢或者业务失败率升高时,只看这些默认指标还不够。

举个订单接口的例子。线上出现下面的现象:

这时就需要把业务过程也变成指标。Actuator 负责暴露端点,Micrometer 负责在代码里注册指标,Prometheus 负责定时抓取和查询。

一个更实用的观测链路是:

复制代码
flowchart LR
    A[Spring Boot 应用] --> B[Micrometer MeterRegistry]
    B --> C[/actuator/prometheus]
    C --> D[Prometheus Scrape]
    D --> E[PromQL 查询]
    E --> F[Grafana 面板 / 告警]

本文的目标不是搭一套复杂监控平台,而是先把"业务指标怎么从 Java 代码里稳定暴露出来"讲清楚。

2. 示例环境和依赖

本文示例环境如下:

组件 示例版本 / 说明
JDK 17+
Spring Boot 3.x
Micrometer Spring Boot Actuator 默认集成
指标导出 Prometheus 格式
验证方式 curl + Prometheus 本地 scrape

Maven 依赖:

复制代码
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

application.yml 开启 Prometheus endpoint:

复制代码
server:
  port: 8080

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: order-service-demo

启动后可以先检查端点是否正常:

复制代码
curl http://localhost:8080/actuator/health
curl http://localhost:8080/actuator/prometheus | head

如果第二个命令能看到类似 jvm_memory_used_byteshttp_server_requests_seconds_count 的文本,说明 Prometheus 格式指标已经暴露出来。

3. 先设计指标,不要一上来就写代码

业务指标最容易踩的坑,是把所有字段都塞进 tag。这样短期查询很方便,长期会把 Prometheus 的时间序列数量打爆。

本文用订单创建接口做例子,设计三类指标:

指标 Micrometer 类型 解决的问题 建议 tag
order_create_total Counter 订单创建请求总数 channel, result
order_create_seconds Timer 订单创建接口耗时 channel, result
order_queue_depth Gauge 当前待处理队列积压 queue
order_create_exception_total Counter 创建订单异常数 exception, stage

这里有一个关键原则:

复制代码
tag 只能放低基数字段。

适合做 tag 的字段:

  • channel=app/h5/openapi
  • result=success/fail
  • stage=validate/inventory/payment
  • queue=create-order

不适合做 tag 的字段:

  • userId
  • orderId
  • traceId
  • phone
  • requestBody

这些高基数字段应该进日志、链路追踪或明细存储,不应该进 Prometheus 指标标签。

4. 用 MeterRegistry 注册 Counter、Timer、Gauge

先写一个专门的指标组件,把指标注册和业务代码隔离开。

java 复制代码
`package com.example.metrics;

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Component
public class OrderMetrics {

    private final MeterRegistry registry;
    private final ConcurrentMap<String, AtomicInteger> queueDepthMap = new ConcurrentHashMap<>();

    public OrderMetrics(MeterRegistry registry) {
        this.registry = registry;
        registerQueueGauge("create-order");
    }

    public void increaseCreateCount(String channel, String result) {
        Counter.builder("order_create_total")
                .description("Total count of order create requests")
                .tag("channel", channel)
                .tag("result", result)
                .register(registry)
                .increment();
    }

    public void recordCreateCost(String channel, String result, long costMillis) {
        Timer.builder("order_create_seconds")
                .description("Order create latency")
                .tag("channel", channel)
                .tag("result", result)
                .publishPercentileHistogram()
                .register(registry)
                .record(costMillis, TimeUnit.MILLISECONDS);
    }

    public void increaseException(String stage, String exception) {
        Counter.builder("order_create_exception_total")
                .description("Order create exception count")
                .tag("stage", stage)
                .tag("exception", exception)
                .register(registry)
                .increment();
    }

    public void setQueueDepth(String queueName, int depth) {
        queueDepthMap.computeIfAbsent(queueName, this::registerQueueGauge).set(depth);
    }

    private AtomicInteger registerQueueGauge(String queueName) {
        AtomicInteger value = new AtomicInteger(0);
        Gauge.builder("order_queue_depth", value, AtomicInteger::get)
                .description("Current order queue depth")
                .tag("queue", queueName)
                .register(registry);
        return value;
    }
}
`

这里有几个细节值得注意。

第一,Counter 只能递增,适合记录请求数、成功数、失败数、异常数。不要用 Counter 表示当前库存、当前队列长度这类可升可降的值。

第二,接口耗时优先用 Timer,不要自己维护一个"平均耗时 Gauge"。Timer 会同时记录 count、sum、max,也可以配合 histogram 做分位数查询。

第三,Gauge 要持有被观测对象的引用。上面代码用 ConcurrentMap<String, AtomicInteger> 保存 AtomicInteger,避免对象被 GC 后 Gauge 读不到值。

5. 在接口里埋点:用 try/finally 保证耗时记录

接下来写一个简单的 Controller,模拟订单创建接口。

java 复制代码
`package com.example.web;

import com.example.metrics.OrderMetrics;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderMetrics orderMetrics;

    public OrderController(OrderMetrics orderMetrics) {
        this.orderMetrics = orderMetrics;
    }

    @PostMapping("/create")
    public Map<String, Object> create(@RequestHeader(value = "X-Channel", defaultValue = "app") String channel) {
        long start = System.nanoTime();
        String result = "success";

        try {
            simulateValidate();
            simulateInventory();
            simulatePayment();

            orderMetrics.increaseCreateCount(channel, result);
            return Map.of("success", true, "message", "created");
        } catch (IllegalArgumentException ex) {
            result = "fail";
            orderMetrics.increaseException("validate", ex.getClass().getSimpleName());
            orderMetrics.increaseCreateCount(channel, result);
            return Map.of("success", false, "message", ex.getMessage());
        } catch (Exception ex) {
            result = "fail";
            orderMetrics.increaseException("unknown", ex.getClass().getSimpleName());
            orderMetrics.increaseCreateCount(channel, result);
            return Map.of("success", false, "message", "internal error");
        } finally {
            long costMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
            orderMetrics.recordCreateCost(channel, result, costMillis);
        }
    }

    private void simulateValidate() {
        if (ThreadLocalRandom.current().nextInt(100) < 3) {
            throw new IllegalArgumentException("invalid order");
        }
    }

    private void simulateInventory() throws InterruptedException {
        Thread.sleep(ThreadLocalRandom.current().nextInt(20, 80));
    }

    private void simulatePayment() throws InterruptedException {
        Thread.sleep(ThreadLocalRandom.current().nextInt(30, 200));
    }
}
`

上面代码省略了 java.util.concurrent.TimeUnit 的 import,实际使用时记得补上:

java 复制代码
`import java.util.concurrent.TimeUnit;
`

埋点建议遵守三条规则:

  1. 耗时记录放在 finally,避免异常场景漏记。
  2. 异常计数要带上阶段,例如 validateinventorypayment,不要只记一个总异常数。
  3. tag 值要收敛,最好用枚举或白名单,不要直接把外部请求参数原样打到 tag。

6. 本地验证指标是否暴露

启动应用后,先请求几次接口:

复制代码
for i in $(seq 1 20); do
  curl -s -X POST http://localhost:8080/orders/create \
    -H 'X-Channel: app' \
    -o /dev/null
  echo "request $i done"
done

再查看 Prometheus endpoint:

复制代码
curl -s http://localhost:8080/actuator/prometheus | grep 'order_create'

你应该能看到类似下面的输出:

复制代码
# HELP order_create_total Total count of order create requests
# TYPE order_create_total counter
order_create_total{application="order-service-demo",channel="app",result="success",} 19.0
order_create_total{application="order-service-demo",channel="app",result="fail",} 1.0

# HELP order_create_seconds Order create latency
# TYPE order_create_seconds summary
order_create_seconds_count{application="order-service-demo",channel="app",result="success",} 19.0
order_create_seconds_sum{application="order-service-demo",channel="app",result="success",} 2.43

注意:不同 Micrometer / Prometheus registry 版本对 histogram、summary、bucket 暴露形式可能略有差异。重点不是逐字匹配示例输出,而是确认业务指标已经出现在 /actuator/prometheus 中。

7. 配置 Prometheus 抓取 Spring Boot 应用

本地 Prometheus 最小配置可以这样写:

复制代码
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: "order-service-demo"
    metrics_path: "/actuator/prometheus"
    static_configs:
      - targets: ["host.docker.internal:8080"]

如果 Prometheus 直接跑在宿主机,可以把 target 改成:

复制代码
- targets: ["localhost:8080"]

Docker 启动示例:

复制代码
docker run --rm \
  -p 9090:9090 \
  -v "$PWD/prometheus.yml:/etc/prometheus/prometheus.yml" \
  prom/prometheus

启动后打开 Prometheus 的 targets 页面,确认 order-service-demo 是 UP。

复制代码
http://localhost:9090/targets

如果 target 是 DOWN,优先检查三件事:

检查项 说明
应用端口 Spring Boot 是否真的跑在 8080
metrics_path 是否配置为 /actuator/prometheus
网络地址 Docker 场景下 localhost 指的是容器自身,不是宿主机

8. 用 PromQL 看接口耗时、异常和队列积压

Prometheus 抓取成功后,可以用 PromQL 查询业务指标。

接口 QPS:

复制代码
sum(rate(order_create_total[1m])) by (channel, result)

异常增长速率:

复制代码
sum(rate(order_create_exception_total[5m])) by (stage, exception)

平均耗时:

复制代码
rate(order_create_seconds_sum[5m]) / rate(order_create_seconds_count[5m])

如果开启了 histogram bucket,可以看 P95:

复制代码
histogram_quantile(
  0.95,
  sum(rate(order_create_seconds_bucket[5m])) by (le, channel, result)
)

队列积压:

复制代码
order_queue_depth{queue="create-order"}

Grafana 面板可以先从这 4 类图开始:

面板 推荐查询 用途
订单创建 QPS sum(rate(order_create_total[1m])) by (result) 看流量和成功失败比例
创建订单 P95 histogram_quantile(0.95, sum(rate(order_create_seconds_bucket[5m])) by (le)) 看用户感知耗时
异常阶段分布 sum(rate(order_create_exception_total[5m])) by (stage) 定位失败集中在哪一步
队列积压 order_queue_depth 判断是否有消费能力不足

如果没有开启 histogram,先用平均耗时和 max 观察趋势,不要强行写 P95 查询。

9. 常见踩坑和生产边界

9.1 tag 高基数会拖垮指标系统

最常见的问题是这样写:

java 复制代码
`.tag("userId", userId)
.tag("orderId", orderId)
`

这会让每个用户、每个订单都生成新的时间序列。Prometheus 不是明细数据库,指标适合聚合观察,不适合承载所有业务明细。

建议:

  • 明细进日志和 trace;
  • 指标只保留低基数字段;
  • tag 值使用白名单或枚举;
  • 定期检查 Prometheus time series 数量。

9.2 Gauge 必须持有对象引用

Gauge 常见错误是临时创建一个对象后就不管了:

java 复制代码
`Gauge.builder("order_queue_depth", new AtomicInteger(10), AtomicInteger::get)
        .register(registry);
`

对象没有被业务代码持有,后续可能被 GC,指标值就会异常。更稳妥的做法是把 AtomicInteger 放到组件字段或 Map 中长期持有。

9.3 Timer 不是万能链路追踪

Timer 适合回答"某类操作总体耗时如何"。如果要知道一次请求具体慢在哪个 SQL、哪个 RPC、哪个下游服务,就需要 trace 或日志配合。

可以把三者分工理解为:

工具 适合回答的问题
Metrics 最近 5 分钟接口是否变慢、错误是否变多
Logs 某次失败请求的上下文和异常栈
Traces 一次请求跨服务调用到底慢在哪一段

不要指望一个 Timer 替代完整的链路追踪。

9.4 指标命名要稳定

指标名一旦被 Grafana 面板和告警规则引用,修改成本很高。建议命名时遵守:

复制代码
业务域_动作_单位

例如:

  • order_create_total
  • order_create_seconds
  • order_queue_depth

不要今天叫 order_create_cost,明天叫 order_latency,否则面板和告警规则都要跟着改。

10. 推荐的落地步骤

如果团队准备在已有 Spring Boot 项目里补业务指标,不建议一次性把所有接口都埋上。可以按下面顺序推进:

复制代码
第一步:先接 Actuator + Prometheus endpoint
第二步:确认 Prometheus 能抓到默认 JVM / HTTP 指标
第三步:选 1 个核心接口注册 Counter + Timer
第四步:为关键失败阶段注册异常 Counter
第五步:只给低基数字段加 tag
第六步:在 Grafana 建 3 到 5 个核心面板
第七步:再逐步补告警阈值和 SLO

一个核心接口的最小指标集可以是:

指标 必要性 说明
请求总数 必须 看流量趋势和成功失败比例
接口耗时 必须 看平均值、P95 或 max
异常计数 必须 看失败阶段和异常类型
当前积压 可选 有队列、线程池或批处理任务时再加
下游耗时 可选 有明确外部依赖时再加

11. 总结

Spring Boot Actuator 解决的是"指标如何暴露",Micrometer 解决的是"Java 代码如何记录指标",Prometheus 解决的是"如何抓取和查询指标"。只接 /actuator/health 只能说明服务大概率还活着,不能说明核心业务接口是不是正在变慢、失败是不是集中在某个阶段、队列是不是正在积压。

落地业务指标时,最重要的不是多埋点,而是指标设计要克制:指标名稳定、tag 低基数、Counter / Timer / Gauge 用对场景、异常和耗时都能被 PromQL 查询。这样后续接 Grafana、告警和 SLO 才有可靠的数据基础。

如果你关注 Java 后端、Spring Boot 可观测性、架构治理和线上问题排查,可以关注我的 CSDN 专栏。

相关推荐
Full Stack Developme1 小时前
Spring Integration 教程
java·后端·spring
摇滚侠1 小时前
MyBatis 入门到项目实战 MyBatis 分页插件 65-66
java·开发语言·sql·mybatis
星辰_mya1 小时前
autowired和resource区别
java·后端·spring·架构·原理
我登哥MVP1 小时前
走进 Gang of Four 设计模式:装饰器模式
java·spring boot·设计模式·装饰器模式
云恒要逆袭2 小时前
Java类型转换详解:小数字转大自动跑,大数字转小要小心
java·后端
星辰_mya2 小时前
openfeign之在回首
java·架构·dubbo·springcloud·openfeign
青山木2 小时前
Hot 100 --- 滑动窗口最大值
java·数据结构·算法·leetcode·动态规划
青山木2 小时前
Hot 100 --- 除自身以外数组的乘积
java·数据结构·算法
invicinble2 小时前
关于springsecurity技术栈,逻辑概念的总结
spring boot