Actuator 与 Micrometer:生产级监控体系搭建

20 阅读9分钟

1. 引言:让 Spring Boot 应用"可观测"

一个应用能跑起来,和能在生产环境里被放心地跑,中间隔着一整套可观测能力。线上 CPU 突然飙高、接口 P99 从 80ms 涨到 2s、数据库连接池被打满、某个下游服务开始超时——这些问题如果只能靠用户投诉来发现,那排查成本会高得离谱。

Spring Boot 生态给出的答案是两层组合:

  • Spring Boot Actuator:提供开箱即用的运维端点(健康检查、指标、环境信息、日志级别动态调整等),是"应用对外的运维接口"。
  • Micrometer:一个与厂商无关的指标门面(SLF4J 之于日志,Micrometer 之于指标),把应用内的度量数据统一抽象成 Counter、Gauge、Timer 等原语,再通过不同的 registry 输出到 Prometheus、Datadog、InfluxDB 等后端。

本文按"端点 → 自定义健康检查 → 指标原语 → Prometheus 抓取 → Grafana 面板 → 日志与链路关联 → 告警规则"的顺序,搭一套可以直接落地的生产级监控体系。

技术栈版本约定:Spring Boot 3.2.x、Java 17、Micrometer 1.12.x、Prometheus 2.50+、Grafana 10.x、AlertManager 0.26+。

先看整体架构,理解各组件之间的数据流向:

flowchart TD
  App["Spring Boot 应用"] --> Act["Actuator 端点"]
  App --> Mic["Micrometer 指标门面"]
  Mic --> Reg["MeterRegistry"]
  Reg --> Prom["/actuator/prometheus"]
  Act --> Prom
  Prom --> Scrape["Prometheus 抓取"]
  Scrape --> Graf["Grafana 可视化"]
  Scrape --> Alert["AlertManager 告警"]
  App --> Log["日志 + traceId"]
  Log --> Agg["Loki / ELK"]
  Graf --> Agg
  Alert --> Notify["PagerDuty / Slack / 邮件"]

2. Actuator 端点体系:用途与安全配置

2.1 引入依赖

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

2.2 核心端点一览

端点用途生产建议
/actuator/health聚合健康状态,供 K8s 探针、负载均衡使用对外暴露,但只返回 status
/actuator/metrics查看单个指标的原始数据(调试用)仅内网暴露
/actuator/env查看配置项与环境变量默认关闭,含敏感信息
/actuator/loggers查询与动态修改日志级别仅内网 + 鉴权
/actuator/prometheusPrometheus 格式的指标输出仅监控网段可访问
/actuator/threaddump、/actuator/heapdump线程/堆转储仅内网,慎开

2.3 配置示例

management:
  endpoints:
    web:
      base-path: /actuator
      exposure:
        include: health,info,metrics,prometheus,loggers
        exclude: env,beans,configprops,heapdump,threaddump
  endpoint:
    health:
      show-details: when-authorized   # 未授权时只返回 UP/DOWN
      probes:
        enabled: true                 # 开启 liveness / readiness 探针
    loggers:
      enabled: true
  server:
    port: 9090                        # 管理端口与业务端口分离

关键实践:管理端口与业务端口分离。 把 Actuator 放到独立端口(如 9090),在 K8s 里只让监控系统访问该端口,业务端口完全不暴露运维接口,这是最省事也最有效的隔离手段。

端口分离后的访问路径如下:

flowchart LR
  User[&#34;外部用户 / 业务流量&#34;] --> Biz[&#34;业务端口 8080&#34;]
  Monitor[&#34;Prometheus / K8s 探针&#34;] --> Mgmt[&#34;管理端口 9090&#34;]
  Biz --> App[&#34;Spring Boot 应用&#34;]
  Mgmt --> App
  Mgmt -.->|&#34;仅监控网段可达&#34;| Sec[&#34;鉴权 + IP 白名单&#34;]

2.4 安全加固

如果管理端口无法分离,至少要做鉴权:

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
        http.securityMatcher(EndpointRequest.toAnyEndpoint())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
                .anyRequest().hasRole("MONITOR"))
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

再配合 Nginx / 网关做 IP 白名单,形成"网络层 + 应用层"双重防护。

3. 自定义健康检查指示器(HealthIndicator)

内置的 DataSourceHealthIndicator、RedisHealthIndicator 只能覆盖基础设施。业务层面的"健康"需要自己定义,比如:核心依赖的第三方接口是否可达、本地缓存是否预热完成、消息队列消费者是否存活。

3.1 实现 HealthIndicator

@Component("paymentGateway")
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final PaymentClient paymentClient;

    public PaymentGatewayHealthIndicator(PaymentClient paymentClient) {
        this.paymentClient = paymentClient;
    }

    @Override
    public Health health() {
        try {
            long start = System.nanoTime();
            boolean ok = paymentClient.ping();
            long costMs = (System.nanoTime() - start) / 1_000_000;

            if (!ok) {
                return Health.down()
                        .withDetail("reason", "ping returned false")
                        .build();
            }
            return Health.up()
                    .withDetail("latencyMs", costMs)
                    .withDetail("endpoint", paymentClient.endpoint())
                    .build();
        } catch (Exception e) {
            return Health.down(e).build();
        }
    }
}

访问 /actuator/health 时会看到:

{
  "status": "UP",
  "components": {
    "paymentGateway": { "status": "UP", "details": { "latencyMs": 23 } },
    "db": { "status": "UP" },
    "diskSpace": { "status": "UP" }
  }
}

3.2 用 HealthContributor 做分组

当健康检查项变多时,需要区分"影响流量的"和"仅告警的"。用 HealthContributor 分组,再配合 K8s 探针:

@Configuration
public class HealthGroupConfig {

    @Bean
    public HealthContributor readinessContributor(PaymentGatewayHealthIndicator payment,
                                                  DataSourceHealthIndicator db) {
        Map<String, HealthContributor> map = new LinkedHashMap<>();
        map.put("paymentGateway", payment);
        map.put("db", db);
        return CompositeHealthContributor.fromMap(map);
    }
}
management:
  endpoint:
    health:
      group:
        readiness:
          include: readinessContributor,ping
        liveness:
          include: ping

注意:liveness 只应包含"进程自身是否卡死"的判断,绝不能把数据库、下游服务放进去——否则下游抖动会导致 K8s 重启你的 Pod,引发雪崩。

健康检查分组与 K8s 探针的配合关系:

flowchart TD
  K8s[&#34;Kubernetes&#34;] --> Live[&#34;liveness 探针&#34;]
  K8s --> Ready[&#34;readiness 探针&#34;]
  Live --> LGroup[&#34;liveness 组:仅 ping&#34;]
  Ready --> RGroup[&#34;readiness 组:paymentGateway + db + ping&#34;]
  LGroup --> Decision1{&#34;进程是否卡死&#34;}
  RGroup --> Decision2{&#34;依赖是否可用&#34;}
  Decision1 -->|&#34;否&#34;| Restart[&#34;重启 Pod&#34;]
  Decision1 -->|&#34;是&#34;| Keep[&#34;保持运行&#34;]
  Decision2 -->|&#34;是&#34;| Traffic[&#34;接入流量&#34;]
  Decision2 -->|&#34;否&#34;| Remove[&#34;摘除流量&#34;]

4. Micrometer 指标抽象层:四大原语

Micrometer 的核心价值是"写一次,输出到任意后端"。四个最常用的原语:

原语语义典型场景
Counter单调递增计数请求数、错误数、消息消费条数
Gauge瞬时值快照队列长度、连接池活跃数、缓存条目数
Timer耗时分布 + 计数接口响应时间、外部调用耗时
DistributionSummary数值分布(非时间)请求体大小、批量处理条数

四类原语的选型可以按下面的思路判断:

flowchart TD
  Start[&#34;需要记录一个度量&#34;] --> Q1{&#34;是否只增不减?&#34;}
  Q1 -->|&#34;是&#34;| Counter[&#34;Counter:累计计数&#34;]
  Q1 -->|&#34;否&#34;| Q2{&#34;是否表示耗时?&#34;}
  Q2 -->|&#34;是&#34;| Timer[&#34;Timer:耗时分布 + 计数&#34;]
  Q2 -->|&#34;否&#34;| Q3{&#34;是否需要观察瞬时值?&#34;}
  Q3 -->|&#34;是&#34;| Gauge[&#34;Gauge:瞬时快照&#34;]
  Q3 -->|&#34;否&#34;| Summary[&#34;DistributionSummary:数值分布&#34;]

4.1 注入 MeterRegistry

@Service
public class OrderMetrics {

    private final Counter orderCreated;
    private final Counter orderFailed;
    private final Timer orderProcessTimer;
    private final DistributionSummary payloadSize;

    public OrderMetrics(MeterRegistry registry) {
        this.orderCreated = Counter.builder("order.created.total")
                .description("成功创建的订单数")
                .tag("channel", "app")
                .register(registry);

        this.orderFailed = Counter.builder("order.failed.total")
                .description("创建失败的订单数")
                .register(registry);

        this.orderProcessTimer = Timer.builder("order.process.duration")
                .description("订单处理耗时")
                .publishPercentiles(0.5, 0.95, 0.99)
                .publishPercentileHistogram()
                .register(registry);

        this.payloadSize = DistributionSummary.builder("order.payload.bytes")
                .description("订单请求体大小")
                .baseUnit("bytes")
                .register(registry);
    }

    public void recordSuccess(int bytes) {
        orderCreated.increment();
        payloadSize.record(bytes);
    }

    public void recordFailure() {
        orderFailed.increment();
    }

    public <T> T time(Supplier<T> supplier) {
        return orderProcessTimer.record(supplier);
    }
}

4.2 Gauge 的正确写法

Gauge 最容易踩坑:它持有的是对象的弱引用,如果被观测对象被 GC 回收,指标会变成 NaN。正确做法是绑定一个长期存活的对象:

@Component
public class QueueMetrics {

    private final BlockingQueue<Task> taskQueue;

    public QueueMetrics(MeterRegistry registry, BlockingQueue<Task> taskQueue) {
        this.taskQueue = taskQueue;
        Gauge.builder("task.queue.size", taskQueue, BlockingQueue::size)
             .description("待处理任务队列长度")
             .register(registry);
    }
}

4.3 用注解简化埋点

@Timed(value = "api.order.create", percentiles = {0.5, 0.95, 0.99})
@PostMapping("/orders")
public OrderVO create(@RequestBody OrderDTO dto) {
    // ...
}

需要开启 @Timed 的 AOP 支持:

@Configuration
public class MetricsConfig {
    @Bean
    public TimedAspect timedAspect(MeterRegistry registry) {
        return new TimedAspect(registry);
    }
}

4.4 标签(Tag)设计原则

标签是 Prometheus 的维度,但基数(cardinality)是杀手。以下写法必须避免:

// ❌ 错误:userId 有百万级取值,会撑爆 Prometheus
Counter.builder("request.total").tag("userId", userId).register(registry);

// ✅ 正确:只使用有限枚举值的维度
Counter.builder("request.total")
       .tag("uri", "/api/orders")
       .tag("status", "200")
       .register(registry);

经验法则:单个指标的标签组合数控制在 1000 以内,任何可能无限增长的字段(用户 ID、订单号、时间戳、异常堆栈)都不能作为标签。

5. Prometheus 集成:端点暴露与抓取

5.1 引入 registry

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

引入后 /actuator/prometheus 自动可用,输出形如:

# HELP http_server_requests_seconds
# TYPE http_server_requests_seconds summary
http_server_requests_seconds_count{method="GET",status="200",uri="/api/orders"} 1523.0
http_server_requests_seconds_sum{method="GET",status="200",uri="/api/orders"} 87.42

5.2 通用标签

management:
  metrics:
    tags:
      application: ${spring.application.name}
      env: ${ENV:prod}
      instance: ${HOSTNAME:unknown}
    distribution:
      percentiles-histogram:
        http.server.requests: true
      slo:
        http.server.requests: 100ms,300ms,1s

application 标签是 Grafana 面板按应用筛选的基础,务必配置。

5.3 Prometheus 抓取配置

# prometheus.yml
global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'spring-boot-app'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets:
          - '10.0.1.11:9090'
          - '10.0.1.12:9090'
        labels:
          env: 'prod'
          app: 'order-service'

K8s 环境下用服务发现更合适:

  - job_name: 'spring-boot-k8s'
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_port]
        action: replace
        target_label: __address__
        regex: (.+)
        replacement: ${1}:9090

5.4 验证抓取

在 Prometheus UI 的 Status → Targets 页面确认目标为 UP,然后在 Graph 页执行 http_server_requests_seconds_count{application="order-service"} 验证数据。

完整的指标采集链路如下:

sequenceDiagram
  participant App as &#34;Spring Boot 应用&#34;
  participant Act as &#34;Actuator /prometheus&#34;
  participant Prom as &#34;Prometheus&#34;
  participant Graf as &#34;Grafana&#34;
  Prom->>Act: &#34;每 15s 发起 scrape&#34;
  Act->>App: &#34;读取 MeterRegistry 指标&#34;
  App-->>Act: &#34;返回文本格式指标&#34;
  Act-->>Prom: &#34;200 OK + metrics&#34;
  Prom->>Prom: &#34;写入 TSDB&#34;
  Graf->>Prom: &#34;PromQL 查询&#34;
  Prom-->>Graf: &#34;返回时间序列&#34;

6. Grafana 面板搭建

6.1 数据源与变量

添加 Prometheus 数据源后,在 Dashboard 设置中定义变量:

变量名:application
类型:Query
查询:label_values(http_server_requests_seconds_count, application)

这样一套面板可以复用到所有服务。

6.2 JVM 指标面板

面板PromQL
堆内存使用sum(jvm_memory_used_bytes{area="heap",application="$application"}) by (instance)
非堆内存sum(jvm_memory_used_bytes{area="nonheap",application="$application"}) by (instance)
GC 暂停时间(每秒)rate(jvm_gc_pause_seconds_sum{application="$application"}[1m])
线程数jvm_threads_live_threads{application="$application"}
类加载数jvm_classes_loaded_classes{application="$application"}

6.3 HTTP 请求指标面板

# QPS(按 URI)
sum(rate(http_server_requests_seconds_count{application="$application"}[1m])) by (uri)

# P99 响应时间
histogram_quantile(0.99,
  sum(rate(http_server_requests_seconds_bucket{application="$application"}[5m])) by (le, uri))

# 错误率
sum(rate(http_server_requests_seconds_count{application="$application",status=~"5.."}[5m]))
/
sum(rate(http_server_requests_seconds_count{application="$application"}[5m]))

6.4 数据库连接池面板

HikariCP 的指标由 Micrometer 自动绑定,前缀为 hikaricp_:

# 活跃连接数
hikaricp_connections_active{application="$application"}

# 等待获取连接的线程数(>0 说明池子不够)
hikaricp_connections_pending{application="$application"}

# 连接获取耗时 P99
histogram_quantile(0.99,
  sum(rate(hikaricp_connections_acquire_seconds_bucket{application="$application"}[5m])) by (le))

hikaricp_connections_pending 持续大于 0 是最典型的"连接池打满"信号,应直接配置告警。

6.5 面板布局建议

按"总览 → 资源 → 业务"三层组织:

  1. 第一行:QPS、错误率、P99 延迟、实例数(四个 Stat 面板,一眼看全局)
  2. 第二行:JVM 堆内存、GC 频率、线程数
  3. 第三行:HTTP 各 URI 的延迟热力图、状态码分布
  4. 第四行:连接池、缓存命中率、消息队列积压

7. 日志与链路追踪的关联:MDC + TraceId

指标告诉你"出问题了",日志告诉你"问题在哪"。两者之间需要一个共同的关联键——traceId。

7.1 引入链路追踪

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
    <groupId>io.zipkin.reporter2</groupId>
    <artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
  tracing:
    sampling:
      probability: 0.1   # 生产环境采样 10%
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans

7.2 把 traceId 写入 MDC

Micrometer Tracing 会自动把 traceId、spanId 放进 MDC,只需在日志格式中引用:

logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n"

输出效果:

2026-11-01 10:41:41.203 [http-nio-8080-exec-3] INFO  [a3f9c2b1e8d74f60,9b2c1d4e] c.e.order.OrderService - 订单创建成功 orderId=10086

7.3 异步线程的 MDC 传递

@Async、线程池、CompletableFuture 会丢失 MDC 上下文,需要显式包装:

public class MdcTaskDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Map<String, String> context = MDC.getCopyOfContextMap();
        return () -> {
            if (context != null) {
                MDC.setContextMap(context);
            }
            try {
                runnable.run();
            } finally {
                MDC.clear();
            }
        };
    }
}

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(32);
        executor.setTaskDecorator(new MdcTaskDecorator());
        executor.initialize();
        return executor;
    }
}

7.4 日志聚合侧关联

在 Loki / ELK 中,用 traceId 作为检索键:

{app="order-service"} |= "a3f9c2b1e8d74f60"

Grafana 中还可以配置 Exemplar,让指标图上的点直接跳转到对应的 trace,实现"指标 → 链路 → 日志"的三级下钻。

8. 告警规则设计:Prometheus + AlertManager

8.1 告警规则文件

# rules/spring-boot-alerts.yml
groups:
  - name: spring-boot-app
    interval: 30s
    rules:
      - alert: HighErrorRate
        expr: |
          sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) by (application)
          /
          sum(rate(http_server_requests_seconds_count[5m])) by (application)
          > 0.05
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.application }} 5xx 错误率超过 5%"
          description: "当前错误率 {{ $value | humanizePercentage }},持续 5 分钟"

      - alert: HighLatencyP99
        expr: |
          histogram_quantile(0.99,
            sum(rate(http_server_requests_seconds_bucket[5m])) by (le, application)
          ) > 1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.application }} P99 延迟超过 1s"

      - alert: ConnectionPoolExhausted
        expr: hikaricp_connections_pending > 0
        for: 3m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.application }} 连接池出现等待"

      - alert: JvmHeapHigh
        expr: |
          sum(jvm_memory_used_bytes{area="heap"}) by (application, instance)
          /
          sum(jvm_memory_max_bytes{area="heap"}) by (application, instance)
          > 0.9
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.instance }} 堆内存使用率超过 90%"

      - alert: InstanceDown
        expr: up{job="spring-boot-app"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.instance }} 抓取失败"

8.2 AlertManager 路由与抑制

route:
  receiver: 'default'
  group_by: ['alertname', 'application']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  routes:
    - matchers: ['severity="critical"']
      receiver: 'pagerduty'
      continue: true
    - matchers: ['severity="warning"']
      receiver: 'slack'

inhibit_rules:
  # 实例宕机时,抑制该实例上的其他告警,避免告警风暴
  - source_matchers: ['alertname="InstanceDown"']
    target_matchers: ['severity=~"warning|critical"']
    equal: ['instance']

8.3 告警设计的三条原则

  1. 每个告警都必须可行动。如果收到告警后不知道该做什么,这条规则就该删掉。
  2. 用 for 抑制抖动。瞬时尖刺不告警,持续 N 分钟才告警。
  3. 分级 + 抑制。critical 走电话/值班,warning 走 IM;上游故障时抑制下游衍生告警。

9. 总结

把上面的内容串起来,一套完整的生产级监控体系是这样的:

flowchart LR
  A[&#34;Spring Boot 应用&#34;] --> B[&#34;Actuator 端点&#34;]
  A --> C[&#34;Micrometer 指标&#34;]
  C --> D[&#34;/actuator/prometheus&#34;]
  B --> D
  D --> E[&#34;Prometheus 抓取&#34;]
  E --> F[&#34;Grafana 面板&#34;]
  E --> G[&#34;AlertManager 告警&#34;]
  A --> H[&#34;日志 + TraceId&#34;]
  H --> I[&#34;Loki / ELK&#34;]
  F --> I
  G --> J[&#34;PagerDuty / Slack&#34;]

落地清单:

  • Actuator 管理端口与业务端口分离,env/heapdump 等敏感端点关闭
  • 核心业务依赖实现 HealthIndicator,liveness 只含进程自身检查
  • 指标标签基数受控,禁止把 ID 类字段作为 tag
  • Prometheus 抓取配置 application 标签,Grafana 用变量复用面板
  • 日志格式包含 traceId,异步线程池配置 TaskDecorator
  • 告警规则带 for 持续时间,配置抑制规则防告警风暴

监控体系不是一次搭完就结束的工程,它需要随着业务演进持续调整阈值、补充指标、清理无效告警。先让"能看见"成为常态,再逐步追求"看得准"。