可观测性三件套:指标、日志、链路怎么串起来
有监控但查不到原因,是因为缺了「调用链耗时」这一层。四个黄金信号、结构化日志、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 万条数据循环里
log.debug,即使级别关着,字符串拼接也会执行。用if (log.isDebugEnabled())包起来,或者用占位符({}形式只在需要时才格式化)。 - 打整个对象。
log.info("obj={}", hugeObject)会把几 MB 的 toString 写进去,还可能触发懒加载异常。 - 打敏感信息。手机号、身份证、密码、token 必须脱敏。
- 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 秒」。
最后一句:可观测性的目标是「不问人也能查到原因」。 如果每次出事都要拉群问「谁改了什么」「哪里慢了」,那监控做得再花哨也只是装饰。
正在加载评论…