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 的陷阱有三个:
- 拿原始值画图,得到一条永远向上的斜线,然后你从斜率里”目测”QPS。别目测,用
rate()。 - 忘了处理重置。
rate()会自动处理 counter 重置(检测到后一段比前一段小,就认为是重启了并做补偿),这是 Prometheus 帮你做的脏活;但如果你自己在代码里做减法,就炸了。 - 用 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 的三个超能力:
- 可以算分位数(近似):Prometheus 的
histogram_quantile(0.99, rate(..._bucket[5m]))。 - 可以跨实例聚合:把多个实例的同一个桶相加,得到的仍然是合法的直方图。这是它能算全局分位数的根本原因。
- 可以事后算平均值:
_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% 在超时。
这就是”平均值陷阱”最恶毒的地方:它不是让你看不清楚,它是让你看着一片绿色,然后心安理得地去睡觉。
第三层(正确做法):
- 用 Histogram,合并桶再算分位数。 这是唯一正确的路:
注意histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])) )sum by (le)—— 保留le标签、把实例维度聚合掉,然后一次性算分位数。顺序不能反:先合并分布,后算分位数。 - 加权,而不是算术平均。 如果你只能拿到各实例的 P99,那么至少按请求数加权。但这依然是错的(分位数加权平均在数学上仍不成立),只是”错得没那么离谱”。能用 Histogram 就别走这条路。
- 同一个坑的另一个伪装:平均的比率。
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 | ”排查特定问题时才需要,平时是噪音” | 每次函数调用的入参、中间变量 |
四种典型的级别误用:
- 把”用户输入错误”打成 ERROR。 用户填错手机号、参数校验失败——这是预期的业务分支,不是系统故障。它该是 INFO 甚至 DEBUG。打成 ERROR 的后果:任何一个爬虫扫你的接口,你的错误率就爆表。
- 把”重试成功了”打成 ERROR。 中间那次失败打了 ERROR,但请求最终 200 返回。你的错误率指标因此虚高十倍,而实际上用户毫无感知。
- 把”下游返回 404”打成 ERROR。 404 是下游给你的正常业务响应。
- 捕获了异常、打 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) |
kind | SERVER / CLIENT / PRODUCER / CONSUMER / INTERNAL |
start_time / end_time | 起止时间 |
status | OK / ERROR(+ 描述) |
attributes | 键值对标签(http.status_code=500、db.statement=...) |
events | 时间轴上的离散事件(“异常”、“重试第 2 次”) |
links | 指向其他 span 的因果链接(不是父子) |
注意 kind 这个字段,它是理解跨服务追踪的关键。
一次 HTTP 调用会产生两个 span:
- 客户端侧的
CLIENTspan(发起请求) - 服务端侧的
SERVERspan(处理请求)
它们是父子关系,中间隔着网络。两个 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 的三个坑:
- 它会放大请求体积。 Baggage 会跟着每一个下游请求走,包括扇出到几十个服务的场景。放太多东西,header 就膨胀了。
- 它跨越信任边界。 Baggage 会传到第三方服务、传到内部其他团队的服務。绝对不要放 PII、token、密码。 这是一条安全红线。
- 它不应该用来控制采样。 早期有提案想用 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 上。
追踪的最后一块:采样为什么必须在架构上想清楚
回到子主题四的采样,补两个精确的结论:
- 头部采样的实现就是
traceparent里的那个 bit。 它的优点和缺点都源于此:简单、无状态、开销为零,但你无法在看到结果后再反悔。 - 尾部采样需要一个”能看到整条 trace”的地方。 单个服务实例看不到全貌,所以必须有一个收集点把所有 span 攒起来再决策——这就是 OTel Collector 的
tail_samplingprocessor 存在的意义。它需要:缓冲整条 trace(内存开销)、等待一个超时窗口(延迟)、以及所有同一 trace 的 span 必须路由到同一个 Collector 实例(这需要load_balancingexporter 按 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.versiondeployment.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 把它变成了一次点击。
没有关联的后果,具体长什么样
我们来算一笔时间账。
有关联的排障路径(分钟级):
- 告警:支付 P99 3s(0 分钟)
- 点 exemplar → 看到一条慢 trace(1 分钟)
- trace 显示:时间全在风控服务的 DB 查询(2 分钟)
- 点 span → 跳到对应日志 → 看到全表扫描的 SQL(3 分钟)
没有关联的排障路径(小时级):
- 告警:支付 P99 3s(0 分钟)
- 猜测:是不是 DB 慢?是不是网络?是不是依赖服务?(开始穷举)
- 去日志系统搜”3:14 到 3:20 之间的 ERROR 日志”(时间对得齐吗?各服务的时钟同步了吗?)
- 搜出来 5000 条,人肉翻(20 分钟)
- 隐约看到风控服务有慢查询,但不知道是不是同一批请求(30 分钟)
- 想用 trace?trace 系统里的 trace_id 没法从日志里拿到(因为日志没带)(放弃)
- 加日志、发版、等复现(几小时到几天)
差距是 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 就烧在哪。
传统剖析为什么不能用于生产?
- 开销太大。 传统的 CPU profiler(比如 Go 的
pprof全量采集、Java 的采样 profiler)通常有 5%~10% 的开销。生产环境常开?没人敢。 - 是一次性快照。 你得先知道”出问题了”,然后登机器、开 profiler、抓 30 秒、拿回来分析。但性能问题常常是间歇性的——你开始抓的时候它可能已经好了。
- 需要重启或改代码。 Go 要
-cpuprofile,Java 要挂 agent。 - 测试环境抓不到生产的问题。 生产的数据量、数据分布、并发模式,测试环境根本复现不了。这是最关键的一点:性能问题是数据的函数,而测试数据永远不等于生产数据。
eBPF:让”常开”成为可能的那个变量
eBPF(extended Berkeley Packet Filter)是 Linux 内核里的一个安全虚拟机。
原始用途是网络包过滤,但后来它被扩展成一个通用能力:你可以在内核里运行一段沙箱化、经过内核校验器(verifier)严格检查的程序,把它挂到几乎任何内核事件上。
用生活化的类比:
传统 profiler 像是在每个员工的工位上装摄像头,还要求每个人都佩戴计步器。 eBPF 像是在大楼的电梯里装了个计数器——它不需要打扰任何人,只是在某些”必经之处”(perf 事件、kprobe、uprobe)看一眼,记录下”谁在往上走”。
eBPF 做剖析的具体机制:
- 注册一个 perf event(通常是定时中断,比如每秒 100 次 = 100Hz)
- 每次中断触发时,eBPF 程序在内核里抓取当前 CPU 上正在执行的调用栈(用户态栈 + 内核态栈)
- 关键一步:在内核里做聚合。 eBPF 程序把栈折叠成一个”栈 ID”,在一个 eBPF map(内核里的哈希表)里把它对应的计数 +1
- 用户态程序只定期读取这个 map 的结果
第 3 步是开销能压到 1% 以下的根本原因:聚合发生在内核里,用户态拿到的已经是”栈 → 次数”的汇总结果,而不是每一次采样的原始数据。
如果每次采样都要往用户态送一次数据,光是上下文切换和拷贝就把机器压垮了。在内核里聚合,把 N 次事件压缩成一张小表——这就是全部魔法。
eBPF 剖析的四个额外优势:
- 无侵入。 不重启、不改代码、不装 agent。进程完全不知道自己被剖析了。
- 全系统视角。 能看到内核态的开销(系统调用、网络协议栈、页错误、调度延迟),也能看到 GC。这是纯用户态 profiler 做不到的。
- 语言无关(大部分)。 只要能拿到栈并做符号化:Go、C/C++、Rust 有帧指针或 DWARF;Java 有 JVM 的符号接口;Python/Node 这类解释型语言需要额外支持(栈里是解释器的帧,需要映射回脚本行号)。
- “持续”带来的时间维度。 ⭐ 这才是真正的杀手锏
为什么”持续”这么重要? 因为你有了一个可对比的历史:
- 差分火焰图:拿今天的火焰图和昨天同一时刻的对比,直接看出”多出来的 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%?
三个理由:
- 数学上不可能。 分布式系统里,依赖的网络、硬件、上游,没有任何一环能保证 100%。
- 成本上不经济。 从 99.9% 到 99.99%,成本可能涨 10 倍;从 99.99% 到 99.999%,再涨 10 倍。每多一个 9,都是指数级的投入。
- 100% 的 SLO 会误导依赖方。 如果下游相信你 100% 可靠,它就不会做容错设计。而一旦你某天没做到 100%(你一定会),它的故障会被你的故障放大成雪崩。 反直觉但极其重要:宣称 100% 可靠,实际上是在鼓励别人不做防御,从而让整个系统的可靠性下降。
另一个常见的错误:SLO 定得太松。 如果 SLO 是 95%,而用户从 99% 开始就抱怨了——那你的 SLO 全绿,用户全跑光。SLO 的意义是”保护用户体验”,不是”给团队一个好看的数字”。
错误预算:把可靠性变成决策依据 ⭐
错误预算(Error Budget)= 1 - SLO。
这是 SRE 领域最重要的一个发明。 因为它把”可靠性”这个模糊的目标,转化成了一个可以花掉的资源。
算一下:
| SLO | 30 天允许的错误预算 | 折合每天 |
|---|---|---|
| 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 速度”的零和博弈,变成了一个有明确规则的、可自我调节的系统。这就是为什么叫”预算”——它可以被花费,也可以被攒着。
几个必须落实的细节:
- 预算必须有明确的”所有者”和”后果”。 如果没有人因为预算耗尽而真的停止发版,那这套机制就是摆设。这是落地失败的最主要原因。
- 预算要按”窗口”滚动计算,不是每月清零。 常用 28 天或 30 天滚动窗口。
- 预算消耗要有分级响应,而不是”耗尽才动”:耗尽 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.4 | 2% | 呼叫(Page) |
| 严重 | 30 分钟 | 6 小时 | 6 | 5% | 呼叫 |
| 工单 | 2 小时 | 1 天 | 3 | 10% | 工单 |
| 工单 | 6 小时 | 3 天 | 1 | 10% | 工单 |
为什么要用两个窗口做 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 通道,而是:
- 每个 span 先写到本地日志文件
- 一个独立的 Dapper daemon 读取这些日志
- daemon 汇总后写入 Bigtable
为什么这么绕?三个理由,每一个都很硬:
- 不影响业务路径。 如果 span 数据随 RPC 一起上报,那么追踪系统挂了会拖累业务,业务挂了会丢追踪数据。带外收集彻底解耦了这两者。
- 对应用透明。 因为追踪信息是 daemon 从本地日志捞的,应用进程只需要”写文件”,不需要知道 Collector 在哪、不需要网络调用、不需要重试逻辑。
- 天然削峰。 本地日志是一个缓冲区,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): 客户端主动把数据发给后端。
拉模型的优点(这些优点比多数人意识到的更根本):
-
“没数据”本身就是信息。 ⭐ Prometheus 知道”哪些 target 应该存在”,如果一个 target 抓不到了,它会自动产生
up{job="x"} = 0。这意味着”服务挂了”和”服务没流量”是可以区分的。而纯推模型里,后端收到数据就存,收不到……是因为服务挂了?还是因为没请求?还是因为网络问题?后端不知道。你必须额外做心跳来补这个信息。
-
天然限流,没有”推送风暴”。 大规模重启时,如果 5000 个实例同时推送,后端会被打爆。而拉模型里,节奏完全由 Prometheus 掌握——它按自己的 scrape_interval 慢慢抓,客户端只是被动应答。
-
可调试性极好。
curl http://target:8080/metrics—— 就这么简单。你能直接看到一个服务在上报什么、标签对不对、有没有高基数问题。 这种”所见即所得”对排查监控系统本身的问题价值巨大。 -
配置集中。 加一个监控目标,改的是 Prometheus 的配置(或服务发现),被监控方完全无感知。
拉模型的缺点:
- 网络可达性要求高。 Prometheus 必须能访问每一个 target 的端口。跨网段、NAT、防火墙、边缘节点、短生命周期任务,都很麻烦。
- 对短生命周期任务(cronjob)不友好。 任务跑完就退出了,Prometheus 可能一次都没抓到。 → 补丁是 Pushgateway。但 Pushgateway 是个充满陷阱的东西:它是”最后一次推送的值”的聚合点,不会自动过期(数据会永久保留直到你手动删)、多个实例的推送会互相覆盖、它本身成了单点。官方的建议是:只在极少数场景(批处理任务)使用,不要拿它当通用推送通道。
- 无状态的服务发现压力大。 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.versionhttp.method、http.route、http.status_codedb.system、db.statement、db.namemessaging.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 |
演进的驱动力始终是三个:
- 系统复杂度上升 → 需要跨服务的全局视野(催生 Dapper)
- 数据规模上升 → 需要更好的组织与查询方式(催生 Prometheus 的多维模型)
- 厂商碎片化 → 需要统一标准避免锁死(催生 OTel)
而所有的技术选择,本质上都在做同一个权衡:信息量 vs 成本。 采样是砍信息量换成本,聚合是砍维度换成本,分层存储是砍时间跨度换成本。可观测性工程,就是在这条曲线上找一个你能承受的点。
子主题十四:从零建立可观测性——优先级排序与实操路线
理论全部讲完了。最后给一条可以照着执行的路线。
假设你接手了一个”几乎裸奔”的系统:只有几台机器、几个服务、日志打在文件里、监控只有 CPU 和内存。从哪开始?
一个反直觉的开场:先别买平台
最常见的错误第一步:选型、采购、部署一整套可观测性平台。
为什么要不得?因为平台不产生数据,数据来自你的代码和配置。在一个没有结构化日志、没有 trace_id、service 命名混乱的系统上装最贵的平台,结果只是”用很高的成本存储一堆没用的数据”。
正确的第一步是第 0 步,它不花一分钱。
第 0 步:统一身份(半天工作量,收益贯穿全程)
做三件事:
- 给每个服务定一个全局唯一、稳定、与部署和版本无关的
service.name,写进配置,全平台统一 - 统一环境标识:
prod/staging/dev - 统一服务版本上报:
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% 但队列是空的,很好)。队列在排队,才是真出问题了。 饱和度是”快要出问题”的先行指标,使用率是”已经出问题”的同步指标。
三个必须做对的技术细节:
- 延迟必须用 Histogram,不能用平均值。 理由见子主题六——平均值会把长尾完全抹平。“平均延迟 50ms”和”99% 的请求 50ms、1% 的请求 10 秒”是完全不同的两件事,而平均值把它们显示成同一个数字。
route必须是路由模板(/user/{id}而不是/user/123)。理由见子主题七——这是基数控制的第一道防线,也是框架层必须做对的事。status_code要有,但error_message不要进 label。 前者是低基数的分类,后者是近乎唯一的字符串。
这一步的验收标准:任何一个服务,你能立刻回答”它现在健康吗、QPS 多少、错误率多少、P99 多少”。
第 2 步:结构化日志 + Trace ID 注入
为什么日志排在追踪前面? 因为成本低得多、见效快得多。追踪需要埋点、上下文传播、采样策略、后端存储;而结构化日志 + trace_id 只需要改日志库配置。
具体做:
- 换掉字符串拼接的日志,改用结构化日志库(zap / zerolog / logrus / slog / logback+JSON / structlog)
- 在日志 middle 层自动注入
trace_id和span_id(从 context 里取),不要在每个打日志的地方手写 - 统一字段命名(snake_case)、统一时间戳格式(RFC3339)
message保持静态模板(子主题八那条最容易忽略的要点)
注意:这一步做的时候,你可能还没有完整的追踪系统。没关系——先把 trace_id 生成出来(哪怕只是网关生成一个随机 ID 往下传),日志先用它串起来。等追踪系统上线时,这些 ID 直接就能用。
这是”先铺管道,后通水”的思路:trace_id 的传播机制是可以先于追踪系统上线的。
这一步的验收标准:给你一个 trace_id,你能在日志系统里查出这次请求在所有服务里的全部日志。
第 3 步:SLO 与告警
先定 2~3 个 SLI,不要多。 通常从这两个开始:
- 可用性(非 5xx 比例)
- 延迟(< 阈值的比例)
配告警时遵守三条:
- 基于燃尽率,不基于原始阈值(子主题十二)。用多窗口多燃尽率。
- 先少后多。 一开始只告警”用户能感知到的坏”。其他一律进仪表盘。告警是可以后加的,信任是不可恢复的——一旦你的告警被当成噪音,你再发什么都没人看了。
- 每条告警必须能回答”那又怎样”。 如果一条告警响了,值班的人不知道该做什么,那它就不该存在。配告警时同时写 runbook(“看到这个,先查 X,再看 Y,如果是 Z 就这样处理”)。
第 4 步:分布式追踪
到这一步才上追踪,是有意的——因为此时你的日志已经能带 trace_id 了,追踪一接上,关联立刻生效。
实施顺序:
- 先自动埋点。用 OTel 的自动 instrumentation(Java agent / Python / Node / .NET)或框架的中间件,先把 HTTP、RPC、DB、MQ、Redis 客户端全覆盖了。自动埋点的性价比高得离谱——零代码改动,覆盖 80% 的链路。
- 再补业务 span。在关键业务流程上手动加 span:订单创建、风控校验、库存扣减。这些是”业务逻辑”的可见性,自动埋点给不了你。
- 修断链。用子主题九的四个原因逐个排查:异步线程、自研框架、网关透传、采样配置。
- 采样策略:
- 流量小时:100% 全采(别省这个钱,全采时你能发现的问题最多)
- 流量上来后:在 Collector 里配尾部采样——错误 100%、慢请求(P99 以上)100%、其他 1%~5%
- 一定要配 Collector 的
load_balancingexporter 按 trace_id 路由,否则尾部采样不生效
第 5 步:打通关联
这一步是把前面四步串成一张网:
- 开启 exemplar(metrics SDK 传 context,几乎零成本)
- 日志里确保有
trace_id(第 2 步已做) - 在 Grafana 里配好跳转:指标面板 → trace、trace → 日志、日志 → trace
- 核对 Resource 元数据一致性:三个系统里
service.name必须一字不差
验收标准不是”配好了”,而是一次实战演练:
随便挑一个历史故障(或者故意制造一个),从告警响起的那一刻开始计时,看多久能定位到根因。
这是唯一有意义的验收方式。 目标:5 分钟内能回答”影响了哪些用户、坏在哪个环节”。做不到,说明还有断链的地方——而只有真实演练才能暴露它。
第 6 步:持续剖析
什么时候加? 当你遇到”慢,但不报错”这类问题时。这类问题是指标和追踪都很难定位的(指标说慢,追踪说慢在某段代码,但不知道是哪行)。
- CPU + 分配(alloc)剖析常开(开销 < 1%,收益极高)
- Off-CPU 剖析按需开启(排查锁竞争和 I/O 阻塞时)
- 关键是有了历史数据后,故障可以回溯——不需要在故障发生时刚好在现场
第 7 步:成本治理(这不是最后一步,是持续的一步)
- 基数审计:定期看哪个指标的 series 数最多、增长最快。增长最快那个,通常就是你马上要出事的地方。
- 采样率调优:按预算反推——“我愿意为可观测性花多少?“然后倒推采样率
- 日志分级存储:热(7 天,可查)→ 温(30 天,慢查)→ 冷(1 年,只在需要时恢复)
- 删除僵尸仪表盘。⭐ 这条常被忽略
仪表盘是负债,不是资产。 每一个没人看的仪表盘,都在消耗维护精力(“这个图什么意思?还准吗?”),并且在出事时增加认知负担(“我该看哪个?”)。定期清理,宁缺毋滥。
一张优先级总表
| 顺序 | 做什么 | 为什么是这个位置 | 成本 | 收益 |
|---|---|---|---|---|
| 0 | 统一 service.name 等元数据 | 关联的锚点,越晚改越贵 | 极低 | 极高 |
| 1 | RED / USE 黄金指标 | 先能回答”是否健康” | 低 | 高 |
| 2 | 结构化日志 + trace_id | 比追踪便宜得多,是关联的基础 | 低 | 极高 |
| 3 | SLO + 燃尽率告警 | 让告警有优先级,避免疲劳 | 中 | 高 |
| 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 无侵入观测);“可观测性驱动开发”成为新范式。
主线:可观测性的演进,是从”看系统是否活着”(监控)走向”理解系统为什么这样”(可观测性)。驱动力很简单:系统越来越复杂,复杂到你不可能预先想到所有会出的问题——所以你必须保留足够的信息,让事后能问出”你事先没想到的问题”。
本章思考题
【思考题】
-
监控和可观测性的核心区别是什么?请给出那个”精准判据”。
-
三大支柱(指标、日志、追踪)各自的优劣势是什么?它们怎么配合完成一次排障?
-
为什么”头部采样”会漏掉错误?尾部采样怎么解决这个问题?
-
为什么说”可观测性必须在设计阶段考虑,不可能事后加上”?
-
为什么不能对多个实例的 P99 求算术平均?请举一个具体的反例说明平均值会如何误导告警。PromQL 里的正确写法是什么(写出关键部分)?
-
Counter、Gauge、Histogram、Summary 四类指标各自的用途和主要陷阱是什么?为什么业界默认推荐 Histogram 而不是 Summary?
-
什么是”高基数(high cardinality)“?为什么把 user_id 作为指标 label 会让时序数据库崩溃?除了”不用它”,还有哪些降基数的手段?请额外说明 churn 为什么比单纯的基数更危险。
-
结构化日志为什么要求 message 保持静态模板、把动态值放进字段?如果违反会破坏什么能力?另外,日志级别为 ERROR 的正确判据是什么,ERROR 泛滥的真实危害是什么?
-
什么是”日志要打在决策点上”?请给出至少 4 类该记的、和 4 类不该记的日志。错误日志为什么不应该在每一层都记一遍?
-
W3C Trace Context 的
traceparent头部包含哪些字段?其中trace-flags的 sampled 位带来了什么能力、又造成了什么限制?baggage 与 span attributes 的本质区别是什么?使用 baggage 有哪些风险? -
在消息队列(异步)场景中,为什么不该把 consumer span 设为 producer span 的子 span?正确做法是什么?批量消费 1000 条消息时又该如何处理?
-
exemplar 机制解决了什么问题?它为什么能做到”几乎零成本”?除了 trace_id,三大支柱还需要靠什么统一元数据来关联?
-
持续剖析(Continuous Profiling)为什么被称为”第四种可观测数据”?eBPF 是如何把开销压到 1% 量级的(关键在哪个环节)?“持续”相比”一次性快照”最大的优势是什么?CPU 火焰图在哪种情况下会完全看不见问题?
-
SLI、SLO、错误预算三者的关系是什么?99.9% 的 SLO 在 30 天窗口内对应多少错误预算?什么是燃尽率告警,为什么要用”短窗口 AND 长窗口”两个条件?从零给一个裸系统建立可观测性,请给出前五步的优先级排序并说明理由。
【参考答案】
-
监控收集预先定义的指标、回答”已知问题发生了吗”;可观测性保留足够信息、让你能在事后提出任意新问题并作答、回答”发生了什么我从没见过的问题”。精准判据:如果你只能通过”加新的埋点、重新发版”才能回答一个新问题,那你的系统只有监控,不具备可观测性。
-
指标:聚合数值,存储成本低、查询快、适合告警,但丢失细节(只知道”100 个失败”不知”哪 100 个”)。日志:离散事件,信息最丰富,但成本高、查询慢,适合看根因。追踪:跨服务调用链,唯一能看清跨服务问题(回答请求在哪些服务各花多久、哪步出错),但实现复杂、需采样。配合流程:指标发现异常并下钻(缩小到某区域/接口)→ 追踪定位具体环节(慢在调用风控)→ 用 Trace ID 关联日志看根因(全表扫描)。关键是三者通过 Trace ID 关联,否则是三座孤岛。
-
头部采样在请求进来时就随机决定(如 1%),若错误率只有 0.1%,则大部分错误请求根本没被采样到——最需要的数据反而最可能丢。尾部采样在请求完成后再决定:保留所有出错请求和慢请求(100%),正常请求低采样率。这样最有价值的数据一个不漏,代价是需要缓冲整个 trace 直到完成,实现更复杂。
-
因为可观测性依赖具体的代码级基础设施:Trace ID 需要跨服务传递(HTTP header 等机制)、结构化日志需要在打日志时就带字段和 Trace ID、关键路径的 span 埋点需要在代码中显式声明。这些都是写代码时决定的,事后无法”凭空”生成——你不可能在系统出事后,才让过去发生的请求凭空拥有 Trace ID。所以可观测性是设计属性,不是运维工具。
-
因为分位数是顺序统计量,不是可加的矩,跨实例做算术平均在数学上就没有意义。反例: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]))。口诀:凡是”先算比值/分位数、再聚合”的写法基本都是错的。 -
Counter:单调递增累计值,重启归零;几乎永远不该用原始值,要用
rate()/increase(),注意别用它记录会减少的量。Gauge:可上可下的瞬时值(内存、队列深度、并发数);不能对它用rate();最大问题是它是采样,看不见采集间隔内的尖峰,所以关心峰值的量要配 Histogram 或max_over_time()。Histogram:把观测值计入预定义桶(le)+_sum/_count;可算近似分位数、可跨实例聚合(桶相加)、可算均值;陷阱是桶边界必须提前定且事后难改、桶粗则分位数误差大、桶细则基数爆炸(OTel Exponential Histogram 和 Prometheus Native Histogram 用”固定相对误差”解决了桶难定的问题)。Summary:客户端算好分位数再上报,致命问题是不可聚合(分位数不可加,原始分布已丢失);只有单实例、无法预定义桶、且不跨实例聚合时才用。业界默认 Histogram,因为只有它能正确回答”全局 P99 是多少”。 -
基数 = 指标名 + 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_configsdrop、Collector cardinality limiter、后端 series 上限(超限要计数告警);④预聚合与分层存储(近期明细、远期聚合);⑤边缘聚合(sidecar/Collector 先聚再送);⑥治理流程:新增 label 需评审。判据:“这个字段有多少种取值?若答案是’不知道’或’会随时间一直增长’,它就不配当 label。” churn(流失率)比单纯基数更危险:pod_name/container_id这类 label 在每次发版、扩缩容、重启时都产生一批新 series,旧的变”死”但仍占 head block 索引直至压缩清理,症状是内存”锯齿形”上涨、每次发版跳一下且永不回落,最终 OOM——基数大是”楼里塞满人”,churn 高是”用一栋楼办流水席”,先崩的是登记处(索引)。 -
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;日志级别的唯一目的是驱动行动。 -
决策点 = 代码做出”非显然的、事后你会想知道为什么”的选择的地方。 该记:①走了哪个分支/策略(
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 查日志”经常查不到东西,完整性比采样率更重要。 -
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 负责,混用会导致语义混乱)。 -
因为父子关系隐含”时间包含”:子 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 上。 -
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 是关联的锚点。 -
为什么是第四种数据:三大支柱都答不了”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 内存。 -
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 + 跳转 + 演练计时验收)与持续剖析。