目录
2803 字
14 分钟
可观测性三支柱:Metrics、Logs 与 Traces 不是三种数据,而是同一条事件流的三种粒度

一、三支柱全都装了,事故依然定位不了#

先看一个很典型的事故现场。某个 GET /api/orders 接口的 p99 从 80ms 涨到 2.4s,持续了二十分钟。

翻三个系统,得到的答复是这样的:

  • Metrics:http_server_request_duration_seconds 的 p99 确实有个尖峰。但那条曲线上一共只有 method、route、status_code 三个标签,聚合之后看不出是哪个下游拖的——只知道”这个接口变慢了”。
  • Logs:grep 这个接口的日志,满屏都是 200。因为慢,不是错。
  • Traces:采样率设的是 1%,那二十分钟里恰好一条慢请求都没被采到。图上看,调用链一片健康。

三个系统各自都是”正常”的。问题出在它们对不上:日志里没有 trace_id,所以你无法从一个可疑的日志行跳到完整的调用链;metric 上的那个毛刺也无法定位到某一条具体的慢 trace;而 trace 的采样策略又在最需要它的时候把证据筛掉了。

这就是三支柱最常见的误用——把它当成”买三个工具”,而不是”让一条事件流在三个粒度上都能互相 join”。下面这张表是全文的骨架。

二、每条支柱回答的问题根本不同#

支柱回答什么数据形态单位成本典型失效方式
Metrics有没有问题、影响多大预聚合的数值时间序列低(维度少)高基数把 TSDB 打爆;聚合过头,丢了排查所需的维度
Traces问题在哪个环节带父子关系的 span 树中(靠采样压量)采样把错误/慢请求筛掉了;span 属性太少,等于只有调用图
Logs为什么会这样全量事件文本高(几乎无压缩)非结构化,无法聚合统计;没带上下文,孤立的一行毫无意义

所以正确的追问顺序是:Metrics 告诉你”有问题”,Traces 告诉你”哪里慢”,Logs 告诉你”为什么慢”。三者不是同一个问题的三种答案,而是排查链条上的三个环节。任何一环断掉,链条就接不起来。

做 Metrics 时最容易忽略的一点,是 Google SRE 那套”四大黄金指标”里对 latency 的强调:成功请求和失败请求的延迟必须分开统计。一个 2ms 就返回 500 的请求会把平均延迟拉低,让你在最该报警的时候看到”一切正常”。

三、Metrics:高基数是最常见的”自作自受”#

Metrics 便宜的前提是维度少。它便宜不是因为存储省,而是因为你已经付过”聚合”这笔账了——聚合发生之后,原始信息就没了。

PostgreSQL 的高基数事故几乎都长这样:

// ❌ 灾难写法
const httpDuration = new client.Histogram({
name: 'http_request_duration_seconds',
labelNames: ['method', 'url', 'user_id', 'request_id'],
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});

算一下这条度量会产生多少个时间序列:method(5) × url(假设 200 个不同路径) × user_id(10 万) × 直方图 7 个桶 ≈ 7 亿条。这不是”内存占得多一点”,而是 scrape 目标直接超时、Prometheus 内存爆掉。

user_id、request_id、原始 URL、时间戳、IP,这些都是无界值,必须换成有界的、可枚举的标签:

// ✅ 正确写法:路由模板而非原始 URL,只保留有界维度
const httpDuration = new client.Histogram({
name: 'http_server_request_duration_seconds',
help: 'HTTP 请求耗时(秒)',
labelNames: ['method', 'route', 'status_code'], // route 形如 /users/:id
buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});

命名上,Prometheus 官方规范值得当成硬约束:单一前缀 + 基础单位 + 单位后缀,计数器以 _total 结尾,累积量的单位用基础单位(秒而不是毫秒)。这些不是审美问题——单位混用的度量在跨服务聚合时会产生静默的错误结果。

至于”每条请求的唯一标识”该放哪,答案是 exemplar:它挂在直方图的桶上,不占额外的标签维度,专门解决”从聚合指标跳回单次请求”这件事。

四、Traces:价值在 span 属性,不在调用图#

自动埋点给你的是一张调用图,而排查靠的是属性——属性才是可查询、可过滤的维度。

const { trace, SpanStatusCode } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');
async function createOrder(req) {
return tracer.startActiveSpan('create_order', async (span) => {
// 这些属性决定了这条 trace 将来能不能被查出来
span.setAttribute('order.item_count', req.items.length);
span.setAttribute('user.tier', req.user.tier);
span.setAttribute('payment.provider', req.paymentProvider);
try {
const order = await db.insertOrder(req);
span.setAttribute('order.id', order.id);
return order;
} catch (err) {
// 不记录异常,这条 trace 就只是一段静默的耗时
span.recordException(err);
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
throw err;
} finally {
span.end();
}
});
}

比属性更容易踩坑的是采样,而它恰好是第一节事故的直接原因:

  • 头部采样(head-based):请求一进来就决定采不采,通过 W3C traceparent 把 sampled 标记传给下游。便宜、无需缓冲,SDK 内置(traceidratio、parentbased_traceidratio 等)。致命缺点:做决定的时候,这条请求是快是慢、成没成功都还不知道——它必然会随机丢掉一部分错误和慢请求。
  • 尾部采样(tail-based):在 OpenTelemetry Collector 的 tail_sampling processor 里缓冲 span,按 trace ID 聚合,等 decision_wait 之后再决定,因此可以”保留所有 ERROR、保留所有超过 1s 的、其余按 1% 概率抽”。代价是所有 span 都要在内存里压着,而且同一条 trace 的 span 必须汇聚到同一个 Collector 实例上,否则拼不完整。

生产环境的常见组合是两层:先头部采样砍掉明显无用的量,再尾部采样从中挑出有信号的那部分。

还有一个会静默生效的坑:环境变量 OTEL_TRACES_SAMPLER=always_off 会让整条链路一条 trace 都不上报,不报错、不告警,只在某个时刻你发现追踪面板是空的。

五、Logs:目标是能被 join,不是能被 grep#

日志的问题从来不是”没记”,而是”记了但用不上”。一行拼接出来的字符串:

console.log(`订单 ${orderId} 创建成功,耗时 ${ms}ms`);

这行日志只能被 grep,不能被 count by、不能被 group by、也跳不到任何 trace。

改成结构化输出,并且把当前 span 的上下文塞进去——这是投入产出比最高的一处改动:

const { trace } = require('@opentelemetry/api');
function log(level, msg, fields = {}) {
const ctx = trace.getActiveSpan()?.spanContext();
console.log(JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
// OpenTelemetry 的日志数据模型里,这两个字段就是用来做关联的
trace_id: ctx?.traceId,
span_id: ctx?.spanId,
...fields,
}));
}

完成后,第一节那个事故里”某条日志 → 完整调用链”的跳转就成立了。顺带一提,日志是按量计费的,把高频 INFO 噪声降级到 DEBUG,通常比换一个更便宜的日志后端见效更快。

六、把三者接起来:三种接法#

三支柱真正的工程量不在采集,而在关联。按性价比排序:

1. 统一 resource attributes(先做这个)

让三个信号共用同一套 service.name、service.version、deployment.environment。这是 OpenTelemetry 里 Resource 的用途——有了它,你才能在日志系统里按 service.name 过滤、在 trace 里按同一个值筛选、让 metric 的 job 标签对得上。

2. 日志里注入 trace_id(第二做这个)

如果整个改造只能做一件事,就做这件。它把”孤立的一行日志”变成”某条 trace 上的一步”。

3. 用 exemplar 从 metric 跳到 trace

在直方图上记录观测值时附带 trace ID,就能在 Grafana 里从 p99 那个毛刺直接点进那条具体的慢 trace。要注意两件事:Prometheus 侧需要开启 exemplar 存储(早期版本要显式加 --enable-feature=exemplar-storage),且暴露格式得是 OpenMetrics——标准 Prometheus 文本格式不支持 exemplar,很多埋点跑不通就卡在这。

但这里有个容易忽略的反面:exemplar 不是”把 trace_id 当标签用”。trace ID 只能挂在 exemplar 上,绝不能加进普通标签——否则就回到了第三节的高基数灾难。

七、一个更激进的看法#

Honeycomb 的 CTO Charity Majors 有个流传很广的判断:“there are no pillars”——支柱是个营销词,技术词是”信号”(signal)。她认为把 metrics、logs、traces 分成三份独立存储,本质上是同一份数据付了三次钱,还制造了三处互相接不上的死角;更合理的做法是宽事件(wide events):把请求的全部上下文压进一条结构化事件、只存一次,metrics 和 trace 都是读时从这个统一存储里派生出来的视图,并且拥抱高基数——因为”哪个用户的哪次请求失败了”这种问题,聚合掉的维度恰好就是答案。

公平地说,三家分立的模型在基础设施和运维反馈环里仍然成立(那里的遥测不由你写),真正别扭的是你自己写的业务代码。

所以务实的态度不是推翻现有技术栈,而是在设计事件时就按”宽事件”的思路来:把上下文尽量塞进同一条事件里,而不是先拆到三个系统,再想办法 join 回来。

八、小结#

  • 三支柱的价值在于能互相 join。 装了三个系统而日志里没有 trace_id、metric 上没有 exemplar,等于装了三个孤岛。
  • Metrics 的高基数几乎都是自己造的。 user_id、request_id、原始 URL 一律不许进标签;改用路由模板和有界枚举值,唯一标识交给 exemplar。
  • 头部采样是”盲”的,它必然随机丢掉一部分错误和慢请求。要保证错误与慢请求不丢,得靠 Collector 的尾部采样,代价是内存和同实例汇聚。
  • span 的属性比调用图重要。 只有调用图,你只能知道”慢”,不知道”为什么慢”。
  • 日志先结构化,再注入 trace_id。 只做一件事的话就做这件。
  • 四黄金指标里 latency 要成功/失败分开看,否则 fail-fast 的 500 会把延迟曲线洗白。
  • exemplar 用来连接,不能用来当标签。 把 trace ID 写进标签是把关联手段变成了基数炸弹。

参考来源#

可观测性三支柱:Metrics、Logs 与 Traces 不是三种数据,而是同一条事件流的三种粒度
https://www.hehonglei.cn/posts/observability-three-pillars-metrics-logs-traces/
作者
Honglei He
发布于
2026-10-07
许可协议
CC BY-NC-SA 4.0