“这段应该很慢,我优化一下”——这是性能优化里最贵的一句话。凭直觉改代码,改完压测发现没变化,甚至更慢,这类事在大多数团队里都发生过。
原因不神秘:现代软件的性能瓶颈位置高度反直觉。一个看起来很重的序列化调用可能只占 2% 的时间,而一个不起眼的 HashMap 扩容占了 30%。经验可以缩小搜索范围,但替代不了测量。
别猜,先量
直觉定位的失败率高,有具体的机制性原因:
- 调用次数比单次耗时更重要。 一个 100 微秒的函数被调用 50 万次,远比一个 10 毫秒的函数调用 10 次更值得优化。读代码看不出行数背后的调用频次。
- 热点随数据分布漂移。 测试数据集和生产数据集的分布不同,热点位置也不同。
- 瓶颈会转移。 优化掉 A 之后,B 会成为新的瓶颈,而且 B 往往和 A 不在同一个模块里。
profiling 的作用就是把”哪里慢”从主观判断变成有数据支撑的事实。
CPU 剖析:采样与插桩
两种采集方式,代价和精度不同。
| 采样(Sampling) | 插桩(Instrumentation) | |
|---|---|---|
| 原理 | 定时中断,记录当前调用栈 | 在函数进出点注入计时代码 |
| 开销 | 低,通常个位数百分比 | 高,可能数倍放大 |
| 精度 | 统计意义上的近似 | 精确的调用次数与耗时 |
| 适用场景 | 线上持续采样 | 测试环境、短任务、需要调用计数 |
采样的工作方式是:每隔一个固定周期(例如 10 毫秒)触发一次中断,记录当前线程的完整调用栈。跑足够长时间后,某个函数出现在栈顶的次数越多,说明它消耗 CPU 的时间越多。这里有个关键点:采样统计的是”在栈上的时间”,栈顶样本对应正在执行,栈中样本对应正在被调用。
插桩则是在编译期或运行期给函数加上计时代码,能给出精确的调用次数和耗时,但会显著拖慢程序,而且开销分布不均——小函数被插桩后相对开销远大于大函数,容易扭曲结论。
对多数线上场景,采样是唯一现实的选择。
火焰图的读法
火焰图是采样结果最直观的呈现。
[main] <- 最底层是入口
________|___________
| |
[handleRequest] [scheduleTask]
______|______
| |
[serialize] [queryDB] <- queryDB 更宽,说明更耗时
___|___ ___|___
| | | |
[toJSON][gzip] [buildSQL][execute] <- 最顶层是叶子函数
读法只有三条规则:
- 宽度 = 耗时占比,不是调用次数。一个很宽的框意味着它(含它调用的所有子函数)占用了大量 CPU 时间。
- 从下往上是调用栈,下层调用上层。最顶层没有任何子调用的”平台”,才是真正在执行代码的函数。
- 看顶层平台的宽度,不要看中间层。中间层宽只是因为它调用了耗时的子函数,真正的热点在它的某个子调用里。
一个典型误判:看到 handleRequest 占了 60%,就去优化 handleRequest 本身的代码。但它的时间几乎全在 queryDB 里,优化它自己的逻辑毫无用处。
icicle(冰柱图)是火焰图的倒置版本,上层是入口、向下生长,语义完全一样,只是有些工具默认用它。
内存剖析:泄漏与占用高是两回事
内存问题要先分类,因为两类的诊断路径完全不同。
泄漏(leak): 对象被无意持有引用,无法回收,存活对象数持续增长,直到 OOM。
识别方法:强制 GC 后观察老年代/堆的基线。如果每次 GC 后的基线不断抬高,就是泄漏。
GC 后堆基线: 120MB -> 145MB -> 178MB -> 220MB # 明显泄漏
GC 后堆基线: 120MB -> 118MB -> 125MB -> 121MB # 只是占用高
定位泄漏靠的是支配树(dominator tree)和引用链。先看哪些类的实例数和保留大小(retained size)异常,再看是谁在持有它们。Java 生态里 jmap 导出堆转储后用 MAT 分析,Go 用 pprof 的 alloc_space/inuse_objects,.NET 用 dotMemory,路径不同但思路一致。
常见泄漏源:静态集合不断 add、未注销的监听器/回调、线程局部变量(ThreadLocal)未清理、连接池泄漏、缓存无上限无过期。
占用高(high footprint): 没有泄漏,但峰值内存需求本身就大。这类问题的解法不是找引用,而是改数据结构和访问模式——比如把一次性全量加载改成流式分页,把对象数组改成原始类型数组,调整 JVM 堆参数或 GC 策略。
分配热点与 GC 压力
即使没有泄漏,过高的分配速率也会拖垮性能。每次分配都要消耗 CPU,并且加速 GC 触发。
在 Go 里直接看分配:
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
在 Java 里开启分配采样(JFR 或 async-profiler):
asprof -e alloc -d 60 -f alloc.html <pid>
关注两个指标:
- 分配速率(如 MB/s)。经验上,每秒分配量达到堆大小的相当比例时,GC 就会明显影响延迟。
- 对象平均存活时间。短命小对象本该在新生代就被回收,若因晋升策略不当进入老年代,会放大 GC 成本。
典型分配热点:循环内的字符串拼接、自动装箱、每次调用都新建的临时集合、[]byte 转 string、正则 Pattern 未复用。
优化手段有两类:一是减少分配(对象池、复用缓冲区、预分配容量),二是降低单次分配成本(原始类型特化、栈上分配)。前者要小心——对象池用不好会造成内存占用长期不降,反而增加 GC 压力。
IO 剖析
IO 密集型的程序,CPU 火焰图往往是”平”的:大量时间耗在等待上,采不到正在执行的栈。
区别在于等待时间 vs 计算时间:
- 等待:线程阻塞在
read、recv、poll、锁获取、条件变量上,CPU 空闲; - 计算:CPU 在忙于解析、压缩、序列化。
区分方法:看 CPU 使用率和吞吐的关系。CPU 上不去而吞吐上不去,基本是等待;CPU 打满了吞吐还不够,是计算。
# 系统调用层面看时间花在哪
strace -c -p <pid> -f -w -e trace=network,read,write
# off-cpu 分析:谁能看到"等待了多久、等的是什么"
offcputime-bpfcc -p <pid> -f 60 > out.stacks
off-cpu 火焰图是 IO 剖析里最有用的工具:它记录线程被调度出去时的栈和阻塞时长,直接告诉你”在等什么”。注意它的宽度含义和 CPU 火焰图相反——越宽表示阻塞时间越长。
数据库慢查询也属于 IO 剖析范畴,但通常交给数据库侧的慢日志和执行计划分析,不在应用进程内解决。
剖析开销与线上采样
profiling 不是免费的:
| 工具/方式 | 典型开销量级 | 线上可用性 |
|---|---|---|
| 采样 profiling(低频) | 1%–5% | 可持续开启 |
| 采样 profiling(高频) | 5%–15% | 短时开启 |
| 堆转储 | 会 STW 停顿,耗时秒级 | 谨慎,低峰期 |
| 插桩 profiling | 数倍 | 仅测试环境 |
| 全量 trace 采集 | 高 | 需采样降级 |
线上采样的几条纪律:
- 默认低频持续采样,按需提高频率。 罕见的尖峰问题往往只在高频采样下才能抓到,所以需要一个”随时可以临时调高频率”的开关。
- 采到的样本要能关联到具体请求。 单纯的函数热点对排障帮助有限,能定位到”哪个租户的什么请求触发的”才有价值。
- 注意安全。 profiling 端点(如 Go 的
pprof)不能暴露在公网,堆转储文件包含完整的内存数据,可能含敏感信息。 - 容器环境要处理符号表。 stripped 的二进制或者 Alpine 基础镜像会让火焰图上全是地址而不是函数名。
决策路径:先定类型,再选工具
| 观察到的现象 | 瓶颈类型 | 该用什么 |
|---|---|---|
| CPU 打满,吞吐上不去 | CPU 计算 | CPU 采样 + 火焰图 |
| CPU 不高,延迟高,线程多阻塞 | IO 等待 | off-cpu 火焰图、strace、慢查询日志 |
| 内存持续增长,最终 OOM | 内存泄漏 | 堆转储 + 支配树 + 引用链 |
| 内存占用高但不增长 | 内存占用 | 分配剖析 + 数据结构调整 |
| GC 频繁,停顿明显 | 分配压力 | 分配采样(alloc) |
| 偶发尖峰,平时正常 | 采样盲区 | 提高采样频率 + 持续采样 |
这张表的用法是自上而下:先用最便宜的信号(CPU 使用率、内存曲线、GC 日志)判断瓶颈属于哪一类,再上对应的工具。直接开火焰图而不看 CPU 使用率,就像不做血常规直接做 CT。
profiling 只解决”哪里慢”。知道哪里慢之后,还要回答”为什么慢”和”该不该改”——后者取决于这段代码在业务上的重要性和改造的风险,那是另一个话题了。
Comments (0)
No comments yet — be the first.
Please Sign in to join the discussion.