凌晨两点半,告警响了。订单查询接口 P99 从 80ms 涨到 600ms,错误率没变,就是慢。打开监控面板,应用容器的 CPU 使用率长期在 30% 上下,波动很小,负载也没高。值班的第一反应是扩容,从 8 个实例加到 16 个,流量均摊下去之后 P99 还是 600ms,CPU 还是 30%。机器上有七成算力闲着,吞吐量就是上不去。
问题出在这条曲线本身。CPU 使用率是一个比值:在一个采样周期内,CPU 处于非 idle 状态的时间占比,Linux 下来自 /proc/stat 里 user、system、nice、irq 等各态 tick 的累加。它回答的是"用了多少",不回答"花在哪里",更不回答"没用到的那部分去哪了"。当吞吐瓶颈不在计算上,这个数字就和吞吐没有对应关系——它既不升高也不降低,只是稳定地告诉你还剩 70%。这时候继续盯着它看,等于盯着一个不参与讨论的变量。
还有一层反直觉:等待会把 CPU 使用率往下拉。假设 200 个 worker 线程全部阻塞在数据库 socket read 上,这些线程处于 sleep 状态,不消耗 CPU 时间,此时 CPU 使用率不但不会升高,反而比健康时更低。于是你会看到一种很难解释的组合——请求变慢了、CPU 变低了、加机器没用。这不是监控坏了,是这三个指标本来就在描述不同的东西,只是过去业务正常时它们恰好同向变化,让人误以为存在因果。
平均值还会掩盖单核打满。8 核机器上整体 30% 意味着大约 2.4 个核在忙,但这 2.4 个核可能是均匀分摊的,也可能是某 1 个核被 GC 线程或者单个串行段压到 100%、其余 7 个核接近空闲。后者的吞吐上限由那一个核决定,加机器只能复制这个瓶颈。所以判断 CPU 是不是瓶颈,第一步不是看平均值,而是看 per-core 分布:如果最大值长期贴着 100% 而其他核很闲,那是串行化问题;如果所有核都均匀在 30%,那瓶颈几乎肯定不在 CPU 上。
加机器没用通常只有三种原因,而且都不体现在 CPU 曲线上:一是共享资源有硬性上限,比如数据库连接池配了 20、下游服务给的并发配额是 50、线程池队列长度固定,加多少个实例都抢同一批连接;二是关键路径存在串行段,按 Amdahl 定律,无论并行度怎么扩,串行部分的时间是省不掉的;三是时间在等待上而不是在计算上,扩容只是增加了"一起等"的线程数。要区分这三种,需要的是另一类数据。
一个请求的墙上时间(wall clock time)可以拆成两部分:on-CPU 时间是线程真正在 CPU 上执行指令的时间,off-CPU 时间是不在 CPU 上的时间。这个拆分是排障里最关键的一步,因为 CPU 使用率这个指标只覆盖 on-CPU 部分,对 off-CPU 完全盲。请求 RT 从 80ms 涨到 600ms 而 CPU 使用率不变,几乎可以确定是 off-CPU 时间增加了 520ms。
off-CPU 不是一种状态,是一大类状态,原因不同、解法完全不同,必须再往下分:
可运行但没被调度上(run queue 延迟):线程状态是 R,它想跑但 CPU 被别的任务占着。这在 CPU 使用率上是"忙",但你的进程没拿到时间片。容器被 cpu quota 限流(cgroup v1 的 cpu.stat 里 nr_throttled 和 throttled_time)是典型来源——面板上 CPU 使用率看起来才 30%,实际上进程被周期性掐断,throttled_time 在涨。这个字段不看容器自己的 cgroup 统计就永远发现不了。
阻塞在锁上:互斥量、读写锁、信号量、Go 的 channel 收发、Java 的 synchronized 和 ReentrantLock。线程进入 S 状态,CPU 使用率掉下去。Go 提供 block profile 和 mutex profile 两把尺子,分别由 runtime.SetBlockProfileRate 和 runtime.SetMutexProfileFraction 控制;Java 的 JFR 用 jdk.JavaMonitorEnter、jdk.ThreadPark 两类事件记录监控器进入与线程挂起。这两类数据的采样单位是"时长"而不是"次数",这是它们比锁竞争计数更有用的地方。
阻塞在 IO 上:磁盘读写、网络收发、DNS 解析、数据库驱动里的 socket read。JFR 里对应 jdk.SocketRead、jdk.SocketWrite、jdk.FileRead、jdk.FileWrite。这一类最常见,也最容易和"下游慢"混为一谈——区别在于剖析数据能直接告诉你时间花在哪个调用栈上,是驱动内部的缓冲区拷贝、还是 TLS 握手、还是应用层的反序列化。
被运行时暂停:GC 的 STW 阶段、安全点(safepoint)等待、Go 的调度器抢占延迟。STW 期间应用线程全部挂起,CPU 可能被 GC 线程占着(此时 CPU 使用率反而升高),也可能完全空闲(Go 在某些阶段需要所有 goroutine 到达安全点)。如果 P99 的尖刺和 GC 日志时间戳对齐,那问题在堆分配速率,不在业务代码。
缺页与内存回收:major page fault 会触发磁盘读,swap 开启后更明显;容器内内存接近 limit 时的直接回收(direct reclaim)会让线程卡在内核里。这些时间既不算应用 CPU,也不算 idle,混在 system 时间里很难分辨。
分完之后逻辑就清楚了:on-CPU 时间高 → 找热点函数,优化算法或减少调用次数;off-CPU 时间高 → 找阻塞点,提高并发度、加连接、改异步、调 GC。CPU 使用率 30% 而吞吐上不去,属于第二种。
这三样东西经常被当成同一件事的不同产品,实际上它们的数据结构、采样方式、能回答的问题都不一样,代价也差一个数量级。最省事的理解方式是按问题分:指标告诉你"慢了",链路追踪告诉你"哪个请求、哪个服务、哪个 span 慢",剖析告诉你"哪段代码吃掉了时间"。指标是聚合后的数值,成本低、保留久,但丢掉了因果;追踪保留了单次请求的因果链,但同样一段慢 span,它不会告诉你是 span 内部的哪个函数在烧 CPU;剖析按函数和调用栈聚合时间,天然跨请求,能回答"这段代码一共占了多少",却看不到单个请求的上下文。
一个具体的分工例子:P99 涨了,指标告警;追踪发现是 /order/query 这个接口里 db.query 这个 span 从 20ms 涨到 300ms,但 span 名字本身不能再往下拆;剖析显示 db.query 的调用栈里,60% 的样本落在 encoding/json 的 Unmarshal 上,15% 落在 database/sql 的连接获取上。到这里才知道要做什么——要么是结果集太大需要改查询字段,要么是连接池不够。没有剖析,你会在"数据库慢"这个结论上停住,然后去查数据库,而数据库那边看到的是完全正常的查询耗时。
日志在这套体系里的位置是"补充证据"而不是"定位手段":它记录离散事件,粒度到单次,但代价高、受采样与打印位置限制,且只能看到你提前打了点的东西。临时剖析(手动 perf record、pprof 抓一次、JFR 录制一段)和持续剖析的区别在于时间维度,前者是"我在场",后者是"它一直在"。下面这张表把五类手段放在同一组维度上对照。
| 手段 | 回答的问题 | 典型粒度 | 采集开销 | 存储量级 | 看不出什么 |
|---|---|---|---|---|---|
| 指标(metrics) | 系统或服务整体的状态与趋势:慢不慢、涨没涨、还剩多少 | 时间序列,秒级到分钟级,维度受 cardinality 限制 | 极低,进程内计数器累加,几乎不引入额外 CPU | 小,每 series 每小时通常几百字节到几 KB | 看不出时间花在哪个函数、哪条调用路径上;高基数字段不可加 |
| 日志(logs) | 某个时刻发生了什么事件、带了什么参数 | 单条事件,语义由打印点决定 | 中到高,格式化、IO、落盘与采集链路都吃 CPU 和带宽 | 大,随打印量线性增长,通常按天或按量计费 | 看不出热点分布;没打点的地方完全空白;难以反推耗时构成 |
| 链路追踪(tracing) | 一次请求跨了哪些服务、卡在哪个 span | 单次请求,span 级,通常按百分比采样 | 中,每个 span 有创建、序列化、上报成本,采样率决定总量 | 中,与采样率 × 请求量 × span 数成正比 | 看不出 span 内部哪个函数耗时;看不到未埋点代码;采样外请求无数据 |
| 持续剖析(continuous profiling) | 任意历史时刻,CPU 与等待时间花在哪些函数和调用栈上 | 函数与调用栈,10 秒级窗口,全量实例长期采集 | 低但必须核算:与采样率、栈深度、符号化位置相关,单核通常在 1% 量级(需按自己环境实测) | 中大,与实例核数、窗口内去重栈数量、保留天数成正比,需先算再上 | 看不到单次请求的业务维度;极低频函数和极短生命周期进程统计不可靠 |
| 临时剖析(one-off profiling) | 此刻正在跑的代码里,什么最耗时 | 函数与调用栈,可临时提高采样率到很高 | 仅采集期间存在,可用高开销换取高精度 | 几乎为零,抓完即弃 | 看不到过去;抖动发生在你不在场时就没有数据;无法做版本间对比 |
火焰图最常见的误读是把它当成时序图。它的横轴不是时间轴,而是采样样本的聚合——每一块矩形的宽度代表这条调用栈在总样本里占的比例,同一层的矩形按函数名排序(通常按字母序)排列,左右位置没有先后含义。所以从左往右扫一遍找"最慢的地方"是无效动作。真正要看的是宽度:某一个函数自身(self time,也就是它自己那一层、不被子调用分摊的部分)特别宽,说明样本直接落在它的指令里,这是优化的第一目标。
第二个容易搞错的是 self 与 total 的区别。火焰图里一个函数的宽度是 total time,包含它调用的所有子函数。一个很宽的 main 或者 HTTP handler 不代表它慢,只代表它下面挂着很多东西。真正有意义的是"自身很宽、且下面是平的"那种矩形——业内叫 plateau,平顶。相反,一根细高的塔(调用很深但每层都很窄)通常是正常的分层调用,不是热点。
读图时还应该先看语言运行时和标准库的比例。如果一张 CPU 火焰图里 runtime.mallocgc、runtime.gcBgMarkWorker、或者 JVM 的 G1 相关帧占了三成以上,那么优化业务代码收益有限,应该先处理分配速率和堆大小;如果 encoding/json、reflect 之类占了大面积,那是序列化路径的问题。反过来,如果大部分宽度是业务函数,才轮到算法和调用次数。
这里有个前置条件很容易被忽略:符号化。如果抓到的是原始地址而没有解析成函数名,火焰图上会显示十六进制地址或者 [unknown],这张图基本没法用。不同语言的准备工作不一样,而且都得在上线前做完:Go 默认在 amd64 上保留 frame pointer,但打包时如果加了去掉符号表和 DWARF 的链接参数,服务端就解析不出函数名,交付镜像时要确认二进制里还有符号;Java 需要开启保留帧指针的 JVM 参数(Perf 类剖析依赖它,纯 JFR 方式不依赖)并提供 JIT 编译后的方法映射;Node.js 需要运行时参数生成 perf map 文件,并且要打开解释帧才能看到 JS 函数名而不是 V8 内部帧;Python 需要外部采样器读进程内存,注意权限和阻塞风险;C/C++ 若编译时省略帧指针,栈回溯要走调试信息展开,代价明显更高。
最后一个细节:栈深度。eBPF 里 bpf_get_stackid 的默认深度上限是 127 帧,超过会被截断。深度特别大的框架(大量装饰器、代理、ORM)需要注意截断会不会把根因那一层切掉,必要时调大上限,同时记得栈越深采集成本越高。
临时剖析的最大短板不是精度,是时机。抖动发生 03:12,你 09:40 到公司,抓一张图,看到的是 09:40 的状态,和 03:12 没关系。凌晨的低流量、不同的缓存命中率、定时任务的竞争,这些条件在白天根本复现不出来。就算值班当场抓到了,抓的也多半是"抖动已经过去之后"的稳态。
持续剖析做的事很朴素:每隔一个固定窗口(常见 10 秒)抓一次 profile,把带时间戳的序列存起来,保留若干天到若干月。于是"那次抖动"从一次性的现场变成了可以任意回放的历史。它带来的几个能力是临时抓取给不了的:
时间对比(diff):把两个时间窗口的 profile 相减,直接得到"多出来的时间在哪"。这是最有价值的一个视图——正常态和故障态的背景噪音大部分相同,相减之后剩下的就是增量,通常一两张图就能定位。做法上就是把同 service、同 label 的两个窗口按栈聚合值做差,用红蓝两色渲染增量与减量。
版本对比:发版前后各取一天同一时段的 profile 做 diff,能在没有性能压测环境的情况下,看出这次改动引入了哪些新的热点。这对"没人敢在大促前发版"这种局面特别有用,因为验证不再依赖压测环境和压测流量是否真实。
维度聚合:把 service、region、version、pod、甚至请求类型做成标签,就能回答"是不是只有华东这一个可用区的实例慢""是不是只有新版本慢"。注意标签是高基数风险的入口,后面会讲。
代价也很明确,而且必须在上之前算清楚:采集是常驻的,开销会长期存在,不是抓一次就结束;数据要落盘,保留越久成本越高;标签一旦放开 cardinality,存储和查询都会失控。持续剖析不是"精度更高的临时剖析",它是用持续的成本换时间维度上的可回溯性,值不值取决于你的故障是不是经常发生在没人盯着的时候。
Pyroscope 的部署结构可以拆成三段,每段吃的东西不一样,规划资源时要分开看。
agent 段:负责在被观测机器上采集栈、聚合成 profile、上报。形态有三种——语言 SDK(Go、Java、Python、Node、Ruby、.NET 等,进程内采样,能拿到语言特有的 profile 类型)、eBPF 采集器(不侵入进程,靠内核 perf event 采样,天然覆盖机器上所有语言,但拿不到语言运行时内部的语义,比如分不清是哪个 goroutine 或哪个 Java 线程类别)、以及 Grafana Alloy 这类统一采集代理(把 profile 和 metrics、logs、traces 用同一条管道送出去)。SDK 方案的资源消耗落在业务进程里,直接表现为业务容器的 CPU 和内存增量;eBPF 方案落在独立进程里,需要内核版本和权限支持(Linux 内核通常要求 4.9 以上并开启相关 BPF 特性;权限上要么给容器 CAP_PERFMON(5.8 以上内核)或者 CAP_SYS_ADMIN,要么把 perf_event_paranoid 调低,默认值 2 在多数发行版下会挡住内核态采样)。
server 段:负责接收、符号化、建索引、压缩、提供查询和 UI。CPU 主要花在两处——接收时的符号化与栈聚合、查询时的多实例合并与差分;内存受"查询时间窗口 × 活跃 series 数"影响,一次跨几百个实例、跨 24 小时的差分查询,内存峰值可能远超常态;网络入向取决于 agent 数量与上报频率。实践里 server 是最容易被低估的一段,尤其是在大家同时打开大时间范围对比的时候。默认 HTTP 端口是 4040,上报走同一个端口的 ingest 路径,规划防火墙和监控时要按这个口径。
存储段:profile 数据是典型的"写多读少、按时间冷热分明",本地 SSD 放近期热数据、对象存储(S3 兼容)放长期冷数据是常见做法。容量由三件事决定:每秒落盘字节、保留天数、副本数。选型时先看盘的顺序写带宽和 IOPS 够不够 ingest 峰值,再看容量够不够保留期,最后看恢复时间——server 挂了之后数据补写能不能追上。需要独立物理机或者大规格云主机来承载这套采集与存储服务时,可以参考一万网络这类深耕 19 年(成立于 2007 年)的 IDC 服务商在售机型清单里的 CPU、内存、硬盘与带宽组合来对齐规格,具体报价需实时询价,价格主要受 CPU、内存、存储、带宽、IP 和线路影响。
还有一个部署上的选择:要不要上多租户与高可用。单节点 server 挂掉期间只是停止采集(agent 本地通常有缓冲,不会丢太多),历史数据照常可查,对于排查型用途,可用性要求其实比监控系统低一档——因为它不负责告警。这一点值得写进容量规划里:profiling 系统的故障域和 Prometheus 不是一回事,不要照搬后者的高可用预算。
先把开销拆开看。一次采样的成本由四部分组成,量级差别很大:
第一部分是中断入口与上下文切换,perf event 溢出触发内核回调,这部分通常在微秒量级。
第二部分是栈回溯,这是真正的大头。基于帧指针的回溯就是沿着栈帧链表走,每帧几次内存读,代价与深度线性相关,通常几微秒;基于调试信息(DWARF/ORC)的回溯需要解析展开表,单次可以到几十微秒甚至更高,栈越深越贵。这也是为什么很多语言推荐编译时保留帧指针——不是为了精度,是为了把这一项成本降一个数量级。
第三部分是写到环形缓冲区、用户态读取与聚合(哈希表查找、必要时分配内存)。
第四部分是符号化。把地址翻译成函数名需要读进程的 maps、解析 ELF 与调试信息。如果在 agent 侧同步做,成本最高且可能阻塞;放到 server 侧延迟做,或者用进程本地缓存复用,能省掉绝大部分。
于是可以给出一个可复算的粗算公式:单核开销占比约等于采样率乘以单样本耗时。比如单样本总成本按 20 到 40 微秒估,100Hz 下每秒 100 次,合计每秒 2 到 4 毫秒,也就是单核 0.2% 到 0.4%;如果走调试信息展开、单样本到 100 微秒以上,同样 100Hz 就变成 1% 以上。这里的单样本耗时必须按自己环境实测,不同栈深度、不同语言、是否开启内核栈,结果可以差好几倍。注意这里算的是"单核",不是整机百分比,别拿它直接和面板上 30% 那个数比。
除了 CPU,还要盯三项:常驻内存(符号缓存、栈映射表、上报缓冲,随窗口内不同栈数量增长)、网络出向(agent 到 server 的上报流量,压测估算见下一节)、磁盘写入(server 侧)。这三项里最容易出事的是内存,因为它和"窗口内去重后的栈数量"强相关,而后者在代码热路径复杂或者开启高基数标签时会突然膨胀。
压开销的手段,按收益从高到低:一是降采样率,很多场景 19Hz 或 49Hz 已经够用,定位占比 5% 以上的热点完全没问题;二是只采用户栈不采内核栈,除非你确实在查系统调用;三是限制栈深度,把 127 帧上限调到一个合理值;四是把符号化挪到 server 侧;五是抽样实例,比如每个服务只让一部分实例开启采集,或者按小时轮换,用覆盖率换总量——这适合实例数很多、同构性强的服务,不适合需要对比单实例差异的场景。
还有两个反直觉的点。第一,采样频率最好避开整数和周期任务的倍数,100Hz 容易和 10ms、100ms 的定时任务共振,导致某些路径被系统性漏采或过采,用 97 或 99 这类非整数频率可以打散这种对齐。第二,采样有统计误差,某函数真实占比为 p、采到 n 个样本时,相对误差大约是 1 除以根号 n。一个占比 2% 的函数,用 100Hz 采 300 秒得到 30000 个样本、其中约 600 个落在它身上,相对误差约 4%,勉强可用;如果只采 10 秒,样本只有 1000 个、落在它身上的约 20 个,相对误差超过 20%,这个数字就不可信了。所以"短时间窗口里看小占比函数"是持续剖析的典型误用,看小占比热点要么延长窗口,要么聚合多个实例。
容量规划要做两次估算,因为存在两个完全不同的口径,混在一起算会得出离谱的数字。
口径一:原始样本流,用于评估 agent 内部处理压力和 agent 到 server 的网络流量。公式是每秒样本数等于采样率乘以被采样的核数(或线程数,取决于采集器按 CPU 还是按线程采样),每样本大小约等于栈帧数乘以每帧字节数。eBPF 和 perf 拿到的原始栈是指令指针数组,每帧 8 字节。举一个例子:单台 8 核机器、100Hz、平均栈深 60 帧,每秒样本数是 8 乘以 100 等于 800,每样本 60 乘以 8 等于 480 字节,两者相乘得到每秒 384000 字节,约 375 KiB/s,折合每小时约 1.3 GiB、每天约 32 GiB。这是未经任何聚合的原始量,现实中 agent 不会把这 32 GiB 全送出去,它先在内存里按栈聚合,但这个数字决定了 agent 的 CPU 和内存压力上限,也决定了异常情况下(比如符号化失败导致栈无法合并)会不会把网络打满。
口径二:聚合后落盘,用于评估磁盘容量。agent 在窗口内把栈聚合成一张"栈到计数"的表,落盘的是去重后的栈,不是每个样本。公式变成:窗口内不同栈的数量乘以单条栈的字节数加上计数字节。延续上面的例子,假设 10 秒窗口内去重后有 3000 条不同的栈,每条栈按字典化后的符号 ID 表示(每帧 4 字节,60 帧即 240 字节)加上计数 8 字节,单条约 248 字节,一个窗口约 0.74 MB,折合每秒 74 KB,单台每天约 6.4 GB。再经过块级压缩,栈数据重复度很高,压缩比通常能到几倍到十倍(需按自己环境实测),单台每天大致落在 0.6 到 1.3 GB 这个区间(示例推导,非实测)。
把单机数字放大到集群,公式是:总容量约等于单机每日落盘量乘以实例数乘以保留天数乘以副本数再乘以一个索引与元数据的余量系数(实践中按 1.2 到 1.4 估)。继续上面的例子,50 台被采集机器、单机每日 1 GB、保留 14 天、副本 2、余量 1.3,总容量约等于 1 GB 乘 50 乘 14 乘 2 乘 1.3,约 1820 GB,也就是 1.8 TB 左右。这个量级对单台大规格存储节点完全可控,但如果实例数是 500 台而不是 50 台,或者保留期放到 90 天,数字会直接翻到几十 TB,这时候就必须把冷数据下沉到对象存储,或者降低采样率、抽样部分实例。
另外两个会显著改变结果的变量:一是 profile 类型的数量,CPU 只是其中一种,如果同时开内存分配、锁竞争、阻塞事件、goroutine 数量,落盘量按类型数近似线性增长,所以"只开 CPU"和"全类型全开"差的不止一点;二是标签基数,每增加一个标签值组合就多一批 series,把 pod 名做成标签还算可控(几百个),把请求 ID 或者用户 ID 做成标签会直接把存储打爆。规划时先定死标签白名单,再算容量,顺序不能反。
持续剖析不是万能的,有些场景下它给出的数字本身不可信,硬上只会得到一堆看着专业、实际错误的图。
短生命周期进程是最典型的一类。采样本质是统计,样本数量决定可信度。一个进程从启动到退出只活 200 毫秒,100Hz 下最多采到 20 个样本,任何占比的置信区间都宽得离谱;传统 PHP-FPM、CGI、命令行工具、短任务 Pod、Serverless 冷启动都属于这一类。这类场景要么改成常驻进程(FPM 的 pm.max_requests 调大、或者换成常驻框架),要么改用按请求粒度的追踪,不要指望 profile。
语言运行时的特性也会让数据失真。Python 有全局解释器锁,多线程程序的 CPU 剖析只能反映持有锁的那个线程,真正的并发瓶颈要靠别的手段;异步框架里大量时间在事件循环等待上,CPU 火焰图上只看到一个空转的循环,看不出哪个协程在等什么。Node.js 是单线程加事件循环,同样存在"计算很少、等待很多"的结构性问题,CPU profile 信息量有限,需要配合异步上下文追踪。Rust 和 C++ 如果编译时省略帧指针且剥离符号,采集成本会显著上升,图上还会出现大量无法解析的帧。
没有链路追踪配套时,价值会打折。剖析能定位到函数,但定位不到"哪类请求触发了它"。如果服务同时承载十几种请求类型,看到某个序列化函数是热点,仍然不知道是哪个接口带来的。这种情况下要么先补追踪并按请求类型给 profile 打标签(只打低基数标签),要么接受"只能做整体优化"这个限制。
权限与内核限制也是硬门槛。容器里跑 eBPF 采集器需要相应的 capability 或者调低内核参数,托管 Kubernetes 服务、安全基线下禁止提权的集群、老内核(4.9 以下)环境,都可能出现装不上或者装上后只能采到部分栈的情况。Windows 与部分非 Linux 平台的 eBPF 支持有限,通常只能用语言 SDK 方案,覆盖面要提前确认。
合规方面要评估两件事。一是数据内容,符号化后的 profile 包含函数名、源文件路径、行号,本质上是代码结构信息;栈里只有地址,不含局部变量的实际值,风险比日志低,但如果把 profile 上传到第三方云服务,等于把代码结构交出去,需要过一遍内部的数据外发评估。二是数据采集范围,剖析是按进程采集的,如果机器上混部了不属于本次排查范围的服务,eBPF 方式会一并采到,需要在采集配置里按进程名或容器做过滤,避免采到不该采的东西。
最后一条:如果团队还没有人能稳定地读懂火焰图,先别急着铺开。持续剖析的产出是一堆图,图不会自己说话。比较务实的做法是先在两三个关键服务上跑起来,用真实故障练出两三个能讲清楚定位过程的案例,再考虑全量推广。
落地顺序建议按下面走,每一步都有可验证的产物,不要跳步。
第一步,选一个真实的慢服务作为试点,并且事先写下"期望看到什么"。比如"P99 从 80ms 涨到 600ms 时,profile 里应当出现某个函数的样本占比显著上升"。有了这个预期,事后才能判断是"没查出问题"还是"根本没采到"。
第二步,先打通符号化再谈别的。逐个语言确认:Go 的交付二进制是否还保留符号表和 DWARF;Java 是否开启保留帧指针的 JVM 参数、JIT 方法映射是否可用;Node.js 是否生成 perf map 文件并开启解释帧;Python 的采样器权限是否到位。验收方式很直接——抓一张图,看上面是函数名还是十六进制地址,出现地址就是没通。
第三步,只开 CPU 一种类型,用较低的采样率,在两三个实例上灰度跑满 24 小时。灰度期间同时采集基线:开启前后的 CPU 使用率、P99、RSS、网络出向。这 24 小时的目的是拿到你自己环境里的真实开销数字,不要跳过。
第四步,核对开销与容量。用第三步的实测数字代入前面的公式,推算全量铺开后的集群总开销和总存储量,确认在预算内再往下走。
第五步,按需开启 off-CPU 相关类型。Go 的阻塞与互斥 profile 需要显式设置采样率参数才会生效,Java 用 JFR 的事件体系。注意这一类通常比 CPU profile 的开销更难预测,因为成本与阻塞事件频率相关,而不是与 CPU 时间相关。
第六步,定死保留期与标签白名单。标签只允许 service、region、version、instance 这类低基数字段,明确禁止用户 ID、请求 ID、追踪 ID 入库,这条要写进配置模板而不是靠人自觉。
第七步,和告警联动。把"告警触发时间"作为入参,自动拉取该时刻前后各几分钟的 profile 做差分,让排查路径从"值守时手动抓"变成"事后随时看"。
四项验收标准,全部以数据为准,不靠看图感觉:
一、开销有界且可复现。在同等流量下,开启剖析前后的 CPU 使用率增量、RSS 增量、网络出向增量都有明确数值,并且连续三天波动不超过一个可接受的幅度。判断方法是 A/B 对比同一时间段的同类实例,而不是看单台机器的瞬时值。
二、覆盖率与样本量达标。被采集实例数除以总实例数达到预定比例(比如关键服务 100%、非关键服务抽样),且每个 profile 窗口的样本量满足统计要求——按 100Hz、10 秒窗口算,每核应当约 1000 个样本,若持续明显偏低,说明采集器被限流或者进程根本没在跑。
三、可回溯性可用。能取到 7 天前某个具体时刻的 profile,且从发起查询到拿到差分结果的时间在可接受范围内(比如几十秒内)。这一项要定期抽查,因为随着数据增长,查询性能会退化。
四、闭环证据。至少有一次真实故障是靠 profile 定位的,并且留下记录:定位前的排查耗时、定位到的函数、修复后的指标变化。没有这一条,前面三项做得再好也只是成本。
已经有了链路追踪,还需要再上持续剖析吗?需要,前提是你的问题经常卡在"知道哪个 span 慢、但不知道 span 里哪段代码慢"。追踪的粒度到 span,span 是人为划分的边界,一个 span 内部可能有几十次函数调用,追踪不给这个信息。反过来,如果你的瓶颈多数是跨服务调用和网络问题,span 边界本身已经足够定位,那剖析的边际收益就低,可以先不上。
采样率选 100Hz 还是 19Hz,会漏掉短函数吗?会,但要分清"漏掉"和"不可信"。占比很小的函数在低采样率下采不到足够样本,误差变大;占比 5% 以上的热点,19Hz 采几分钟也足够定位。建议从 19Hz 或者 49Hz 起步,确认精度不够再调高,而不是一上来就 100Hz 全量铺开。
eBPF 采集和语言 SDK 该选哪个?两者不是互斥的。eBPF 的优势是零侵入、覆盖机器上所有进程,适合先摸清"这台机器上到底谁在烧 CPU";SDK 的优势是能拿到语言特有的语义(分配、锁、协程)和更准确的符号。实际部署里常见组合是:eBPF 做全量兜底,关键服务再加 SDK 补精细类型。
容器里跑采集器要开什么权限?Linux 5.8 以上内核优先给 CAP_PERFMON,老内核只能给 CAP_SYS_ADMIN(风险更高,需要单独评估),或者把宿主机的 perf_event_paranoid 参数调低。托管集群如果不允许提权,只能退回语言 SDK 方案。
profile 数据要不要上云托管?取决于两件事:代码结构能不能外发,以及长期存储成本谁更低。自建的隐性成本是运维和容量规划,托管的隐性成本是数据外发评估和网络出口。小团队通常先用托管验证价值,规模上来之后再评估自建。
保留多久合适?按排查习惯定。绝大多数性能问题的回溯需求集中在 7 到 14 天内,超过一个月的查询很少;但如果要做版本间的长期对比(比如季度级的性能回归),就需要按月保留聚合后的低频数据。折中做法是热数据保留 14 天全精度,之后降采样长期保存。
CPU 之外要不要一次全开分配、锁、阻塞?不建议。先只开 CPU 跑两周,建立开销基线,再逐个加类型,每加一个都重新测一遍开销。这三类里阻塞类的开销最不可预测,因为成本跟阻塞事件频率挂钩。
上了之后 CPU 反而涨了几个点怎么办?先确认涨在哪:看是采集进程自己的 CPU,还是业务进程内的 SDK 开销,还是 server 侧符号化导致的间接影响。然后按降采样率、限栈深、符号化挪到 server 侧这个顺序压,压完之后重新测一轮,确认 P99 没有因为采集而变差。
本文里的百分比和容量数字分两类:一类是可推导的公式(采样开销公式、两个口径的数据量公式、统计误差公式),推导过程都写在正文里;另一类是必须实测才能确定的量(单样本耗时、压缩比、窗口内去重栈数量),文中已经标注需按自己环境实测。下面给出复算方法。
复算单样本耗时:在同一台机器上,用内核自带的采样工具对目标进程按固定频率采样一段时间,采样结束后用统计功能查看采样器自身的开销占比,再除以总样本数,就得到平均单样本耗时。更稳妥的做法是 A/B 对比——在恒定流量下分别测关闭采集和开启采集时的 CPU 使用率与 P99,差值除以期间的样本总数。两个方法互相印证,差距大时以 A/B 结果为准,因为它包含了符号化和上报的全部成本。
复算窗口内去重栈数量:直接在 agent 侧或 server 侧统计单个 profile 里 series 的条数,连续取一天的最大值和中位数。这个数字是后面所有容量推导的输入,不要拍脑袋估。
复算压缩比:取一段时间的原始上报字节数(agent 侧埋点或 server 侧入口计数)和落盘后的实际占用字节数,两者相除。注意要区分"压缩后"和"含索引"两个口径,索引往往占不小的比例,算容量时别漏掉,这也是正文里给 1.2 到 1.4 余量系数的原因。
复算统计误差:取目标函数在多个连续窗口里的样本占比,看它们的离散程度。如果同一函数在相邻窗口里占比波动超过按 1 除以根号 n 估算的误差,通常说明存在采样共振(比如采样频率和定时任务周期对齐),换个非整数频率再测。
最后提醒一句:所有容量数字都要在"标签白名单定死之后"重算一遍。标签每多一个维度,series 数量不是加一项而是乘一组,这一步的顺序错了,前面算得再准也没用。
上一篇:checkpoint 从 30 秒拖到 8 分钟还没跑完:Flink 状态后端、增量快照与对齐机制这三处该怎么排
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品