第 20 章 可观测性:内核怎么让不可见的东西可见
这是全书的最后一章。
前面十九章,我们讲了一个极其复杂的系统——操作系统——如何管理资源、协调并发、优化性能、应对失败。 这一章,我们回到一个”元问题”:一个这么复杂的系统,人怎么看得懂它在干什么?
答案是可观测性(observability)。而操作系统,恰恰是”可观测性设计”的最佳范本——它从诞生起,就在思考”怎么让不可见的东西可见”。
引子:一个系统,“跑起来”和”可观测”,是两回事
先说一个残酷的现实:
一个系统”能跑”,不代表”能看懂”。
你写了一个服务,它能正常处理请求。但一旦出问题(变慢、卡死、内存涨), 你能回答这几个问题吗?
- 它现在在干什么?(CPU 花在哪、内存在哪、卡在哪)
- 它为什么变慢了?(哪个函数、哪个调用、哪个资源是瓶颈)
- 它什么时候开始出问题的?(是慢慢恶化,还是突然崩的)
如果答不上来,你的系统就是”不可观测的”——它是个”黑盒”。
而操作系统,从设计之初就在避免”黑盒”:
- /proc:进程信息、内存信息(文本文件,随时可读)
- perf:性能采样(CPU 花在哪)
- tracepoint / kprobe:内核的"埋点"(函数调用、事件)
- eBPF:在内核里跑"观察代码"(可编程的观测)
- 火焰图:把"性能热点"可视化
这一章,我们拆解操作系统的”可观测性工具箱”,并把它迁移到你的工程里。
给你一句总纲:
可观测性 = 让系统的”内部状态”,能被”外部”观察和推理。
它的核心矛盾是:观测本身有成本(性能、存储),而”问题”往往发生在”没观测到”的地方。
所以可观测性的设计,永远是:在”成本”和”覆盖”之间,用”采样”和”分层”来平衡。
本章路线图
第一幕:可观测性的基础
Q1 为什么"抽象越深,可观测性越差"?可观测性要解决什么?
Q2 指标(metrics)、日志(logs)、追踪(traces)三者有什么区别?
第二幕:内核的观测工具箱
Q3 tracepoint / kprobe / uprobe 分别是什么?层次怎么分?
Q4 perf 的采样原理是什么?为什么采样比全量靠谱?
Q5 eBPF 为什么是"可观测性的革命"?
第三幕:可视化与诊断
Q6 火焰图是什么?怎么读?
Q7 Go 的 pprof 是什么原理?和 perf 什么关系?
第四幕:工程实战
Q8 怎么设计一个"可观测"的系统?
Q9 全书回顾:二十章,其实就讲了三个词。
第一幕:可观测性的基础
Q1 为什么”抽象越深,可观测性越差”?可观测性要解决什么?
这个伏笔,我在第 2 章就埋下了。现在收回来。
规律:每加一层抽象,可观测性就降一分。
进程:ps、top、/proc 全能看(可观测性好)
线程:top -H 能看(还好)
goroutine:只能靠 Go 的 pprof(可观测性变差,内核不知道 goroutine)
async task:只能靠语言的 tracing(更差)
为什么?因为”抽象”把”底层细节”藏起来了。
抽象的本质(第 1 章):把"复杂"藏起来,暴露"简单接口"。
但"藏起来"意味着:
- 你在"抽象之上",看不到"抽象之下"发生了什么
- 出问题时,"藏起来的细节"恰恰是你需要的
例:
- goroutine 是用户态抽象,内核不知道它的存在
→ 用 ps/top 看不到 goroutine 的状态
→ 排查 goroutine 泄漏,只能用 Go 自己的工具
可观测性要解决什么?
解决"抽象泄漏"(第 1 章)的问题:
抽象隐藏了细节,但问题往往出在细节。
可观测性 = 在"抽象之上",提供"看到细节"的能力。
具体是三个问题:
1. 状态可见:系统的"当前状态"是什么?(指标、日志)
2. 行为可追踪:一次请求/调用,经历了什么?(追踪)
3. 原因可推理:为什么变慢/出错?(采样、火焰图、eBPF)
核心矛盾:观测的成本 vs 覆盖。
可观测性不是"免费"的:
- 打日志:占 CPU、占磁盘、占内存
- 埋指标:每个指标都要采集、存储、聚合
- 全量追踪:每个请求都记录,成本爆炸
所以可观测性设计,永远是"权衡":
观测什么(覆盖)、观测多细(精度)、观测多久(保留)、花多少成本(预算)。
可观测性的第一原则:
“观测”本身是有成本的,所以要”分层 + 采样 + 按需”。
- 平时:只观测”粗粒度”的(指标、采样)
- 出问题:再观测”细粒度”的(详细日志、全量追踪)
- 极端情况:动态开启(eBPF、动态调试)
这就是”可观测性”和”监控”的区别: 监控是”预设的告警”,可观测性是”能回答未知问题的能力”。
Q2 指标(metrics)、日志(logs)、追踪(traces)三者有什么区别?
这是”可观测性三大支柱”,理解它们的区别,是设计可观测系统的第一步。
指标(metrics):聚合后的数值。
例子:QPS、延迟 P99、CPU 使用率、内存占用、错误率
特点:
1. 聚合的(不是单个事件,是一段时间的统计)
2. 便宜(存一个数字,比存一条日志便宜得多)
3. 能回答"是什么"(发生了什么),但难回答"为什么"
例:CPU 使用率 90%,你知道"CPU 忙",
但不知道"为什么忙"(哪个函数、哪个请求)。
日志(logs):离散的事件记录。
例子:错误日志、访问日志、调试日志
特点:
1. 离散的(每条日志是一个事件)
2. 详细(能记录上下文、堆栈、变量)
3. 能回答"发生了什么细节",但"量大"(成本高)
例:一条错误日志,记录了"哪个请求、什么错误、堆栈",
但日志量大,全存成本高。
追踪(traces):一次请求的完整路径。
例子:分布式追踪(一次请求经过哪些服务、每个服务耗时多少)
特点:
1. 关联的(一次请求的完整调用链)
2. 能回答"一次请求经历了什么"(跨服务、跨进程)
3. 成本高(每个请求都追踪,量大)
例:一次下单请求,追踪显示"网关 10ms → 订单服务 100ms → 数据库 500ms",
一眼看出"数据库是瓶颈"。
三者的对比:
| 指标 metrics | 日志 logs | 追踪 traces | |
|---|---|---|---|
| 内容 | 聚合数值 | 离散事件 | 请求路径 |
| 成本 | 低 | 中 | 高 |
| 回答什么 | 发生了什么 | 细节是什么 | 路径是什么 |
| 粒度 | 粗(聚合) | 细(单条) | 中(单次请求) |
| 典型工具 | Prometheus | ELK | Jaeger/Zipkin |
三者的配合(不是三选一):
指标:发现"有问题"(CPU 90%、P99 飙升)
↓ 指标告警了,但不知道为什么
日志:定位"问题在哪"(错误日志、堆栈)
↓ 日志找到"哪个服务、哪个函数"
追踪:理解"影响范围"(这次请求,影响了哪些下游)
三者配合:
指标 → 发现(快、便宜)
日志 → 定位(细、贵一点)
追踪 → 理解(跨服务、最贵)
三者的本质:可观测性的”三个分辨率”。
- 指标:粗分辨率(看整体趋势)
- 日志:中分辨率(看单个事件)
- 追踪:细分辨率(看完整路径)
而”成本”随”分辨率”递增,所以正确用法是: 先用便宜的(指标)发现,再用贵的(日志/追踪)定位。
第二幕:内核的观测工具箱
Q3 tracepoint / kprobe / uprobe 分别是什么?层次怎么分?
这是 Linux 内核”埋点”的三个层次,理解它们能帮你理解”怎么观测内核”。
tracepoint(静态埋点):
定义:内核源码里"预先埋好"的观测点。
特点:
1. 静态的:内核开发者提前在关键位置(调度、网络、文件系统)埋了点
2. 稳定的:是"官方 API",不会随便变
3. 零开销(不启用时):不启用 tracepoint,几乎无开销
例:sched_switch(进程切换)、net_dev_queue(网络发送)
这些是"预先埋好的点",你只需要"挂载"上去观测。
命令:ls /sys/kernel/tracing/events 可以看到所有 tracepoint
kprobe(动态内核探针):
定义:在"运行中的内核"里,动态地"插入"探针。
特点:
1. 动态的:不需要内核预先埋点,可以"任意函数"插探针
2. 灵活:你想观测哪个内核函数,就插哪个
3. 有开销:动态插桩有开销(且可能不稳定,函数改了探针失效)
例:你想观测 tcp_sendmsg(TCP 发送函数),
用 kprobe 插进去,每次调用都记录。
uprobe(用户态探针):
定义:在"用户态程序"里,动态地插入探针。
特点:
1. 观测用户态:可以观测你的 Go/C 程序的函数
2. 灵活:任意用户态函数都能插
3. 有开销:动态插桩
例:观测你 Go 程序里的某个函数调用次数、耗时。
三者的层次:
| tracepoint | kprobe | uprobe | |
|---|---|---|---|
| 位置 | 内核(预埋) | 内核(动态) | 用户态(动态) |
| 稳定性 | 稳定(官方) | 不稳定(函数可能变) | 不稳定 |
| 开销 | 零(不启用时) | 有 | 有 |
| 灵活 | 低(只有预埋的) | 高(任意函数) | 高 |
这个”静态埋点 + 动态探针”的分层,是一个经典的可观测性设计:
- 静态埋点(tracepoint):稳定、零开销,但覆盖有限(只有预埋的)
- 动态探针(kprobe/uprobe):灵活、任意函数,但有开销、不稳定
工程启示:可观测性要”静态埋点”和”动态探针”结合—— 关键路径静态埋点(稳定、零开销),排查问题时动态探针(灵活、按需)。
Q4 perf 的采样原理是什么?为什么采样比全量靠谱?
问题:怎么知道”CPU 花在哪个函数”?
选项一:全量记录(每个函数调用都记录)
→ 成本爆炸(函数调用太频繁了),而且大量数据没法看
选项二:采样(sample)
→ 每隔一段时间(比如 1ms),记录"当前 CPU 在哪个函数"
→ 采样足够多次,统计"哪个函数被采样到的次数多"
→ 采样次数多的函数,就是"CPU 花得多的函数"
perf 的采样原理:
1. 设置采样频率(比如每秒 1000 次,即每 1ms 采样一次)
2. 每次采样(由定时器中断触发):
- 记录当前"程序计数器"(PC,正在执行哪条指令)
- 记录调用栈(哪个函数调用的)
3. 采样很多次后,统计:
- 函数 A 被采样 5000 次 → 占 50% 的采样 → 约 50% 的 CPU
- 函数 B 被采样 1000 次 → 占 10% → 约 10% 的 CPU
4. 结论:函数 A 是热点(CPU 花在它身上最多)
为什么采样比全量靠谱?
1. 成本低:
采样是"固定频率"的(每秒 1000 次),
不管函数调用多频繁,采样开销是固定的(约 1-5%)。
而全量记录,开销随"调用频率"增长(可能 10-50%)。
2. 统计上可靠:
采样是"随机抽样",只要采样次数够多,
"采样分布"就逼近"真实分布"(大数定律)。
→ 采样的结果,足够可信。
3. 可扩展:
采样能观测"整个系统"(所有进程、内核、用户态),
全量记录做不到(数据太多)。
采样的局限:
1. 采样频率决定了"分辨率":
采样 1ms 一次,看不到"1ms 以内"的细节
(一个 0.5ms 的函数,可能采样不到)
2. 采样是"概率性"的:
短时间的尖峰,可能采样不到(碰巧没采到)
→ 采样适合"稳态分析",不适合"瞬态定位"
采样的本质:用”统计抽样”代替”全量记录”,用”概率可信”换”成本可控”。
这和第 12 章”摊销”、第 9 章”缓存”是同一个思想: 用”足够好的近似”代替”昂贵的精确”。
工程启示:可观测性里,“采样”是控制成本的核心手段—— 指标是”聚合”(降分辨率),追踪是”采样”(只追踪一部分请求)。
Q5 eBPF 为什么是”可观测性的革命”?
第 13 章讲过 eBPF 的原理,这里从”可观测性”的角度看它的革命性。
传统可观测性的局限:
1. 观测内核难:
- 改内核(重新编译、重启)→ 不现实
- 内核模块(危险,bug 崩内核)→ 风险大
- tracepoint(只有预埋的点)→ 覆盖有限
2. 观测用户态难:
- 要改代码、重新部署
- 或者用 ptrace(极慢)
eBPF 的革命性:
1. 安全地观测内核:
eBPF 程序经过"验证器"检查,安全(不会崩内核)
→ 能观测内核,不用改内核、不用冒模块的风险
2. 动态地观测任意位置:
kprobe/uprobe + eBPF,任意函数都能观测
→ 不用改代码、不用重新部署
3. 高性能:
eBPF 被 JIT 编译成机器码,接近原生速度
→ 能用于"生产环境"(开销可接受)
4. 可编程:
你能写"任意逻辑"的观测(不只是预定义的指标)
→ 能回答"未知问题"
eBPF 在可观测性里的典型应用:
1. 追踪函数调用(bpftrace):
bpftrace -e 'kprobe:tcp_sendmsg { @count[comm] = count(); }'
→ 统计哪个进程调 tcp_sendmsg 最多
2. 性能分析(类似 perf,但更灵活):
观测 CPU 热点、内存分配、I/O 延迟
3. 网络观测(Cilium):
观测每个容器的网络流量、延迟、丢包
4. 安全观测(Falco):
观测异常行为(提权、异常系统调用)
eBPF 的革命性,本质是”把可观测性变成了可编程的”:
传统:观测能力是”预设的”(tracepoint 预埋、指标预定义), 你只能观测”别人想到的”。
eBPF:观测能力是”可编程的”(你能写任意观测逻辑), 你能观测”你想到的”。
这就是”可观测性”(observability)和”监控”(monitoring)的区别: 监控回答”已知的问题”,可观测性回答”未知的问题”。 eBPF 让”回答未知问题”成为可能。
第三幕:可视化与诊断
Q6 火焰图是什么?怎么读?
火焰图(Flame Graph)是 Brendan Gregg 发明的”性能热点可视化”,是读性能数据的最强工具。
火焰图是什么:
火焰图把"采样到的调用栈",画成一张图:
- X 轴:函数(按字母排序)
- Y 轴:调用栈深度(从下到上,是调用关系)
- 宽度:该函数"被采样到的次数"(越宽 = CPU 占用越多)
形似火焰(上宽下窄),所以叫火焰图。
火焰图的结构:
[函数 C] [函数 D]
[函数 B ] [函数 C ]
[函数 A ]
[main ]
↓ 调用方向(从下往上调用)
X 轴宽度 = CPU 占用
- 函数 A 宽 → A 占 CPU 多
- 函数 C 窄 → C 占 CPU 少
怎么读火焰图(关键):
1. 看宽度:找"最宽的"函数
→ 最宽 = 占用 CPU 最多 = 性能热点
2. 看调用栈:从下往上读
→ 底部是"调用入口"(main、handler)
→ 顶部是"被调用的函数"(真正干活的)
3. 找"平顶"(plateau):
→ 一个函数很宽,但它的"子函数"都不宽
→ 说明这个函数"自己"在耗 CPU(不是调用的子函数)
→ 这就是要优化的目标
4. 对比:两份火焰图(优化前后)对比
→ 看"哪个函数变窄了/变宽了"
一个读火焰图的例子:
假设火焰图显示:
main → handler → process → json.Unmarshal(很宽)
解读:
- json.Unmarshal 很宽 → CPU 花在 JSON 解析上
- 优化方向:换更快的 JSON 库、减少解析次数、用 protobuf
而如果显示:
main → handler → process → runtime.gc(很宽)
→ CPU 花在 GC 上 → 优化方向:减少分配、调 GC 参数
火焰图的价值:
1. 直观:一眼看出"CPU 花在哪"(不用看枯燥的采样数据)
2. 快速定位:直接指向"最值得优化"的函数
3. 对比:优化前后对比,验证优化效果
火焰图的启示:
“可视化”是把”数据”变成”洞察”的关键一步。
采样数据(哪个函数占多少 CPU)是一堆数字,难看懂。 火焰图把它变成”一眼看懂”的图。
工程启示:可观测性不只是”采集数据”,更是”呈现数据”。 好的可视化,让”问题”自己跳出来。
Q7 Go 的 pprof 是什么原理?和 perf 什么关系?
pprof 是 Go 的”性能分析工具”,它和 perf 是同一个思想(采样),但面向 Go 的抽象。
pprof 的原理(采样):
pprof 也是"采样":
1. 定时器触发(默认 100Hz,每 10ms 采样一次)
2. 每次采样,记录"当前 goroutine 的调用栈"
3. 采样很多次,统计"哪个函数被采样多"
和 perf 的区别:
perf 采的是"CPU 在哪个函数"(硬件视角)
pprof 采的是"goroutine 在哪个函数"(Go 的视角)
pprof 能分析什么:
1. CPU profile:CPU 花在哪(采样)
2. Heap profile:内存分配在哪(采样内存分配)
3. Goroutine profile:goroutine 卡在哪(快照)
4. Block profile:阻塞在哪(锁、channel)
5. Mutex profile:锁竞争在哪
pprof 的用法:
import _ "net/http/pprof"
// 采集 CPU profile(采样 30 秒)
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// 分析堆内存
go tool pprof http://localhost:6060/debug/pprof/heap
// 可视化
go tool pprof -http=:8080 profile.outpprof 和 perf 的关系:
相同:都是"采样"(统计抽样,找热点)
不同:观测的"层次"不同
- perf:硬件/OS 层(CPU 指令、内核函数、系统调用)
- pprof:Go 运行时层(goroutine、Go 函数、内存分配)
配合使用:
1. 先用 pprof 找"Go 层面的热点"(哪个 Go 函数慢)
2. 如果热点是"系统调用"、"GC"、"锁",再用 perf 深入
(看内核在干什么、缓存 miss、TLB miss)
一句话:pprof 看"Go 的问题",perf 看"系统的问题"。
一个诊断流程(Go 服务变慢):
1. 看指标:P99 飙升、CPU 高 → 确认"有问题"
2. pprof CPU profile:找"哪个 Go 函数耗 CPU"
→ 如果是业务函数 → 优化业务逻辑
→ 如果是 runtime.gc → 内存分配过多,优化分配
→ 如果是 runtime.lock → 锁竞争,优化锁
3. 如果 pprof 看不出(CPU 花在"系统"):
→ 用 perf 看内核(系统调用、缓存 miss、TLB miss)
4. 火焰图可视化:把 profile 画成火焰图,找热点
pprof 的启示:
可观测性工具是”分层”的,每一层有对应的工具:
- 应用层:pprof(Go 的函数、内存、goroutine)
- 系统层:perf(CPU、缓存、系统调用)
- 内核层:eBPF(内核函数、事件)
而诊断问题,是”从上往下钻”: 先看应用层(pprof),再深入系统层(perf),再深入内核层(eBPF)。
对应本书的”分层抽象”(第 1 章):每一层抽象,都需要”对应层次的观测工具”。
第四幕:工程实战
Q8 怎么设计一个”可观测”的系统?
这一章的落地总结,可观测性设计的”检查清单”。
第一层:指标(先能”看到”)。
必监控的指标(RED 原则 / USE 原则):
RED(请求视角):
Rate(请求速率)、Errors(错误率)、Duration(延迟)
USE(资源视角):
Utilization(利用率)、Saturation(饱和度)、Errors(错误)
具体到 Go 服务:
- QPS、P99 延迟、错误率(RED)
- CPU、内存、goroutine 数、GC 频率(USE)
工具:Prometheus + Grafana
第二层:日志(能”定位”)。
日志设计:
1. 结构化日志(JSON,方便检索)
2. 分级(debug/info/warn/error)
3. 关键信息:请求 ID、用户 ID、耗时、错误
关键:请求 ID(trace ID)贯穿日志,
这样才能"关联"一次请求的所有日志。
第三层:追踪(能”理解”)。
分布式追踪:
- 每个请求一个 trace ID
- 跨服务传递 trace ID(context)
- 记录每个服务的耗时
工具:OpenTelemetry、Jaeger、Zipkin
第四层:按需深挖(能”回答未知问题”)。
pprof(Go 的性能分析)、perf(系统性能)、
eBPF(内核观测)、火焰图(可视化)
这些是"出问题时"才用的,平时不用(有开销)。
一个关键设计:trace ID 贯穿一切。
func handleRequest(w http.ResponseWriter, r *http.Request) {
// 1. 生成/提取 trace ID
traceID := extractTraceID(r)
ctx := context.WithValue(r.Context(), "trace_id", traceID)
// 2. 日志带 trace ID
log.WithField("trace_id", traceID).Info("request started")
// 3. 指标记录
start := time.Now()
defer func() {
duration := time.Since(start)
metrics.ObserveDuration("request", duration)
}()
// 4. 下游调用,传递 trace ID(context)
result, err := callDownstream(ctx)
// ...
}可观测性设计的核心原则:
1. 分层(便宜的先看,贵的按需):
指标(便宜)→ 日志(贵)→ 追踪(最贵)→ 采样/火焰图(按需)
2. 采样(控制成本):
指标全量,追踪采样(比如 1%),日志按级别
3. 关联(trace ID):
一次请求的指标、日志、追踪,用 trace ID 串起来
4. 面向"未知问题":
不只监控"已知的指标",还要有"回答未知问题的能力"
(pprof、eBPF、火焰图)
可观测性的最终落脚点:
可观测性不是”装一堆监控”,而是”让系统在出问题时,能回答’为什么’”。
而”回答为什么”的能力,来自:
- 分层(指标→日志→追踪→采样,从粗到细)
- 关联(trace ID,把一次请求串起来)
- 可深挖(pprof、perf、eBPF,能钻到最底层)
这和第 19 章的”隔离”、第 13 章的”特权级”是呼应的:每一层抽象,都需要”对应层次的观测”。
Q9 全书回顾:二十章,其实就讲了三个词。
最后一问,我们把二十章收束起来。
我在 README 开头说过,这本书有三条主线。现在,二十章读完,回头看这三条主线,它们贯穿了所有内容:
第一招:加一层(抽象)。
虚拟内存(第 1 章):物理内存上加一层"地址翻译"
文件系统(第 3 章):设备上加一层"VFS"
设备 I/O(第 4 章):硬件上加一层"read/write"
特权级(第 13 章):应用和硬件之间加一层"内核"
容器(第 15 章):进程上加一层"namespace"
虚拟机/WASM(第 19 章):系统上加一层"Hypervisor/类型系统"
共同点:加一层,换来"隔离、抽象、解耦"。
代价:性能下降、可观测性下降(抽象泄漏)。
第二招:摊销(批处理)。
多级页表(第 1 章):只建用到的页表(延迟)
fork COW(第 1 章):用到才复制(延迟)
写回(第 3 章):攒脏页一起写(批量)
组提交(第 12 章):攒事务一起 fsync(批量)
预读(第 10 章):提前读未来的数据(预取)
零拷贝(第 10 章):省掉无用的拷贝(少做无用功)
共同点:把"随机的大代价"变成"可预测的小代价"。
代价:尾延迟、不确定性。
第三招:承认失败是常态。
日志/WAL(第 14 章):先记录,崩溃了重放
隔离/配额(第 15 章):邻居会捣乱,划边界
背压/超时/降级(第 16 章):过载时优雅失败
租约/fencing(第 17 章):死亡和时钟不可靠
共识(第 18 章):节点会挂,用多数派
共同点:不幻想"不出错",而是设计"出错后能恢复"。
代价:复杂度、性能。
而贯穿这三招的,是两个根本的权衡:
1. 抽象 vs 性能:
加一层(隔离、解耦)→ 慢一点
少一层(性能)→ 耦合、危险
2. 一致性 vs 性能:
强一致(可靠)→ 慢
弱一致(快)→ 可能不一致
二十章,三个词,两个权衡。
如果你只能带走一样东西,我希望是这句话:
操作系统(和所有好系统)的设计,本质是”在约束下做权衡”。
- 约束:硬件慢、内存小、会出错、不可信
- 权衡:抽象 vs 性能、一致 vs 性能、隔离 vs 共享
而”精通”的含义,不是”背下所有机制”,而是: 理解了每个设计”解决什么问题、付出了什么代价、在什么假设下成立”。
这样,当你的约束变化时(数据变大、延迟要求变高、需要更强隔离), 你能知道”该改什么、为什么改、代价是什么”。
这,就是”能自己造工具”的底层能力。
思考题
思考题 1
面试官问:“指标、日志、追踪三者有什么区别?什么时候用哪个?“请回答,并说明”trace ID 贯穿三者”的价值。
参考答案
核心答案:指标是聚合数值(发现”发生了什么”),日志是离散事件(定位”细节”),追踪是请求路径(理解”跨服务”)。三者用 trace ID 关联,从”发现”到”定位”到”理解”层层递进。
三者的区别:
指标(metrics):
聚合数值(QPS、P99、CPU、错误率)
特点:便宜、粗粒度、能看趋势
回答:发生了什么(CPU 高了、延迟涨了)
工具:Prometheus、Grafana
日志(logs):
离散事件(错误日志、访问日志、堆栈)
特点:详细、量大、能看细节
回答:细节是什么(哪个请求、什么错误)
工具:ELK、Loki
追踪(traces):
请求路径(一次请求经过哪些服务、各耗时多少)
特点:关联、跨服务、成本高
回答:路径是什么(哪里是瓶颈)
工具:Jaeger、Zipkin、OpenTelemetry
什么时候用哪个:
1. 平时(监控):用指标
- 便宜,能看整体趋势
- 指标异常(P99 飙升)→ 触发告警
2. 告警后(定位):用日志
- 指标告诉你"有问题",但不知道为什么
- 日志告诉你"哪个请求、哪个函数、什么错误"
3. 跨服务(理解):用追踪
- 一次请求经过多个服务,日志是"分散"的
- 追踪把"分散的"串起来,看完整路径
trace ID 贯穿三者的价值:
问题:一次请求,在指标、日志、追踪里是"分散"的,
没有 trace ID,你无法"关联"它们。
有 trace ID:
1. 指标告警:P99 飙升,但不知道是哪些请求
2. 用 trace ID 查日志:找到"慢的请求"的详细日志
3. 用 trace ID 查追踪:看到这个请求的完整路径(哪个服务慢)
trace ID 的价值:把"指标 → 日志 → 追踪"串起来,
实现"从发现到定位到理解"的完整链路。
一个完整的排查流程:
1. Grafana 看指标:P99 飙升 → 发现问题
2. 找到"慢请求"的 trace ID(指标里关联)
3. 用 trace ID 查 Jaeger:看到"数据库调用占 500ms" → 定位瓶颈
4. 用 trace ID 查日志:看到"慢查询 SQL" → 理解原因
5. 修复,验证指标恢复
这道题考察什么:
- 知不知道三者的本质区别(聚合 vs 事件 vs 路径)
- 知不知道各自的成本(指标便宜,追踪贵)
- 知不知道”从发现到定位”的递进关系
- 知不知道 trace ID 的”关联”价值
思考题 2
你的 Go 服务突然 CPU 100%,但不知道原因。请描述你的排查步骤,从”指标”到”火焰图”,说明每一步用什么工具、看什么、得出什么结论。
参考答案
核心思路:从”粗”到”细”,层层深入:指标 → pprof → 火焰图 → perf → eBPF。
第一步:看指标(确认”什么现象”)。
工具:Prometheus + Grafana
看什么:
- CPU 使用率(user% vs sys%)
- user% 高 → 业务代码在忙(Go 函数)
- sys% 高 → 系统调用/内核在忙(I/O、锁、GC 的 syscall)
- QPS、延迟(是否流量突增?还是流量没变但 CPU 涨?)
- goroutine 数、GC 频率(是否 goroutine 泄漏、GC 压力大)
结论:
- 流量突增 → 可能只是"过载"(限流、扩容)
- 流量没变但 CPU 涨 → 有"热点"或"泄漏"
第二步:pprof CPU profile(找”哪个 Go 函数”)。
# 采集 30 秒 CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30看什么:
- top 命令:占用 CPU 最多的 Go 函数
结论(分情况):
- 业务函数(比如 json.Marshal)→ 业务逻辑热点,优化它
- runtime.gc → 内存分配过多,GC 压力大
- runtime.lock / sync.Mutex → 锁竞争
- runtime.morestack → goroutine 栈增长频繁
- syscall 相关 → 系统调用多(进入第三步)
第三步:火焰图(可视化找热点)。
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30
# 在浏览器看火焰图看什么:
- 最宽的"平顶"函数(自己耗 CPU,不是调子函数)
- 调用栈(从下往上,找到"谁调用了热点")
结论:
- 找到"最值得优化"的具体函数和调用路径
第四步:perf(如果热点在”系统层”)。
# 如果 pprof 显示热点是 syscall、内核函数
perf top -p <pid> # 实时看内核热点
perf record -p <pid> -g -- sleep 30 # 记录
perf report # 分析看什么:
- 内核函数(比如 page_fault、flush_tlb、_raw_spin_lock)
- 缓存 miss、TLB miss、上下文切换
结论(第 1 章的知识):
- clear_page_erms → 频繁分配新页(内存分配模式问题)
- flush_tlb_multi → 频繁改页表(munmap/madvise 频繁)
- 上下文切换多 → 线程/goroutine 太多
第五步:eBPF(深挖内核,如果需要)。
# 用 bpftrace 追踪具体的内核函数
bpftrace -e 'kprobe:tcp_sendmsg { @count[comm] = count(); }'看什么:
- 具体的系统调用、内核事件、I/O 模式
结论:
- 定位到"最底层"的原因(哪个 syscall、哪个内核路径)
一个完整的排查示例:
现象:CPU 100%,QPS 没变
1. 指标:user% 80%,sys% 20% → 业务代码在忙
2. pprof top:json.Unmarshal 占 60% → 热点是 JSON 解析
3. 火焰图:main → handler → json.Unmarshal(平顶)
4. 结论:请求体解析 JSON 太慢
5. 优化:换更快的 JSON 库、减少解析、缓存解析结果
这道题考察什么:
- 知不知道”从粗到细”的排查思路
- 知不知道 pprof、火焰图、perf、eBPF 各自的用途
- 知不知道”user% vs sys%“的判断(业务 vs 系统)
- 能不能用第 1-12 章的知识解读 perf 结果(flush_tlb、clear_page)
思考题 3
“采样”和”全量记录”是性能分析的两条路。为什么采样(perf、pprof)是主流?采样的局限是什么?什么场景下必须全量(比如 tracing)?
参考答案
核心答案:采样用”统计抽样”代替”全量记录”,用”概率可信”换”成本可控”。局限是”分辨率”和”瞬态”捕捉不到。必须全量的场景是”要精确还原每一次请求”(比如分布式追踪的定位、审计)。
为什么采样是主流:
1. 成本可控:
采样是"固定频率"(每秒 1000 次),
开销固定(约 1-5%),不随"负载"增长。
全量记录,开销随"函数调用频率"增长,
高频调用下,开销可能 10-50%,甚至"观测"本身成为瓶颈。
2. 统计上可靠:
采样是"随机抽样",采样次数足够多,
"采样分布"逼近"真实分布"(大数定律)。
→ 采样的结论(哪个函数热)足够可信。
3. 可扩展:
采样能观测"整个系统"(所有进程、内核、用户态),
全量做不到(数据量爆炸)。
采样的局限:
1. 分辨率受限:
采样频率 100Hz(10ms 一次),
看不到"10ms 以内"的细节。
一个 1ms 的函数,可能"碰巧"采样不到。
2. 瞬态捕捉不到:
采样是"概率性"的,
短时间的"尖峰"(比如 100ms 的 CPU 尖峰),
可能"碰巧"没采样到。
→ 采样适合"稳态分析",不适合"瞬态定位"。
3. 无法"精确还原":
采样告诉你"哪个函数占 50% CPU",
但无法告诉你"第 3 次调用这个函数时,参数是什么"。
→ 采样是"统计",不是"记录"。
什么场景必须全量(或接近全量):
1. 分布式追踪(要还原每次请求):
追踪要"精确"记录"第 N 次请求,经过哪些服务、各耗时多少"。
采样追踪(比如 1%)能看"统计",但定位"某次具体的慢请求",
需要"全量追踪"(或至少对"慢请求"全量)。
2. 审计/合规(要还原每次操作):
金融、合规场景,每次操作都要记录(不能采样)。
采样 = 漏记录 = 合规风险。
3. 调试(要还原"某次"的执行细节):
复现一个 bug,要"精确"看到"这次执行"的每一步。
采样看不到"这一次",要全量(或 gdb 单步)。
4. 安全分析(要捕捉"每次"异常):
入侵检测,要记录"每一次"异常系统调用。
采样可能漏掉"关键的一次"。
折中:采样 + 条件全量。
实践中,常用"采样 + 条件全量":
- 平时:采样 1%(成本低)
- 慢请求(> 阈值):全量追踪(重点观测慢的)
- 错误请求:全量追踪(重点观测错的)
这样:成本可控,且"关键请求"(慢的、错的)不遗漏。
这道题考察什么:
- 知不知道采样”成本可控”的优势
- 知不知道采样的局限(分辨率、瞬态、不能精确还原)
- 知不知道”什么场景必须全量”(追踪、审计、调试)
- 知不知道”采样 + 条件全量”的折中
思考题 4(开放题)
这本书讲了二十章,核心是”加一层、摊销、承认失败”三个词,以及”抽象 vs 性能、一致 vs 性能”两个权衡。请你用这三个词和两个权衡,分析一个你自己熟悉的系统(或技术),说明它的设计”用了哪一招、付出了什么代价”。
参考答案
开放题,考察你能不能把全书的思想框架,应用到具体系统。
这里给出一个”分析框架”,你可以套用到任何系统:
分析一个系统的四步:
1. 它"加了哪一层"(抽象)?
→ 抽象了什么、解决了什么问题
2. 它"摊销了什么"(批处理/预取/延迟)?
→ 把什么随机的大代价,变成可预测的小代价
3. 它"怎么应对失败"(日志/隔离/背压)?
→ 承认了什么失败、怎么恢复
4. 它"付出了什么代价"(权衡)?
→ 抽象 vs 性能?一致 vs 性能?
示例一:分析”消息队列(Kafka)”。
1. 加了一层(抽象):
- 生产者/消费者解耦(加了一层"队列")
- 解决:生产者和消费者的速度不匹配、故障隔离
2. 摊销(批处理):
- 批量发送、批量消费、顺序追加写
- 把"每次网络往返、每次随机写"变成"批量、顺序"
- 解决:摊薄固定开销
3. 承认失败(日志):
- partition 是"追加日志"(第 14 章的 WAL 思想)
- 消费者挂了,用 offset 重放(幂等)
- 解决:崩溃后不丢消息、可重放
4. 代价(权衡):
- 加了"队列"这一层 → 延迟增加(消息要"排队")
- 批量 → 尾延迟(第 12 章)
- 一致性:Kafka 是"至少一次"(可能重复),不是"精确一次"
→ 用"一致"换"性能"
示例二:分析”数据库的 buffer pool”。
1. 加了一层(抽象):
- 数据库自己管"内存缓存"(buffer pool)
- 解决:磁盘慢,用内存缓存热数据
2. 摊销(预取/批量):
- 预读(顺序读相邻页)、批量刷盘(攒脏页)
- 把"随机磁盘 I/O"变成"顺序/批量"
- 解决:摊薄磁盘寻道、I/O 开销
3. 承认失败(日志):
- WAL(第 14 章)+ 崩溃恢复
- 解决:崩溃后不丢已提交事务
4. 代价(权衡):
- buffer pool 是"缓存" → 一致性(缓存和磁盘可能不一致)
- 用"一致"换"性能"(第 9 章)
- WAL 的 fsync → 性能代价(组提交缓解)
示例三:分析”微服务架构”。
1. 加了一层(抽象):
- 服务之间加了"网络边界"(第 13 章的"隔离 vs 通信")
- 解决:独立部署、故障隔离
2. 摊销:
- 连接池复用、批量调用
- 把"每次建连接"变成"复用"
3. 承认失败(背压/超时/降级):
- 熔断、重试、降级(第 16 章)
- 解决:下游挂了,不拖垮上游
4. 代价(权衡):
- 网络边界(隔离)→ 通信成本(网络延迟)
- 分布式事务 → 牺牲一致性(最终一致)
- 用"隔离"换"性能"(网络慢),用"一致"换"可用"
这道题的价值:
它检验你是否真正”内化”了这本书的思想,而不是”记住”了知识点。
能用”三个词 + 两个权衡”分析任何系统,说明你掌握了”操作系统设计的方法论”, 而不仅仅是”操作系统的知识”。
而这,正是这本书的目标: 不是教你”操作系统的 API”,而是教你”像系统设计者一样思考”。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 20 章 · 可观测性:内核怎么让不可见的东西可见 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【可观测性的核心矛盾】 │
│ 抽象越深,可观测性越差(细节被藏起来) │
│ 观测本身有成本(性能、存储),问题总在"没观测到"的地方 │
│ 解法:分层 + 采样 + 按需 │
│ │
│ 【三大支柱】 │
│ 指标(聚合,便宜,看趋势)→ 日志(事件,定位)→ 追踪(路径,理解)│
│ 三者用 trace ID 关联,从"发现"到"定位"到"理解" │
│ │
│ 【内核的观测工具箱】 │
│ tracepoint(静态埋点)/ kprobe(内核动态)/ uprobe(用户态动态)│
│ perf:采样原理(统计抽样,成本可控) │
│ eBPF:可编程观测(回答未知问题) │
│ 火焰图:可视化(让问题自己跳出来) │
│ │
│ 【Go 的观测】 │
│ pprof(Go 层)→ perf(系统层)→ eBPF(内核层) │
│ 从上往下钻:先应用,再系统,再内核 │
│ │
│ 【全书回顾:三个词 + 两个权衡】 │
│ 加一层(抽象):隔离、解耦,代价是性能和可观测性 │
│ 摊销(批处理):随机的大代价 → 可预测的小代价,代价是尾延迟 │
│ 承认失败:日志、隔离、背压,代价是复杂度 │
│ 两个权衡:抽象 vs 性能、一致 vs 性能 │
│ │
│ 【一句话】 │
│ 可观测性是"让黑盒变透明",分层采样是它的方法论; │
│ 而整本书,不过是"三个词 + 两个权衡"在二十个主题上的反复变奏。 │
│ │
│ 【全书完】 │
└──────────────────────────────────────────────────────────────┘
结语
老哥,二十章,到这里就写完了。
回看全书,我想你其实只带走三样东西就够了:
第一,加一层。 你以后看任何系统——不管是一个框架、一个中间件,还是一个同事写的服务——先问一句”它在哪加了层?把什么藏起来了?“。这一问,能让你看懂 80% 的设计。
第二,摊销。 面对”慢”和”贵”,别急着”做得更快”,先想想”能不能批量、能不能提前、能不能推迟”。这一想,能解决 80% 的性能问题。
第三,承认失败。 别幻想”不出错”,先问”出错了怎么恢复”。这一问,能让你从”写代码的人”变成”设计系统的人”。
而这一切的底层,是那句我在第 1 章就说过、现在仍然想再说一遍的话:
大部分”最佳实践”,都是某个特定约束条件下的最优解。记住它的约束,比记住它的结论重要一百倍。因为结论会过时,而约束变化的那一刻,恰恰是你的机会。
操作系统是这样,你的系统也是这样。
去造你的工具吧。