持续分析:时间花在哪里的第四类信号

上一篇结尾,追踪把我们带到了一堵墙前:它能精确地告诉你”这次请求的 1840 毫秒里,1240 毫秒花在了库存服务这一跳”——但如果这一跳的慢,不是因为等数据库锁(那个 tag 救了你),而是因为这个服务内部某段代码在低效地空转,追踪就只能耸耸肩说:“就是这个服务慢。” 至于服务里哪个函数慢,它看不进去。

持续分析(continuous profiling)就是钻进这堵墙后面的信号。它是可观测性的第四类信号,回答一个前三类——指标、日志、追踪——都答不出的问题:时间,到底花在哪个函数上? 这一篇,我们走完”信号的扩张”这条子带的最后一块拼图。

一、钻进那一跳:持续分析在做什么

追踪止步的地方

把四类信号在一次性能问题上的视野,从粗到细排一遍:

  • 指标:结账 P99 是 1.8 秒。(哪个系统慢了)
  • 追踪:1.8 秒里 1.24 秒在库存服务。(哪一跳慢了)
  • 持续分析:库存服务的这 1.24 秒,0.9 秒花在 serializeResponse() 这个函数里。(哪段代码慢了)

视野一层层钻进去,追踪到”服务”为止,持续分析接着钻到”函数”。追踪看的是服务之间的边界,持续分析看的是服务内部的代码。 这是它独占的领地。

第四类信号的定义

收紧成定义:持续分析,是对程序在一段时间内的资源消耗(CPU、内存、锁等待……)做代码级、累计的归因——它告诉你,每一个函数、每一行代码,累计消耗了多少资源。

注意”累计”这个词,它是持续分析和追踪最根本的区别。追踪讲的是某一次请求的故事;持续分析讲的是所有请求在一段时间里,累计把 CPU 喂给了哪些函数。它不关心单次请求的旅程,它关心的是宏观的资源去向。

它回答”哪段代码慢/费”

一个服务的 CPU 莫名涨到了 80%。指标告诉你”涨了”,追踪告诉你每一跳的耗时——但每一跳看起来都正常,没有哪个 span 特别离谱。CPU 却实实在在地高。

这种”指标发现异常,但追踪找不到原因”的处境,正是持续分析的主场。因为这种 CPU 升高,往往不是某一次请求的某一跳慢,而是某个函数被以更高的频率调用了、或者本身变低效了——它分摊在每一次请求里都不明显,但累计起来吃掉了大量 CPU。这是一个只有”累计视角”才能看见的问题,前三类信号天生看不到。

二、读懂火焰图:profiling 的数据形态

持续分析的数据,最终几乎总是呈现为一张火焰图(flame graph)。看懂火焰图,就看懂了 profiling 在说什么。

一次采样 = 一个调用栈快照

profiling 的原始数据采集方式,朴素得惊人:周期性地(比如每秒 100 次)打断程序,记录下它此刻正在执行的调用栈。 一次打断,得到一个栈快照,比如:

main → handleRequest → serializeResponse → json.Marshal → reflect.Value

这告诉你:在这个被抓住的瞬间,CPU 正在 reflect.Value 里,而它是被 json.Marshal 调用的,再往上是 serializeResponse……一个快照只是一个瞬间,没什么意义。但当你一秒抓 100 次、连抓几分钟,你就有了几万个这样的快照。

把海量栈快照聚合 = 火焰图

把这几万个栈快照按相同的调用路径聚合统计——某条路径出现得越多,说明 CPU 在那里待的时间越长——再画出来,就是火焰图:

█████████████████████████████████████████████████  main (100%)
█████████████████████████████████  handleRequest (68%)   ███████  dbQuery (28%)
███████████████████████████  serializeResponse (55%)
███████████████████████  json.Marshal (48%)
█████████████████  reflect.Value (35%)

火焰图的读法只有两条规则:

  • 宽度 = 该函数(及其调用的子函数)累计占用的采样比例,也就是它吃掉的 CPU 时间占比。越宽 = 越耗。
  • 纵向 = 调用栈的深度,上面的函数调用下面的(注意:纵向不代表时间先后,只代表调用嵌套)。

怎么读:找最宽的”平顶”

读火焰图的诀窍,是找最宽的那个”平顶”——一个很宽、但它上面没有更宽的子函数的块。在上面那张图里,reflect.Value 占了 35% 的宽度且接近顶端——这意味着 CPU 有三分之一的时间,实打实地耗在了反射上。这就是热点。 顺着它往下(往栈底)看,你立刻知道这个反射是 json.Marshal 序列化引起的,根因浮现:序列化用了反射,而它被调用得太频繁了。

火焰图的美在于:几万个栈快照的信息,被压缩成一张一眼能找到热点的图。 你不需要读代码,宽度替你指出了 CPU 都去了哪。

on-CPU vs off-CPU:在跑什么 vs 在等什么

前面描述的采样,抓的是”CPU 此刻在执行什么”——这叫 on-CPU profiling,它回答”CPU 忙的时候在算什么”。但有一类性能问题,CPU 根本不忙——程序在:等锁、等磁盘 IO、等网络、等数据库返回。这时候 on-CPU 火焰图是空的(CPU 闲着呢),但请求就是慢。

这就需要 off-CPU profiling——它采样的不是”在跑什么”,而是”在等什么、等了多久”。它把线程被阻塞、被换出 CPU 的时间归因到对应的调用栈上。

on-CPU 回答”算得慢”,off-CPU 回答”等得久”——两个互补的视角。一个请求慢,要么是算得多(on-CPU 热点),要么是等得久(off-CPU 热点),两张火焰图合起来才是完整的时间去向。

三、从 profile a moment 到 profile always

火焰图本身不是新东西——Brendan Gregg 普及它已经十多年了。持续分析真正的革新,藏在”持续”这两个字里。

传统 profiler:手动抓一个瞬间

传统的 profiling 是这样用的:服务出问题了,工程师登上某台机器,手动跑一次 perf record(Linux 通用)或触发一次 pprof(Go),采集个几十秒,拿到一张火焰图,分析完就结束。它是一次性的、按需的、抓一个瞬间的。

瞬间的问题

这种”按需抓瞬间”的方式,在生产环境有一个致命的错配:生产的性能问题,往往不在你抓的那个瞬间。

  • 性能退化常常是渐变的——某个函数随着数据量增长越来越慢,你今天抓一张图看不出异常,要和三周前对比才看得出趋势。
  • 性能问题常常是负载相关的——只在每天高峰、或某种特定流量组合下才出现。你手动登上去抓的那十秒,恰好可能是风平浪静的十秒。
  • 最坏的情况:故障已经过去了。你想分析”昨晚两点那次 CPU 飙高”,可你两点在睡觉,没人去抓那张图,现场永远丢了。

你不可能预知该在哪一刻去抓 profile——这又是 "预判"的困境,只不过这次预判的是”何时去采样”。

持续分析:profile always

持续分析的回答,和 可观测性对监控的回答如出一辙:别预判该在哪一刻抓,把所有时刻都抓下来。

它在整个生产环境、所有实例上,7×24 地以很低的频率持续采样(低到几乎不影响性能,通常 CPU 开销 1% 上下),把火焰图数据连续地存起来。于是:

  • 你能回到过去任意时刻——“给我看昨晚两点那个服务的火焰图”,现场一直都在。
  • 你能对比——版本 2.3.0 和 2.3.1 的 CPU 火焰图叠在一起,一眼看出新版本多出来的那块红色是哪个函数引入的。
  • 你能发现渐变——同一个函数的 CPU 占比随周缓慢爬升,趋势暴露。

“持续”二字的全部意义

所以从 profile a momentprofile always 的这一跳,本质上和指标→可观测性是同一种范式转变:从”预判着去采”到”无差别地全采、事后任意回溯”。 持续分析,就是 profiling 这件事的可观测性化。这也是为什么它属于云原生范式——它的世界观,和 可观测性是同一个。

四、eBPF:零侵入是怎么做到的

“7×24 全生产采样”听起来很美,但它有一个前提:采样本身必须足够便宜、足够通用,便宜到能一直开着,通用到不挑语言、不用改代码。让这件事成为可能的技术,是 eBPF。

传统 profiling 的两道枷锁

传统 profiler 为什么不能一直开着、不能到处用?因为它戴着两道枷锁:

  • 要改代码 / 要特定运行时pprof 只服务 Go,JVM 的 profiler 只服务 Java,每种语言一套,还常要在代码里引入库、开个端口。一个混合了 Go、Java、Python、C++ 的系统,你得用五套互不相通的工具。
  • 开销与侵入:有些 profiler 开销大到只能临时开、不能常驻;有的要重启进程才能挂上。

这两道枷锁,把 profiling 锁死在了”按需、单语言、抓瞬间”的旧世界里。

eBPF perf event 采样原理

eBPF 把这两道枷锁一起砸开。它的采样原理,是在内核里做文章:

eBPF 程序挂到内核的 perf event 上,让内核以固定频率(比如 99Hz)产生一个中断;每次中断,eBPF 程序就地抓取当前正在 CPU 上运行的那个进程的调用栈,写进一块内核与用户空间共享的 Map,用户空间的采集器再把它读走、聚合成火焰图。

这个机制的三个特性,正好解开旧世界的锁:

  • 不改应用:采样发生在内核,应用完全无感,一行代码都不用动。
  • 不重启、不挑语言:内核抓的是进程的栈,它不在乎这个进程是 Go 还是 Java 还是 C++——对内核来说,运行的都是机器指令和栈帧。一套 eBPF profiler,全语言通吃。
  • 开销极低:99Hz 的采样,对生产负载的影响通常在 1% 以内,便宜到可以一直开着——这正是”持续”得以成立的物理基础。

(eBPF 为什么能安全地在内核里跑这些程序——verifier 如何证明它不会崩溃内核、Maps 如何通信——是内核机制的话题,见 云原生与平台工程 MOC 第 06 篇。这里我们只用它的能力,不拆它的内脏。)

符号解析的难处

eBPF 抓到的栈,其实是一串内存地址——0x4a3f120x4b8801……要变成人能读的 json.Marshal,需要符号解析:拿着地址去程序的符号表里反查函数名。这一步是 profiling 工程上最麻烦的部分之一:编译优化会内联函数、strip 过的二进制没有符号表、不同语言的栈帧布局不一样(尤其是 JIT 的 Java、有 async 栈的语言)。一张火焰图好不好用,一半取决于符号解析做得干不干净——栈抓对了但符号化失败,你看到的就是一堆 0x4a3f12,等于没看。

★ 故障剧场:追踪正常,CPU 却涨了

把这一切落到一个具体故障上,也正好兑现开头那个场景。

你发布了 2.3.1。上线后,某个服务的 CPU 从 40% 悄悄爬到了 75%,但没有任何请求变慢的迹象——P99 平稳,追踪里每一跳的 span 耗时都正常,错误率也没动。指标在喊”CPU 高”,追踪却说”一切都好”。你陷入僵局:CPU 被谁吃了?

你打开持续分析,做一件追踪做不到的事——把 2.3.1 和 2.3.0 的 CPU 火焰图叠在一起对比。差异瞬间跳出来:2.3.1 多出来一大块宽度,栈是 handleRequest → logRequest → json.Marshal。原来新版本给请求日志加了一个”把整个请求体序列化成 JSON 记下来”的逻辑,每个请求都跑一次重序列化——单次开销小到不影响延迟(所以追踪看不出),但累计起来吃掉了 35% 的 CPU。

这个问题,只有”代码级 + 累计 + 可对比版本”的持续分析能抓到。 指标看见了症状,追踪两手一摊,而火焰图一眼指出了那个被高频调用的序列化函数。

五、工具与边界

工具:Parca / Pyroscope 的存储难题

持续分析的工程难点,不在采集(eBPF 解决了),在存储。想想数据量:所有实例、7×24、每秒上百个栈样本、每个样本是一条带符号的调用栈——这是一个高维、高频、海量的数据流。

  • Parca(Polar Signals 出品):用一套类似 Prometheus 的思路存 profile——把火焰图数据当成带 label 的时间序列来组织、压缩、查询。你能像查指标一样,按 service、version 等维度切分 profile。
  • Pyroscope(已并入 Grafana):另一套高效的 profile 存储引擎,和 Grafana 生态深度集成,能把 profile 和指标、日志、追踪放在同一个界面里关联看。

两者都在解同一道题:怎么把”持续”产生的海量火焰图,存得起、查得快。 这又是一次 存储汇率的较量——和 Loki、Tempo 一脉相承,都在想方设法压低”留下一切”的成本。

关联追踪:从哪一跳慢到哪段代码慢

持续分析单独用已经很强,但它和追踪联起来才完整。现代工具支持 trace/span profiling:当 追踪定位到”库存服务这一跳慢”,你可以直接跳到那一跳那段时间的火焰图,看那一跳内部的代码热点。

这就把 06 结尾那堵墙彻底打通了:追踪定位到服务边界(哪一跳),持续分析钻进服务内部(哪段代码)——两个信号接力,从”系统级”一路追到”代码行”。

边界:它看不见”为什么走这段”

持续分析也有它答不了的问题,又是一次分工。它告诉你 serializeResponse 累计很热,但它看不见”为什么某一次请求会走进这个函数”——是哪个用户、哪个参数、哪条业务分支触发的?那是单次请求的因果,属于 追踪宽事件

持续分析回答”哪段代码热”(累计、宏观),追踪回答”为什么这次走了这段”(单次、因果)。 没有哪类信号全能——这是贯穿整个云原生范式的同一个真相:四类信号各有盲区,组合使用才完整。

内核机制不在本篇

最后划清边界:本篇讲的是 eBPF 作为一种采样能力,怎么让 profiling 零侵入、可持续。至于 eBPF 自己是怎么在内核里安全运行的——verifier 的验证、Maps 的通信、它和容器网络/安全的关系——那是另一个完整的话题,见 云原生与平台工程 MOC。在这里,eBPF 是工具,不是主角。

六、小结:坐标与去向

持续分析在五维标尺上的坐标

那把尺子给它定位:

维度持续分析的坐标
① 故障预判权机器事后回答——全采全留,回溯任意时刻,不预判何时去采
② 存储汇率高频高维——付高存储税,靠 Parca/Pyroscope 的压缩压回来
③ 信号分工回答”时间花在哪段代码”——前三类都缺的代码级累计视角
④ 注意力阀门不直接告警,是诊断工具
⑤ 仪器化负担被 eBPF 压到接近零——不改、不重启、不挑语言(最大特征)

持续分析最鲜明的坐标,是第⑤维——它把仪器化负担压到了四类信号里的最低点。这正是 那条"仪器化代价越来越低"的轨迹的当前终点:从手写 SDK,到框架自动注入,到 eBPF 内核零侵入,让系统”开口”的成本逼近于零。

去向:信号扩张收尾,转入工程化

到这里,可观测性的四类信号——指标、日志、追踪、持续分析——全部就位。“信号的扩张”这条子带走完了。从下一篇起,云原生范式转入第二条子带——工程化与标准化:信号都有了,怎么用好它们?

那个贯穿全系列的问题:持续分析把天平推向了哪边?它把”仪器化负担”这一端,几乎归了零——用 eBPF 在内核里零侵入地采样,换来代码级的、持续的、全语言的可见性。它是”留下一切、事后任意提问”这条路上,成本最低的那一类信号。四类信号集齐,下一篇我们换个问题:有了这么多信号,到底什么才叫”可靠”?

返回 可观测性与运维工程 MOC | 上一篇 06-分布式追踪-因果链的精确重建 | 下一篇 08-SLO-把可靠性变成数学