14 可观测性:日志、指标、追踪,以及如何真正定位问题

开篇提问

先给你一个几乎每个后端工程师都经历过的噩梦:

凌晨 3 点,告警响了:“API 错误率上升,当前 8%“。你爬起来,打开监控,看到:P99 延迟涨了、错误码 500 占比上升、CPU 正常、内存正常、QPS 正常。

现在问题来了:你接下来做什么?

如果你只能看到”某个指标异常”,但没有办法回答”具体是哪些请求失败了、失败在哪个环节、影响了哪些用户”——那你就只能靠猜。而凌晨 3 点的猜测,通常效率极低、且极易出错。

请想一个问题:为什么”CPU 正常、内存正常、QPS 正常”这些监控指标,反而让你更困惑了? 这些指标到底缺了什么?

答案指向一个核心概念:可观测性(Observability)≠ 监控(Monitoring)。理解这两者的区别,是这一章的起点。


子主题一:监控 vs 可观测性——“已知问题”和”未知问题”

先看定义上的区别:

监控(Monitoring):收集预先定义好的指标,当指标超过阈值时告警。

  • 它回答的是**“已知的问题发生了吗?”**
  • 例子:CPU > 80% 告警、错误率 > 1% 告警、磁盘剩余 < 10% 告警

可观测性(Observability):让系统保留足够的信息,使得你能在事后提出任意新问题并得到答案。

  • 它回答的是**“发生了什么我从没见过的问题?为什么?”**
  • 例子:某个特定用户的请求为什么慢?某类特定参数的请求失败率为什么高?

关键区别在”能问新问题”:

  • 监控只能回答你事先想到的问题(因为指标是预先定义的)
  • 可观测性让你在系统出事之后,能问出你事先没想到的问题

回到开篇那个场景:CPU/内存/QPS 都正常,但错误率涨了。这时候你需要问的是”哪些请求失败了?它们有什么共同点?“——这个问题你事先没想到,所以你没预先埋点。如果系统不具备可观测性,你就只能干瞪眼。

一个精准的判据(这是业界流传很广的一条):

如果你只能通过”加新的埋点、重新发版”才能回答一个新问题,那你的系统不具备可观测性,只有监控。


子主题二:三大支柱——日志、指标、追踪

可观测性有三个经典的数据源,被称为”三大支柱”。它们各有分工,缺一不可。

支柱一:指标(Metrics)—— 聚合的、低成本的数值

  • 形式:时间序列数据(比如每秒请求数、P99 延迟、错误率)
  • 优势:存储成本极低(聚合后只存数字)、查询极快、适合告警和趋势图
  • 劣势:丢失了细节——你只知道”有 100 个请求失败了”,不知道”是哪 100 个”
  • 典型用途:告警、仪表盘、容量规划

指标的关键设计:维度(Dimensions / Labels / Tags) 一个指标如果只有”总请求数”,价值有限;如果带上维度(service、endpoint、status_code、region),你就能做下钻分析——从”错误率 8%“下钻到”是 /api/pay 这个接口、在上海区域、返回 500”。

这是可观测性的关键技巧:维度让你能”切分”数据,从而定位问题。 但也带来”维度爆炸”的风险(高基数维度如 user_id 会让时序数据量爆炸)。

支柱二:日志(Logs)—— 离散的、带上下文的事件记录

  • 形式:一条条文本/结构化的事件记录
  • 优势:信息最丰富,能记录任意细节
  • 劣势:成本高(量大、存储贵)、查询慢
  • 典型用途:排查具体问题的根因

现代日志的关键实践:结构化日志(Structured Logging)。 不要用 log.Printf("user %d failed: %v", id, err) 这种拼字符串的方式,而是输出 JSON 结构化字段。为什么?因为结构化之后,日志可以被查询、聚合、过滤——你可以问”所有 status=500 且 user_type=vip 的日志”,而纯文本日志只能靠 grep。

日志级别的正确用法(很多人用错):

  • ERROR:需要人立刻处理的(真的要有人被叫醒)
  • WARN:异常但系统自己处理了(重试成功、降级了)
  • INFO:关键业务事件的记录
  • DEBUG:排查用的详细信息(生产通常关闭)

最常见的错误是把 WARN 当 INFO 用、把 ERROR 当 WARN 用,导致”告警疲劳”——真正的错误淹没在噪音里。

支柱三:追踪(Tracing)—— 跨服务的调用链

  • 形式:一次请求在所有服务间的完整调用路径(span 组成的有向无环图)
  • 优势:唯一能看清”跨服务”问题的手段——它回答”这个请求在 5 个服务里各花了多久、在哪一步出错”
  • 劣势:实现复杂、有采样率(通常不能全采)
  • 典型用途:定位性能瓶颈、理解跨服务故障传播

追踪的核心概念:Trace ID 和 Span。

  • 一个请求 = 一个 Trace(有唯一 Trace ID)
  • 请求经过的每个服务/每个关键操作 = 一个 Span(有 Span ID、父 Span ID、耗时、标签)
  • Trace ID 必须贯穿整条调用链(通过 HTTP header 等机制传递)

追踪的革命性价值:它把”分布式系统的调试”从”在 N 个服务里各自 grep 日志再人肉拼接”,变成了”用一个 ID 查出完整链路”。这是从”盲人摸象”到”全局视野”的跃迁。


子主题三:三大支柱如何配合——一次真实的排障

理论讲完了,我们走一次真实的排障流程,看它们怎么配合:

第一步:指标发现异常。 告警:“支付接口 P99 延迟从 200ms 涨到 3 秒”。 → 指标的作用:告诉你”出问题了”,以及大致范围(哪个服务、哪个接口)

第二步:指标下钻,缩小范围。 看维度的拆分:是特定区域?特定机房?特定用户群体? → 发现:“只有上海区域慢,其他区域正常” → 指标的作用:通过维度切分,快速缩小怀疑范围

第三步:追踪找到具体慢在哪。 取几条慢请求的 Trace,看调用链。 → 发现:“时间都花在调用风控服务的那一步” → 追踪的作用:定位到具体的环节

第四步:日志看根因。 用 Trace ID 关联到风控服务的日志。 → 发现:“风控服务在查某张表时走了全表扫描” → 日志的作用:给出根因细节

这个流程的关键:三大支柱通过”关联”串起来。

  • 指标 → 追踪:从”某个接口慢”跳到”具体的慢请求”
  • 追踪 → 日志:用 Trace ID 关联到具体日志

没有这个关联,三大数据源就是三座孤岛,你依然只能靠猜。


子主题四:可观测性的成本与采样

可观测性不是免费的,而且成本可能惊人。

成本现实: 在大规模系统里,全量采集追踪数据的成本可能超过业务本身的成本。所以必须采样。

采样策略:

1. 头部采样(Head-based Sampling): 请求进来时就决定要不要采(比如随机 1%)。

  • 优点:简单
  • 缺点:会漏掉罕见错误——如果错误率只有 0.1%,采 1% 意味着大部分错误你根本看不到

2. 尾部采样(Tail-based Sampling): 请求完成后再决定。

  • 聪明的策略:保留所有出错的请求、所有慢请求,正常请求只采样一小部分
  • 优点:你最需要的数据一个不漏
  • 缺点:实现复杂(要缓冲整个 trace 直到完成)

3. 自适应采样: 根据系统负载动态调整采样率。

关键洞察:采样不是”随便扔掉一些数据”,而是”有策略地保留最有价值的数据”。

  • 错误请求 100% 保留(这是你最需要的)
  • 慢请求 100% 保留
  • 正常请求低采样率

日志的成本控制:

  • 分级存储:热数据(近几天)可查,冷数据(更早)归档到便宜存储
  • 采样:高频的 DEBUG 日志可以采样
  • 结构化 + 压缩:能显著降成本

子主题五:可观测性的反模式

反模式一:埋了一堆指标,但没有一个能定位问题。 典型症状:有 500 个仪表盘,出事时一个都用不上。 原因:指标缺乏有意义的维度,或者指标选错了(监控了 CPU 但没有监控业务指标)。

更好的思路:从”用户能感知到什么”倒推指标。 用户能感知的是”慢、失败、不可用”——所以延迟、错误率、可用性是第一等公民,CPU/内存是辅助。

反模式二:日志没有 Trace ID,无法串联。 这是最常见的可观测性缺陷。每条日志都必须带上 Trace ID,否则跨服务排查就是灾难。

反模式三:告警太多,导致告警疲劳。 如果每天收到 200 条告警,等于一条都没收到。 原则:只在”需要人立刻行动”时才告警。 其余的做成仪表盘或日报。

反模式四:只在生产出事时才想起可观测性。 可观测性必须在系统设计阶段就考虑——你不可能事后”加”上可观测性,因为需要的埋点、Trace ID 传递、结构化日志,都是要在代码里写的。


子主题六:指标的四种类型——以及那个价值百万的分位数陷阱

先做个思想实验。

你的服务有 10 个实例,每个实例各自上报 P99 = 150ms。请问:整个服务的 P99 是多少?

如果你的第一反应是”150ms”——恭喜,你精准地踩进了可观测性领域最贵、最普遍、也最不容易被发现的那个坑。

先别急着认错,我们退一步,把指标的类型讲清楚,再回头看这个问题有多荒谬。

Counter:只会往上走的计数器

Counter 是单调递增的累计值,重启时归零(或者说”重置”)。典型:http_requests_total、kafka_messages_produced_total。

Counter 的关键:你几乎永远不该用它原始的值。 原始值回答的是”从进程启动到现在一共多少”——这个问题没人关心。你关心的是速率:

rate(http_requests_total{route="/api/pay"}[5m])   # 每秒请求数
increase(http_requests_total{route="/api/pay"}[1h]) # 一小时总量

Counter 的陷阱有三个:

  1. 拿原始值画图,得到一条永远向上的斜线,然后你从斜率里”目测”QPS。别目测,用 rate()。
  2. 忘了处理重置。 rate() 会自动处理 counter 重置(检测到后一段比前一段小,就认为是重启了并做补偿),这是 Prometheus 帮你做的脏活;但如果你自己在代码里做减法,就炸了。
  3. 用 Counter 去记录”会减少”的量。 队列长度今天 500 明天 100?那不是 Counter,那是 Gauge。用错类型的后果是 rate() 算出负数,increase() 算出一堆零。

类比:Counter 是汽车里程表。 你不会问”里程表现在多少”,你会问”这趟开了多远、平均时速多少”。

Gauge:此刻的瞬时值

Gauge 是可以上上下下的瞬时采样值:内存占用、队列深度、当前并发请求数、连接池里的活跃连接、线程池排队数。

Gauge 的陷阱:

  • 对 Gauge 用 rate() 是错的(它会算出”内存增长速率”,这通常不是你想要的东西;而且 Gauge 下降时会出现被 reset 逻辑误判的怪值)。想要”内存增长趋势”请用 deriv() 或直接看原始曲线。
  • Gauge 最大的问题是:它是采样,不是全量。 采集间隔 15 秒,那么 15 秒内队列曾经冲到 10000 又降回 0,你看到的永远是 0。Gauge 天生看不见尖峰。

这引出一个非常重要的实践结论:

任何你关心”峰值”的量,都不该只用 Gauge 表达。 队列深度应该配一个 Histogram(记录每次观测到的深度),或者至少配一个 max_over_time()。否则你会对着一片平静的曲线,困惑于”为什么用户说卡,我这儿一切正常”。

Histogram:把观测值扔进桶里

Histogram 的做法:预先定义一组边界(bucket),然后统计”有多少观测值 ≤ 这个边界”。

比如桶边界是 [50ms, 100ms, 200ms, 500ms, 1s, +Inf],一次 230ms 的请求会给 le=500ms、le=1s、le=+Inf 这三个桶各加 1。同时维护 _sum(所有观测值之和)和 _count(观测总数)。

Histogram 的三个超能力:

  1. 可以算分位数(近似):Prometheus 的 histogram_quantile(0.99, rate(..._bucket[5m]))。
  2. 可以跨实例聚合:把多个实例的同一个桶相加,得到的仍然是合法的直方图。这是它能算全局分位数的根本原因。
  3. 可以事后算平均值:_sum / _count。

Histogram 的陷阱:

  • 桶边界必须提前定,事后改不了(改了历史数据就断层了)。桶太粗 → 分位数误差巨大;桶太细 → 基数爆炸(每个桶都是一条 series)。
  • 分位数是近似的,不是精确的。 histogram_quantile 的核心假设是”桶内的值是均匀分布的”,然后用线性插值估算。如果你的真实分布是在 200ms 处有个尖峰,桶内均匀这个假设就离谱了。所以 P99 在桶设计不合理时可以错得很离谱。
  • 业界正在解决”桶难定”这个问题:OpenTelemetry 的 Exponential Histogram(底数为 2 的指数桶,相对误差恒定)和 Prometheus 的 Native Histogram 都在用”相对误差固定”取代”手工定边界”。你不再需要纠结桶该切在哪,只要声明”我要 5% 的相对误差”即可。这是近年指标领域最重要的进步之一。

Summary:客户端算好分位数再上报

Summary 在客户端(你的进程里)直接算出分位数,然后上报 quantile="0.99" 这样的值。

听起来很美?它的致命问题是:不可聚合。

两个实例的 Summary 各报一个 P99=150ms,你没有任何数学手段把它们合并成一个”全局 P99”。因为分位数是顺序统计量,不是可加的矩。你丢掉了原始分布,只留了一个数字,信息已经不可逆地没了。

所以业界的共识非常明确:

默认用 Histogram。只有在”单实例、且你确实无法预先定义桶、且你不需要跨实例聚合”时才用 Summary。

而在指数直方图普及之后,连这一点点生存空间都快没了。

回到开头那个问题:为什么不能对 P99 求平均

现在答案已经呼之欲出了,但我们把它讲透。

第一层(数学层):分位数不可线性平均。

举个极端例子。两个实例:

  • A 实例:10000 个请求,P99 = 100ms
  • B 实例:10 个请求,P99 = 2000ms

平均值 = (100 + 2000) / 2 = 1050ms。

真实的全局 P99 呢?总请求 10010 个,P99 意味着最慢的约 100 个请求。B 实例只有 10 个请求,全都在 2000ms 那一档,它们甚至填不满最慢的 100 个。所以真相是 B 实例的所有用户都在受苦,而你的”平均 P99 = 1050ms”既低估了 B 用户的痛苦,也高估了 A 用户的体验——两头都不对。

再看:如果 B 实例有 100 万个请求而 A 只有 10 个呢?平均值还是 1050ms,但真实全局 P99 应该接近 2000ms。同样是 1050ms,两种截然不同的现实。这个平均值不携带任何有效信息。

第二层(工程层):告警会彻底失真。

你配了”P99 > 500ms 告警”。某天 10 个实例里 1 个实例的 P99 飙到 3 秒,其余 9 个都是 100ms。平均值 = 390ms → 不告警。而你的负载均衡还在把 10% 的流量打给那个坏实例,那 10% 的用户 100% 在超时。

这就是”平均值陷阱”最恶毒的地方:它不是让你看不清楚,它是让你看着一片绿色,然后心安理得地去睡觉。

第三层(正确做法):

  1. 用 Histogram,合并桶再算分位数。 这是唯一正确的路:
    histogram_quantile(0.99,
      sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
    )
    注意 sum by (le) —— 保留 le 标签、把实例维度聚合掉,然后一次性算分位数。顺序不能反:先合并分布,后算分位数。
  2. 加权,而不是算术平均。 如果你只能拿到各实例的 P99,那么至少按请求数加权。但这依然是错的(分位数加权平均在数学上仍不成立),只是”错得没那么离谱”。能用 Histogram 就别走这条路。
  3. 同一个坑的另一个伪装:平均的比率。 avg(实例错误率) ≠ 总错误率。正确写法永远是:
    sum(rate(requests_total{code=~"5.."}[5m])) / sum(rate(requests_total[5m]))
    先分别求和,再相除。 这条规则和分位数那条是同一个病根:先聚合分子分母,后做除法/求分位;顺序反了,结果就是错的。

记住这句话:凡是”先算比值/分位数、再聚合”的写法,基本都是错的。凡是把”原始数据先合并、再算统计量”的写法,才是对的。


子主题七:维度与基数——为什么一个 user_id 能拖垮整个监控系统

子主题二提过”维度爆炸”,这里把它彻底拆开。因为这是生产环境里最常把监控系统搞崩的单一原因,而且它崩的方式很隐蔽:加 label 的那一刻一切正常,三天后 Prometheus OOM。

先搞清楚”时间序列”到底是什么

在 Prometheus 这类时序库里,一个时间序列(series)的唯一身份 = 指标名 + 一组 label 键值对的确定组合。

也就是说:http_requests_total{route="/pay", method="POST", status="200", region="sh"},只要 label 里任何一个值变了,就是一条全新的时间序列——有自己的索引条目、自己的内存缓冲区、自己的磁盘块、每 15 秒自己写一个数据点。

基数(cardinality)就是”这个指标一共有多少条活跃的 series”。

一个让人清醒的乘法

假设你有:

  • route:50 个值
  • method:5 个值
  • status_code:10 个值
  • region:3 个值
  • instance:10 个值

理论组合上界 = 50 × 5 × 10 × 3 × 10 = 75,000 条 series。实际上不会全出现(不是每个 route 都有每个 status),活跃的可能就几千条。这很健康,Prometheus 毫不在意。

现在,某位好心的同事加了一个 label:user_id。他有 500 万用户。

理论组合上界 = 75,000 × 5,000,000 = 3750 亿。

你可能会说:“不对啊,实际活跃组合受限于真实请求,不会到乘积上界。”

说得对,但没救。假设一天内有 20 万活跃用户,每个用户平均只访问 3 个 route、2 个 status:活跃组合 ≈ 200,000 × 3 × 2 × 5(method) × 3(region) × 10(instance,实际不会全打) ≈ 保守估计也是千万量级的 series。

Prometheus 单实例能舒服承载的活跃 series 大约在百万到千万级(取决于内存),千万级就开始剧烈挣扎了。 你的一条 label,直接吃掉了整台机器的预算。

比基数更阴险的:churn(流失率)

基数大是”胖”,churn 高是”一直在换血”。后者更致命。

经典案例:把 pod_name 或 container_id 作为 label。每次发版、每次 HPA 扩容、每次 OOM 重启,都产生一批全新的 series,同时旧的那批变成”死的”——但它们在 head block 里仍然占着索引,要等到落盘、压缩之后才可能被清理。

症状是什么? 监控系统的内存”锯齿形”上涨,每次发版跳一下,永远降不回来,直到 OOM。

类比:基数大 = 你把一栋楼塞满了人;churn 高 = 你在用一栋楼办流水席,人不停地进进出出,而每个人进来都要登记、走的时候还要归档。哪天登记处(索引)先崩了,跟楼里坐着多少人没关系。

哪些 label 是”高基数嫌疑犯”

看到下面这些,请立刻拉响警报:

危险的 label为什么危险该用什么替代
user_id / tenant_id用户数就是基数user_tier(free/pro/vip)、tenant_plan
trace_id / request_id每个请求都唯一 = 基数等于 QPS不要用 label! 用 exemplar 或日志
error_message异常文本近乎唯一error_type(分类后的错误码)
url_path(未模板化)/user/123、/user/124…路由模板 /user/{id}
sql(原始语句)带具体参数值sql_fingerprint(归一化后的语句指纹)
pod_name / ip随发布 churn低基数时可用,但必须配合降采样/短期保留

处理维度爆炸的六板斧

第一斧:把高基数值踢出指标系统,塞进日志和追踪。 这是最重要的一条。指标系统是为”聚合”设计的,不是为”个体”设计的。 你想知道”某个用户为什么慢”——那是 trace 的活儿,不是指标的活儿。指标只该回答”有多少用户慢、慢在哪个环节”。

第二斧:降基数映射(cardinality reduction)。 把连续/海量的值域映射到小而稳定的分类上:

  • user_id → user_tier
  • /user/123 → /user/{id}(这是框架层必须做对的事,路由模板化是 HTTP 指标的地基)
  • 错误信息 → 错误分类
  • IP → 地域/运营商

第三斧:限流与熔断。

  • Prometheus 侧:metric_relabel_configs 里直接 action: drop 掉不需要的 label
  • OTel Collector 侧:cardinality limiter
  • 后端侧:设置”单指标 series 上限”,超过的新 series 直接丢弃并计数(一定要计数并告警,否则你连自己丢了多少都不知道)

第四斧:预聚合 + 分层存储。 用 recording rules 把高基数的原始数据聚合成低基数的派生指标,只长期保留聚合结果。

  • 近期(7 天):全量明细,随便下钻
  • 远期(1 年):只保留按 service/route 聚合的版本 这就是”分层”的本质:用时间换空间,你不可能既要 1 年明细又要省钱。

第五斧:边缘聚合。 在数据量爆炸的源头(比如 sidecar、Collector)就做聚合,只把聚合后的低基数结果往中心送。这跟采样是同一个思想:尽早减少数据的维度,而不是等它堆到中心。

第六斧:治理流程。 label 是 API,不是注释。 一旦上线,就会有仪表盘和告警依赖它,删掉就是 breaking change。所以:新增 label 需要评审,评审的问题只有一个——“这个字段的值域是有界的吗?上界是多少?”

一个极其好用的判据:

问自己:这个字段有多少种可能的取值?如果答案是”不知道”或者”会随时间一直增长”,它就不配当 label。


子主题八:结构化日志——为什么你的日志搜不动、聚不拢、还吵得要命

日志是三大支柱里最”老”的技术,也是被误解最深的一个。很多人觉得”日志谁不会打”,然后打出一堆既搜不动、又贵得要死、还没人看的文本。

纯文本日志的四个死穴

假设你写了这么一行:

log.Printf("user %d failed to pay order %s: %v", uid, orderID, err)

生成:

2026-09-02 03:14:15 user 88123 failed to pay order A-99231: insufficient balance

死穴一:不可聚合。 你想知道”支付失败里,余额不足占多少”?没法查。你只能 grep 出所有行,然后人肉数。数据量一大就没人干了。

死穴二:不可数值比较。 想筛出”耗时超过 1 秒的请求”?文本里 “1024ms” 和 “0.9s” 是字符串,你没法做 > 1000 的比较(至少没法高效地做)。

死穴三:格式会漂移。 哪天同事把这条日志改成 "payment failed for user %d, order %s, reason: %v",你的所有正则、所有告警规则、所有解析管道当场失效。而你在下个月才会发现。

死穴四:字段边界模糊。 用户昵称里有个空格怎么办?错误信息里有换行怎么办(堆栈!)?你的正则会在这些地方悄悄漏掉数据。

类比:纯文本日志像是把数据库表 dump 成 Word 文档。 信息都在,但你每次查询都要靠肉眼和 Ctrl+F。而结构化日志是把 Word 换回数据库——查询从”文本匹配”降级为”字段过滤”,代价是你得在写入时就想好字段。

JSON 结构化日志的实操要点

要点一:字段命名和类型必须稳定。 同一字段绝不能一会儿是字符串一会儿是数字。这是最常见的翻车点——今天 duration_ms: 123,明天某个分支写成 duration_ms: "123"。ES/ClickHouse 会直接拒绝写入或静默建出新字段类型冲突。 用强类型的日志库(zap 的 Int64/String、serilog、structlog)而不是 fmt.Sprintf 拼 JSON。

要点二:message 必须是静态模板,动态值进字段。 ⭐ 这是最容易被忽略、也最有价值的一条

// 好:message 是模板,可被聚类
{"message":"payment failed","user_id":88123,"order_id":"A-99231","reason":"insufficient_balance"}
 
// 坏:message 是拼出来的,每条都唯一
{"message":"user 88123 failed to pay order A-99231: insufficient balance"}

为什么这条这么重要? 因为现代的日志分析(异常检测、日志聚类、模板挖掘如 Drain/Spell 算法)第一步就是对 message 做模板聚类。如果你的 message 里有 ID,每一条日志都是一个独特的”模板”,聚类彻底失效——你看到的永远是 10 万种不同的日志,而不是”3 类日志,其中一类在暴涨”。

换句话说:把 ID 拼进 message,等于亲手废掉日志系统的智能分析能力。

要点三:时间戳要规范。 ISO8601/RFC3339,带时区、带毫秒以上精度。别用本地时间的 2026-09-02 03:14:15——跨机房、跨时区、跨服务对齐时间的时候,你会恨死自己。

要点四:错误堆栈单独放字段。 stack_trace 单列,不要塞进 message。堆栈是多行文本,塞进去会破坏单行 JSON、破坏聚合、还让 message 变得巨大。

要点五:脱敏与体积控制。

  • 别打完整的 request/response body(既有 PII 风险,又是成本黑洞)
  • 密码、token、身份证、手机号必须脱敏
  • 日志字段也有基数问题:高基数字段(比如把整个 JSON 请求体塞进一个 field)会让倒排索引爆炸,跟指标的高基数 label 是一模一样的病

要点六:别过度嵌套。 {"user":{"profile":{"id":123}}} 让查询变成 user.profile.id,而且很多日志系统的嵌套支持很糟糕。一层或两层,扁平为王。

日志级别:一个被滥用到失去意义的分类

子主题二给过定义,这里给可执行的判据:

问一个问题就能定级别:

级别判据问题例子
ERROR”现在需要有人放下手头的事来处理吗?“数据库连接彻底断了、关键依赖不可用、数据写入失败且重试耗尽
WARN”出问题了,但系统自己扛住了,人只需要事后看一眼”重试第二次成功、降级到备用方案、缓存击穿但回源成功
INFO”不出事时,我要靠它还原业务流程”服务启动、配置加载、订单创建、支付回调
DEBUG”排查特定问题时才需要,平时是噪音”每次函数调用的入参、中间变量

四种典型的级别误用:

  1. 把”用户输入错误”打成 ERROR。 用户填错手机号、参数校验失败——这是预期的业务分支,不是系统故障。它该是 INFO 甚至 DEBUG。打成 ERROR 的后果:任何一个爬虫扫你的接口,你的错误率就爆表。
  2. 把”重试成功了”打成 ERROR。 中间那次失败打了 ERROR,但请求最终 200 返回。你的错误率指标因此虚高十倍,而实际上用户毫无感知。
  3. 把”下游返回 404”打成 ERROR。 404 是下游给你的正常业务响应。
  4. 捕获了异常、打 ERROR、然后又吞掉了它。 日志说”出大事了”,系统说”一切正常”。这是最坏的一种:它同时制造了假警报和真隐患。

ERROR 泛滥的后果不是”吵”,是”瞎”。

告警疲劳的本质不是”消息太多让人烦”,而是:当告警的信噪比低于某个阈值,人类会启动心理防御机制——把所有告警归类为噪音并忽略。 这时候你的告警系统从”救命工具”退化成”背景噪音”,它的价值变成了负数:它还在消耗你的值班精力,但不提供任何信息。

一条硬规则: 如果一条 ERROR 级日志永远不会触发任何人做任何事,它就不该是 ERROR 级。日志级别的唯一目的是驱动行动。

日志要打在”决策点”上

这是日志实践里最高级、也最少被讲清楚的一条。

什么叫决策点?就是代码做出一个”非显然的、事后你会想知道为什么”的选择的地方。

该记的(决策点清单):

  • 走了哪个分支:{"msg":"routing decision","strategy":"fallback_b","reason":"provider_a_timeout"}
  • 为什么降级/熔断:{"msg":"circuit breaker opened","target":"risk-svc","failure_rate":0.42}
  • 重试了几次、结果如何:{"msg":"retry","attempt":3,"of":5,"result":"success"}
  • 缓存命中还是穿透:{"msg":"cache miss","key_hash":"ab12","reason":"expired"}
  • 选了哪个下游实例/分片:{"msg":"shard selected","shard":7,"of":16,"policy":"consistent_hash"}
  • 限流/配额判断:{"msg":"rate limited","quota":"1000/d","used":1000,"client_tier":"free"}
  • 状态机的转移:订单从 pending → paid → shipped

不该记的:

  • 循环体内每次迭代(这是日志体积爆炸的头号原因)
  • 正常路径的每一步(“进入函数 A”→“离开函数 A”——这是 DEBUG,且大部分时候连 DEBUG 都不需要)
  • 心跳、健康检查(除非你要用它做存活性证明,否则它是纯粹的噪音与成本)
  • 能从指标或 trace 推导出来的东西(重复记录 = 三倍成本,零倍收益)
  • 已经在上层记过的、下层又记一遍的同一个错误(见下)

两个进阶实践

实践一:错误只记一次(log once)。

Go 里最经典的坏味道:

if err != nil {
    log.Error("query failed", "err", err)
    return err   // 调用方又记一次,再往上又记一次
}

结果:一个底层错误在 5 层调用栈里被记了 5 遍,日志量 ×5,而且每一条的 message 都不一样,检索时你根本分不清哪个是根因。

正确做法:在最底层加上下文(wrap),在最上层(能决定怎么处理的地方)记一次日志。

// 底层:只加上下文,不记日志
return fmt.Errorf("query user %d: %w", uid, err)
// 边界层(HTTP handler / MQ consumer):记一次,带完整链
log.Error("request failed", "err", err, "trace_id", tid)

实践二:日志采样必须和 trace 采样对齐。

如果你对日志做了随机采样(比如只留 10%),但对 trace 做了另一种采样,那么”用 trace_id 查日志”会经常查不到东西——trace 在,日志没了。

正确做法:按 trace 决策采样,同一个 trace 内的日志要么全留、要么全丢。 这样你点开任何一条 trace,都能看到完整的日志。这个”完整性”比”采样率”重要得多。


子主题九:分布式追踪的深水区——Span 关系、上下文传播、以及异步场景如何不断链

基础的 Trace/Span 概念子主题二讲过了。现在我们进到真正会卡住工程师的部分。

Span 的完整骨架

一个 Span 远不止”耗时”这么简单。它的字段构成:

字段含义
trace_id整条链路的唯一 ID(128 bit)
span_id当前 span 的 ID(64 bit)
parent_span_id父 span 的 ID(根 span 没有)
name操作名(GET /api/pay、SELECT users)
kindSERVER / CLIENT / PRODUCER / CONSUMER / INTERNAL
start_time / end_time起止时间
statusOK / ERROR(+ 描述)
attributes键值对标签(http.status_code=500、db.statement=...)
events时间轴上的离散事件(“异常”、“重试第 2 次”)
links指向其他 span 的因果链接(不是父子)

注意 kind 这个字段,它是理解跨服务追踪的关键。

一次 HTTP 调用会产生两个 span:

  • 客户端侧的 CLIENT span(发起请求)
  • 服务端侧的 SERVER span(处理请求)

它们是父子关系,中间隔着网络。两个 span 之间的时间差,就是网络传输 + 排队的时间。 这就是为什么好的追踪系统能直接告诉你”时间花在网络上还是花在处理上”——这是纯日志永远做不到的事。

父子关系 vs 因果关系的区别 ⭐

父子关系(parent-child)隐含的是”包含”:子 span 的时间区间必须被父 span 的时间区间完整包含。

这个约束在同步调用里天经地义。但在异步场景里,它会崩。

W3C Trace Context:跨服务传播的标准

问题:Trace ID 怎么从服务 A 传到服务 B? 答案是 HTTP header。但具体用什么 header、什么格式?

2019 年之前是群雄割据:Zipkin 用 X-B3-TraceId,Jaeger 用 uber-trace-id,各家格式不兼容,只要链路穿过两个用不同追踪系统的团队,链路就断了。

W3C Trace Context 标准解决了这个问题。 它定义了两个 header:

1. traceparent —— 核心传播字段:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             │  │                                │                │
             │  trace-id (32 hex = 128 bit)      span-id (16 hex)  flags
             └─ version (2 hex)
  • version:00 是当前版本
  • trace-id:32 个十六进制字符,全局唯一,同一条链路上所有服务共享
  • span-id:16 个十六进制字符,当前这一段的唯一 ID(下游服务会把它当作自己的 parent_span_id)
  • trace-flags:最低位是 sampled 标志。01 = 采样,00 = 不采样

这里有个极其重要但常被忽略的设计:sampled 标志跟着请求一起传。

这意味着采样决策在链路入口处做一次,然后沿链传播——整条链路要么全采、要么全不采。这就是”头部采样”的实现机制,也是它”简单”的原因:每个服务不需要知道全局策略,只需要读 header 里的一个 bit。

(顺带说明:这就是为什么头部采样在入口决定了就没法改——因为下游只看到一个 bit,不知道后面的请求会不会失败。)

2. tracestate —— 厂商扩展字段:

tracestate: vendor1=value1,vendor2=value2

用于跨追踪系统传递各自的额外信息(比如不同厂商的采样率、租户信息)。大多数时候你不用管它,但它是保证多厂商互操作的关键。

传播机制的实现: OpenTelemetry 抽象了 TextMapPropagator 接口,负责把上下文 inject 到载体(HTTP header / gRPC metadata / MQ 消息属性)和从载体 extract 出来。OTel 默认同时启用 W3C TraceContext 和 W3C Baggage,并兼容 B3(Zipkin)——这让新旧系统可以平滑过渡。

为什么链路会”断”:上下文丢失的四个经典原因

这是追踪落地时最痛的问题:代码里明明埋了点,但链路图就是断成两截。

原因一:异步执行丢了上下文。

这是头号杀手。上下文的传播依赖”执行流的上下文载体”:

  • Go:context.Context(必须显式传,不传就断)
  • Java:ThreadLocal(跨线程就断)
  • Python/Node:类似的上下文变量

一旦你把任务丢进线程池、goroutine、协程、消息队列、定时任务,上下文就没了。

// 断链:新线程没有父 span 的上下文
executor.submit(() -> doSomething());
 
// 正确:显式传播(或者用 OTel 的 Executor 装饰器)
executor.submit(Context.current().wrap(() -> doSomething()));

Go 尤其容易踩:因为 context.Context 必须显式作为第一个参数传递。任何 go func() 而没传 ctx 的地方,链路就断了。

原因二:中间件/客户端库没被自动埋点。 用了自研的 RPC 框架、或者是比较冷门的 MQ 客户端,OTel 的自动埋点库不认识它 → 没有 inject/extract → 断链。解决办法:自己写 instrumentation,或者至少在客户端包一层手动传播。

原因三:网关/Nginx/Sidecar 没有透传 header。 Nginx 默认不会透传带下划线的 header,也可能因为配置把未知 header 丢掉。检查方法很简单:抓包看 header 有没有到下游。

原因四:跨语言/跨团队的采样配置不一致。 A 服务采样率 100%,B 服务 1%。即使 A 采了,B 也可能丢。用统一的采样配置(或者干脆在 Collector 里做尾部采样)。

Baggage:链路上驮着的业务上下文

traceparent 解决”这条链路是谁”,Baggage 解决”这条链路带着什么业务信息”。

baggage: tenant_id=acme,experiment_id=exp-42,user_tier=vip

它和 Span attributes 的本质区别是什么?

Span attributes 只属于当前这个 span,不会自动传给下游。 Baggage 会自动传播到整棵调用树的每一个 span。

这是 Baggage 唯一的、但极其强大的能力:你只需要在入口(网关)设置一次,全链路的所有服务都能在日志和 span 里读到 tenant_id。

典型用法:

  • 多租户系统的 tenant_id(这样任何一条日志都能按租户过滤)
  • A/B 实验的 experiment_id(这样你能直接对比实验组和对照组在调用链上的性能差异——这是别的方法做不到的)
  • user_tier(用于把 trace 和”用户价值”关联起来)

Baggage 的三个坑:

  1. 它会放大请求体积。 Baggage 会跟着每一个下游请求走,包括扇出到几十个服务的场景。放太多东西,header 就膨胀了。
  2. 它跨越信任边界。 Baggage 会传到第三方服务、传到内部其他团队的服務。绝对不要放 PII、token、密码。 这是一条安全红线。
  3. 它不应该用来控制采样。 早期有提案想用 baggage 传采样优先级,但 W3C 最终没这么做——采样由 traceparent 的 flags 和 tracestate 负责。混用会导致语义混乱。

异步与消息队列:链路在这里天然断裂 ⭐

这是分布式追踪最难、也最能体现功力的部分。

考虑这个场景:

订单服务 --(发消息)--> Kafka --(消费)--> 履约服务

直觉的做法是:把 consumer 的 span 设成 producer 的 span 的子 span。但这个直觉是错的。

为什么错? 因为父子关系隐含”时间包含”:子 span 必须在父 span 结束前结束。但:

  • producer 发完消息就结束了(耗时 5ms)
  • consumer 可能在30 分钟之后才开始消费,跑 2 秒

如果你把 consumer 设为 producer 的子 span,你就得到了一个”父 span 已经结束 30 分钟,子 span 才开始”的畸形结构。所有基于 span 树的分析(耗时分解、关键路径计算、火焰图)都会在这个点上算出错误的结果。

正确的做法:用 Span Link(因果链接)。

producer span(kind=PRODUCER)── 结束
                    ↑ link(因果关系,不是包含关系)
consumer span(kind=CONSUMER)── 新的执行单元,新的"根"

Link 表达的是”我是由你触发的”,但不表达”我在你里面”。 在 OTel 里:

// Producer:把上下文注入消息头
carrier := propagation.MapCarrier{}
otel.GetTextMapPropagator().Inject(ctx, carrier)
msg.Headers = carrier
 
// Consumer:从消息头提取,并作为 LINK 而不是 parent
carrier := propagation.MapCarrier(msg.Headers)
producerCtx := otel.GetTextMapPropagator().Extract(context.Background(), carrier)
 
ctx, span := tracer.Start(context.Background(), "process order",
    trace.WithLinks(trace.LinkFromContext(producerCtx)),
)

关键点:consumer 的 span 用的是全新的 context.Background()(或新的根),只是通过 Link 指向 producer。 这样:

  • 时间包含关系不被破坏
  • 你在 UI 上依然能从 producer 跳到 consumer
  • 每个”执行单元”有自己独立的耗时统计

批量消费的麻烦: 一个 consumer 批次拉了 1000 条消息,来自 1000 个不同的 trace。那么这一个 consumer span 需要 1000 条 links。这会撑爆 span 的体积。

实践中的做法:

  • 限制 links 数量(比如最多 100 条,其余丢弃并计数)
  • 按批次建 span,而不是按消息建 span,把”批次”作为分析单元
  • 或者,如果你确实需要逐条消息的可见性,为每条消息单独建 span(成本高但语义最清晰)

还有一种场景:定时任务(cron)。 它没有上游请求,天然是链路的起点。正确做法是建一个新的根 span,并把 job.name、schedule 作为 attributes。不要试图把它挂到某个虚构的父 span 上。

追踪的最后一块:采样为什么必须在架构上想清楚

回到子主题四的采样,补两个精确的结论:

  1. 头部采样的实现就是 traceparent 里的那个 bit。 它的优点和缺点都源于此:简单、无状态、开销为零,但你无法在看到结果后再反悔。
  2. 尾部采样需要一个”能看到整条 trace”的地方。 单个服务实例看不到全貌,所以必须有一个收集点把所有 span 攒起来再决策——这就是 OTel Collector 的 tail_sampling processor 存在的意义。它需要:缓冲整条 trace(内存开销)、等待一个超时窗口(延迟)、以及所有同一 trace 的 span 必须路由到同一个 Collector 实例(这需要 load_balancing exporter 按 trace_id 做一致性路由)。

这个”按 trace_id 路由”的细节,是尾部采样在生产环境能不能跑起来的关键,也是很多人配了却不生效的原因。


子主题十:关联(Correlation)——三座孤岛是怎么被连起来的

到这一章的第九个子主题,我们可以回答一个贯穿始终的问题了:

指标、日志、追踪,它们究竟靠什么”串”在一起?

关联的三根线

第一根线:Trace ID —— 贯穿一切的万能钥匙

这是最基础、也是最重要的一根线:每一条日志、每一个 span,都带上 trace_id 和 span_id。

一旦做到这一点:

  • 日志 → trace:在日志里搜 trace_id: abc123,得到这次请求在所有服务里的全部日志
  • trace → 日志:点开一个慢 span,直接跳到它对应的日志

这一步的技术实现比想象中简单:在日志库的 middleware / MDC / zap core 里,从当前 context 里取出 trace_id 自动注入。它应该是”框架层配一次、全局生效”,而不是”每个打日志的地方手写”。

代价极低,收益极高——这是整个可观测性体系里投入产出比最高的一件事。

第二根线:统一的资源元数据(Resource)

如果指标里这个服务叫 payment,日志里叫 pay-svc,trace 里叫 payment-service——那你的三个系统永远连不起来。

OTel 的 Resource 概念就是解决这个的:一组描述”数据来自谁”的属性,附加在所有信号上:

  • service.name(最关键,必须全局唯一且稳定)
  • service.version
  • deployment.environment(prod / staging)
  • k8s.pod.name、host.name、cloud.region

service.name 是关联的锚点。 它必须:

  • 与部署无关(不随实例变化)
  • 与版本无关(发版不改名字)
  • 全局唯一(两个团队不撞名)

听起来像常识?生产环境里违反这三条的比比皆是。 而违反的代价是:你在指标上看到一个服务异常,却无法在日志系统里找到它——因为你根本不知道它叫什么。

第三根线:Exemplar —— 从”聚合数字”跳回”具体那一次”

这是最优雅、也最少被用好的一根线。

问题:指标告诉你”P99 是 3 秒”。但你想知道的是”到底是哪些请求这么慢”。

指标的悲剧在于:它为了便宜,把 100 万个请求压成了几个数字,个体信息被永久丢弃了。

Exemplar 就是那个”留一条活口”的机制:

在 Histogram 的某个桶里,除了计数,额外记录一个这次真实观测的 trace_id。

于是,当你在 Grafana 上看到 P99 曲线飙起来了,那个数据点上会有一个小圆点——点它,直接跳到那一次具体请求的完整调用链。

这就是”从聚合到个体”的桥。 它的精妙之处在于成本:每个桶每个采集周期只额外存一个 trace_id,开销几乎可以忽略,但它把”我知道有问题”变成了”我知道是哪个请求有问题”。

技术上怎么实现的? OpenTelemetry 的 metrics SDK 允许你在记录观测值时传入当前的 context,如果这次请求被采样了,SDK 就自动把 trace_id 作为 exemplar 附加上去。也就是说:只要你的 metrics 埋点传了 context,exemplar 几乎是白送的。 Prometheus 的 exemplar 存储与 OpenMetrics 格式配合,Grafana 可以配置跳转链接。

问自己一个问题:你的 P99 图上,能点吗?

如果答案是”不能”,那你每次排查性能问题,都要经历这样一段绝望的流程:看指标知道慢了 → 去 trace 系统里按时间范围盲找慢请求 → 祈祷能找到一条 → 再看日志。这三步依赖运气,而 exemplar 把它变成了一次点击。

没有关联的后果,具体长什么样

我们来算一笔时间账。

有关联的排障路径(分钟级):

  1. 告警:支付 P99 3s(0 分钟)
  2. 点 exemplar → 看到一条慢 trace(1 分钟)
  3. trace 显示:时间全在风控服务的 DB 查询(2 分钟)
  4. 点 span → 跳到对应日志 → 看到全表扫描的 SQL(3 分钟)

没有关联的排障路径(小时级):

  1. 告警:支付 P99 3s(0 分钟)
  2. 猜测:是不是 DB 慢?是不是网络?是不是依赖服务?(开始穷举)
  3. 去日志系统搜”3:14 到 3:20 之间的 ERROR 日志”(时间对得齐吗?各服务的时钟同步了吗?)
  4. 搜出来 5000 条,人肉翻(20 分钟)
  5. 隐约看到风控服务有慢查询,但不知道是不是同一批请求(30 分钟)
  6. 想用 trace?trace 系统里的 trace_id 没法从日志里拿到(因为日志没带)(放弃)
  7. 加日志、发版、等复现(几小时到几天)

差距是 3 分钟 vs 3 小时,而这个差距在凌晨 3 点会被放大成”用户体验损失 × 崩溃的工程师”。

更隐蔽的代价:关联的缺失会系统性地扭曲你的判断。 当你只能靠”时间上接近”来推断因果时,你会反复得出错误的结论(“这个 error 日志和故障同时出现,一定是它导致的”——实际上它每 5 分钟都出现一次)。你以为你在分析,其实你在编故事。


子主题十一:第四种可观测数据——持续剖析与 eBPF

三大支柱讲完了,现在问一个它们都答不上来的问题:

“我的服务 CPU 跑到了 90%,为什么?”

  • 指标能告诉你:process_cpu_usage = 0.9。然后呢?没了。
  • 日志能告诉你:什么都没说,因为 CPU 高不是”事件”。
  • 追踪能告诉你:/api/recommend 这个接口慢,DB 查询只占 20ms,剩下 800ms 在”业务逻辑”里。

追踪已经很接近了,但它只告诉你”慢在这段代码”,不告诉你”慢在哪个函数、哪一行、哪个循环”。

这剩下的 800ms,三大支柱全都无能为力。 于是有了第四种数据:持续剖析(Continuous Profiling)。

剖析是什么

剖析(Profiling)= 定时采样调用栈,统计”哪些代码路径最常出现在栈上”。

结果就是火焰图(Flame Graph):横轴是”占用比例”,纵轴是调用栈深度。哪个函数最宽,CPU 就烧在哪。

传统剖析为什么不能用于生产?

  1. 开销太大。 传统的 CPU profiler(比如 Go 的 pprof 全量采集、Java 的采样 profiler)通常有 5%~10% 的开销。生产环境常开?没人敢。
  2. 是一次性快照。 你得先知道”出问题了”,然后登机器、开 profiler、抓 30 秒、拿回来分析。但性能问题常常是间歇性的——你开始抓的时候它可能已经好了。
  3. 需要重启或改代码。 Go 要 -cpuprofile,Java 要挂 agent。
  4. 测试环境抓不到生产的问题。 生产的数据量、数据分布、并发模式,测试环境根本复现不了。这是最关键的一点:性能问题是数据的函数,而测试数据永远不等于生产数据。

eBPF:让”常开”成为可能的那个变量

eBPF(extended Berkeley Packet Filter)是 Linux 内核里的一个安全虚拟机。

原始用途是网络包过滤,但后来它被扩展成一个通用能力:你可以在内核里运行一段沙箱化、经过内核校验器(verifier)严格检查的程序,把它挂到几乎任何内核事件上。

用生活化的类比:

传统 profiler 像是在每个员工的工位上装摄像头,还要求每个人都佩戴计步器。 eBPF 像是在大楼的电梯里装了个计数器——它不需要打扰任何人,只是在某些”必经之处”(perf 事件、kprobe、uprobe)看一眼,记录下”谁在往上走”。

eBPF 做剖析的具体机制:

  1. 注册一个 perf event(通常是定时中断,比如每秒 100 次 = 100Hz)
  2. 每次中断触发时,eBPF 程序在内核里抓取当前 CPU 上正在执行的调用栈(用户态栈 + 内核态栈)
  3. 关键一步:在内核里做聚合。 eBPF 程序把栈折叠成一个”栈 ID”,在一个 eBPF map(内核里的哈希表)里把它对应的计数 +1
  4. 用户态程序只定期读取这个 map 的结果

第 3 步是开销能压到 1% 以下的根本原因:聚合发生在内核里,用户态拿到的已经是”栈 → 次数”的汇总结果,而不是每一次采样的原始数据。

如果每次采样都要往用户态送一次数据,光是上下文切换和拷贝就把机器压垮了。在内核里聚合,把 N 次事件压缩成一张小表——这就是全部魔法。

eBPF 剖析的四个额外优势:

  1. 无侵入。 不重启、不改代码、不装 agent。进程完全不知道自己被剖析了。
  2. 全系统视角。 能看到内核态的开销(系统调用、网络协议栈、页错误、调度延迟),也能看到 GC。这是纯用户态 profiler 做不到的。
  3. 语言无关(大部分)。 只要能拿到栈并做符号化:Go、C/C++、Rust 有帧指针或 DWARF;Java 有 JVM 的符号接口;Python/Node 这类解释型语言需要额外支持(栈里是解释器的帧,需要映射回脚本行号)。
  4. “持续”带来的时间维度。 ⭐ 这才是真正的杀手锏

为什么”持续”这么重要? 因为你有了一个可对比的历史:

  • 差分火焰图:拿今天的火焰图和昨天同一时刻的对比,直接看出”多出来的 CPU 花在哪了”。这把”为什么变慢了”从猜谜变成了看图。
  • 回溯性:故障发生在凌晨 3 点,你第二天早上来,能直接调出那个时刻的火焰图。传统 profiler 做不到——你不可能在故障发生时刚好在现场。

四种剖析类型,以及它们各自的”病症”

类型回答的问题典型发现
CPU时间烧在哪个函数某个正则回溯、某个序列化热点、某个 O(n²) 循环
堆内存 inuse当前内存被谁占着某个缓存无上限、某个大对象常驻
分配 alloc谁在疯狂创建对象GC 压力的元凶(分配多 → GC 频繁 → CPU 高)。很多”CPU 高”的真凶其实在这里
Off-CPU / 阻塞线程在等什么锁竞争、I/O 等待、channel 阻塞。这类问题 CPU 火焰图完全看不见

Off-CPU 剖析值得单独强调:如果你的服务”慢但 CPU 不高”,那时间一定花在等待上。CPU 火焰图此时是一片空白,你会得出”什么都没干”的错误结论。 Off-CPU 剖析记录的是”线程被挂起时的栈”,直接命中锁竞争和 I/O 阻塞。

还有两个 Go/Java 特有的高价值类型:

  • Goroutine / 线程剖析:协程泄漏(goroutine 数一直在涨)
  • Mutex / Block 剖析:锁竞争最激烈的地方

诚实地说开销

不要相信”零开销”的宣传。真实开销来自:

  • 采样频率:100Hz 比 19Hz 贵 5 倍
  • 栈深度:栈越深,抓取和折叠成本越高
  • 符号化:把地址翻译成函数名。如果符号化在采集端做,会消耗被剖析机器的 CPU;现在主流做法是把符号表传到服务端延迟解析
  • eBPF map 的内存:栈的聚合表本身占内存
  • 解释器语言的额外成本

业界普遍声称 < 1%,实测常见在 0.5%~2% 区间,取决于配置。对绝大多数在线服务,这个成本换来”随时能看生产火焰图”的能力,是极其划算的交易。

生态与标准化

  • pprof 是事实上的数据格式标准(源自 Google,Go 生态带火)
  • Pyroscope(已被 Grafana 收购)、Parca、Datadog Continuous Profiler、Polar Signals 是主要实现
  • OTel Profiling signal 正在标准化中——它想做的和 traces/metrics 一样:统一数据模型和协议,让剖析数据不再锁死在单一厂商

这意味着什么?“三大支柱”这个说法正在变成历史。更准确的表述是:可观测性由多种互补的信号构成,每种信号回答一类别的问题,而它们通过统一的标准(OTel)和统一的关联(trace_id、resource)连成一个整体。


子主题十二:SLO / SLI / 错误预算——可观测性到底服务于什么

前面十一节都在讲”怎么观测”。现在问一个更根本的问题:

我们花这么大力气做可观测性,是为了什么?

一个常见的错误答案是”为了出事时能排查”。这只对了一半。更重要的是:可观测性是可靠性工程的数据基础——它让”可靠性”从一句口号变成可以计算、可以谈判、可以决策的数字。

SLI:怎么定义”好”

SLI(Service Level Indicator,服务等级指标)= 对服务可靠性某个方面的量化度量。

SLI 的标准表达形式是一个比值:

SLI = 好的事件数 / 有效的事件数

比如:

  • 可用性 SLI:HTTP 非 5xx 响应数 / 总响应数
  • 延迟 SLI:耗时 < 300ms 的请求数 / 总请求数

注意”有效的事件数”这个措辞:它意味着你要明确排除一些东西。比如健康检查、爬虫流量、你自己压测的流量,都不该算进分母。分母定义错了,整个 SLI 就是错的。

定义 SLI 的三条原则:

原则一:从用户视角定义。 用户关心”我的请求成不成功、快不快”,不关心”你的连接池还剩几个”。所以 SLI 应该是请求成功率和延迟,而不是 CPU 和内存。

原则二:在尽可能靠近用户的地方测量。

这条特别容易被忽略。如果你只在服务端内部测成功率,你会漏掉:

  • 请求压根没到服务端(DNS 失败、LB 拒绝、连接超时)
  • 客户端超时了但服务端还在慢慢处理

服务端永远是”幸存者视角”——它只看到到达了自己的请求。 所以更准确的测量点是负载均衡器、网关、甚至客户端 RUM/埋点。

原则三:SLI 必须能由指标直接算出来。 这是本章的衔接点:SLI 不是新的数据类型,它是”用指标表达的一个比值”。

# 30 天窗口的可用性 SLI
sum(rate(http_requests_total{code!~"5.."}[30d]))
  /
sum(rate(http_requests_total[30d]))

为什么强调这一点? 因为如果你的指标体系没建好(没有 code 标签、没有按 route 拆分),你的 SLI 就算得不准,而你的所有可靠性决策都会建立在一个错误的数字上。可观测性的质量,直接决定了可靠性工程的质量。

四类常见 SLI

按 Google SRE 的分类:

类别说明例子
可用性请求是否成功非 5xx 比例
延迟是否够快延迟 < 300ms 的比例
质量是否在降级模式响应返回了完整结果 vs 返回了不完整/兜底结果
新鲜度/正确性数据有多新/多准离线数仓:数据延迟 < 1 小时的比例

“质量”这一类最容易被忽略,也最能体现功力:你的服务返回 200,但走的是兜底逻辑、返回的是空列表——用户觉得坏了,你的可用性 SLI 却是绿的。 这就是为什么必须单独定义质量 SLI。

SLO:定多少合适

SLO(Service Level Objective)= SLI 的目标值。

SLO:在 30 天滚动窗口内,99.9% 的请求延迟 < 300ms

一个关键区分:SLO ≠ SLA。

  • SLA(Agreement) 是对外的、有合同效力的,通常附带赔偿条款
  • SLO(Objective) 是对内的目标

实践上,内部 SLO 应该比对外 SLA 更严格,留出缓冲。比如 SLA 承诺 99.5%,内部 SLO 定 99.9%。原因很简单:等你踩到 SLA 的红线,你已经要赔钱了。 SLO 是”预警线”,SLA 是”罚款线”。

为什么 SLO 不能定 100%?

三个理由:

  1. 数学上不可能。 分布式系统里,依赖的网络、硬件、上游,没有任何一环能保证 100%。
  2. 成本上不经济。 从 99.9% 到 99.99%,成本可能涨 10 倍;从 99.99% 到 99.999%,再涨 10 倍。每多一个 9,都是指数级的投入。
  3. 100% 的 SLO 会误导依赖方。 如果下游相信你 100% 可靠,它就不会做容错设计。而一旦你某天没做到 100%(你一定会),它的故障会被你的故障放大成雪崩。 反直觉但极其重要:宣称 100% 可靠,实际上是在鼓励别人不做防御,从而让整个系统的可靠性下降。

另一个常见的错误:SLO 定得太松。 如果 SLO 是 95%,而用户从 99% 开始就抱怨了——那你的 SLO 全绿,用户全跑光。SLO 的意义是”保护用户体验”,不是”给团队一个好看的数字”。

错误预算:把可靠性变成决策依据 ⭐

错误预算(Error Budget)= 1 - SLO。

这是 SRE 领域最重要的一个发明。 因为它把”可靠性”这个模糊的目标,转化成了一个可以花掉的资源。

算一下:

SLO30 天允许的错误预算折合每天
99%7.2 小时14.4 分钟
99.9%43.2 分钟1.44 分钟
99.99%4.32 分钟8.6 秒
99.999%25.9 秒0.86 秒

预算怎么用?这才是精髓。

传统的冲突是这样的:

  • 产品/研发:“要发新功能,要快。”
  • 运维/SRE:“要稳,别乱发。”
  • 结果:靠谁嗓门大、谁级别高、谁更能讲故事来决定。

错误预算把这场争论变成了算术:

预算充足 → 可以发。可以激进。可以上风险变更。可以做混沌实验。 预算耗尽 → 冻结功能发布。全员投入可靠性工作,直到预算恢复。

这个机制的精妙之处在于:它同时给了两边想要的东西。

  • 研发想要”快速发布”?可以,只要你的可靠性做得足够好,预算就一直有。
  • 运维想要”系统稳定”?可以,预算耗尽时规则自动站在你这边,不需要吵架。

它把”可靠性 vs 速度”的零和博弈,变成了一个有明确规则的、可自我调节的系统。这就是为什么叫”预算”——它可以被花费,也可以被攒着。

几个必须落实的细节:

  1. 预算必须有明确的”所有者”和”后果”。 如果没有人因为预算耗尽而真的停止发版,那这套机制就是摆设。这是落地失败的最主要原因。
  2. 预算要按”窗口”滚动计算,不是每月清零。 常用 28 天或 30 天滚动窗口。
  3. 预算消耗要有分级响应,而不是”耗尽才动”:耗尽 50% 时开始审查、耗尽 75% 时提高发布门槛、耗尽 100% 时冻结。

燃尽率:告警应该怎么配

燃尽率(Burn Rate)= 预算消耗的速度。

定义:

  • Burn rate = 1:正好在 30 天窗口结束时耗尽 100% 预算
  • Burn rate = 14.4:照这个速度,2 天就耗尽(30 / 14.4 ≈ 2.08)

关键洞察:你不能用”错误率 > 0.1% 就告警”这种方式。 为什么?

因为短期抖动会疯狂误报,而真正的严重故障(比如全站挂了,错误率 100%)你需要几分钟内就知道,而不是等 30 天窗口结束。

Google SRE Workbook 的多窗口多燃尽率方案:

严重度短窗口长窗口Burn rate消耗预算动作
严重5 分钟1 小时14.42%呼叫(Page)
严重30 分钟6 小时65%呼叫
工单2 小时1 天310%工单
工单6 小时3 天110%工单

为什么要用两个窗口做 AND? 这是这套设计最聪明的地方:

  • 只用短窗口:灵敏,但误报多。抖一下就叫醒你,几次之后你就把它静音了。
  • 只用长窗口:稳定,但迟钝。等你发现的时候,预算已经烧光了。
  • 短窗口 AND 长窗口:短窗口保证”现在真的很严重”,长窗口保证”这不是一过性的抖动”。两个条件同时成立才告警。

这里的哲学和采样那一节是一脉相承的:单一阈值永远不够,你需要用”两个不同时间尺度的信号做交叉验证”来对抗误判。

可观测性如何服务于 SLO:完整的闭环

把这一节和前面所有内容串起来:

指标  → 计算 SLI → 对比 SLO → 燃尽率告警  (判断"是否达标、多严重")
日志  → 诊断"为什么预算被烧掉"            (找根因)
追踪  → 定位"烧在哪个服务/哪个环节"        (定范围)
剖析  → 找出"具体哪行代码"                (解决"慢但不报错")

这就是可观测性的终极价值:它不是为了让你”看到系统”,而是为了让你能对”可靠性”做出有数据支撑的决策。

最后一个反问:如果你的系统没有任何 SLO,那你凭什么判断”这次故障有多严重”?凭感觉?凭老板打没打电话催?

没有 SLO 的告警系统,就像没有刻度盘的仪表——它只能告诉你”有东西不对劲”,但没法告诉你”有多不对劲、该多着急”。


子主题十三:业界对照——Dapper、Prometheus 与 OpenTelemetry

理解了概念,我们来看看历史上三个决定性的工程选择。因为理解”为什么当初这么设计”,比记住”现在该怎么做”重要得多。

Dapper(Google,2010):分布式追踪从 0 到 1

论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》是整个分布式追踪领域的开山之作。

在它之前,业界的做法是黑盒推断(black-box inference):不看系统内部,只通过统计分析各服务之间的消息时序,反推调用关系。问题在于:一旦系统规模上去、调用模式变复杂(比如扇出、异步、条件分支),统计推断就完全不准了。

Dapper 提出的是基于标注(annotation-based)的追踪:在公共库(RPC、线程、回调)里显式埋点,记录明确的调用关系。

Dapper 的三个核心设计决策,每一个都影响至今:

决策一:采样,而且是极低的采样率。 Dapper 默认 1/1024 采样(约 0.1%),后来引入了自适应采样。

为什么敢这么低? 因为 Dapper 的洞察是:追踪的价值在于”统计上代表性的样本”,而不是”全量记录”。 你想知道”典型请求长什么样”、“异常请求长什么样”,几千个样本就够了。全量采集的成本(存储、网络、性能)是天文数字,而收益微乎其微。

这个决策的直接后果就是”采样”成了追踪的固有属性,也带来了后面所有的采样难题(头部采样漏错误 → 需要尾部采样)。

决策二:带外收集(out-of-band collection)。 ⭐ 这是最容易被忽略、但最关键的一个设计

Span 数据不走业务 RPC 通道,而是:

  1. 每个 span 先写到本地日志文件
  2. 一个独立的 Dapper daemon 读取这些日志
  3. daemon 汇总后写入 Bigtable

为什么这么绕?三个理由,每一个都很硬:

  1. 不影响业务路径。 如果 span 数据随 RPC 一起上报,那么追踪系统挂了会拖累业务,业务挂了会丢追踪数据。带外收集彻底解耦了这两者。
  2. 对应用透明。 因为追踪信息是 daemon 从本地日志捞的,应用进程只需要”写文件”,不需要知道 Collector 在哪、不需要网络调用、不需要重试逻辑。
  3. 天然削峰。 本地日志是一个缓冲区,daemon 可以按自己的节奏上传,不会造成”追踪数据风暴”。

这个设计的现代回响是什么?正是 OTel Collector 的架构:应用侧只管往本地(或 sidecar、或同机 agent)快速导出,由独立的 Collector 进程负责批处理、重试、采样、路由。 20 年过去了,答案是同一个。

决策三:通过 RPC 的”附件”传递上下文。 Google 内部的 RPC 框架支持在每个请求里附带 metadata,Dapper 就把 trace_id、span_id 塞进去。这解决的是”透明传播”的问题——业务代码不需要感知。

这个设计的现代版本就是 W3C Trace Context 标准。 思路完全一致,只是从”私有 RPC 框架的附件”变成了”标准化的 HTTP header”。

Dapper 的遗产: Zipkin(Twitter,直接源自 Dapper)→ Jaeger(Uber)→ OpenTracing → OpenTelemetry。今天你在用的每一个 trace 系统,都流淌着这篇论文的血。

Prometheus 的拉模型 vs 推送模型:一场被误解的争论

Prometheus 用”拉”(pull / scrape): Prometheus 服务器主动定期抓取每个 target 暴露的 /metrics 端点。

StatsD / Graphite / Datadog / OTLP 用”推”(push): 客户端主动把数据发给后端。

拉模型的优点(这些优点比多数人意识到的更根本):

  1. “没数据”本身就是信息。 ⭐ Prometheus 知道”哪些 target 应该存在”,如果一个 target 抓不到了,它会自动产生 up{job="x"} = 0。这意味着”服务挂了”和”服务没流量”是可以区分的。

    而纯推模型里,后端收到数据就存,收不到……是因为服务挂了?还是因为没请求?还是因为网络问题?后端不知道。你必须额外做心跳来补这个信息。

  2. 天然限流,没有”推送风暴”。 大规模重启时,如果 5000 个实例同时推送,后端会被打爆。而拉模型里,节奏完全由 Prometheus 掌握——它按自己的 scrape_interval 慢慢抓,客户端只是被动应答。

  3. 可调试性极好。 curl http://target:8080/metrics —— 就这么简单。你能直接看到一个服务在上报什么、标签对不对、有没有高基数问题。 这种”所见即所得”对排查监控系统本身的问题价值巨大。

  4. 配置集中。 加一个监控目标,改的是 Prometheus 的配置(或服务发现),被监控方完全无感知。

拉模型的缺点:

  1. 网络可达性要求高。 Prometheus 必须能访问每一个 target 的端口。跨网段、NAT、防火墙、边缘节点、短生命周期任务,都很麻烦。
  2. 对短生命周期任务(cronjob)不友好。 任务跑完就退出了,Prometheus 可能一次都没抓到。 → 补丁是 Pushgateway。但 Pushgateway 是个充满陷阱的东西:它是”最后一次推送的值”的聚合点,不会自动过期(数据会永久保留直到你手动删)、多个实例的推送会互相覆盖、它本身成了单点。官方的建议是:只在极少数场景(批处理任务)使用,不要拿它当通用推送通道。
  3. 无状态的服务发现压力大。 target 频繁变化时,Prometheus 需要不断重新发现。

推模型的优点:

  • 适合短生命周期任务、Serverless、移动端/浏览器 RUM
  • 跨网络边界、NAT 后、不可被主动访问的环境都能工作
  • 客户端可以在关键事件发生时立即上报(不需要等下一个采集周期)

推模型的缺点:

  • 客户端必须知道后端地址(配置散布在各处)
  • 客户端要自己做服务发现、标签、缓冲、重试(这是复杂度的转移,不是消失)
  • 重启风暴风险
  • 后端无法判断”没数据”是什么意思

一个必须点破的坑:StatsD 的客户端预聚合。 StatsD 是推模型的经典代表,但它在客户端就把数据聚合成了每秒的计数值,然后 fire-and-forget 发 UDP。后果:

  • UDP 会丢包,而你不自知
  • 早期版本没有标签维度,只能靠指标名编码(prod.api.pay.error)
  • 分位数在客户端算好再推(这就是 Summary 的问题,不可聚合)

所以:推 vs 拉,本质上不是技术优劣,而是”谁掌握节奏”和”谁掌握拓扑知识”。

  • 拉:中心掌握节奏和拓扑,客户端极简(只暴露一个端点)
  • 推:客户端掌握节奏和拓扑,中心极简(只接收)

工程的真实答案是什么?中间层。

现代架构基本都是这样的:

应用 --(OTLP push)--> OTel Collector / Grafana Agent --(remote write push)--> 远端存储
                            ↑
                     同时可以 scrape Prometheus 端点(pull)

Collector 这类组件同时具备”能拉”和”能推”的能力,做协议转换和流量整形。 它吸收了拉模型的好处(本地 scrape,看得见 target 状态、天然限流),又具备推模型的好处(跨网络边界、批量高效上传)。

这就是为什么 OTel Collector 在现代可观测性架构里是不可或缺的一环——它是唯一能同时吃掉两种模型优点的位置。

OpenTelemetry:标准统一的那一步

2019 年之前的世界是这样的:

  • OpenTracing:只定义 API(接口),不提供实现。厂商各自实现,但 API 兼容。
  • OpenCensus(Google):既定义 API,又提供 SDK 和导出器。开箱即用,但绑 Google 生态。

两家都在做,都各有优劣,用户被迫二选一。这是标准的分裂期——和当年的 B3 vs uber-trace-id 是同一种病。

2019 年,两个项目宣布合并为 OpenTelemetry。2021 年,OpenTracing 和 OpenCensus 正式归档(sunset)。

OTel 的架构是理解它的关键,分成四层:

┌─ API ───────────── 只定义接口。稳定保证。应用代码只依赖这一层。
├─ SDK ───────────── 实现:采样、批处理、导出。可配置、可替换。
├─ OTLP 协议 ─────── 传输格式(gRPC / HTTP + Protobuf)。
└─ Collector ─────── 独立进程:接收 → 处理 → 导出。

这个分层解决了什么问题?两个,都是要命的:

问题一:库生态的锁死。 以前,一个 HTTP 框架要支持追踪,得为每家厂商各写一个插件(Jaeger 版、Zipkin 版、Datadog 版……)。现在,框架只需要依赖 OTel API,写一次,所有后端通吃。

这是”埋点一次,后端可换”的实现基础。 你的代码里 import "go.opentelemetry.io/otel",跟后端是什么毫无关系——后端由运维在部署时通过环境变量(OTEL_EXPORTER_OTLP_ENDPOINT)配置。

问题二:运维切换成本。 换后端以前意味着改代码、发版。现在意味着改一行 Collector 配置,重启 Collector。 甚至可以配成”双写”(同时发给新旧两个后端),平滑迁移。

OTel 的另外两块价值,容易被低估:

价值一:语义约定(Semantic Conventions)。 ⭐

OTel 规定了一大批标准属性名:

  • service.name、service.version
  • http.method、http.route、http.status_code
  • db.system、db.statement、db.name
  • messaging.system、messaging.operation

为什么这比协议本身更重要? 因为协议只解决”数据怎么传”,而语义约定解决”数据长什么样”。

如果 A 团队把状态码叫 status,B 团队叫 http_status,C 团队叫 code——即使它们都用 OTLP 传输,你依然无法跨服务关联和分析。

统一命名,是跨服务可观测性的真正前提。 这跟子主题十讲的 service.name 一致性是一回事,只是范围扩大到了所有属性。

价值二:Collector 作为”可观测性的数据平面”。

Collector 能做:

  • 接收:OTLP、Jaeger、Zipkin、Prometheus scrape、StatsD、Fluent Bit……
  • 处理:批处理、压缩、限流、尾部采样、脱敏、属性增删改、按 trace_id 做负载均衡路由
  • 导出:发给任意后端,支持多写

Collector 把”应用侧开销”和”后端耦合”同时降低了。 应用只需要以最快的方式把数据丢给本地 Collector(或同机 agent),剩下的脏活(重试、批处理、采样决策、扇出到多个后端)全由 Collector 承担。

这就是 Dapper 的带外收集思想在现代的完美实现。

三个对照放在一起看

解决的核心问题留下的方法论
Dapper分布式系统能不能追踪采样、带外收集、透明传播
Prometheus指标怎么组织与查询多维标签 + PromQL + 拉模型
OpenTelemetry标准分裂与厂商锁死API/SDK/协议分离 + 语义约定 + Collector

演进的驱动力始终是三个:

  1. 系统复杂度上升 → 需要跨服务的全局视野(催生 Dapper)
  2. 数据规模上升 → 需要更好的组织与查询方式(催生 Prometheus 的多维模型)
  3. 厂商碎片化 → 需要统一标准避免锁死(催生 OTel)

而所有的技术选择,本质上都在做同一个权衡:信息量 vs 成本。 采样是砍信息量换成本,聚合是砍维度换成本,分层存储是砍时间跨度换成本。可观测性工程,就是在这条曲线上找一个你能承受的点。


子主题十四:从零建立可观测性——优先级排序与实操路线

理论全部讲完了。最后给一条可以照着执行的路线。

假设你接手了一个”几乎裸奔”的系统:只有几台机器、几个服务、日志打在文件里、监控只有 CPU 和内存。从哪开始?

一个反直觉的开场:先别买平台

最常见的错误第一步:选型、采购、部署一整套可观测性平台。

为什么要不得?因为平台不产生数据,数据来自你的代码和配置。在一个没有结构化日志、没有 trace_id、service 命名混乱的系统上装最贵的平台,结果只是”用很高的成本存储一堆没用的数据”。

正确的第一步是第 0 步,它不花一分钱。

第 0 步:统一身份(半天工作量,收益贯穿全程)

做三件事:

  1. 给每个服务定一个全局唯一、稳定、与部署和版本无关的 service.name,写进配置,全平台统一
  2. 统一环境标识:prod / staging / dev
  3. 统一服务版本上报:service.version(用 git commit 或镜像 tag)

为什么这是第 0 步? 因为这是所有关联的锚点。子主题十说过:名字不统一,三个系统永远连不起来。而改名字的成本,会随着你接入的数据量指数级上升——趁着还没数据,现在改,成本是 0。

这是整个清单里唯一”越晚做越贵”的事情。

第 1 步:黄金指标(RED / USE)

不要一上来设计几百个指标。先上两套标准指标集:

面向服务的 RED(每个服务都要有):

  • Rate:请求速率(http_server_requests_total,counter)
  • Errors:错误率(status_code 标签 + 5xx 计数)
  • Duration:延迟分布(http_server_request_duration_seconds,histogram,必须)

面向资源的 USE(CPU、内存、磁盘、网络、连接池):

  • Utilization:使用率(忙碌时间占比)
  • Saturation:饱和度(排队长度——这是最有预测性的指标)
  • Errors:错误数

为什么饱和度重要? 使用率 100% 不代表有问题(CPU 100% 但队列是空的,很好)。队列在排队,才是真出问题了。 饱和度是”快要出问题”的先行指标,使用率是”已经出问题”的同步指标。

三个必须做对的技术细节:

  1. 延迟必须用 Histogram,不能用平均值。 理由见子主题六——平均值会把长尾完全抹平。“平均延迟 50ms”和”99% 的请求 50ms、1% 的请求 10 秒”是完全不同的两件事,而平均值把它们显示成同一个数字。
  2. route 必须是路由模板(/user/{id} 而不是 /user/123)。理由见子主题七——这是基数控制的第一道防线,也是框架层必须做对的事。
  3. status_code 要有,但 error_message 不要进 label。 前者是低基数的分类,后者是近乎唯一的字符串。

这一步的验收标准:任何一个服务,你能立刻回答”它现在健康吗、QPS 多少、错误率多少、P99 多少”。

第 2 步:结构化日志 + Trace ID 注入

为什么日志排在追踪前面? 因为成本低得多、见效快得多。追踪需要埋点、上下文传播、采样策略、后端存储;而结构化日志 + trace_id 只需要改日志库配置。

具体做:

  1. 换掉字符串拼接的日志,改用结构化日志库(zap / zerolog / logrus / slog / logback+JSON / structlog)
  2. 在日志 middle 层自动注入 trace_id 和 span_id(从 context 里取),不要在每个打日志的地方手写
  3. 统一字段命名(snake_case)、统一时间戳格式(RFC3339)
  4. message 保持静态模板(子主题八那条最容易忽略的要点)

注意:这一步做的时候,你可能还没有完整的追踪系统。没关系——先把 trace_id 生成出来(哪怕只是网关生成一个随机 ID 往下传),日志先用它串起来。等追踪系统上线时,这些 ID 直接就能用。

这是”先铺管道,后通水”的思路:trace_id 的传播机制是可以先于追踪系统上线的。

这一步的验收标准:给你一个 trace_id,你能在日志系统里查出这次请求在所有服务里的全部日志。

第 3 步:SLO 与告警

先定 2~3 个 SLI,不要多。 通常从这两个开始:

  • 可用性(非 5xx 比例)
  • 延迟(< 阈值的比例)

配告警时遵守三条:

  1. 基于燃尽率,不基于原始阈值(子主题十二)。用多窗口多燃尽率。
  2. 先少后多。 一开始只告警”用户能感知到的坏”。其他一律进仪表盘。告警是可以后加的,信任是不可恢复的——一旦你的告警被当成噪音,你再发什么都没人看了。
  3. 每条告警必须能回答”那又怎样”。 如果一条告警响了,值班的人不知道该做什么,那它就不该存在。配告警时同时写 runbook(“看到这个,先查 X,再看 Y,如果是 Z 就这样处理”)。

第 4 步:分布式追踪

到这一步才上追踪,是有意的——因为此时你的日志已经能带 trace_id 了,追踪一接上,关联立刻生效。

实施顺序:

  1. 先自动埋点。用 OTel 的自动 instrumentation(Java agent / Python / Node / .NET)或框架的中间件,先把 HTTP、RPC、DB、MQ、Redis 客户端全覆盖了。自动埋点的性价比高得离谱——零代码改动,覆盖 80% 的链路。
  2. 再补业务 span。在关键业务流程上手动加 span:订单创建、风控校验、库存扣减。这些是”业务逻辑”的可见性,自动埋点给不了你。
  3. 修断链。用子主题九的四个原因逐个排查:异步线程、自研框架、网关透传、采样配置。
  4. 采样策略:
    • 流量小时:100% 全采(别省这个钱,全采时你能发现的问题最多)
    • 流量上来后:在 Collector 里配尾部采样——错误 100%、慢请求(P99 以上)100%、其他 1%~5%
    • 一定要配 Collector 的 load_balancing exporter 按 trace_id 路由,否则尾部采样不生效

第 5 步:打通关联

这一步是把前面四步串成一张网:

  1. 开启 exemplar(metrics SDK 传 context,几乎零成本)
  2. 日志里确保有 trace_id(第 2 步已做)
  3. 在 Grafana 里配好跳转:指标面板 → trace、trace → 日志、日志 → trace
  4. 核对 Resource 元数据一致性:三个系统里 service.name 必须一字不差

验收标准不是”配好了”,而是一次实战演练:

随便挑一个历史故障(或者故意制造一个),从告警响起的那一刻开始计时,看多久能定位到根因。

这是唯一有意义的验收方式。 目标:5 分钟内能回答”影响了哪些用户、坏在哪个环节”。做不到,说明还有断链的地方——而只有真实演练才能暴露它。

第 6 步:持续剖析

什么时候加? 当你遇到”慢,但不报错”这类问题时。这类问题是指标和追踪都很难定位的(指标说慢,追踪说慢在某段代码,但不知道是哪行)。

  • CPU + 分配(alloc)剖析常开(开销 < 1%,收益极高)
  • Off-CPU 剖析按需开启(排查锁竞争和 I/O 阻塞时)
  • 关键是有了历史数据后,故障可以回溯——不需要在故障发生时刚好在现场

第 7 步:成本治理(这不是最后一步,是持续的一步)

  1. 基数审计:定期看哪个指标的 series 数最多、增长最快。增长最快那个,通常就是你马上要出事的地方。
  2. 采样率调优:按预算反推——“我愿意为可观测性花多少?“然后倒推采样率
  3. 日志分级存储:热(7 天,可查)→ 温(30 天,慢查)→ 冷(1 年,只在需要时恢复)
  4. 删除僵尸仪表盘。⭐ 这条常被忽略

仪表盘是负债,不是资产。 每一个没人看的仪表盘,都在消耗维护精力(“这个图什么意思?还准吗?”),并且在出事时增加认知负担(“我该看哪个?”)。定期清理,宁缺毋滥。

一张优先级总表

顺序做什么为什么是这个位置成本收益
0统一 service.name 等元数据关联的锚点,越晚改越贵极低极高
1RED / USE 黄金指标先能回答”是否健康”低高
2结构化日志 + trace_id比追踪便宜得多,是关联的基础低极高
3SLO + 燃尽率告警让告警有优先级,避免疲劳中高
4分布式追踪(自动→手动)需要前两步做铺垫才有意义中高高
5打通关联(exemplar + 跳转)把前三步串成网;演练验收低极高
6持续剖析解决”慢但不报错”中中高
7成本治理(持续)否则系统会自己把自己压垮持续持续

最后:三个常见的”错误优先级”

错误一:先做几百个仪表盘。 仪表盘是”你已知问题的答案”。在你不知道该问什么之前做仪表盘,得到的是一堆没人看的图。 先把数据和关联建起来,仪表盘会自然涌现。

错误二:先上全链路追踪,但日志还是纯文本。 结果:你能看到链路,但看不到链路上发生了什么。trace 告诉你”在哪”,日志告诉你”为什么”——缺一个,你还是得靠猜。

错误三:先把所有日志全量采上来再说。 结果:第一个月的账单让你老板记住你。成本失控是很多可观测性项目被砍掉的直接原因。 一开始就按”分级 + 采样 + 结构化”来做。

最后一个反问,也是这一章的收尾:

如果你的系统现在出问题了,你能在不加代码、不发版的前提下,回答”哪些用户受影响、坏在哪个环节”吗?

如果不能——那么按照子主题一那条判据,你有的只是监控,不是可观测性。而这份清单,就是补上那一步的路线图。


演进脉络

  • 早期:printf 调试、grep 日志文件。单机时代勉强够用。
  • 2000s:集中式日志(ELK:Elasticsearch + Logstash + Kibana);监控系统(Nagios、Zabbix、Graphite)。
  • 2010s:Google 的 Dapper 论文开创了分布式追踪;Prometheus + Grafana 成为指标监控的事实标准;OpenTracing/OpenCensus 标准化。
  • 2010s 后期:OpenTelemetry 统一了三大支柱的标准,成为 CNCF 的旗舰项目。
  • 现代:可观测性从”三大支柱”演进到更广义的概念(包括 profiling、持续 profiling、eBPF 无侵入观测);“可观测性驱动开发”成为新范式。

主线:可观测性的演进,是从”看系统是否活着”(监控)走向”理解系统为什么这样”(可观测性)。驱动力很简单:系统越来越复杂,复杂到你不可能预先想到所有会出的问题——所以你必须保留足够的信息,让事后能问出”你事先没想到的问题”。


本章思考题

【思考题】

  1. 监控和可观测性的核心区别是什么?请给出那个”精准判据”。

  2. 三大支柱(指标、日志、追踪)各自的优劣势是什么?它们怎么配合完成一次排障?

  3. 为什么”头部采样”会漏掉错误?尾部采样怎么解决这个问题?

  4. 为什么说”可观测性必须在设计阶段考虑,不可能事后加上”?

  5. 为什么不能对多个实例的 P99 求算术平均?请举一个具体的反例说明平均值会如何误导告警。PromQL 里的正确写法是什么(写出关键部分)?

  6. Counter、Gauge、Histogram、Summary 四类指标各自的用途和主要陷阱是什么?为什么业界默认推荐 Histogram 而不是 Summary?

  7. 什么是”高基数(high cardinality)“?为什么把 user_id 作为指标 label 会让时序数据库崩溃?除了”不用它”,还有哪些降基数的手段?请额外说明 churn 为什么比单纯的基数更危险。

  8. 结构化日志为什么要求 message 保持静态模板、把动态值放进字段?如果违反会破坏什么能力?另外,日志级别为 ERROR 的正确判据是什么,ERROR 泛滥的真实危害是什么?

  9. 什么是”日志要打在决策点上”?请给出至少 4 类该记的、和 4 类不该记的日志。错误日志为什么不应该在每一层都记一遍?

  10. W3C Trace Context 的 traceparent 头部包含哪些字段?其中 trace-flags 的 sampled 位带来了什么能力、又造成了什么限制?baggage 与 span attributes 的本质区别是什么?使用 baggage 有哪些风险?

  11. 在消息队列(异步)场景中,为什么不该把 consumer span 设为 producer span 的子 span?正确做法是什么?批量消费 1000 条消息时又该如何处理?

  12. exemplar 机制解决了什么问题?它为什么能做到”几乎零成本”?除了 trace_id,三大支柱还需要靠什么统一元数据来关联?

  13. 持续剖析(Continuous Profiling)为什么被称为”第四种可观测数据”?eBPF 是如何把开销压到 1% 量级的(关键在哪个环节)?“持续”相比”一次性快照”最大的优势是什么?CPU 火焰图在哪种情况下会完全看不见问题?

  14. SLI、SLO、错误预算三者的关系是什么?99.9% 的 SLO 在 30 天窗口内对应多少错误预算?什么是燃尽率告警,为什么要用”短窗口 AND 长窗口”两个条件?从零给一个裸系统建立可观测性,请给出前五步的优先级排序并说明理由。

【参考答案】

  1. 监控收集预先定义的指标、回答”已知问题发生了吗”;可观测性保留足够信息、让你能在事后提出任意新问题并作答、回答”发生了什么我从没见过的问题”。精准判据:如果你只能通过”加新的埋点、重新发版”才能回答一个新问题,那你的系统只有监控,不具备可观测性。

  2. 指标:聚合数值,存储成本低、查询快、适合告警,但丢失细节(只知道”100 个失败”不知”哪 100 个”)。日志:离散事件,信息最丰富,但成本高、查询慢,适合看根因。追踪:跨服务调用链,唯一能看清跨服务问题(回答请求在哪些服务各花多久、哪步出错),但实现复杂、需采样。配合流程:指标发现异常并下钻(缩小到某区域/接口)→ 追踪定位具体环节(慢在调用风控)→ 用 Trace ID 关联日志看根因(全表扫描)。关键是三者通过 Trace ID 关联,否则是三座孤岛。

  3. 头部采样在请求进来时就随机决定(如 1%),若错误率只有 0.1%,则大部分错误请求根本没被采样到——最需要的数据反而最可能丢。尾部采样在请求完成后再决定:保留所有出错请求和慢请求(100%),正常请求低采样率。这样最有价值的数据一个不漏,代价是需要缓冲整个 trace 直到完成,实现更复杂。

  4. 因为可观测性依赖具体的代码级基础设施:Trace ID 需要跨服务传递(HTTP header 等机制)、结构化日志需要在打日志时就带字段和 Trace ID、关键路径的 span 埋点需要在代码中显式声明。这些都是写代码时决定的,事后无法”凭空”生成——你不可能在系统出事后,才让过去发生的请求凭空拥有 Trace ID。所以可观测性是设计属性,不是运维工具。

  5. 因为分位数是顺序统计量,不是可加的矩,跨实例做算术平均在数学上就没有意义。反例:10 个实例中 9 个 P99=100ms、1 个 P99=3s,平均值 390ms,若阈值是 500ms 则不告警,但负载均衡仍把约 10% 流量打给坏实例,这部分用户 100% 在超时——平均值让你看着一片绿色然后心安理得去睡觉。再如 A 实例 1 万请求 P99=100ms、B 实例 10 请求 P99=2000ms,平均 1050ms,但真实全局 P99 应由样本量决定:若反过来 B 有百万请求,平均值仍是 1050ms 而真值接近 2000ms——同一个平均值对应两种截然不同的现实,它不携带有效信息。正确写法是先合并分布、后算分位数:histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))。同一病根的另一表现是”平均的比率”:必须先分别求和再相除,sum(rate(...{code=~"5.."}[5m])) / sum(rate(...[5m]))。口诀:凡是”先算比值/分位数、再聚合”的写法基本都是错的。

  6. Counter:单调递增累计值,重启归零;几乎永远不该用原始值,要用 rate()/increase(),注意别用它记录会减少的量。Gauge:可上可下的瞬时值(内存、队列深度、并发数);不能对它用 rate();最大问题是它是采样,看不见采集间隔内的尖峰,所以关心峰值的量要配 Histogram 或 max_over_time()。Histogram:把观测值计入预定义桶(le)+ _sum/_count;可算近似分位数、可跨实例聚合(桶相加)、可算均值;陷阱是桶边界必须提前定且事后难改、桶粗则分位数误差大、桶细则基数爆炸(OTel Exponential Histogram 和 Prometheus Native Histogram 用”固定相对误差”解决了桶难定的问题)。Summary:客户端算好分位数再上报,致命问题是不可聚合(分位数不可加,原始分布已丢失);只有单实例、无法预定义桶、且不跨实例聚合时才用。业界默认 Histogram,因为只有它能正确回答”全局 P99 是多少”。

  7. 基数 = 指标名 + label 组合产生的活跃时间序列条数,任一 label 值变化就产生一条全新的 series(独立索引、内存、每采集周期一个数据点)。user_id 若作为 label:原本 route(50)×method(5)×status(10)×region(3)×instance(10)=7.5 万 series 的健康指标,乘以百万级用户后理论组合上界达千亿,即使按活跃组合折算也常达千万级 series,直接吃满 Prometheus 内存预算。降基数手段:①把高基数值移出指标,放进日志和追踪(trace_id/user_id 属于个体信息,用 exemplar 或日志承载);②降基数映射:user_id→user_tier、/user/123→/user/{id} 路由模板、error_message→error_type、原始 SQL→语句指纹、IP→地域;③限流熔断:metric_relabel_configs drop、Collector cardinality limiter、后端 series 上限(超限要计数告警);④预聚合与分层存储(近期明细、远期聚合);⑤边缘聚合(sidecar/Collector 先聚再送);⑥治理流程:新增 label 需评审。判据:“这个字段有多少种取值?若答案是’不知道’或’会随时间一直增长’,它就不配当 label。” churn(流失率)比单纯基数更危险:pod_name/container_id 这类 label 在每次发版、扩缩容、重启时都产生一批新 series,旧的变”死”但仍占 head block 索引直至压缩清理,症状是内存”锯齿形”上涨、每次发版跳一下且永不回落,最终 OOM——基数大是”楼里塞满人”,churn 高是”用一栋楼办流水席”,先崩的是登记处(索引)。

  8. message 必须是静态模板,因为现代日志分析(异常检测、日志聚类、Drain/Spell 类模板挖掘算法)第一步就是对 message 做模板聚类;一旦把 ID/变量拼进 message,每条日志都是一个独特”模板”,聚类彻底失效——你只会看到”10 万种不同的日志”,而不是”3 类日志、其中一类正在暴涨”,等于亲手废掉日志系统的智能分析能力。动态值应进字段:{"message":"payment failed","user_id":88123,"reason":"insufficient_balance"}。配套要点还有:字段类型稳定(同一字段不能一会儿 string 一会儿 number)、时间戳用 RFC3339 带时区和毫秒、堆栈单独放 stack_trace、脱敏与体积控制(别打完整 body)、避免深层嵌套。ERROR 的判据是一个问题:“现在需要有人放下手头的事来处理吗?” 四种典型误用:用户输入错误/参数校验失败打成 ERROR(那是预期业务分支)、重试中途失败打成 ERROR(最终成功却虚高错误率)、下游返回 404 打成 ERROR(那是正常业务响应)、捕获异常打了 ERROR 又吞掉(同时制造假警报和真隐患)。真实危害不是”吵”而是”瞎”:当告警信噪比低于阈值,人类会启动心理防御把所有告警归类为噪音并忽略,告警系统价值变成负数——它还在消耗值班精力,却不提供任何信息。硬规则:如果一条 ERROR 永远不会触发任何人做任何事,它就不该是 ERROR;日志级别的唯一目的是驱动行动。

  9. 决策点 = 代码做出”非显然的、事后你会想知道为什么”的选择的地方。 该记:①走了哪个分支/策略(strategy=fallback_b, reason=provider_a_timeout);②为什么降级或熔断(含触发时的失败率);③重试第几次、结果如何;④缓存命中还是穿透及原因;⑤选了哪个下游实例/分片;⑥限流与配额判断;⑦状态机转移(pending→paid)。不该记:①循环体内每次迭代(日志体积爆炸头号原因);②正常路径的每一步(“进入/离开函数”);③心跳与健康检查;④能从指标或 trace 推导出来的东西(重复记录 = 三倍成本零收益)。错误不该每层都记一遍:最经典的坏味道是底层 log.Error 后又 return err,上层再记,一个错误在 5 层调用栈里被记 5 遍,日志量 ×5 且每条 message 都不同,检索时根本分不清哪个是根因。正确做法是底层只 wrap 加上下文(fmt.Errorf("query user %d: %w", uid, err))、不记日志;最上层(能决定如何处理的边界层:HTTP handler / MQ consumer)记一次,带完整链和 trace_id。补充:日志随机采样必须与 trace 采样按 trace 对齐(同一 trace 内全留或全丢),否则”用 trace_id 查日志”经常查不到东西,完整性比采样率更重要。

  10. traceparent 格式:00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01,四段分别是 version(2 hex)、trace-id(32 hex = 128 bit,全链路共享)、span-id(16 hex,当前段 ID,下游把它作为自己的 parent_span_id)、trace-flags(最低位 = sampled,01 采样 / 00 不采样)。配套还有 tracestate:厂商扩展的键值列表,用于跨追踪系统传递额外信息、保证多厂商互操作。sampled 位的能力:采样决策在链路入口做一次,沿链传播,整条链路要么全采要么全不采——这就是头部采样的实现机制,优点是每个服务只读一个 bit、无状态零开销;限制也正源于此:下游只看到一个 bit,无法预知请求会不会失败,所以无法在看到结果后反悔(这正是尾部采样必须存在的理由)。baggage 与 span attributes 的本质区别:attributes 只属于当前 span、不会自动传给下游;baggage 会自动传播到整棵调用树的每一个 span,因此在网关设置一次 tenant_id/experiment_id/user_tier,全链路日志和 span 都能读到(典型价值:直接对比 A/B 实验组在调用链上的性能差异)。风险:①放大请求体积(扇出到几十个服务时 header 膨胀);②跨越信任边界,会传到第三方和其他团队服务,绝对不能放 PII/token/密码;③不应用于控制采样(采样由 flags 和 tracestate 负责,混用会导致语义混乱)。

  11. 因为父子关系隐含”时间包含”:子 span 的区间必须被父 span 完整包含。这个约束在同步调用里天经地义,但在 MQ 场景会崩:producer 发完消息就结束(耗时 5ms),consumer 可能在 30 分钟后才开始消费并跑 2 秒——若设为父子,就得到”父已结束 30 分钟子才开始”的畸形结构,所有基于 span 树的分析(耗时分解、关键路径、火焰图)都会算出错误结果。正确做法:consumer 用新的执行单元(新的根),通过 Span Link 指向 producer span,表达”我由你触发”而非”我在你里面”;两个 span 分别用 kind=PRODUCER / kind=CONSUMER,producer 侧 Inject 上下文到消息头,consumer 侧 Extract 后用 trace.WithLinks(trace.LinkFromContext(producerCtx))。批量消费 1000 条消息、来自 1000 个不同 trace 时,一个 consumer span 挂 1000 条 links 会撑爆 span 体积,实践中:限制 links 数量(如最多 100 条,其余丢弃并计数)、按批次建 span 以批次为分析单元、或为每条消息单独建 span(成本最高但语义最清晰)。另注:定时任务无上游,应建新的根 span 并附 job.name/schedule,不要挂到虚构的父 span 上。

  12. exemplar 解决的是”从聚合数字跳回具体那一次请求”的问题:指标为便宜把百万请求压成几个数字、个体信息被永久丢弃,于是”P99 飙到 3 秒”只能让你知道有问题、却不知道是哪些请求。exemplar 在 Histogram 的某个桶里额外记录一个真实观测的 trace_id,于是 Grafana 曲线上出现可点击的小圆点,一次点击即跳到该次请求的完整调用链——把”我知道有问题”变成”我知道是哪个请求有问题”,把从指标到追踪的盲找(按时间范围翻 trace)变成一次点击。几乎零成本是因为每个桶每个采集周期只额外存一个 trace_id,存储与传输开销可忽略;实现上 OTel metrics SDK 允许记录观测值时传入 context,若该请求被采样则自动附加 exemplar(metrics 埋点传了 context,exemplar 基本是白送的)。除 trace_id 外,还必须靠统一的 Resource 元数据关联,核心是 service.name(需全局唯一、与部署和版本无关)、service.version、deployment.environment、k8s.pod.name 等;若指标里叫 payment、日志里叫 pay-svc、trace 里叫 payment-service,三个系统永远连不起来——所以 service.name 是关联的锚点。

  13. 为什么是第四种数据:三大支柱都答不了”CPU 跑到 90%,到底烧在哪行代码”——指标只给 0.9,日志无事件可记,追踪只能定位到”慢在某段业务逻辑”却不知哪个函数/哪行/哪个循环。剖析(定时采样调用栈 + 火焰图)正是补这个缺口的。eBPF 把开销压到 1% 量级的关键环节是”在内核里做聚合”:perf event 定时中断触发 eBPF 程序抓取调用栈,在 eBPF map 中把栈折叠成 ID 并 +1 计数,用户态只定期读取已聚合的”栈→次数”小表;若每次采样都往用户态送原始数据,光上下文切换和拷贝就压垮机器。“持续”相比”一次性快照”的最大优势是拥有可对比的历史:差分火焰图能直接看出”今天的 CPU 比昨天多花在哪”,且故障发生在凌晨 3 点、你第二天早上依然能调出那个时刻的火焰图(传统 profiler 做不到,因为你不可能在故障发生时刚好在现场)。CPU 火焰图在”慢但 CPU 不高”时完全失效——时间花在等待(锁竞争、I/O、channel 阻塞、GC 停顿)上,此时必须用 Off-CPU / 阻塞剖析(记录线程被挂起时的栈);此外”分配 alloc 剖析”能抓到很多”CPU 高”的真凶(分配多→GC 频繁→CPU 高)。开销要诚实看待:0.5%~2%,取决于采样频率、栈深度、符号化(主流做法是传符号表到服务端延迟解析以减轻采集端负担)和 eBPF map 内存。

  14. SLI = 量化的可靠性指标,标准是比值”好事件数/有效事件数”(如非 5xx 比例、延迟 < 300ms 的比例),须从用户视角定义、在靠近用户处测量(服务端是”幸存者视角”,看不到没到达的请求和客户端超时)、且必须能由指标直接算出;SLO = SLI 的目标值(如 30 天窗口内 99.9%);错误预算 = 1 - SLO,即允许失败的那部分。99.9% 对应 30 天 43.2 分钟(43200 × 0.1%),折合每天 1.44 分钟;99.99% 则只有 4.32 分钟。预算的精髓是把”可靠性 vs 发布速度”的争吵变成算术:预算充足→可激进发布、做风险变更和混沌实验;预算耗尽→冻结功能发布、全员投入可靠性,从而把零和博弈变成有规则、可自我调节的系统(落地失败的主因是”没有人因预算耗尽而真的停止发版”)。燃尽率 = 预算消耗速度,1× 表示正好在窗口末耗尽,14.4× 表示约 2 天耗尽;用”短窗口 AND 长窗口”是因为单一阈值必然失败:只用短窗口灵敏但误报多(几次之后被静音),只用长窗口稳定但迟钝(发现时预算已烧光),两者 AND 才能同时保证”现在真的很严重”且”不是一过性抖动”——这与采样里”用不同尺度交叉验证”是同一思想。从零建立的前五步:①统一身份(service.name/环境/版本),这是关联的锚点且越晚改越贵,是唯一成本随时间指数上升的事项;②RED/USE 黄金指标(延迟必须 Histogram、route 必须模板化、error_message 不进 label),先能回答”是否健康”;③结构化日志 + trace_id 自动注入(比追踪便宜得多,且 trace_id 的传播机制可以先于追踪系统上线,属于”先铺管道后通水”),先能按 ID 串起全链路日志;④SLO + 燃尽率告警(先少后多,每条告警配 runbook,因为告警可以后加而信任不可恢复);⑤分布式追踪(先自动埋点覆盖 HTTP/RPC/DB/MQ,再补业务 span,再修断链,最后配尾部采样并必须配 Collector 按 trace_id 的负载均衡路由,否则尾部采样不生效)。之后才是打通关联(exemplar + 跳转 + 演练计时验收)与持续剖析。