Java 基础体系 · 第 33/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 可观测性:Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO

先定义“可观测性”解决什么问题

可观测性(Observability)不是“把日志、指标和 Trace 都接上”。它关注的是:系统内部状态是否能够仅通过外部产生的遥测数据被推断出来。

生产故障通常先表现为用户可感知的结果:

  • 请求成功率下降;
  • 延迟超过可接受范围;
  • 消息积压;
  • 数据库连接池耗尽;
  • 某个租户或接口异常;
  • JVM 没有崩溃,但线程、堆或锁竞争导致服务不可用。

可观测性系统要把这些结果与原因连接起来。通常需要四类数据:

  1. 日志(Logs):某个事件发生时,记录了什么上下文。
  2. 指标(Metrics):在一段时间内,某类事件聚合后的数量、比例或分布。
  3. Trace:一次请求跨越多个服务、线程和外部系统时,完整的因果路径。
  4. 剖析与诊断证据:例如 JFR、线程转储、堆转储和 GC 日志,用于解释 JVM 内部行为。

OpenTelemetry 主要定义和实现遥测数据的生成、上下文传播和导出;Micrometer 主要提供 Java 应用中的指标与观测抽象;日志框架负责记录日志事件;SLO 则把“系统是否足够可靠”定义成可计算的目标。

它们的关系不是替代关系:

flowchart LR
    A[HTTP 请求] --> B[Spring MVC/WebFlux]
    B --> C[业务代码]
    C --> D[数据库/消息队列/远程服务]

    B --> E[Observation]
    C --> F[自定义指标]
    C --> G[结构化日志]
    E --> H[Trace Span]
    E --> I[Timer/Histogram]

    H --> J[OpenTelemetry SDK 或 Java Agent]
    I --> K[Micrometer Registry]
    G --> L[日志采集器]

    J --> M[OTel Collector]
    K --> M
    L --> M

    M --> N[Metrics Backend]
    M --> O[Trace Backend]
    M --> P[Log Backend]

    N --> Q[SLI/SLO/告警]
    O --> R[Trace 检索]
    P --> S[事件上下文]

关键路径是:

  • 请求进入应用后,框架可能创建一个 Observation;
  • Observation 可以关联一个 Trace Span,也可以产生计时指标;
  • 日志通过上下文携带 trace_idspan_id
  • 指标判断“哪里异常”,Trace 定位“哪一次请求慢”,日志解释“这一请求发生了什么”。

如果三类数据没有共享服务名、环境、版本和关联 ID,它们就只是三个互相独立的查询系统。


遥测数据的共同基础:资源、上下文和传播

资源属性标识“谁产生了数据”

遥测数据不能只有指标名或日志文本,还需要资源属性(Resource Attributes)。资源描述产生数据的进程、服务和部署环境,例如:

service.name=orders
service.version=2025.09.3
deployment.environment=prod
service.instance.id=orders-7f8d9c6b8d-x2k4m
cloud.region=cn-shanghai

其中:

  • service.name 用于把同一服务的日志、指标和 Trace 归并;
  • service.version 用于比较发布前后的错误率和延迟;
  • service.instance.id 用于定位单个实例;
  • 环境、区域和集群属性用于分组查询。

资源属性与业务标签不同。service.name 通常是低基数属性;user.id、订单号和请求 ID 则不适合直接作为指标标签。

上下文决定“当前发生在什么操作中”

OpenTelemetry Context 是当前执行位置的上下文,最重要的内容是当前 Span。一个 Span 通常包含:

trace_id       一次端到端请求的标识
span_id        当前操作的标识
parent_span_id 父操作标识
name           操作名称
start/end      开始和结束时间
attributes     结构化属性
events         时间点事件
status         操作状态

Trace 是一棵有向树。例如:

Trace: 4bf92f3577b34da6a3ce929d0e0e4736

HTTP GET /orders/42
└── orders.load
    ├── SQL SELECT orders
    └── HTTP payment.authorize
        └── Redis GET payment-token

Span 的父子关系表示调用关系,但不自动证明业务因果关系。异步任务、消息队列和批处理需要显式传播上下文,否则下游会出现新的 Trace,或者所有消息错误地挂到同一个父 Span 下。

HTTP 传播不是把完整 Trace 放进 URL

常见的 W3C Trace Context 使用 HTTP Header:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
tracestate: ...

接收方从 Header 提取上下文,创建服务端 Span;发送方把当前上下文注入出站请求。

传播必须经过可信边界。来自公网的 Trace Header 不能被当作认证信息,也不能把任意请求 Header 原样写入日志。Trace ID 可用于关联数据,但不应承担权限校验。

在线程池、CompletableFuture、定时任务和 Reactor 中,ThreadLocal 上下文都可能失效:

executor.submit(() -> {
    // 这里未必仍然处于提交任务时的 Span 上下文中
});

原因是执行任务的线程可能不同,且线程复用时不会自动复制提交线程的 ThreadLocal。框架集成通常会负责传播;自行封装线程池时,应使用框架提供的上下文传播机制,或者显式捕获并恢复上下文。不能通过“把 Trace ID 放入一个全局变量”解决,否则并发请求会互相覆盖。


Micrometer:应用指标和观测的 Java 抽象

Meter 是什么

Micrometer 把指标抽象为 Meter。常见类型如下。

Counter

Counter 表示只增不减的事件累计量,例如请求数、失败数和重试数:

Counter counter = registry.counter(
        "orders.payment.failures",
        "provider", "alipay"
);
counter.increment();

Counter 的绝对值通常没有直接意义,监控系统会计算速率:

failure_rate=ΔfailuresΔt\text{failure\_rate} = \frac{\Delta \text{failures}}{\Delta t}

如果应用重启,进程内 Counter 可能归零。Prometheus 等后端通常把它识别为单调计数器并处理重置,但查询必须使用适合计数器的函数,例如 rate()increase(),不能直接把多个实例的当前值相加后当成每秒失败数。

Gauge

Gauge 表示当前值,可以上升也可以下降,例如队列长度、连接池活动连接数和 JVM 内存使用量:

AtomicInteger queueSize = new AtomicInteger();

Gauge.builder("orders.queue.size", queueSize, AtomicInteger::get)
        .description("Current order queue size")
        .register(registry);

Gauge 不表示事件累计量。读取 Gauge 的时刻才决定它的值;如果被观测对象被垃圾回收,某些实现还可能无法继续提供有效值。因此不要用 Gauge 记录“累计处理订单数”。

Timer

Timer 记录耗时和调用次数:

Timer timer = registry.timer("orders.payment.duration");

timer.record(() -> paymentClient.authorize(order));

Timer 通常至少产生 count 和 total time,配置了直方图后还可以提供延迟分布。平均延迟为:

mean=total_timecount\text{mean} = \frac{\text{total\_time}}{\text{count}}

但平均值不能回答“有多少请求超过 500 ms”。SLO 通常需要阈值计数或直方图分位数。

DistributionSummary

DistributionSummary 与 Timer 类似,但记录的是数值分布而不是时间,例如订单金额、响应大小和批量大小。金额和响应大小通常需要明确单位,避免同一指标在不同代码路径中一处使用字节、一处使用 KB。

Observation 把指标和 Trace 连接起来

Micrometer Observation 是比单独创建 Timer 更高层的观测抽象。一个 Observation 可以有:

  • 开始和结束时间;
    -低基数 Key-Value,用于指标标签和 Span 属性;
  • 高基数 Key-Value,只放到 Trace 或日志上下文中;
  • 错误状态;
  • Observation Handler,用于连接 Micrometer Tracing 或其他后端。

示例:

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.stereotype.Service;

@Service
public class PaymentService {

    private final ObservationRegistry observationRegistry;
    private final PaymentClient paymentClient;

    public PaymentService(
            ObservationRegistry observationRegistry,
            PaymentClient paymentClient) {
        this.observationRegistry = observationRegistry;
        this.paymentClient = paymentClient;
    }

    public PaymentResult authorize(Order order) {
        Observation observation = Observation.createNotStarted(
                        "orders.payment.authorize",
                        observationRegistry)
                .lowCardinalityKeyValue("provider", order.provider())
                .lowCardinalityKeyValue("result", "unknown")
                .highCardinalityKeyValue("order.id", order.id());

        return observation.observe(() -> {
            try {
                PaymentResult result = paymentClient.authorize(order);

                // 这里只是示意:实际项目中应在 Observation 生命周期内
                // 通过合适的 Observation API 更新结果标签。
                return result;
            } catch (RuntimeException e) {
                observation.error(e);
                throw e;
            }
        });
    }
}

这里的设计重点不是方法名,而是标签分类:

  • provider=alipay:可能只有有限几个值,适合低基数标签;
  • result=success|failure:低基数;
  • order.id=...:每个订单不同,属于高基数,只适合 Trace 或日志。

在 Spring 应用中,HTTP Server、RestClient、WebClient、数据库访问等组件通常可以自动创建 Observation,具体覆盖范围取决于 Spring Boot、底层客户端和所加入的依赖。自定义 Observation 应覆盖业务边界,而不是给每一行代码都创建 Span。

Micrometer 与 OpenTelemetry 的关系

Micrometer 可以把指标导出到多种后端,例如 Prometheus、OTLP、Graphite 和 JMX。OpenTelemetry 则提供统一的 Metrics、Traces 和 Logs 数据模型、SDK、API、自动埋点和 OTLP 协议。

常见组合有三种:

  1. Micrometer Metrics + Prometheus

    • 应用通过 /actuator/prometheus 暴露指标;
    • Prometheus 主动抓取;
    • 适合已经采用 Prometheus 的系统。
  2. Micrometer Metrics + OTLP

    • Micrometer 使用 OTLP registry 推送指标;
    • 指标进入 OTel Collector 或兼容 OTLP 的后端;
    • 适合统一遥测出口。
  3. Micrometer Observation + Micrometer Tracing + OpenTelemetry

    • Observation 负责应用侧观测;
    • Micrometer Tracing 负责与 Trace API 和传播机制集成;
    • OpenTelemetry SDK 或 OTLP exporter 负责导出。

不要把“使用 Micrometer”理解为“已经使用 OpenTelemetry”。Micrometer 是观测抽象与指标生态;OpenTelemetry 是跨语言遥测标准和实现体系。二者可以桥接,也可以分别使用。


OpenTelemetry:从埋点到后端的数据路径

OpenTelemetry 的典型组件包括:

  • API:应用代码依赖的接口,例如 TracerMeter
  • SDK:采样、批处理、资源、处理器和导出器的实现;
  • Instrumentation:对 HTTP、数据库、消息客户端等库的自动或手动埋点;
  • Exporter:将数据发送到后端或 Collector;
  • Collector:接收、处理、批量、采样、路由和导出遥测数据。

Java Agent 与 SDK 埋点

Java Agent 适合快速获得框架和库级别的自动埋点,启动方式类似:

java \
  -javaagent:/opt/otel/opentelemetry-javaagent.jar \
  -Dotel.service.name=orders \
  -Dotel.resource.attributes=deployment.environment=prod,service.version=2025.09.3 \
  -Dotel.exporter.otlp.endpoint=http://otel-collector:4317 \
  -Dotel.exporter.otlp.protocol=grpc \
  -Dotel.traces.exporter=otlp \
  -Dotel.metrics.exporter=otlp \
  -jar orders.jar

前置条件:

  • Agent JAR 必须与部署镜像一起发布;
  • Collector 必须监听对应的 OTLP gRPC 端口;
  • 应确认 Agent 版本支持目标 JDK、框架和客户端;
  • 生产环境应固定 Agent 版本,而不是使用不受控的动态下载。

Java Agent 的优点是无需修改大量业务代码,缺点是:

  • 自动埋点名称和属性受 Agent 版本影响;
  • 可能捕获过多数据库或 HTTP 细节;
  • 与应用内手动埋点叠加时会出现重复 Span;
  • 启动参数和类加载增强会增加排障复杂度。

SDK 手动埋点适合业务语义,例如“库存预占”“支付授权”和“订单状态机迁移”:

import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.StatusCode;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

public final class InventoryService {

    private final Tracer tracer;

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

    public void reserve(String sku, int quantity) {
        Span span = tracer.spanBuilder("inventory.reserve")
                .setAttribute("inventory.sku", sku)
                .setAttribute("inventory.quantity", quantity)
                .startSpan();

        try (Scope ignored = span.makeCurrent()) {
            // 业务代码、数据库客户端调用等在当前 Span 下执行
            doReserve(sku, quantity);
        } catch (RuntimeException e) {
            span.recordException(e);
            span.setStatus(StatusCode.ERROR, "reserve failed");
            throw e;
        } finally {
            span.end();
        }
    }

    private void doReserve(String sku, int quantity) {
        // 实际库存操作
    }
}

这个例子展示了完整生命周期:

  1. startSpan() 创建 Span;
  2. makeCurrent() 将其放入当前上下文;
  3. 子调用可以继承当前上下文;
  4. 异常要 recordException
  5. 失败状态要明确设置;
  6. 无论成功还是失败都必须 end()

如果只创建 Span 而不 end(),导出器无法得到完整结束时间;如果只记录异常但不设置错误状态,后端按状态筛选时可能漏掉失败请求。

OpenTelemetry Collector

Collector 不是必须组件,但在生产环境常用于隔离应用和后端。一个最小配置如下:

receivers:
  otlp:
    protocols:
      grpc:
      http:

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512
  batch:
    send_batch_size: 1024
    timeout: 5s

exporters:
  debug:
    verbosity: basic

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [debug]

它表示:

  1. 从 OTLP gRPC 或 HTTP 接收数据;
  2. memory_limiter 防止 Collector 在内存压力下无限增长;
  3. batch 减少小批量网络发送;
  4. 暂时输出到调试导出器。

生产环境通常将 debug 替换成具体后端 exporter,并配置重试、队列、TLS、认证和资源限制。Collector 内存限制不是“保证永不丢数据”;当上游速率持续高于下游处理能力时,系统必须丢弃、限流或反压,不能凭配置消除容量问题。

OTLP 常见端口是:

  • 4317:OTLP/gRPC;
  • 4318:OTLP/HTTP。

端口只是常见约定,不是所有部署都必须使用它们。应用和 Collector 的协议、地址、TLS 和认证必须一致。


日志:记录事件,而不是复制 Trace

结构化日志的基本字段

文本日志便于人工阅读,但不便于可靠查询:

2025-09-03 12:00:01 payment failed order=1001 provider=alipay

结构化日志将字段编码为 JSON 或其他机器可解析格式:

{
  "timestamp": "2025-09-03T12:00:01.123Z",
  "level": "ERROR",
  "service.name": "orders",
  "service.version": "2025.09.3",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "event.name": "payment.authorize.failed",
  "provider": "alipay",
  "order_id": "1001",
  "error.type": "TimeoutException",
  "message": "payment provider timed out"
}

日志字段应区分:

  • 稳定查询字段:event.nameprovidererror.type
  • 关联字段:trace_idspan_id
  • 业务定位字段:订单号、租户号,但必须遵守隐私和访问控制;
  • 展示文本:message

密码、Token、Cookie、完整银行卡号和个人敏感信息不能因为“方便排查”而写入日志。日志一旦进入集中系统,访问面通常比业务数据库更广。

日志级别不是 SLO

ERROR 只表示开发者认为事件值得以错误级别记录,不等于一次 SLO 失败。反过来,HTTP 500 如果没有日志,也仍然是可计算的错误请求。

例如:

  • 业务拒绝支付可能是正常业务结果,不应全部记为 ERROR;
  • 超时导致请求 500,通常应影响可用性 SLI;
  • 调试日志丢失不一定影响 SLO,但会影响诊断能力。

因此,SLO 应从请求结果、延迟和业务成功定义中计算,不能直接用“ERROR 日志数量”代替。

Trace ID 的生成、传播和日志关联

在同步调用中,日志框架常通过 MDC 保存当前 Trace ID:

logger.info("payment authorization started");

日志编码器从 MDC 读取 trace_idspan_id。但 MDC 是线程局部状态,在线程池和异步链路中可能:

  • 丢失 Trace ID;
  • 错配到上一个任务;
  • 在任务结束后污染线程池线程。

因此,必须使用框架或库提供的上下文传播,而不是手工在请求开始时 MDC.put 后就认为所有异步代码都安全。测试时应并发执行多个请求,检查每条日志的 Trace ID 是否与所属请求一致。


指标:从事件聚合出系统状态

指标的四个关键问题

一个生产指标至少需要回答:

  1. 它测量的对象是什么;
  2. 单位是什么;
  3. 标签有哪些;
  4. 数据在重启、扩缩容和时钟变化时如何解释。

例如:

http.server.request.duration
unit: seconds
attributes:
  http.request.method
  http.route
  http.response.status_code
  service.name

相比之下,下面的指标设计风险很高:

request.duration{url="/orders/123456",user_id="u-987654"}

因为 URL 参数和用户 ID 会造成高基数。

基数为什么会造成故障

假设指标有三个标签:

  • method 有 5 个值;
  • status 有 10 个值;
  • user_id 有 1,000,000 个值。

理论时间序列数为:

5×10×1,000,000=50,000,0005 \times 10 \times 1{,}000{,}000 = 50{,}000{,}000

即使实际组合远少于理论值,后端也需要为每个组合维护索引、样本和查询结构。指标内存与标签组合数量近似增长,而不是与“代码行数”增长。

正确做法是:

  • 指标标签使用有限集合,例如 HTTP 方法、路由模板和状态类别;
  • 用户 ID、订单 ID 放到日志或 Trace;
  • 使用路由模板 /orders/{id},不要使用实际 URL;
  • 对动态标签设置白名单或在采集端丢弃。

计数、直方图与分位数

若只记录平均延迟:

count = 100
total = 10s
mean = 100ms

并不能推导出尾延迟。两组请求可以拥有相同平均值:

A: 100 次都是 100ms
B: 99 次 1ms,1 次 9.901s

两者平均值都约为 100 ms,但 B 的用户体验完全不同。

直方图把观测值放入桶。例如延迟桶:

le=0.05   80
le=0.1    95
le=0.5    99
le=1.0    100

le=0.1 表示不超过 100 ms 的累计请求数是 95。Prometheus 直方图桶通常是累计的,因此计算分位数时不能把相邻桶直接当作互斥桶。

如果后端支持 Prometheus Histogram,可以用近似的 99 分位数:

histogram_quantile(
  0.99,
  sum by (le) (
    rate(http_server_request_duration_seconds_bucket{
      service="orders",
      route="/orders/{id}"
    }[5m])
  )
)

这只是桶边界内的近似值,结果受桶配置影响。桶太少会导致分位数不准确;桶太多会增加时间序列数量。

Exemplar 把指标点连接到 Trace

Exemplar 是附着在指标样本上的少量关联信息,通常包含 Trace ID。例如某个延迟桶的样本旁边附带:

trace_id=4bf92f3577b34da6a3ce929d0e0e4736

这样可以从“延迟突然升高”的指标图直接跳到一个具体慢请求。Exemplar 不是把每个 Trace 都存进指标,也不是高基数标签;它通常是采样关联数据,具体能力取决于 Micrometer、采集协议和指标后端。


Trace:解释一次请求为什么失败或变慢

Span 的边界必须对应有意义的操作

合理的 Span:

HTTP GET /orders/{id}
SQL SELECT order
HTTP payment.authorize

不合理的 Span:

Order.getId()
String.toUpperCase()
每一次循环迭代

Span 有启动、属性、事件和结束成本。给每个函数都创建 Span 会增加 CPU、内存、导出流量和后端索引压力,却不一定增加诊断信息。

Span 名称应稳定,不能把动态 ID 拼进名称:

正确:GET /orders/{id}
错误:GET /orders/100001

动态值可以作为受控属性或日志字段,但也要考虑敏感信息和基数。

Span 状态与异常记录是两件事

发生异常时:

  • recordException(e) 记录异常事件和堆栈;
  • setStatus(ERROR, ...) 表示这个操作失败;
  • end() 完成生命周期。

捕获异常后重新抛出是常见做法,因为上层框架可能据此设置 HTTP 状态码。若异常被业务逻辑正常吸收,例如备用支付渠道成功,则原始异常可以作为事件记录,但最终 Span 是否为 ERROR 应按照这个操作对调用者的最终结果判断。

采样不是只在后端删除数据

采样决定哪些 Trace 被保留。常见策略包括:

  • Head sampling:请求开始时决定;
  • Tail sampling:Collector 收集完整 Trace 后,根据错误、延迟或业务属性决定;
  • 按比例采样;
  • 错误和慢请求保留,成功请求降低比例。

Head sampling 的问题是:请求刚进入系统时还不知道它最终是否超时或报错。如果一开始丢弃了 Trace,后续再发现错误也无法补回完整链路。

Tail sampling 更适合“保留错误 Trace 和慢 Trace”,但需要 Collector 暂存同一 Trace 的多个 Span,并正确处理超时、内存和多实例分片。它不是免费能力,Collector 需要按 Trace ID 将 Span 路由到同一处理节点。

采样会影响排障证据,但通常不应改变 SLO 的计算。SLO 指标应尽量基于完整请求计数或可靠的服务端指标,而不是仅根据采样 Trace 推导。


Spring Boot 中的端到端接入方式

下面示例采用 Spring Boot 的 Actuator、Micrometer Observation 和 OpenTelemetry 生态。具体依赖版本应由与 Java 25 兼容的 Spring Boot 版本管理;Spring Boot 和 Spring Framework 的属性名、默认埋点范围应以项目实际版本的官方 Reference 为准。

依赖职责

典型依赖职责如下:

spring-boot-starter-actuator
    暴露健康检查、指标端点和观测基础设施

micrometer-registry-prometheus
    通过 Prometheus 格式暴露指标

micrometer-registry-otlp
    通过 OTLP 导出 Micrometer 指标

micrometer-tracing-bridge-otel
    将 Micrometer Observation 与 OpenTelemetry Trace 集成

opentelemetry-exporter-otlp
    将 Trace 等数据通过 OTLP 导出

不要同时无目的地启用 Java Agent 自动埋点和应用内完整 SDK 埋点。二者都可能为同一个 HTTP 或数据库调用创建 Span,结果是重复 Trace。若必须并用,应明确边界:Agent 负责库级埋点,应用代码只负责业务 Span。

Actuator 指标端点

一种常见的开发配置是:

management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.probes.enabled=true

前置条件是加入 Actuator 和 Prometheus registry。访问:

curl -s http://localhost:8080/actuator/health
curl -s http://localhost:8080/actuator/prometheus | head

可能看到:

# HELP http_server_requests_seconds
# TYPE http_server_requests_seconds histogram
...

实际指标名称可能因 Spring Boot、Micrometer 和命名约定发生变化,不能在告警规则中只凭人肉记忆猜名称。应在目标版本中查看实际输出,并固定测试验证。

Actuator 端点不应无保护地暴露到公网:

  • /health 可通过探针需要的最小信息暴露;
  • /metrics/prometheus 可能泄露业务量、路径和实例信息;
  • 管理端口、网络策略和认证应与业务端口隔离;
  • Prometheus 抓取失败时要监控抓取状态,而不是认为“应用没有指标”。

调整采样率时的语义

在支持的 Spring Boot 版本中,可以配置 Trace 采样概率,例如:

management.tracing.sampling.probability=0.1

这表示以约 10% 的概率采样 Trace,但具体实现仍可能受传播、Agent 或 SDK 配置影响。调高到 1.0 适合短时验证,不适合长期生产运行,原因是 Trace 存储、网络出口和后端索引成本都会增加。

对于 SLO,推荐单独使用服务端请求计数和延迟直方图;不要因为 Trace 采样率为 10% 就把 Trace 数量乘以 10 后作为精确请求量。

使用 HTTP 服务指标计算错误率

假设后端提供按路由、状态码统计的指标,可以定义:

sum(rate(http_server_requests_seconds_count{
  service="orders",
  route="/orders/{id}",
  status=~"5.."
}[5m]))
/
sum(rate(http_server_requests_seconds_count{
  service="orders",
  route="/orders/{id}"
}[5m]))

分子是 5xx 请求速率,分母是全部请求速率。这个公式隐含了两个前提:

  1. 分子和分母使用相同的时间窗口和标签范围;
  2. 指标覆盖了所有应计入 SLI 的请求。

如果把健康检查、静态资源和管理端点混入分母,低质量请求会稀释错误率;如果只统计某个实例,扩缩容后结论也会变化。


SLI、SLO 与错误预算

SLI 是可测量的服务指标

SLI(Service Level Indicator)是衡量服务表现的指标。例如可用性 SLI:

Availability=Good EventsValid Events\text{Availability} = \frac{\text{Good Events}}{\text{Valid Events}}

其中:

  • Valid Events 是应该计入的请求总数;
  • Good Events 是符合成功条件的请求数。

如果只把 HTTP 2xx 算成功,可能遗漏业务层失败。例如 HTTP 200 返回:

{"code": "PAYMENT_FAILED"}

那么业务可用性不能仅由 HTTP 状态码决定。SLI 的“好”必须和用户结果一致。

SLO 是目标,不是测量

SLO(Service Level Objective)是对 SLI 的目标约束,例如:

过去 30 天,订单查询的有效请求中至少 99.9% 在 500 ms 内完成。

这个目标包含:

  • 服务范围:订单查询;
  • 时间窗口:30 天;
  • 合格条件:耗时不超过 500 ms;
  • 目标比例:99.9%。

SLA 通常是面向用户或客户的合同承诺,可能包含赔偿条款;SLO 是工程目标;SLI 是实际测量值。三者不能混用。

完整算例:请求型可用性 SLO

某服务在 30 天内处理了:

有效请求数 = 10,000,000
成功请求数 = 9,990,500
失败请求数 = 9,500

可用性为:

SLI=9,990,50010,000,000=0.99905=99.905%\text{SLI} = \frac{9{,}990{,}500}{10{,}000{,}000} = 0.99905 = 99.905\%

若 SLO 是 99.9%,允许的错误比例为:

10.999=0.001=0.1%1 - 0.999 = 0.001 = 0.1\%

错误预算为:

10,000,000×0.001=10,00010{,}000{,}000 \times 0.001 = 10{,}000

实际消耗 9,500,剩余预算:

10,0009,500=50010{,}000 - 9{,}500 = 500

所以该窗口满足 SLO,但只剩 500 个错误事件预算。若接下来继续以当前速度失败,预算会很快耗尽。

对于时间型 SLO,例如“实例可用时间 99.9%”,30 天的理论错误预算约为:

30×24×60×60×0.001=2592 秒=43 分钟 12 秒30 \times 24 \times 60 \times 60 \times 0.001 = 2592\text{ 秒} = 43\text{ 分钟 }12\text{ 秒}

请求型 SLO 和时间型 SLO 不等价。一次持续 10 分钟的全站故障可能影响大量请求;一次只影响单个租户的大量业务错误则可能在时间型可用性指标中被低估。

延迟 SLO 应使用合格事件比例

假设延迟 SLO 为:

99% 的有效请求耗时不超过 500 ms

正确的 SLI 是:

Latency Compliance=duration0.5s 的请求数有效请求总数\text{Latency Compliance} = \frac{\text{duration} \le 0.5s\text{ 的请求数}} {\text{有效请求总数}}

不要把“P99 小于 500 ms”机械地等同于这个比例,尤其在窗口边界、直方图近似和后端分位数算法不同的情况下。可以使用直方图桶直接计算:

sum(rate(http_server_requests_seconds_bucket{
  service="orders",
  route="/orders/{id}",
  le="0.5"
}[30d]))
/
sum(rate(http_server_requests_seconds_count{
  service="orders",
  route="/orders/{id}"
}[30d]))

查询语法和指标名称必须按实际后端数据模型调整。关键是分子使用 le=0.5 的累计桶,分母使用全部请求数。

Error Budget Burn Rate

错误预算消耗率(Burn Rate)描述当前错误速度相对于允许错误速度的倍数:

Burn Rate=实际错误比例允许错误比例\text{Burn Rate} = \frac{\text{实际错误比例}} {\text{允许错误比例}}

对于 99.9% SLO,允许错误比例是 0.1%。如果最近 1 小时错误比例为 1%,则:

Burn Rate=1%0.1%=10\text{Burn Rate} = \frac{1\%}{0.1\%} = 10

这意味着按当前速度消耗预算的速度是正常允许速度的 10 倍。

短窗口能快速发现事故,长窗口能避免短时噪声。常见做法是同时检查:

短窗口:过去 5 分钟 Burn Rate 很高
长窗口:过去 1 小时 Burn Rate 也明显升高

只有两个窗口都超过阈值才告警,可以降低单次抖动造成的误报。阈值不是规范固定值,应根据窗口、目标和响应时间推导。例如希望 1 小时内消耗完 30 天预算,Burn Rate 大约为:

30×241=720\frac{30 \times 24}{1} = 720

但实际告警还要考虑采集延迟、统计误差和人工响应时间。


用 SLO 驱动告警,而不是用单个机器指标告警

CPU 80% 不一定表示用户失败,CPU 30% 也可能存在数据库连接池耗尽。告警应该分为两层:

用户结果告警

直接关注:

  • 可用性 SLI;
  • 延迟 SLI;
  • 消息处理成功率;
  • 数据新鲜度;
  • 错误预算消耗率。

这些告警回答“用户是否已经受到影响”。

原因线索告警

用于缩短定位时间:

  • JVM 堆使用率和 GC 暂停;
  • 活跃线程和线程池队列;
  • 数据库连接池等待;
  • 下游 HTTP 超时;
  • Kafka 消费延迟;
  • 容器重启和 OOMKilled;
  • Collector 发送失败或队列堆积。

这些指标回答“可能为什么发生”。

原因指标不能替代用户结果指标。否则容易产生“机器看起来健康,但用户已经失败”的盲区。


日志、指标和 Trace 的联合排障流程

一次典型的延迟事故可以按证据链排查:

  1. SLO 告警

    • 发现订单查询延迟合格率下降;
    • 确认是全服务、单区域还是单版本。
  2. 指标分组

    • routestatusinstanceversion、下游依赖分组;
    • 判断是 5xx、超时还是长尾延迟。
  3. Exemplar 或 Trace

    • 从高延迟直方图跳到具体 Trace;
    • 查看服务端 Span、数据库 Span 和远程调用 Span 的耗时占比。
  4. 结构化日志

    • trace_id 检索同一请求的日志;
    • 查看重试、降级、异常类型和业务参数。
  5. JVM 诊断

    • 如果 Trace 显示应用内部等待,使用 JFR、JMC、jcmd、线程转储或堆转储;
    • 判断是 GC、锁竞争、线程池阻塞、类加载还是内存泄漏。

例如 Trace 显示:

HTTP /orders/{id}        1200 ms
└── orders.load           1180 ms
    └── SQL SELECT         1170 ms

下一步应检查数据库连接池等待和数据库端执行计划,而不是先增加 JVM 堆。

如果 Trace 显示:

HTTP /orders/{id}        1200 ms
└── orders.load           1200 ms
    └── 没有明显子 Span

这并不证明业务代码很快。可能是:

  • 手动埋点缺失;
  • CPU 计算没有细分 Span;
  • 线程被锁或条件变量阻塞;
  • 上下文传播丢失;
  • Span 导出被采样或丢弃。

此时 JFR 和线程转储比继续增加日志更有价值。可观测性数据是证据链的一部分,但任何一种数据都不能单独解释所有问题。


生产中的故障路径与数据丢失

遥测链路本身也会故障:

sequenceDiagram
    participant App as Java 应用
    participant Agent as SDK/Agent
    participant C as OTel Collector
    participant B as 后端

    App->>Agent: 创建 Span/Metric/Log
    Agent->>Agent: 采样、批处理、入队
    Agent->>C: OTLP gRPC/HTTP
    C->>C: 限制内存、批处理、重试
    C->>B: 导出
    B-->>C: 成功或失败
    C-->>Agent: 接收结果
    Agent-->>App: 继续处理请求

可能的失败点:

  1. 应用队列满

    • SDK 为保护应用而丢弃遥测;
    • 不能为了保留 Trace 阻塞所有业务请求。
  2. Collector 不可达

    • 网络、DNS、TLS 或认证失败;
    • 应用可能重试,也可能丢弃。
  3. Collector 内存压力

    • memory_limiter 触发;
    • 数据被拒绝或丢弃,具体表现取决于处理器和导出器。
  4. 后端限流

    • Collector 的发送队列增长;
    • 最终可能丢数据或增加延迟。
  5. 后端索引成本过高

    • 高基数标签导致查询慢、写入贵或拒绝数据。

生产取舍通常是:遥测系统不能拖垮业务系统。因此应配置有界队列、批处理、超时和内存限制,并监控遥测自身的丢弃率、导出错误和队列长度。fire-and-forget 不等于可靠投递;需要可靠审计的数据不能只依赖日志或 Trace,应该进入专门的持久化消息或数据库流程。


采集内容、隐私和访问控制

可观测性会扩大数据暴露范围。以下字段尤其需要审查:

  • 用户 ID、手机号、邮箱;
  • 订单详情和支付信息;
  • Authorization Header;
  • Cookie 和 Session;
  • SQL 参数;
  • 请求体和响应体;
  • Trace 属性中的动态业务数据。

推荐先定义字段白名单,再决定采集方式:

  • 指标:只保留有限集合的聚合维度;
  • Trace:保留用于定位路径的属性;
  • 日志:保留诊断所需事件和受控业务标识;
  • 敏感数据:脱敏、哈希或完全禁止采集。

哈希不一定等于匿名化。如果原始取值空间很小,攻击者仍可枚举并反推出原值。日志后端和 Trace 后端也应配置租户隔离、角色权限、保留期和删除策略。


与 Java 25 运行时诊断的边界

Java 可观测性数据回答的是不同层次的问题:

数据 擅长回答
SLO/SLI 用户结果是否达标
指标 哪个维度、哪个时间段异常
Trace 一次请求经过了哪些操作,耗时在哪里
日志 某个事件发生时的详细上下文
JFR JVM 和应用运行期间的低开销事件证据
线程转储 当前线程在运行、阻塞或等待什么
堆转储 对象引用关系和内存保留原因
GC 日志 垃圾回收行为和暂停情况

例如,指标显示 P99 延迟上升,Trace 显示没有明显下游耗时,线程转储发现大量线程等待同一把锁,JFR 则可以进一步提供锁竞争和 CPU 采样证据。这个链路比“看到慢请求后盲目调大堆”更可靠。

Java 25 本身不会自动保证所有框架埋点或 OTel Agent 都兼容。应在目标 JDK、目标 Spring Boot、目标 Agent 和目标容器镜像的组合上验证:

  • 启动是否成功;
  • HTTP、数据库、消息 Trace 是否存在;
  • 异步上下文是否正确;
  • JFR、jcmd 和转储操作是否满足容器权限;
  • 采样和导出是否在压力下稳定。

常见误解与失败表现

“有日志就有可观测性”

失败表现是日志量很大,却无法回答:

  • 哪个版本开始失败;
  • 哪个接口的错误率上升;
  • 一次请求经过了哪个下游;
  • 失败是否超出 SLO。

根因是日志是离散事件,不能自然替代聚合指标和跨服务 Trace。

“有 Trace 就能计算准确 SLO”

采样 Trace 不能代表完整请求集合。采样策略还可能偏向成功请求、入口服务或某些路径。SLO 应使用完整的服务端计数和延迟分布,或者使用经过验证的请求型指标。

“把用户 ID 加到指标标签,查询会更方便”

短期查询方便,长期会导致基数爆炸。正确的关联方式是:

指标:route=/orders/{id}, status=500
Trace:trace_id=...
日志:trace_id=..., order_id=...

“平均延迟下降,系统就变快了”

平均值会隐藏长尾。必须结合直方图、阈值合格率和分位数观察。尤其是支付、登录和数据库查询,少量极慢请求也可能造成明显用户影响。

“Collector 可以保证遥测不丢”

Collector 可以缓冲、重试和路由,但不能突破网络带宽、后端容量和进程内存上限。需要明确可接受的丢失策略,并对自身健康状态建立指标和告警。

“日志中的 Trace ID 一定正确”

在线程池、异步回调和 Reactor 链路中,上下文传播失败会造成 Trace ID 丢失或串线。应通过并发测试验证,而不是只在单线程开发环境中查看几条日志。


验证清单

一个 Java 服务完成接入后,至少应验证以下事实:

  1. 每个遥测信号都有稳定的 service.name 和版本信息;
  2. HTTP 请求能生成服务端 Trace;
  3. 出站 HTTP、数据库和消息调用能形成正确的父子关系;
  4. 同一个 Trace 的日志包含一致的 trace_id
  5. 异步线程、定时任务和消息消费不会串线;
  6. 指标标签不包含订单号、用户 ID 和原始 URL 参数;
  7. 直方图桶覆盖 SLO 的延迟阈值;
  8. 指标、日志和 Trace 的时间窗口与时区处理一致;
  9. Collector 不可达时,业务请求不会无限阻塞;
  10. 导出失败、队列堆积和丢弃数量可被监控;
  11. SLO 分母明确排除了哪些请求;
  12. 发布版本、实例和区域可以作为诊断维度;
  13. 高错误率和高长尾延迟都能产生可行动告警;
  14. 发生告警后可以从指标定位到 Trace,再从 Trace 定位到日志或 JVM 证据。

可观测性的最终价值不是遥测数据的数量,而是能否形成一条可验证的推理链:

SLO 不达标
→ 指标确认影响范围
→ Trace 找到慢操作或失败依赖
→ 日志提供事件上下文
→ JFR/线程转储/堆转储解释 JVM 内部原因
→ 修复后用同一 SLI 验证恢复

只有当这条链路在正常流量、异常流量、扩缩容、发布和依赖故障下都能工作时,Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO 才真正构成了 Java 生产系统的可观测性。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。