Spring Boot Actuator + Micrometer 实战:自定义业务指标并接入 Prometheus 观测接口耗时
摘要:很多 Spring Boot 项目接入 Actuator 后,只停留在 /actuator/health 和 JVM 默认指标,真正排查接口慢、异常升高、队列积压时仍然缺少业务视角。本文用一个最小 Spring Boot 3.x demo 演示如何通过 Micrometer 注册 Counter、Timer 和 Gauge,再通过 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_bytes、http_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/openapiresult=success/failstage=validate/inventory/paymentqueue=create-order
不适合做 tag 的字段:
userIdorderIdtraceIdphonerequestBody
这些高基数字段应该进日志、链路追踪或明细存储,不应该进 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;
`
埋点建议遵守三条规则:
- 耗时记录放在
finally,避免异常场景漏记。 - 异常计数要带上阶段,例如
validate、inventory、payment,不要只记一个总异常数。 - 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_totalorder_create_secondsorder_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 专栏。