为什么 Metrics 是可观测性的基础信号#

在日志、链路追踪(Traces)和 Metrics 三大可观测性信号中,Metrics 往往是最先被告警系统用到的那个。

Metrics 是聚合数据:它告诉你"过去 1 分钟,/api/hello 接口平均延迟 320ms,错误率 0.3%"。日志是个体事件:它记录每一次请求的具体参数。两者不是替代关系,而是分工:Metrics 负责趋势感知和告警触发,日志负责单点定位和细节还原,链路追踪负责调用链拼接。

RED 方法论是 Metrics 最重要的一套设计框架,适合所有面向用户的服务:

指标 英文 含义
请求速率 Rate 每秒处理多少请求
错误率 Error 失败请求占比
请求耗时 Duration P50 / P95 / P99 延迟分布

本文以 springboot3.5-otel 项目(三服务微服务架构)为参考,整理一次 Spring Boot 应用层 Metrics 埋点实践。虽然该项目通过 OTel Collector 导出 Metrics,但本文大部分应用层代码对直接使用 Prometheus 的团队同样适用——因为埋点 API 是 Micrometer 的抽象层,与后端无关。


一、Spring Boot Metrics 技术栈:后端无关的 Micrometer 抽象#

Micrometer 的核心价值#

Micrometer 是 Spring Boot 的 Metrics 门面(类似 SLF4J 之于日志),它提供统一的埋点 API,在应用层之上屏蔽了不同监控后端的差异:

应用代码(@Observed / ObservationRegistry / MeterRegistry)
         ↓ Micrometer 抽象层
多注册中心(任选其一或多个):
  - PrometheusMeterRegistry  → /actuator/prometheus → Prometheus 抓取
  - OtlpMeterRegistry        → OTLP HTTP           → OTel Collector
  - DatadogMeterRegistry     → Datadog Agent
  - CloudWatchMeterRegistry  → AWS CloudWatch
  - ...

关键结论:应用层的埋点代码(@ObservedObservationRegistryObservation API)与最终的导出后端无关。团队现在用 Prometheus,未来切换到 OTel Collector,通常不需要修改业务代码,只需调整依赖和配置。

参考项目的导出链路#

Spring Boot 应用
  └─ micrometer-registry-otlp(每 10s push)
       └─ OTLP HTTP 4318
            └─ OTel Collector
                 └─ Prometheus(Grafana LGTM 内置)

直接使用 Prometheus 的团队,导出链路更简单:

Spring Boot 应用
  └─ micrometer-registry-prometheus(Actuator 暴露)
       └─ /actuator/prometheus
            └─ Prometheus(主动抓取,scrape_interval: 15s)

二、开箱即用的自动埋点#

Spring Boot Actuator + Micrometer 自动配置覆盖了大量常见场景,无需任何手动代码。

HTTP Server(大多数应用都会有)#

只要引入 spring-boot-starter-actuator,所有 HTTP 请求自动生成 http.server.requests 指标:

http.server.requests{method="GET", uri="/api/hello", status="200", outcome="SUCCESS"}
  • 低基数维度(适合作为 Label):methoduri(路由模板,不是完整 URL)、statusoutcome
  • 自动统计:计数、总耗时、最大耗时、分位数(需额外配置)

Micrometer 对 uri 做了路由模板归一化处理:/api/users/123/api/users/{id},避免高基数爆炸。

HTTP Client(RestClient / RestTemplate)#

// 这里要注入 Spring 管理的 RestClient.Builder,才能获得自动埋点
// Spring Boot 自动配置会在 RestClient.Builder 上挂载 ObservationInterceptor
@Configuration
public class HttpClientConfig {

    @Bean
    public RestClient userServiceClient(RestClient.Builder builder) {
        return builder
            .baseUrl("http://user-service")
            .build();
    }
}

注意:RestClient.builder() 手动 new 出来的 builder 不会经过 Spring Boot 的自动配置,缺少 ObservationInterceptor,导致出站请求的 http.client.requests 指标和 Trace Span 都不会生成。这里更稳妥的做法仍然是注入 RestClient.Builder

自动维度:methoduristatusclient.name(目标服务)。

JDBC#

连接池指标(内置,零配置):Spring Boot 集成 HikariCP 后,自动暴露连接池维度的指标:

指标名 含义
hikaricp.connections.active 当前活跃连接数
hikaricp.connections.idle 空闲连接数
hikaricp.connections.pending 等待获取连接的线程数
hikaricp.connections.creation 连接创建耗时
hikaricp.connections.acquire 获取连接耗时(排队等待时间)

per-query Span(需额外依赖):连接池指标无法定位是哪条 SQL 慢。若需要 SQL 级别的 Trace Span,引入 datasource-micrometer:

// user-service/build.gradle.kts
implementation("net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.6")

引入后,所有 SQL 查询自动生成 db.query Trace Span,包含 SQL 语句和执行耗时,无需修改任何 Repository 代码。

Redis(Lettuce 自动仪表)#

spring-boot-starter-data-redis 默认使用 Lettuce 驱动,Micrometer 对其有开箱即用的 Observation 支持:

db.redis{command="GET", local.address="...", remote.address="..."}

Kafka Producer / Consumer#

在配置文件中开启观测即可:

spring:
  kafka:
    template:
      observation-enabled: true    # KafkaTemplate 生产者 metrics
    listener:
      observation-enabled: true    # @KafkaListener 消费者 metrics

自动生成 messaging.publish(生产)和 messaging.process(消费)指标,以及对应 Trace Span,并自动通过 Kafka Header 传播 TraceContext——这是分布式追踪的关键。

JVM 基础指标#

指标名 含义
jvm.memory.used JVM 各内存区域已使用量
jvm.threads.live 存活线程数(Java 21+ 包含虚拟线程)
jvm.gc.pause GC 暂停时间
jvm.classes.loaded 已加载类数量
process.cpu.usage 进程 CPU 使用率

Virtual Threads 与 Observation 上下文传播(Java 25 / Spring Boot 3.5):

Spring Boot 3.5 启用虚拟线程只需一行配置:

spring:
  threads:
    virtual:
      enabled: true

关键特性:在 Spring Boot 3.5 + Java 25 这组组合里,Micrometer 的 ObservationRegistry 已经能比较自然地配合虚拟线程使用——TraceContext 和 Observation 上下文通常可以自动传播,不需要额外样板代码。这意味着:

  • 每个虚拟线程的 Observation 链路自动隔离,互不污染
  • 异步任务(async、Kafka listener)在虚拟线程中执行时,TraceContext 仍正确传播
  • 性能指标(如 http.server.requestsgreeting.resolve)在虚拟线程环境下统计准确

虚拟线程在 Java 25 上已经是比较稳妥的选择,但吞吐提升幅度仍然高度依赖应用模型、数据库连接池和下游限流条件。对于 I/O 密集型场景,它经常能带来收益;至于具体能提升多少,最好还是以自己的压测结果为准。

Metrics 侧通常不需要为虚拟线程额外写特殊处理,正常的 @ObservedObservation 埋点在虚拟线程中运行时一般可以自动适配。详见《Spring Boot 3.5 + OpenTelemetry 实践记录(2026)》中虚拟线程配置和性能基准章节。


三、应用层手动埋点实践#

自动埋点覆盖了基础的 Infrastructure 层面,但业务逻辑的 Metrics 需要手动埋点。

3.1 @Observed 注解:最简单的埋点方式#

@Observed 是 Micrometer 提供的 AOP 注解,标注在方法上,自动记录调用次数、耗时,并创建 Trace Span。

启用前提(Spring Boot 3.5 需显式开启,Spring Boot 4 默认开启):

management:
  observations:
    annotations:
      enabled: true
<!-- pom.xml 或 build.gradle:AOP 支持 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

基本用法

@Service
public class HelloService {

    @Observed(
        name = "hello.service.getHello",     // Metric 名称(低基数,用于聚合)
        contextualName = "getHello"           // Span 名称(出现在链路追踪中)
    )
    public HelloResponse getHello(Long userId, String acceptLanguage) {
        // 业务逻辑
    }
}

@Observed 自动生成:

  • Metrichello.service.getHello(计数 + 耗时)
  • Trace SpangetHello(与父 Span 自动关联)

附加低基数维度

@Observed(
    name = "greeting.resolve",
    contextualName = "resolveGreeting",
    lowCardinalityKeyValues = {"cache", "redis"}   // 固定值 key-value,适合做 Label
)
public Greeting getGreeting(String language) {
    // ...
}

lowCardinalityKeyValues 接受固定键值对,值域必须有限(如枚举、布尔、服务名)。不要把 userId、订单号等高基数值放在这里。

3.2 高基数 vs 低基数字段的本质区别#

这是 Metrics 埋点中最重要的设计决策,混淆两者会导致监控系统崩溃。

低基数字段(Low Cardinality):

  • 值域有限、可枚举,如:method=GETstatus=200cache=redisregion=cn-east
  • 适合作为 Prometheus Label / OTel Metric Attribute
  • Prometheus 会为每个 Label 组合创建一个时间序列,低基数才可控

高基数字段(High Cardinality):

  • 值域无限或极大,如:userId=123456orderId=uuidtraceId=...
  • 不适合作为 Metric Label,否则时间序列数量爆炸,Prometheus 内存耗尽
  • 适合放到 Trace Span 的 Attribute 中,用于单次请求的细节定位

在代码中的区分方式

@Observed(name = "hello.service.getHello", contextualName = "getHello")
public HelloResponse getHello(Long userId, String acceptLanguage) {

    // ✅ 高基数字段 → 放到 Span Attribute(只在追踪系统中,不进 Metrics)
    var current = registry.getCurrentObservation();
    if (current != null) {
        current.highCardinalityKeyValue("user.id", userId.toString());
    }

    // ❌ 不要这样做:高基数值作为 Metric 维度
    // current.lowCardinalityKeyValue("user.id", userId.toString());
}

highCardinalityKeyValue 只写入 Trace Span(Tempo / Jaeger 可查),不会进入 Metrics 的 Label,从根源规避基数爆炸。

3.3 手动 Observation:细粒度控制#

@Observed 粒度不够(如需要在方法内部对某段逻辑单独计时),使用 Observation API 手动创建:

@Service
public class GreetingService {

    private final ObservationRegistry registry;
    private final StringRedisTemplate redis;

    public GreetingService(ObservationRegistry registry, StringRedisTemplate redis) {
        this.registry = registry;
        this.redis = redis;
    }

    // @Observed 覆盖整个方法,自动处理正常路径
    @Observed(
        name = "greeting.resolve",
        contextualName = "resolveGreeting",
        lowCardinalityKeyValues = {"cache", "redis"}
    )
    public Greeting getGreeting(String language) {
        String cacheKey = "greeting:" + language;

        // 缓存命中:Lettuce 自动生成 db.redis span
        String cachedMessage = redis.opsForValue().get(cacheKey);
        if (cachedMessage != null) {
            return new Greeting(language, cachedMessage);
        }

        // 缓存未命中:手动 Observation 单独记录这段逻辑的耗时
        return resolveFromSource(language, cacheKey);
    }

    private Greeting resolveFromSource(String language, String cacheKey) {
        // ✅ 推荐:.observe() 函数式 API,自动处理 start/stop/error/scope
        return Observation.createNotStarted("greeting.lookup", registry)
            .lowCardinalityKeyValue("language", language)
            .observe(() -> {
                Greeting greeting = GREETINGS.getOrDefault(language, GREETINGS.get("en"));
                redis.opsForValue().set(cacheKey, greeting.message(), CACHE_TTL);
                return greeting;
            });
    }
}

.observe(() -> {...})首选写法:它自动处理 start()openScope()error(e)stop() 的完整生命周期,消除了忘记调用 stop() 或 Scope 泄漏的风险。

当需要在 Lambda 内部访问 Observation 对象本身(如动态添加 KeyValue),才退回到手动模式:

// 手动模式:需要在运行时动态附加维度时使用
private Greeting resolveFromSource(String language, String cacheKey) {
    Observation lookup = Observation.createNotStarted("greeting.lookup", registry)
        .lowCardinalityKeyValue("language", language)
        .start();

    try (Observation.Scope scope = lookup.openScope()) {
        Greeting greeting = GREETINGS.getOrDefault(language, GREETINGS.get("en"));
        // 运行时才能确定的维度
        lookup.lowCardinalityKeyValue("source", greeting.source());
        redis.opsForValue().set(cacheKey, greeting.message(), CACHE_TTL);
        return greeting;
    } catch (Exception e) {
        lookup.error(e);
        throw e;
    } finally {
        lookup.stop();   // finally 确保无论异常与否都记录耗时
    }
}

3.4 异常处理:在 GlobalExceptionHandler 中记录#

对于未被捕获的异常,统一在全局异常处理器中通过 ObservationRegistry 记录:

@RestControllerAdvice
public class GlobalExceptionHandler {

    private final ObservationRegistry registry;

    public GlobalExceptionHandler(ObservationRegistry registry) {
        this.registry = registry;
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleException(Exception ex) {
        // ✅ 通过 Micrometer 抽象层记录异常,后端无关
        Observation observation = registry.getCurrentObservation();
        if (observation != null) {
            observation.error(ex);  // 自动写入 Span exception 事件 + 标记 ERROR 状态
                                    // + 在 Metrics 中增加 error tag,Prometheus 可聚合
        }

        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        problem.setDetail(ex.getMessage());
        return ResponseEntity.status(500).body(problem);
    }
}

observation.error(ex) 在 OTel 后端等效于 span.recordException(ex) + span.setStatus(ERROR),在 Prometheus 后端则对对应 Metric 增加 error tag,一行代码同时覆盖两个后端,且无需引入任何 OTel SDK 依赖。

与业务代码中用 lookup.error(e) 的逻辑相同——始终通过 Observation API 操作,永远不直接调用 Span.current(),保持应用代码的后端无关性。


四、配置要点#

Actuator 暴露(Prometheus 用户必须配置)#

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, prometheus  # 按需暴露
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true    # 开启直方图,支持 Prometheus histogram_quantile()
      percentiles:
        http.server.requests: 0.5, 0.95, 0.99   # 或使用固定分位数(不支持自定义 range)

直方图(percentiles-histogram: true)与固定分位数(percentiles)只能选其一:前者数据量更大,但支持在 Grafana 中灵活计算任意分位数;后者数据量小,但只能查预计算好的分位数。

OTel Metrics 导出配置#

management:
  otlp:
    metrics:
      export:
        url: http://localhost:4318/v1/metrics
        step: 10s    # 推送间隔,生产环境建议 30s~60s,减少带宽压力

@Observed 注解启用(Spring Boot 3.5)#

management:
  observations:
    annotations:
      enabled: true    # Spring Boot 3.5 需显式开启,否则 @Observed 静默无效

生产环境 Actuator 最小化暴露#

# application-prod.yaml
management:
  endpoints:
    web:
      exposure:
        include: health, prometheus    # 生产只暴露健康检查和 metrics 端点
  endpoint:
    health:
      show-details: never             # 不对外暴露详细健康信息

五、Prometheus 用户如何直接使用#

如果你的团队使用 Prometheus 而不是 OTel Collector,应用层代码通常可以保持不变,只需调整依赖和配置:

依赖替换

// build.gradle.kts(Kotlin DSL)

// 移除(如果有):
// implementation("io.micrometer:micrometer-registry-otlp")

// 添加:
implementation("io.micrometer:micrometer-registry-prometheus")
// Spring Boot BOM 已管理版本,无需指定

配置:Prometheus 是拉取模式,不需要配置 push endpoint,只需确保 /actuator/prometheus 已暴露(见上节),然后在 Prometheus 的 prometheus.yml 中添加 scrape 配置:

# prometheus.yml
scrape_configs:
  - job_name: 'hello-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['hello-service:18080']

  - job_name: 'user-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['user-service:18081']

应用层代码@ObservedObservationRegistryObservation API 的使用方式基本一致。这正是 Micrometer 抽象层的价值所在。


六、OTel Collector 侧的最佳实践#

如果使用 OTel Collector 作为中间层,可以在 Collector 层面统一处理一些横切关注点:

PII(个人信息)脱敏#

# otel-collector-config.yaml
processors:
  attributes/sanitize:
    actions:
      - key: user.id
        action: hash      # 对用户 ID 做单向哈希,保留可关联性,去除可识别性
      - key: user.email
        action: delete    # 直接删除邮箱等敏感字段
      - key: user.phone
        action: delete

这是一个重要的合规实践:即使开发者在代码中意外写入了 PII 字段,Collector 侧的 attributes/sanitize 也能作为最后一道防线在数据出应用层后立即清理。

Batch 优化#

processors:
  batch:
    timeout: 5s            # 最多等 5s 凑一批
    send_batch_size: 1024  # 或达到 1024 条就发送

  memory_limiter:
    check_interval: 1s
    limit_mib: 256         # 防止 Collector OOM

七、Metrics 命名规范#

Micrometer 使用 . 分隔的命名(http.server.requests),自动转换为各后端格式:

Micrometer 名称 Prometheus 格式 OTel 格式
http.server.requests http_server_requests_seconds_count http.server.requests
greeting.resolve greeting_resolve_seconds_count greeting.resolve
jvm.memory.used jvm_memory_used_bytes jvm.memory.used

命名建议

  • 使用 <服务域>.<操作> 结构,如 order.paymentgreeting.resolve
  • 避免在名称中带单位(Micrometer 会自动附加),不要用 order.payment.duration.seconds
  • 使用名词而非动词:greeting.resolve 而非 resolve.greeting
  • 保持 name(Metric 名)简短稳定,contextualName(Span 名)可更具描述性

Key 名遵循 OTel Semantic Conventions

在定义 lowCardinalityKeyValues 时,优先使用 OTel Semantic Conventions 规范的 Key 名,而非自定义名称。这样可以直接兼容 Grafana 官方 OTel Dashboard,无需额外映射:

场景 推荐 Key(OTel 规范) 避免自定义 Key
数据库类型 db.system = "postgresql" database_type
消息系统 messaging.system = "kafka" mq_type
HTTP 方法 http.request.method = "GET" method
缓存系统 db.system = "redis" cache_type
错误类型 error.type = ex.getClass().getName() exception_class

Micrometer 自动生成的基础设施指标(HTTP、JDBC、Redis)已遵循 OTel 规范;自定义业务指标的 Key 名同样对齐规范,可避免在 Grafana 中手动转换字段名。


总结#

场景 推荐方式
业务方法的整体耗时 + 计数 @Observed(name=..., contextualName=...)
方法内特定代码段的独立计时 Observation.createNotStarted().start()
请求级别的业务 ID(userId 等) current.highCardinalityKeyValue(...)
固定枚举型维度(status、region) lowCardinalityKeyValues@Observed 参数
未捕获异常的 Span 标记 GlobalExceptionHandlerobservation.error(ex)
HTTP / JDBC / Redis / Kafka 基础指标 依赖 + 配置,零代码

Spring Boot 的 Metrics 体系现在已经相当成熟——自动埋点覆盖了基础设施层,@Observed 覆盖了大部分业务场景,手动 Observation API 负责细粒度控制。团队真正需要主动设计的,主要还是高低基数字段的边界命名规范

项目参考:github.com/meirongdev/springboot3.5-otel