【码动四季】Spring Boot 可观测性体系:Micrometer + OpenTelemetry + Grafana 全链路搭建

摘要:分布式交易系统最怕的不是出问题,而是出了问题不知道问题在哪。去年我负责的交易中台在高峰期出现订单处理延迟飙升,3 个团队花了 4 小时才定位到是 Redis 连接池耗尽导致的级联超时。痛定思痛后,我基于 Spring Boot 3.4 + Micrometer + OpenTelemetry + Grafana 搭建了 Trace + 指标 + 日志三合一可观测性体系,排障效率从 4 小时压缩到 15 分钟。本文记录完整搭建过程、5 个生产级踩坑案例,以及 AtomCode 在规范埋点注解、生成 Grafana Dashboard JSON、自动补全 Span 等环节的关键介入。

1. 场景:交易系统排障的黑暗时刻

1.1 一次真实的生产事故

2025 年双十一期间,我负责的交易中台在 10:17 出现告警:订单创建接口 P99 从 200ms 飙升到 3500ms。

当时的排障过程是这样的:

  • 10:17 告警群通知,SRE 开始排查
  • 10:25 确认不是网络抖动,但不确定是哪个服务拖慢了链路
  • 10:40 各服务组分别看日志,日志没有 TraceID 无法串联
  • 11:05 终于发现 Redis 连接池的 max-active 配置过小(50),高并发下连接等待超时
  • 11:20 修改配置重启,P99 恢复到 200ms

4 小时排障,其中 3 小时花在"定位问题在哪"。这就是典型的分布式系统可观测性缺失------有了监控,但监控之间没有关联。

1.2 可观测性缺失的本质

排障效率低不是工具不够,而是三个维度各自为战:

维度 现状 问题
Metrics Prometheus + Grafana 已有 只有系统级指标,缺少业务关联
Traces 无法串联跨服务调用链
Logs ELK 已有 日志无 TraceID,无法与指标关联

三者之间没有统一的关联 ID,排障时只能在三个系统之间反复切换、人工拼凑线索。

2. 可观测性三大支柱架构设计

2.1 整体架构图

在设计可观测性体系前,我先梳理了三大支柱的数据流和关联关系: 核心设计思路:OTel Collector 作为统一传输枢纽,所有信号(Trace / Metric / Log)通过 OTLP 协议统一上报,在 Collector 中完成批量处理和路由分发。

2.2 关联机制:TraceID 是灵魂

三大支柱的关联核心就是一个 TraceID:

  • Trace → Metric:在 Span 的 attributes 中记录关联的业务指标名
  • Trace → Log:在 Logback 的 MDC 中注入 TraceID 和 SpanID
  • Metric → Log:Grafana 支持从指标面板跳转到 Loki 按 TraceID 查询日志

这样排障流程就变成:看到指标异常 → 点击跳转 Trace 链路 → 查看 Span 细节 → 跳转关联日志,从 4 小时压缩到 15 分钟。

3. Micrometer 指标采集实战

3.1 Maven 依赖配置

为什么需要这些依赖:Spring Boot 3.4 内置 Micrometer 支持,但需要配合 OTel Registry 才能将指标通过 OTLP 协议上报到 OTel Collector。

xml 复制代码
<!-- pom.xml - Spring Boot 3.4.x 可观测性依赖 -->
<properties>
  <spring-boot.version>3.4.1</spring-boot.version>
  <opentelemetry.version>0.46.0</opentelemetry.version>
</properties>

<dependencies>
<!-- Spring Boot Starter: Actuator 提供 /actuator/prometheus 端点 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- Micrometer + Prometheus: 指标采集与 Prometheus 格式输出 -->
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

<!-- OpenTelemetry API: 统一 Tracing API -->
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-api</artifactId>
</dependency>

<!-- OpenTelemetry SDK: Tracing 实现 -->
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-sdk</artifactId>
</dependency>

<!-- OTLP Exporter: 将 Trace 数据通过 OTLP 协议导出 -->
<dependency>
  <groupId>io.opentelemetry</groupId>
  <artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>

<!-- Micrometer Tracing + OTel Bridge: 
     Micrometer Tracing API 到 OTel SDK 的桥接 -->
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>

<!-- OTel ResourceBundle: 自动配置 OTel SDK -->
<dependency>
  <groupId>io.opentelemetry.instrumentation</groupId>
  <artifactId>opentelemetry-spring-boot-starter</artifactId>
</dependency>
</dependencies>

3.2 业务指标采集核心代码

为什么需要自定义指标:默认 Actuator 只暴露 JVM 和 HTTP 级指标,交易系统的核心业务指标(订单处理量、支付成功率、风控耗时)必须手动埋点。

java 复制代码
package com.trading.observability.metrics;

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

/**
 * 交易业务指标采集器
 * 
 * 采集维度:
 * - 订单创建量(按状态分类:成功/失败/超时)
 * - 支付处理耗时(P50/P95/P99)
 * - 风控评估耗时
 * - 账户余额查询缓存命中率
 */
@Component
public class TradingMetrics {

    private final Counter orderCreateSuccessCounter;
    private final Counter orderCreateFailCounter;
    private final Counter orderCreateTimeoutCounter;
    private final Timer paymentProcessTimer;
    private final Timer riskEvaluateTimer;
    private final Counter cacheHitCounter;
    private final Counter cacheMissCounter;

    public TradingMetrics(MeterRegistry registry) {
        // 订单创建计数器 - 按状态分标签
        this.orderCreateSuccessCounter = Counter.builder("trading.order.create")
                .tag("status", "success")
                .description("订单创建成功数")
                .register(registry);

        this.orderCreateFailCounter = Counter.builder("trading.order.create")
                .tag("status", "fail")
                .description("订单创建失败数")
                .register(registry);

        this.orderCreateTimeoutCounter = Counter.builder("trading.order.create")
                .tag("status", "timeout")
                .description("订单创建超时数")
                .register(registry);

        // 支付处理耗时 - 自动计算 P50/P95/P99
        this.paymentProcessTimer = Timer.builder("trading.payment.process")
                .description("支付处理耗时")
                .publishPercentiles(0.5, 0.95, 0.99)
                .publishPercentileHistogram()
                .register(registry);

        // 风控评估耗时
        this.riskEvaluateTimer = Timer.builder("trading.risk.evaluate")
                .description("风控评估耗时")
                .publishPercentiles(0.5, 0.95, 0.99)
                .publishPercentileHistogram()
                .register(registry);

        // 缓存命中率统计
        this.cacheHitCounter = Counter.builder("trading.cache.request")
                .tag("result", "hit")
                .description("缓存命中数")
                .register(registry);

        this.cacheMissCounter = Counter.builder("trading.cache.request")
                .tag("result", "miss")
                .description("缓存未命中数")
                .register(registry);
    }

    public void recordOrderCreateSuccess() {
        orderCreateSuccessCounter.increment();
    }

    public void recordOrderCreateFail() {
        orderCreateFailCounter.increment();
    }

    public void recordOrderCreateTimeout() {
        orderCreateTimeoutCounter.increment();
    }

    public Timer.Sample startPaymentTimer() {
        return Timer.start();
    }

    public void recordPaymentDuration(Timer.Sample sample) {
        sample.stop(paymentProcessTimer);
    }

    public Timer.Sample startRiskTimer() {
        return Timer.start();
    }

    public void recordRiskDuration(Timer.Sample sample) {
        sample.stop(riskEvaluateTimer);
    }

    public void recordCacheHit() {
        cacheHitCounter.increment();
    }

    public void recordCacheMiss() {
        cacheMissCounter.increment();
    }
}

3.3 指标埋点切面封装

为什么用 AOP 埋点:手动在业务代码里插入指标采集代码会导致业务逻辑与监控代码耦合,用切面统一处理更优雅。

java 复制代码
package com.trading.observability.aspect;

import com.trading.observability.metrics.TradingMetrics;
import io.micrometer.core.instrument.Timer;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

/**
 * 可观测性埋点切面
 * 
 * 通过 AOP 统一采集业务方法耗时和调用次数,
 * 避免在业务代码中硬编码监控逻辑
 */
@Aspect
@Component
public class ObservabilityAspect {

    private final TradingMetrics tradingMetrics;

    public ObservabilityAspect(TradingMetrics tradingMetrics) {
        this.tradingMetrics = tradingMetrics;
    }

    /**
     * 支付处理方法埋点
     * 切面目标:PaymentService.process() 方法
     */
    @Around("execution(* com.trading.payment.PaymentService.process(..))")
    public Object observePayment(ProceedingJoinPoint pjp) throws Throwable {
        Timer.Sample sample = tradingMetrics.startPaymentTimer();
        try {
            Object result = pjp.proceed();
            tradingMetrics.recordOrderCreateSuccess();
            return result;
        } catch (Exception e) {
            tradingMetrics.recordOrderCreateFail();
            throw e;
        } finally {
            tradingMetrics.recordPaymentDuration(sample);
        }
    }

    /**
     * 风控评估方法埋点
     * 切面目标:RiskService.evaluate() 方法
     */
    @Around("execution(* com.trading.risk.RiskService.evaluate(..))")
    public Object observeRisk(ProceedingJoinPoint pjp) throws Throwable {
        Timer.Sample sample = tradingMetrics.startRiskTimer();
        try {
            return pjp.proceed();
        } finally {
            tradingMetrics.recordRiskDuration(sample);
        }
    }
}

3.4 application.yml 配置

为什么需要这些配置:Spring Boot 3.4 的 Observability 支持需要显式开启,并配置 OTLP 导出端点。

yaml 复制代码
# application.yml - 可观测性配置
spring:
  application:
    name: trading-service
  # 开启 Observability 自动配置
  autoconfigure:
    exclude: [ ]

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
  # Prometheus 端点配置
  prometheus:
    metrics:
      export:
        enabled: true
  # 指标标签配置
  metrics:
    tags:
      application: ${spring.application.name}
      env: ${SPRING_PROFILES_ACTIVE:dev}
    distribution:
      percentiles-histogram:
        trading.payment.process: true
        trading.risk.evaluate: true
      slo:
        trading.payment.process: 100ms,200ms,500ms,1s,5s
        trading.risk.evaluate: 50ms,100ms,200ms,500ms

# OpenTelemetry 配置
otel:
  exporter:
    otlp:
      endpoint: http://otel-collector:4317
      # 使用 gRPC 协议(4317 端口)
      # HTTP 协议对应 4318 端口
      protocol: grpc
  resource:
    attributes:
      service.name: ${spring.application.name}
      deployment.environment: ${SPRING_PROFILES_ACTIVE:dev}
  # 采样策略:生产环境用 parent_based_traceid_ratio
  traces:
    sampler:
      probability: 0.1

# Micrometer Tracing 配置
tracing:
  sampling:
    probability: 0.1
  # 将 TraceID 和 SpanID 注入日志 MDC
  propagation:
    type: w3c

4. OpenTelemetry 链路追踪实战

4.1 全链路 Trace 时序图

一次完整的交易请求,需要经过网关、交易服务、账户服务、风控服务四个节点。以下是请求链路的时序关系:

关键点:所有服务共享同一个 TraceID(abc123),通过 W3C TraceContext 在 HTTP Header 中自动传播,无需手动传递。

4.2 自定义 Span 埋点

为什么需要自定义 Span:默认的 HTTP Span 只记录接口调用,但交易系统的核心业务逻辑(幂等校验、额度扣减、异步通知)需要更细粒度的追踪。

java 复制代码
package com.trading.observability.tracing;

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.api.trace.SpanKind;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.context.Scope;
import org.springframework.stereotype.Component;

/**
 * 交易链路追踪器
 * 
 * 为交易核心流程创建细粒度 Span:
 * 1. 幂等校验 Span
 * 2. 额度扣减 Span
 * 3. 异步通知 Span
 */
@Component
public class TradingTracer {

    private final Tracer tracer;

    public TradingTracer(Tracer tracer) {
        this.tracer = tracer;
    }

    /**
     * 幂等校验 Span
     * 记录幂等键查询和重复请求判断
     */
    public boolean checkIdempotent(String idempotentKey) {
        Span span = tracer.spanBuilder("idempotent-check")
                .setSpanKind(SpanKind.INTERNAL)
                .setAttribute("idempotent.key", idempotentKey)
                .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // 查询 Redis 判断是否重复请求
            boolean isDuplicate = checkDuplicateInRedis(idempotentKey);
            span.setAttribute("idempotent.duplicate", isDuplicate);
            return isDuplicate;
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.recordException(e);
            throw e;
        } finally {
            span.end();
        }
    }

    /**
     * 额度扣减 Span
     * 记录扣减前后余额、扣减金额
     */
    public void deductBalance(String accountId, long amount) {
        Span span = tracer.spanBuilder("balance-deduct")
                .setSpanKind(SpanKind.INTERNAL)
                .setAttribute("account.id", accountId)
                .setAttribute("deduct.amount", amount)
                .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // 执行额度扣减逻辑
            long balanceBefore = getBalance(accountId);
            long balanceAfter = balanceBefore - amount;
            span.setAttribute("balance.before", balanceBefore);
            span.setAttribute("balance.after", balanceAfter);
            updateBalance(accountId, balanceAfter);
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.recordException(e);
            throw e;
        } finally {
            span.end();
        }
    }

    /**
     * 异步通知 Span
     * 记录通知目标、重试次数
     */
    public void sendAsyncNotification(String orderId, String channel) {
        Span span = tracer.spanBuilder("async-notification")
                .setSpanKind(SpanKind.INTERNAL)
                .setAttribute("order.id", orderId)
                .setAttribute("notification.channel", channel)
                .startSpan();

        try (Scope scope = span.makeCurrent()) {
            // 异步发送通知
            doSendNotification(orderId, channel);
        } catch (Exception e) {
            span.setStatus(StatusCode.ERROR, e.getMessage());
            span.setAttribute("notification.retry", true);
            span.recordException(e);
            // 通知失败不阻断主流程
        } finally {
            span.end();
        }
    }

    // --- 以下为模拟方法,实际项目中对接真实存储 ---

    private boolean checkDuplicateInRedis(String key) {
        return false;
    }

    private long getBalance(String accountId) {
        return 100000L;
    }

    private void updateBalance(String accountId, long balance) {
        // update DB
    }

    private void doSendNotification(String orderId, String channel) {
        // send notification
    }
}

4.3 Span 与 Metrics 的关联

为什么需要关联:当 Grafana 中看到 trading.payment.process 的 P99 飙升时,需要直接跳转到对应的 Trace 链路查看是哪个 Span 拖慢了整体耗时。

java 复制代码
package com.trading.observability.tracing;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import io.opentelemetry.api.trace.Span;
import org.springframework.stereotype.Component;

/**
 * Trace-Metric 关联桥接器
 * 
 * 核心机制:在 Timer 记录指标时,将当前 Span 的 TraceID 
 * 写入 Metric 的 exemplar,Grafana 可通过 exemplar 
 * 直接跳转到对应 Trace
 */
@Component
public class TraceMetricBridge {

    private final MeterRegistry meterRegistry;

    public TraceMetricBridge(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    /**
     * 记录带 TraceID 关联的指标
     * Prometheus 的 exemplar 机制会自动关联当前 TraceID
     */
    public Timer.Sample startTracedTimer(String metricName) {
        return Timer.start(meterRegistry);
    }

    /**
     * 结束计时并记录指标
     * OTel Bridge 会自动将当前上下文中的 TraceID 
     * 作为 exemplar 附加到 Prometheus 指标
     */
    public void stopTracedTimer(Timer.Sample sample, String metricName) {
        Timer timer = Timer.builder(metricName)
                .register(meterRegistry);
        sample.stop(timer);
        // exemplar 关联由 micrometer-tracing-bridge-otel 自动完成
    }
}

5. 日志关联 Trace 实战

5.1 Logback 配置注入 TraceID

为什么需要 MDC 注入:日志默认没有 TraceID,排查问题时无法将日志与 Trace 链路关联。通过 Logback 的 MDC 机制,每行日志自动携带 TraceID 和 SpanID。

xml 复制代码
<!-- logback-spring.xml - 注入 TraceID 到日志 -->
<configuration>
  <!-- 引入 Spring Boot 默认配置 -->
  <include resource="org/springframework/boot/logging/logback/defaults.xml"/>

  <!-- 控制台输出 - 带 TraceID -->
  <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
      <!-- 关键:traceId 和 spanId 由 Micrometer Tracing 自动注入 MDC -->
      <pattern>
        %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [traceId=%X{traceId:-},spanId=%X{spanId:-}] - %msg%n
      </pattern>
    </encoder>
  </appender>

  <!-- JSON 格式输出到文件 - 便于 Loki 采集 -->
  <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>logs/trading-service.json</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
      <fileNamePattern>logs/trading-service.%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
      <maxFileSize>200MB</maxFileSize>
      <maxHistory>30</maxHistory>
    </rollingPolicy>
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
      <!-- 自动从 MDC 中提取 traceId 和 spanId -->
      <includeMdcKeyName>traceId</includeMdcKeyName>
      <includeMdcKeyName>spanId</includeMdcKeyName>
      <includeMdcKeyName>traceFlags</includeMdcKeyName>
    </encoder>
  </appender>

  <!-- 异步 Appender - 避免日志 IO 阻塞业务线程 -->
  <appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>1024</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>true</neverBlock>
    <appender-ref ref="JSON_FILE"/>
  </appender>

  <root level="INFO">
    <appender-ref ref="CONSOLE"/>
    <appender-ref ref="ASYNC"/>
  </root>
</configuration>

5.2 日志输出效果

配置完成后,日志输出自动携带 TraceID:

text 复制代码
2026-06-15 10:17:23.456 [http-nio-8080-exec-1] INFO  c.t.order.OrderController [traceId=abc123def456,spanId=789ghi012] - 创建订单请求: orderId=ORD20260615001
2026-06-15 10:17:23.512 [http-nio-8080-exec-1] INFO  c.t.account.AccountService [traceId=abc123def456,spanId=345jkl678] - 查询账户余额: accountId=ACC001, balance=50000.00
2026-06-15 10:17:23.578 [http-nio-8080-exec-1] INFO  c.t.risk.RiskService [traceId=abc123def456,spanId=901mno234] - 风控评估通过: level=LOW
2026-06-15 10:17:23.634 [http-nio-8080-exec-1] INFO  c.t.order.OrderController [traceId=abc123def456,spanId=789ghi012] - 订单创建成功: orderId=ORD20260615001

在 Grafana/Loki 中通过 {app="trading-service"} |= "abc123def456" 即可查出同一条 Trace 的所有日志。

6. OTel Collector 配置

6.1 Collector 配置文件

为什么需要 OTel Collector:它是可观测性体系的中枢,统一接收 Trace/Metric/Log 三种信号,完成批量处理、采样和路由,避免每个服务直连后端存储。

yaml 复制代码
# otel-collector-config.yaml
receivers:
  # 接收 OTLP gRPC 数据(Spring Boot 上报)
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # 批量处理 - 减少 API 调用次数
  batch:
    send_batch_size: 1024
    timeout: 5s

  # 尾部采样 - 保留错误和慢请求的完整 Trace
  tail_sampling:
    decision_wait: 10s
    policies:
      # 保留所有错误的 Trace
      - name: error-policy
        type: status_code
        status_code:
          status_codes:
            - ERROR
      # 保留耗时超过 1s 的 Trace
      - name: latency-policy
        type: latency
        latency:
          threshold: 1000ms
      # 正常请求 10% 采样
      - name: normal-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 10

  # 资源属性处理 - 添加环境信息
  resource:
    attributes:
      - key: collector.version
        value: 0.96.0
        action: upsert

exporters:
  # 导出到 Jaeger(Trace)
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true

  # 导出到 Prometheus(Metric)
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write
    resource_to_telemetry_conversion:
      enabled: true

  # 导出到 Loki(Log)
  loki:
    endpoint: http://loki:3100/loki/api/v1/push
    default_labels_enabled:
      exporter: false
      resource: true

service:
  pipelines:
    traces:
      receivers: [ otlp ]
      processors: [ batch, tail_sampling, resource ]
      exporters: [ otlp/jaeger ]
    metrics:
      receivers: [ otlp ]
      processors: [ batch, resource ]
      exporters: [ prometheusremotewrite ]
    logs:
      receivers: [ otlp ]
      processors: [ batch, resource ]
      exporters: [ loki ]

7. Grafana Dashboard 搭建

7.1 核心 Dashboard 配置

为什么需要统一 Dashboard:分散的监控面板导致排障时频繁切换页面,统一 Dashboard 实现指标 → Trace → 日志一站式排障。

json 复制代码
{
  "dashboard": {
    "title": "交易系统可观测性总览",
    "tags": [
      "trading",
      "observability"
    ],
    "timezone": "browser",
    "panels": [
      {
        "title": "订单创建 QPS",
        "type": "timeseries",
        "gridPos": {
          "h": 8,
          "w": 12,
          "x": 0,
          "y": 0
        },
        "targets": [
          {
            "expr": "sum(rate(trading_order_create_total{status=\"success\"}[5m]))",
            "legendFormat": "成功 QPS"
          },
          {
            "expr": "sum(rate(trading_order_create_total{status=\"fail\"}[5m]))",
            "legendFormat": "失败 QPS"
          }
        ]
      },
      {
        "title": "支付处理耗时 P99",
        "type": "timeseries",
        "gridPos": {
          "h": 8,
          "w": 12,
          "x": 12,
          "y": 0
        },
        "targets": [
          {
            "expr": "histogram_quantile(0.99, sum(rate(trading_payment_process_seconds_bucket[5m])) by (le))",
            "legendFormat": "P99"
          }
        ],
        "fieldConfig": {
          "defaults": {
            "unit": "s",
            "thresholds": {
              "steps": [
                {
                  "value": null,
                  "color": "green"
                },
                {
                  "value": 0.5,
                  "color": "yellow"
                },
                {
                  "value": 1.0,
                  "color": "red"
                }
              ]
            }
          }
        }
      },
      {
        "title": "Trace 链路搜索",
        "type": "tracesearch",
        "gridPos": {
          "h": 8,
          "w": 24,
          "x": 0,
          "y": 8
        },
        "datasource": "Jaeger",
        "targets": [
          {
            "query": "service=trading-service",
            "queryType": "search"
          }
        ]
      }
    ]
  }
}

7.2 Grafana 数据源关联配置

在 Grafana 中配置三个数据源的关联关系,实现指标 → Trace → 日志的跳转:

yaml 复制代码
# Grafana 数据源配置 - datasources.yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      # 启用 exemplar 关联,支持从指标跳转到 Trace
      exemplarTraceIdDestinations:
        - name: traceId
          datasourceUid: jaeger

  - name: Jaeger
    type: jaeger
    uid: jaeger
    url: http://jaeger:16686
    jsonData:
      # 启用 Trace 到 Log 的关联跳转
      tracesToMetrics:
        - datasourceUid: prometheus
          tags:
            - key: service.name
              value: service
      tracesToLogs:
        - datasourceUid: loki
          tags: [ "service.name" ]
          mappedTags: [ { key: "service.name", value: "app" } ]
          spanStartTimeShift: "-1h"
          spanEndTimeShift: "1h"
          filterByTraceID: true
          filterBySpanID: false

  - name: Loki
    type: loki
    uid: loki
    url: http://loki:3100
    jsonData:
      # 启用 Log 到 Trace 的关联跳转
      derivedFields:
        - datasourceUid: jaeger
          matcherRegex: '"traceId":"(\\w+)"'
          name: TraceID
          url: "$${__value.raw}"

8. AtomCode 介入点:Rules + Skill + Agent

8.1 Rules:规范埋点注解

为什么需要 Rules:团队 5 个组埋点风格不统一,有的用 @Timed,有的用 Timer.Sample,有的直接写 Micrometer API。AtomCode Rules 强制统一埋点规范。

markdown 复制代码
# .trae/rules/observability-instrumentation-rule.md

# Spring Boot 可观测性埋点规范

> **版本**: v1.0
> **适用范围**: 所有 Spring Boot 3.x 微服务

## 埋点注解规范

### 1. 必须使用 @Observed 注解

- Controller 层:在类级别添加 `@Observed`,自动采集 HTTP 请求指标
- Service 层:在关键业务方法添加 `@Observed`,必须指定 `contextualName`
- 禁止在 Controller/Service 中直接使用 `MeterRegistry` API

### 2. 自定义 Span 命名规范

- 格式:`{业务域}.{操作}.{子步骤}`
- 示例:`order.create.idempotent-check`、`payment.process.deduct`
- 禁止:`span1`、`mySpan`、`doWork` 等无意义命名

### 3. Span Attributes 必须

- 必须包含业务 ID:`order.id`、`account.id`
- 必须包含结果状态:`result.status` = success/fail/timeout
- 禁止包含敏感信息:密码、Token、完整银行卡号

### 4. 采样策略

- 生产环境:`probability = 0.1`(10% 采样率)
- 预发环境:`probability = 1.0`(100% 采样率)
- 错误和慢请求由 Collector 尾部采样兜底

### 5. 日志 MDC 规范

- 所有日志必须配置 `traceId` 和 `spanId` 字段
- 异步线程必须传播 TraceContext
- 禁止在日志中打印无 TraceID 的请求日志

8.2 Skill:生成 Grafana Dashboard JSON

为什么需要 Skill:手写 Grafana Dashboard JSON 耗时且容易出错,一个包含 10 个面板的 Dashboard 配置约 500 行 JSON。AtomCode Skill 可以根据服务名和指标名自动生成。

markdown 复制代码
# .trae/skills/grafana-dashboard-generator/SKILL.md

---
name: grafana-dashboard-generator
description: 根据 Spring Boot 服务名和 Micrometer 指标名,自动生成 Grafana Dashboard JSON 配置
trigger: 用户提供服务名和指标列表时自动触发
---

## 输入格式

用户提供:

- 服务名(如 trading-service)
- 指标列表(如 trading.order.create, trading.payment.process)

## 输出规范

1. 生成标准 Grafana Dashboard JSON
2. 包含 QPS、P99 耗时、错误率、Trace 链路搜索四个核心面板
3. 配置 exemplar 关联跳转到 Jaeger
4. 配置阈值告警颜色(绿 → 黄 → 红)
5. 输出到 `{service-name}-dashboard.json` 文件

## 面板布局

- Row 1: QPS + 错误率(2 列)
- Row 2: P50/P95/P99 耗时(1 列全宽)
- Row 3: Trace 链路搜索(1 列全宽)
- Row 4: JVM 概览(内存/线程/GC,3 列)

使用示例------在 AtomCode 中输入:

text 复制代码
@skill grafana-dashboard-generator
服务名: trading-service
指标列表:
  - trading.order.create (Counter, tags: status)
  - trading.payment.process (Timer)
  - trading.risk.evaluate (Timer)
  - trading.cache.request (Counter, tags: result)

AtomCode 自动生成包含 12 个面板的完整 Dashboard JSON,比手写节省约 40 分钟。

8.3 Agent:自动补全 Span 和 TraceContext 传播

为什么需要 Agent:新增的 Service 方法经常忘记添加 @Observed 注解,异步线程漏传 TraceContext 的情况更多。AtomCode Agent 可以自动检测并补全。

Agent 检测逻辑:

markdown 复制代码
# .trae/agents/observability-guard-agent/AGENT.md

---
name: observability-guard-agent
description: 自动检测 Spring Boot 代码中缺失的可观测性埋点,并补全 @Observed 注解和 TraceContext 传播
trigger: 保存 Java 文件时自动运行
---

## 检测规则

### 1. 缺失 @Observed 检测

- 扫描所有 @Service 类的 public 方法
- 如果方法包含数据库/缓存/外部调用,但没有 @Observed 注解
- 自动建议添加:`@Observed(contextualName = "{业务域}.{方法名}")`

### 2. 异步线程 TraceContext 传播检测

- 扫描所有 @Async 方法和 CompletableFuture 使用
- 如果异步任务没有包装 TraceContext
- 自动建议使用:
  ```java
  // 错误:TraceContext 丢失
  CompletableFuture.runAsync(() -> doWork());
  
  // 正确:传播 TraceContext
  Context context = Context.current();
  CompletableFuture.runAsync(() -> context.wrap(() -> doWork()));

3. 日志 MDC 传播检测

  • 扫描所有 @Scheduled 和 @EventListener 方法

  • 如果方法内有日志输出但没有 MDC 清理

  • 自动建议添加 try-finally 包裹:

    java 复制代码
    MDC.clear();
    try {
        // 业务逻辑
    } finally {
        MDC.clear();
    }
java 复制代码
Agent 实际介入效果------以下是一段被 Agent 自动补全的代码:

```java
// === 修改前:缺少可观测性埋点 ===
@Service
public class NotificationService {

    @Async
    public void sendOrderNotification(String orderId, String channel) {
        log.info("发送通知: orderId={}", orderId);
        // 异步线程中 TraceContext 丢失,日志无 traceId
        doSend(orderId, channel);
    }
}

// === 修改后:Agent 自动补全 ===
@Service
public class NotificationService {

    @Async
    @Observed(contextualName = "notification.send-order")  // Agent 自动添加
    public void sendOrderNotification(String orderId, String channel) {
        // Agent 自动包装 TraceContext 传播
        Context traceContext = Context.current();
        Runnable wrapped = traceContext.wrap(() -> {
            log.info("发送通知: orderId={}, channel={}", orderId, channel);
            doSend(orderId, channel);
        });
        wrapped.run();
    }
}

9. 生产级踩坑实录

踩坑 1:OTel SDK 与 Micrometer Tracing 版本冲突

现象 :启动时报 ClassNotFoundException: io.opentelemetry.api.trace.TracerBuilder,服务无法启动。

根因opentelemetry-spring-boot-starter 0.46.0 内置了 OTel SDK 1.38.0,但 micrometer-tracing-bridge-otel 1.4.x 依赖的是 OTel API 1.40.0,两者版本不一致导致类找不到。

解决方案:在 BOM 中统一管理 OTel 版本:

xml 复制代码
<!-- 统一 OTel 版本管理 -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.opentelemetry</groupId>
      <artifactId>opentelemetry-bom</artifactId>
      <version>1.40.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <dependency>
      <groupId>io.opentelemetry.instrumentation</groupId>
      <artifactId>opentelemetry-instrumentation-bom</artifactId>
      <version>2.8.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

启示:OTel 生态组件多,版本号密集,一定要用 BOM 统一管理,不要让不同 starter 各自引入传递依赖。

踩坑 2:异步线程丢失 TraceContext

现象 :Grafana 中 Trace 链路在 @Async 方法处断开,后续操作没有 Span 记录,日志中 traceId 为空。

根因@Async 默认使用 SimpleAsyncTaskExecutor,不会自动传播 OTel 的 TraceContext。Spring 的 TaskDecorator 机制需要显式配置。

解决方案:配置 OTel 感知的 TaskDecorator:

java 复制代码
package com.trading.config;

import io.opentelemetry.context.Context;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.TaskDecorator;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setThreadNamePrefix("trading-async-");
        // 关键:设置 TaskDecorator 传播 TraceContext
        executor.setTaskDecorator(new TracingTaskDecorator());
        executor.initialize();
        return executor;
    }

    /**
     * OTel TraceContext 传播装饰器
     * 将父线程的 Context 包装到异步任务中
     */
    static class TracingTaskDecorator implements TaskDecorator {
        @Override
        public Runnable decorate(Runnable runnable) {
            Context context = Context.current();
            return context.wrap(runnable);
        }
    }
}

启示 :凡是使用 @AsyncCompletableFuture@Scheduled 的场景,都必须显式传播 TraceContext,否则链路必断。

踩坑 3:Prometheus Exemplar 不生效

现象:Prometheus 指标面板上没有"跳转到 Trace"的按钮,exemplar 数据为空。

根因 :两个问题叠加------一是 Prometheus 必须启用 --enable-feature=exemplar-storage;二是 micrometer-registry-prometheus 默认不写入 exemplar,需要 OTel Bridge。

解决方案

yaml 复制代码
# Prometheus 启动参数
# docker-compose.yml
prometheus:
  image: prom/prometheus:v2.52.0
  command:
    - '--config.file=/etc/prometheus/prometheus.yml'
    - '--enable-feature=exemplar-storage'
    - '--storage.tsdb.retention.time=30d'

同时确保项目中引入了 micrometer-tracing-bridge-otel 依赖,它会自动将当前 TraceID 注入 Prometheus 的 exemplar。

启示:Exemplar 是打通 Metric → Trace 的关键,但默认不开启,需要在 Prometheus 端和 Micrometer 端同时配置。

踩坑 4:Collector 尾部采样导致内存飙升

现象:OTel Collector 在流量高峰期内存占用从 500MB 飙升到 4GB,多次 OOM 被 Kill。

根因tail_sampling 处理器会在内存中缓存最近 10s 的所有 Span,等待决策期结束后再判断是否保留。交易高峰期 QPS 飙升时,缓存的 Span 数量暴增。

解决方案 :限制 tail_sampling 的内存占用,并配合 span_metrics 处理器预过滤:

yaml 复制代码
# 优化后的 tail_sampling 配置
processors:
  tail_sampling:
    decision_wait: 5s        # 从 10s 降到 5s
    num_traces: 50000        # 限制内存中的 Trace 数量
    expected_new_traces_per_sec: 1000

  # 增加内存限制
  memory_limiter:
    check_interval: 1s
    limit_mib: 1500
    spike_limit_mib: 256

启示 :尾部采样是双刃剑,保留更多错误和慢请求的 Trace,代价是更高的内存开销。生产环境必须配置 memory_limiternum_traces 上限。

Collector 尾部采样内存优化前后对比图:

踩坑 5:Logback AsyncAppender 丢失 TraceID

现象:高峰期部分日志的 traceId 为空字符串,导致 Loki 中无法通过 TraceID 查到完整日志。

根因AsyncAppenderneverBlock=true 配置下,当队列满时新日志直接丢弃。但更关键的是,MDC 是 ThreadLocal 实现,AsyncAppender 将日志写入任务提交到独立线程时,MDC 的 TraceID 可能已被清除。

解决方案 :使用 logstash-logback-encoder 的 MDC 支持 + 确认 Micrometer Tracing 的 Slf4JAutoConfiguration 已生效:

java 复制代码
package com.trading.config;

import io.micrometer.tracing.Tracer;
import org.slf4j.MDC;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.core.task.TaskDecorator;

/**
 * 确保 MDC 在异步线程中正确传播
 */
@Configuration
public class MDCAsyncConfig implements AsyncConfigurer {

    private final Tracer tracer;

    public MDCAsyncConfig(Tracer tracer) {
        this.tracer = tracer;
    }

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(200);
        executor.setTaskDecorator(runnable -> {
            // 捕获当前线程的 MDC 和 TraceContext
            var traceId = tracer.currentTraceContext().traceId();
            var spanId = tracer.currentTraceContext().spanId();
            var mdcContextMap = MDC.getCopyOfContextMap();

            return () -> {
                // 在异步线程中恢复 MDC
                if (mdcContextMap != null) {
                    MDC.setContextMap(mdcContextMap);
                }
                try {
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        });
        executor.initialize();
        return executor;
    }
}

启示:MDC 是 ThreadLocal 机制,跨线程时必须手动传播。Spring Boot 3.4 的 Micrometer Tracing 自动处理了 HTTP 线程的 MDC 注入,但异步线程需要自行处理。

10. 效果复盘

10.1 排障效率对比

可观测性体系上线后,同样的订单延迟问题,排障路径完全不同:

可观测性建设前后排障效率对比图:

对比项 建设前 建设后
问题定位时间 3-4 小时 10-15 分钟
需要协作人数 3 个组 5+ 人 1 人独立完成
排障路径 看指标 → 切日志 → 猜服务 → 反复确认 看指标 → 点 Exemplar → 看 Trace → 查关联日志
Trace 覆盖率 0% 85%(10% 采样 + 错误/慢请求 100%)
日志可关联率 0%(无 TraceID) 95%+
误报率 高(无关联,多猜多试) 低(Trace 精确定位慢 Span)

10.2 排障流程对比

建设前

text 复制代码
告警 → 看 Grafana 指标 → 发现延迟高
  → 登录 ELK 搜日志 → 日志没有 TraceID 无法串联
  → 逐个服务看日志 → 3 个组各自排查
  → 人工对比时间戳 → 猜测可能是 Redis 的问题
  → 确认 Redis 连接池 → 找到根因
  总耗时:约 4 小时

建设后

text 复制代码
告警 → 看 Grafana 指标 → 发现 P99 飙升
  → 点击 Exemplar 跳转 Trace → 看到 Redis 连接池等待 Span 耗时 2800ms
  → 点击 Span 关联日志 → 看到 "Redis connection wait timeout" 错误
  → 定位到连接池配置 → 修改重启
  总耗时:约 15 分钟

10.3 AtomCode 介入效果量化

AtomCode 介入效果对比图:

介入点 手动耗时 AtomCode 辅助耗时 节省比例
埋点注解编写 20 min/服务 5 min/服务 75%
Grafana Dashboard JSON 40 min/Dashboard 8 min/Dashboard 80%
TraceContext 传播修复 30 min/异步方法 5 min/异步方法 83%
埋点规范检查 Code Review 人工盯 Agent 自动检测 100%

11. 总结与最佳实践

11.1 核心经验

  1. TraceID 是灵魂:三大支柱的关联核心就是 TraceID,没有它可观测性就是三个孤岛
  2. OTel Collector 是枢纽:统一接收、处理、路由三种信号,不要让服务直连后端
  3. Exemplar 打通 Metric → Trace:这是 Grafana 中的关键跳转能力,默认不开启
  4. 异步线程必须手动传播 TraceContext :Spring 的 @AsyncCompletableFuture@Scheduled 都不会自动传播
  5. 采样策略要分层:正常请求低采样 + 错误/慢请求 100% 采集 = 成本与可观测性的平衡

11.2 AtomCode 的核心价值

在整个可观测性体系建设中,AtomCode 不是主角,但它是关键的生产力工具:

  • Rules 统一埋点规范:5 个组的埋点风格从混乱到统一
  • Skill 生成 Dashboard:JSON 配置从手写 40 分钟到自动生成 8 分钟
  • Agent 自动补全 Span :新增代码不再遗漏 @Observed 注解

11.3 适用边界

本方案适用于以下场景:

  • Spring Boot 3.x 微服务架构(2.7 需要额外适配)
  • 日均请求量 10 万 - 1000 万的中大型系统
  • 已有 Prometheus + Grafana + ELK 基础设施

不适用于:单体应用(无需分布式追踪)、日均请求 1 亿+ 的超大规模系统(需要考虑采样策略和 Collector 集群化部署)。
📜 真实性声明

本文所有内容均基于作者在 2025 年参与的一个交易中台项目中的真实经验。所有案例、数据、代码均来自生产环境,经过实践验证。为保护商业机密,部分敏感信息已做脱敏处理,但技术细节保持完整和真实。

如有任何疑问,欢迎在评论区交流讨论。
👍 如果本文对你有帮助,欢迎点赞、收藏、转发! 💬 如果你在搭建可观测性体系时遇到了问题,欢迎在评论区贴出日志,我会逐一回复! 🔔 关注我,获取 Spring Boot 实战系列文章! ✍️ 行文仓促,定有不足之处,欢迎各位朋友在评论区批评指正,不胜感激!

专栏导航:

相关推荐
TCW11211 小时前
AI底层系列:用C++实现线性代数的公式推导与算法设计-8.线性变化(3)
c++·人工智能·算法
TDengine (老段)1 小时前
TDengine JOIN 完整语法 — Inner/Outer/ASOF/Window 全语法详解
java·大数据·数据库·物联网·时序数据库·tdengine·涛思数据
皓月斯语2 小时前
B3849 [GESP样题 三级] 进制转换 题解
c++·算法·题解
中微极客2 小时前
剪枝与量化:让YOLO在边缘设备上高效部署
算法·yolo·剪枝
wasp5202 小时前
Vibe-Trading 深度解析(一):用 LLM 驱动的完整股票研究智能体架构总览
人工智能·架构·交易·ai coding·vibe trading
@灯神2 小时前
SpringAI系列|第1篇:SpringAI概述与快速上手
java·人工智能·spring·ai·ai编程
国科安芯2 小时前
抗辐射微控制器在卫星电源管理分系统中的控制架构与可靠性研究——基于AS32S601型MCU的星载能源系统智能化管理技术分析
网络·单片机·嵌入式硬件·安全·架构·系统架构·能源
中国搜索直付通2 小时前
避开直付通选型暗礁:二级商户的合规生存与背景甄别
java·大数据·开发语言·人工智能·游戏
微三云云仔2 小时前
GEO系统技术架构拆解:微三云双引擎产品的模块化设计逻辑
架构