可观测性三件套:指标、日志、链路怎么串起来

有监控但查不到原因,是因为缺了「调用链耗时」这一层。四个黄金信号、结构化日志、1% 采样链路,以及告警该怎么分级。

一次线上事故的复盘:接口大面积超时,从 14:20 开始,14:50 才定位到原因。中间 30 分钟里,团队做了什么?

  • 看监控大盘,CPU、内存、QPS 都正常
  • 看应用日志,没有 ERROR
  • 看数据库,连接数正常
  • 看网络,没有丢包

四个「都正常」,但服务就是慢。 最后发现是一个下游 HTTP 接口响应时间从 50ms 涨到 3 秒,而这个调用链路的耗时没有任何地方记录——只有应用的整体 P99 涨了,但看不出慢在哪一段。

这就是「有监控但没可观测性」的典型状态。监控告诉你「有问题」,可观测性告诉你「问题在哪」。

一、三个支柱的分工

指标 Metrics 日志 Logging 链路 Tracing
回答什么 出了什么问题 具体发生了什么 慢在哪一段
数据结构 时序数值 文本行 调用树
存储成本 低(聚合后) 高 中(采样)
查询方式 聚合查询 全文检索 traceID 关联
典型工具 Prometheus Loki / ELK Jaeger / Tempo

三者不是替代关系。指标用于发现,日志用于定位单个事件,链路用于理解调用关系。

那个事故里缺的就是第三根支柱:有指标(知道 P99 涨了),有日志(知道每个请求处理了),但没有链路(不知道 3 秒花在哪个下游)。

二、指标:四个黄金信号

Google SRE 提出的四个信号,覆盖了大部分服务的健康判断:

信号 含义 典型指标
Latency 请求耗时 P50 / P95 / P99
Traffic 请求量 QPS / 消息数
Errors 错误率 5xx 比例 / 业务异常数
Saturation 饱和度 CPU / 内存 / 连接池 / 队列长度

为什么用分位数而不是平均值?

一个接口:99% 的请求 10ms,1% 的请求 3 秒。平均值 = 39ms,看起来很健康。但 P99 = 3 秒,说明有 1% 的用户在受苦。如果这个接口每天 100 万次调用,就是 1 万次慢请求。

P99 才是用户体验的真实反映。 平均值会被大量快请求稀释。

分位数的正确读法:

P50 = 12ms   → 一半用户感受到的速度
P95 = 45ms   → 95% 的用户在此之内
P99 = 380ms  → 尾部延迟,通常是 GC、锁、下游抖动
P999 = 2.1s  → 极端情况,通常是超时重试

P99 和 P50 差距很大时,一定有排队或资源竞争。 差距小说明处理时间稳定。

Prometheus 指标命名规范

// 反例
Counter requests = Counter.build().name("requests").help("...").register();

// 正例:<namespace>_<subsystem>_<name>_<unit>
Counter.builder("http_server_requests_total")
    .description("HTTP 请求总数")
    .tag("method", "GET")
    .tag("uri", "/api/orders")
    .tag("status", "200")
    .register(registry);

常用后缀:

  • _total:Counter(单调递增)
  • _seconds / _bytes:单位必须带
  • _bucket:Histogram 的桶
  • _info:静态信息

label 的基数(cardinality)是最大陷阱。 把 userId 做 label,100 万用户就有 100 万个时间序列,Prometheus 直接被打爆。

// 绝对禁止
.tag("user_id", userId)
.tag("order_id", orderId)
.tag("trace_id", traceId)      // 这个也不行

// 可以
.tag("uri", "/api/orders/{id}")   // 用路由模板,不是实际路径
.tag("status", "200")
.tag("method", "GET")

判断标准:label 的取值数量应该 < 100。 超过就要考虑换成日志或 tracing。

一个服务的必备指标清单

# JVM
jvm_memory_used_bytes{area="heap"}       # 堆使用
jvm_gc_pause_seconds_sum                 # GC 总停顿
jvm_gc_pause_seconds_count               # GC 次数
jvm_threads_live_threads                 # 活跃线程

# HTTP
http_server_requests_seconds_bucket      # 耗时分布(算 P99)
http_server_requests_total{status=~"5.."}  # 错误数

# 连接池
hikaricp_connections_active              # 活跃连接
hikaricp_connections_pending             # 等待连接的线程数 ← 关键
hikaricp_connections_timeout_total       # 获取连接超时次数

# 线程池
executor_active_threads                  # 活跃线程
executor_queued_tasks                    # 队列积压 ← 关键

# 业务
order_created_total                      # 业务量
order_failed_total{reason="insufficient_stock"}  # 业务失败

hikaricp_connections_pending 和 executor_queued_tasks 特别值得配告警——它们是「即将雪崩」的最早信号。

三、日志:结构化是前提

// ✗ 不可检索
log.info("用户 " + userId + " 下单成功,金额 " + amount);

// ✓ 结构化
log.info("order created userId={} orderId={} amount={}", userId, orderId, amount);

配合 JSON 输出:

<!-- logback-spring.xml -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
  <customFields>{"app":"order-service","env":"prod"}</customFields>
</encoder>

输出:

{
  "@timestamp": "2026-11-05T14:20:33.412Z",
  "level": "INFO",
  "message": "order created",
  "userId": "10086",
  "orderId": "NO20261105001",
  "amount": 299.00,
  "app": "order-service",
  "env": "prod",
  "traceId": "a3f2b1c9d8e7f6a5",
  "spanId": "1a2b3c4d"
}

有了 traceId 就能从日志跳到链路追踪。

日志级别的正确使用

级别 用途 生产量
ERROR 需要人介入的问题 应该接近 0
WARN 可自愈但值得关注 少量
INFO 关键业务节点 每请求 1~3 条
DEBUG 调试细节 生产关闭
TRACE 极详细 生产关闭

INFO 的粒度控制是关键。 每个请求打 20 条 INFO,1000 QPS 就是每秒 2 万条日志,一天 17 亿条——存储成本爆炸。

建议:每个请求打「入口 + 出口 + 关键分支」,3 条以内。

@Around("@annotation(LogExecution)")
public Object logExec(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();
    try {
        Object r = pjp.proceed();
        log.info("ok method={} cost={}ms", pjp.getSignature().getName(),
                 (System.nanoTime() - start) / 1_000_000);
        return r;
    } catch (Exception e) {
        log.error("fail method={} cost={}ms", pjp.getSignature().getName(),
                  (System.nanoTime() - start) / 1_000_000, e);
        throw e;
    }
}

不要打的四类日志

  1. 循环里打日志。1 万条数据循环里 log.debug,即使级别关着,字符串拼接也会执行。用 if (log.isDebugEnabled()) 包起来,或者用占位符({} 形式只在需要时才格式化)。
  2. 打整个对象。log.info("obj={}", hugeObject) 会把几 MB 的 toString 写进去,还可能触发懒加载异常。
  3. 打敏感信息。手机号、身份证、密码、token 必须脱敏。
  4. catch 后只打 message。log.error(e.getMessage()) 丢了堆栈,等于没打。

四、链路追踪:解决「慢在哪」

核心概念

Trace(一次完整请求)
  └── Span(一个操作单元)
        ├── span_id, parent_span_id
        ├── start_time, duration
        ├── tags(http.method, db.statement)
        └── logs(事件)

一次请求经过 gateway → order-service → inventory-service → MySQL,形成一棵树:

[traceId=a3f2]
gateway                 ████ 12ms
  order-service           ████████████ 180ms
    inventory-rpc              ████████ 150ms   ← 瓶颈在这
    mysql-query           ██ 8ms

一眼看出 150ms 花在 inventory-rpc 上。

Spring Boot 3 接入(Micrometer Tracing)

Spring Boot 3 用 Micrometer Tracing 替代了 Spring Cloud Sleuth:

<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: 1.0        # 开发环境全采样
  zipkin:
    tracing:
      endpoint: http://zipkin:9411/api/v2/spans

采样率是成本与可见性的权衡:

环境 采样率 说明
开发 1.0(100%) 全采,便于调试
测试 1.0 同上
生产 0.01~0.1 1%~10%,配合「错误请求全采」

生产环境 1% 采样,一天 100 万请求也有 1 万个 trace,足够分析性能分布。但要注意:低概率的偶发问题可能采不到。所以要配「错误和慢请求 100% 采样」:

@Bean
public Sampler customSampler() {
    return new Sampler() {
        @Override
        public boolean isSampled(long traceId) {
            // 基础采样 5%
            if (ThreadLocalRandom.current().nextInt(100) < 5) return true;
            // 慢请求或错误请求全采(由业务标记)
            return SlowRequestContext.isSlowOrError();
        }
    };
}

跨线程传递 traceId

异步任务、线程池会丢失上下文:

// 错误:异步线程里没有 traceId
executor.execute(() -> log.info("async task"));   // 日志里 traceId 为空

// 修复:用 TraceableExecutorService 包装
ExecutorService traced = ExecutorServiceTaskDecorator.decorate(executor);

Spring Boot 3 提供了 TaskDecorator:

@Bean
public TaskDecorator tracingDecorator() {
    return new ContextPropagatingTaskDecorator();
}

HTTP 调用也要传递:

// RestTemplate 会自动加 traceparent 头(如果配了 ObservationRegistry)
RestTemplate rt = new RestTemplateBuilder()
    .observationRegistry(observationRegistry)
    .build();

MQ 消息同理——消息生产时把 traceId 放进 header,消费时取出并恢复上下文。

五、三者的关联:从指标跳到日志再跳到链路

这是可观测性的最高价值,也是最多人没做的部分。

告警(P99 突增)
  → 点开指标面板,找到时间点和接口
  → 点击该时段,跳到日志(带 traceId 过滤)
  → 从日志里的 traceId 跳到链路追踪
  → 看到瓶颈在下游 RPC

实现关键是三处共用一个 ID:

<!-- logback 里输出 traceId -->
<pattern>%d{ISO8601} %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n</pattern>

%X{traceId} 从 MDC 取,Micrometer Tracing 会自动写入。这样日志和 trace 就对上了。

Grafana 里配「Exemplar」可以直接从指标图跳转到 trace:

# 在 Histogram 上记录 exemplar
http_server_requests_seconds_bucket{le="0.5"} # {trace_id="a3f2b1c9"}

六、告警设计:少而准

告警最大的问题是太多。一天 200 条告警,所有人都会忽略。

分级

级别 触发条件 通知方式 响应
P0 核心链路不可用 电话 + 短信 立即
P1 错误率 > 5% 或 P99 > 3s 群消息 @全体 15 分钟内
P2 单项指标异常 群消息 当天
P3 趋势异常 邮件/看板 观察

P0 级告警一个月不应超过 2 次,超过说明阈值定错了。

好的告警规则

groups:
- name: api
  rules:
  # ✓ 基于「用户可感知」的指标
  - alert: HighErrorRate
    expr: |
      sum(rate(http_server_requests_total{status=~"5.."}[5m]))
      / sum(rate(http_server_requests_total[5m])) > 0.05
    for: 3m
    labels: { severity: P1 }
    annotations:
      summary: "错误率 {{ $value | humanizePercentage }},超过 5%"

  # ✓ 基于「饱和度」的预警(提前发现问题)
  - alert: ConnectionPoolExhausting
    expr: hikaricp_connections_pending > 10
    for: 2m
    labels: { severity: P2 }
    annotations:
      summary: "连接池有 {{ $value }} 个线程在等待"

  # ✗ 基于原因的指标(CPU 高不一定影响用户)
  - alert: HighCpu
    expr: cpu_usage > 80
    # 应该改成「CPU 高且错误率上升」或「CPU 高且 P99 上升」

关键原则:告警应该基于「用户受影响」,而不是「某个资源指标高」。

CPU 80% 但接口正常,不该半夜打电话。CPU 60% 但错误率飙升,才是真问题。

for 的作用

for: 3m      # 持续 3 分钟才触发

没有 for 的告警会被瞬时抖动触发。一次 GC 让 CPU 冲高 10 秒,不该告警。

经验值:P0 用 for: 1m,P1 用 for: 3m,P2 用 for: 10m。

七、落地顺序

不要一上来就搭全套。按收益排:

阶段 1(1 天):基础指标 + 4 条告警

- HTTP 错误率
- HTTP P99
- JVM 堆使用率
- 连接池 pending

Prometheus + Grafana + Spring Boot Actuator,半天能跑起来。这四条告警能覆盖 70% 的线上问题。

阶段 2(3 天):结构化日志 + 集中收集

Loki 或 ELK,把日志变成可检索的。关键是加上 traceId。

阶段 3(1 周):链路追踪

先接入主链路(gateway → 核心服务 → DB),不用覆盖所有服务。1% 采样。

阶段 4(持续):关联与告警调优

指标 → 日志 → trace 的跳转,以及按实际误报率调整阈值。

小结

支柱 解决 最小实现
指标 发现「有问题」 Actuator + Prometheus + 4 条告警
日志 知道「发生了什么」 JSON 格式 + traceId + Loki
链路 知道「慢在哪一段」 Micrometer Tracing + 1% 采样

那个 30 分钟定位不到原因的事故,缺的不是工具,而是「调用链耗时」这一层数据。有了链路追踪,同样的故障 2 分钟就能定位到「inventory-rpc 耗时 3 秒」。

最后一句:可观测性的目标是「不问人也能查到原因」。 如果每次出事都要拉群问「谁改了什么」「哪里慢了」,那监控做得再花哨也只是装饰。

查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。