async-profiler 与 FlameGraph:从采样事件到可解释性能证据
CPU 不高,为什么请求仍然慢
一个 Java 实例的 P99 达到 2 s,CPU 只有 30%。直接采一张 CPU 火焰图,最宽的可能只是 JSON 序列化,于是团队优化了序列化,延迟却几乎不变。原因不是火焰图失效,而是事件选错:CPU 采样只在执行线程获得 CPU 时取栈,数据库等待、锁等待、线程池排队和 park 不会按业务墙钟时间变宽。
性能取证从问题分类开始。CPU 持续高,先看 cpu;RT 高而 CPU 不高,看按线程拆分的 wall;分配速率与 GC 压力高,看 alloc;Java monitor 竞争看 lock。每种事件的计数单位、采样机制和能回答的问题都不同。火焰图只是聚合栈的视图,不会替使用者选择正确事件。
采样前记录应用版本、JDK、实例、宿主与容器 PID、UID、CPU quota、流量阶段、QPS、错误率、P99、CPU 和 GC。窗口内发生发布、扩缩容、限流或流量切换时,样本群体已经改变,不能与另一张图直接比较。
安装与制品校验
async-profiler 的预编译包按平台和架构发布。下面固定使用 4.4 的 Linux x64/arm64 发行包,并绑定官方 Release 资产登记的 SHA256。团队制品库继续保存压缩包、来源 URL、版本、摘要、许可证和审批记录,生产节点不依赖临时访问 GitHub。
本地演练使用唯一目录,不覆盖已有版本或结果:
LAB_DIR="$(mktemp -d "${TMPDIR:-/tmp}/asprof-lab.XXXXXX")"
chmod 700 "$LAB_DIR"
VERSION='4.4'
case "$(uname -m)" in
x86_64|amd64)
AP_ARCH='x64'
AP_SHA256='1233f26fc95753e75ce32733bbcaf8f0bedc2c098b0e798af87935b08a63b24e'
;;
aarch64|arm64)
AP_ARCH='arm64'
AP_SHA256='86ff97b4436accdb6d7bb65c1cf6e38a756f2037a921994d8fa1dcb97d1dc53c'
;;
*)
printf 'unsupported architecture: %s\n' "$(uname -m)" >&2
exit 1
;;
esac
ARCHIVE="$LAB_DIR/async-profiler.tar.gz"
AP_ASSET="async-profiler-${VERSION}-linux-${AP_ARCH}.tar.gz"
AP_URL="https://github.com/async-profiler/async-profiler/releases/download/v${VERSION}/${AP_ASSET}"
curl --fail --location --proto '=https' --tlsv1.2 "$AP_URL" -o "$ARCHIVE"
printf '%s %s\n' "$AP_SHA256" "$ARCHIVE" | sha256sum -c -
mkdir "$LAB_DIR/ap"
tar -xzf "$ARCHIVE" -C "$LAB_DIR/ap" --strip-components=1
ASPROF="$LAB_DIR/ap/bin/asprof"
"$ASPROF" --version摘要校验通过、版本输出为 4.4 且架构匹配,安装链才闭合。macOS 使用独立 ZIP,Windows 需要 WSL2 或 Linux 诊断节点;不能把 Linux 压缩包强行套到其他平台。升级版本时必须同步更新资产名和摘要,并先在同 JDK、同内核的代表性环境复测 Attach、事件支持和开销。
做一个可控的 CPU 与等待现场
下面的临时程序交替做素数计算和睡眠。它只写入 LAB_DIR,结束后可整体删除:
cat >"$LAB_DIR/ProfileLab.java" <<'JAVA'
public class ProfileLab {
static boolean prime(long n) {
for (long i = 2; i * i <= n; i++) if (n % i == 0) return false;
return true;
}
public static void main(String[] args) throws Exception {
while (true) {
for (long n = 10_000_000; n < 10_020_000; n++) prime(n);
Thread.sleep(200);
}
}
}
JAVA
javac -d "$LAB_DIR" "$LAB_DIR/ProfileLab.java"
java -cp "$LAB_DIR" ProfileLab >"$LAB_DIR/app.log" 2>&1 &
APP_PID=$!
ps -o user,pid,pcpu,args -p "$APP_PID"用目标进程的同一用户运行 asprof。先列出目标可用事件,再采一个有固定时长的 CPU 窗口:
RUN_DIR="$(mktemp -d "$LAB_DIR/run.XXXXXX")"
chmod 700 "$RUN_DIR"
"$ASPROF" list "$APP_PID"
"$ASPROF" -e cpu -i 10ms -d 15 -f "$RUN_DIR/cpu.html" "$APP_PID"
test -s "$RUN_DIR/cpu.html"
"$ASPROF" status "$APP_PID"预期 HTML 可以搜索到 ProfileLab.prime,status 显示没有活动采样会话。默认 CPU 频率通常为 100 Hz,即约每 10 ms CPU 时间一次;更短间隔产生更多样本,也增加信号、栈回溯、聚合和输出开销。先用 15 至 30 秒验证样本质量,再按业务阶段调整。
反实验:用 CPU 图寻找睡眠
同一负载再采 wall,并按线程拆分:
"$ASPROF" -e wall -t -i 50ms -d 15 -f "$RUN_DIR/wall.html" "$APP_PID"
test -s "$RUN_DIR/wall.html"CPU 图中 Thread.sleep 不应按 200 ms 等待时间形成同等宽度;wall 图会周期性采所有 Running、Sleeping、Blocked 线程,更容易看到睡眠或等待栈。这个差异稳定证明“CPU 样本占比不等于业务耗时占比”。wall 图中的大量 park、read 或线程池等待也不自动等于故障:空闲工作线程本来就会等待,必须用线程名、请求流量、队列长度和下游时延区分空闲与阻塞。
正向结论写成:“在同一 15 秒负载窗口,CPU 图的主要执行样本落在 prime,wall 图同时暴露 sleep 等待;二者分别解释 CPU 消耗与墙钟驻留。”错误结论是:“sleep 在 wall 图占 60%,所以它消耗 60% CPU。”
四种事件怎样形成样本
CPU:执行时间
"$ASPROF" -e cpu -i 10ms -d 30 -f "$RUN_DIR/cpu-30s.html" "$APP_PID"Linux CPU 模式把 perf_events 产生的 native/内核栈与 JVM 的 Java 栈匹配,可同时看到 Java 方法、native 调用、JVM 代码和内核函数。样本宽度表示栈被 CPU 事件观察到的次数或对应权重,不是方法调用次数,也不是端到端耗时。CPU quota 节流期间线程想运行却拿不到 CPU,图形还要与 cgroup throttling 指标交叉解释。
wall:墙钟驻留
"$ASPROF" -e wall -t -i 50ms -d 30 -f "$RUN_DIR/wall-30s.html" "$APP_PID"wall 对所有线程按周期采样,线程越多,单轮工作量越大。-t 把线程维度保留到栈中,否则业务线程、GC 线程和空闲池可能聚在一起。它适合启动、I/O、排队和阻塞现场,但不能单独区分“正常等待”与“资源瓶颈”。
alloc:分配压力
"$ASPROF" -e alloc --alloc 1m -d 30 -f "$RUN_DIR/alloc.html" "$APP_PID"alloc 依赖 HotSpot 的 TLAB 与慢路径分配回调,--alloc 1m 表示平均每分配约 1 MiB 取一个样本。顶部帧通常是分配对象类型,计数表示分配字节压力。它不代表 GC 后仍存活的堆,也不能单独证明泄漏;泄漏需要与 GC 后占用趋势、对象存活、引用链或 native memory 证据结合。减小间隔会提高小热点可见性,也会增加回调与记录成本。
lock:竞争等待
"$ASPROF" -e lock -t -i 5ms -d 30 -f "$RUN_DIR/lock.html" "$APP_PID"lock 记录 Java monitor 进入竞争,顶部帧是锁或 monitor 的类,计数单位是进入该锁所等待的纳秒数。它不是“持锁时长排行榜”,也不覆盖所有并发原语。native pthread 锁需要对应的 native lock 模式;数据库锁、分布式锁和队列等待要从各自系统取证。
分阶段采样与退出验证
需要精确包围一次压测阶段时,可以拆成 start/stop:
"$ASPROF" start -e cpu -i 10ms "$APP_PID"
"$ASPROF" status "$APP_PID"
# 在另一个受控终端执行测试流量;看护人同时观察业务停止线
"$ASPROF" stop -f "$RUN_DIR/staged-cpu.html" "$APP_PID"
"$ASPROF" status "$APP_PID"每个 start 都要有看护人、截止时间和对应 stop。终端断开不证明 Agent 已停止;重新执行 status 才能确认。自动化优先使用 -d 固定时长,避免无人值守会话。若业务指标跨过停止线,先 stop;仍未恢复则摘除实例并滚动重建,不在同一故障实例连续提高采样频率。
输出格式决定保留多少证据
.html 会自动选择交互式 Flame Graph,适合搜索和缩放;JFR 保留更多事件与时间信息,便于 JMC 或 jfrconv 后处理;collapsed 每行是分号分隔的调用栈加计数,适合可复现转换;flat 与 traces 便于文本审查,但会弱化调用上下文。
async-profiler 自带交互式 Flame Graph 输出,不需要预装 /opt/FlameGraph。直接从同一次有界采样生成 HTML,并校验产物存在:
"$ASPROF" -e cpu -i 10ms -d 30 -o flamegraph \
-f "$RUN_DIR/cpu-flamegraph.html" "$APP_PID"
test -s "$RUN_DIR/cpu-flamegraph.html"
sha256sum "$RUN_DIR/cpu-flamegraph.html"需要机器可比较的中间数据时,再额外输出 collapsed:
"$ASPROF" -e cpu -i 10ms -d 30 -o collapsed \
-f "$RUN_DIR/cpu.folded" "$APP_PID"
test -s "$RUN_DIR/cpu.folded"
sha256sum "$RUN_DIR/cpu.folded"async-profiler 已完成 JVM Attach、栈采集和折叠,不要再用 stackcollapse-perf.pl 二次处理;那个脚本面向 Linux perf 文本。若团队确实需要 Brendan Gregg FlameGraph 的静态 SVG,必须把 FlameGraph 仓库固定到审核过的 commit,并记录脚本摘要和渲染参数,不能依赖漂移的默认分支或假定 /opt/FlameGraph 已存在。原始 collapsed/JFR、转换版本、参数和生成图摘要应一起进入证据记录。
火焰图最常见的五种误读
每个矩形是一个栈帧,纵向是调用深度,底部通常是较早调用者,向上是被调用者。横向宽度表示该帧出现在多少样本或多少权重中;左右位置通常不是时间顺序。
宽度不是单次耗时。一个短而高频的方法也会很宽。颜色通常是调色板或帧类型提示,红色不天然代表严重。CPU 图没有等待栈,不代表系统没有等待。
alloc 宽度是分配压力,不是存活堆,更不是泄漏大小。搜索百分比可能累计多个分支,不等于该方法的 self time。
顶部宽叶子更接近 self hotspot,宽平台表示一组后续调用累计了较多样本。优化前要回到源代码、调用频率、吞吐和业务价值:把热点移到另一个方法,或者以更低吞吐换来比例下降,都不是性能改善。
容器、权限和符号
从容器内采样时使用容器 namespace 中的 PID,并让执行 UID 有权 Attach。由宿主采样容器时使用宿主 PID;官方实现会切换到目标 pid/mount namespace 并匹配目标凭证,同时要求容器能以同一绝对路径访问 libasyncProfiler.so,或用 --libpath 指定容器内路径。
Docker 默认 seccomp 可能限制 perf_event_open。可选路径依次评估:最小化定制 seccomp、使用 fdtransfer、退回 ctimer;某些环境还可能需要额外 capability。--security-opt seccomp=unconfined、SYS_ADMIN 或 --privileged 会显著扩大内核观测和进程控制面,不能作为长期通用配置。临时变更结束后恢复 seccomp、capability、sysctl、挂载或 sidecar,并用部署清单和实际 Pod spec 验证。
perf_event_open 失败时检查 PID/UID、kernel.perf_event_paranoid、kernel.kptr_restrict、seccomp、capability、内核事件支持和容器 CPU 限制。不要为了得到一张图直接全局降低宿主安全参数。先在同内核、同运行时的压测节点验证最小权限组合。
解析 libjvm 内部帧需要对应 JDK 的 debug symbols。大量 [unknown]、[unknown_Java] 或只有部分 Java 栈,还可能来自 native 展开模式、JIT 栈恢复、优化代码或错误库路径。先记录 unknown 比例,再核对 JDK build 与符号包;更换 fp、dwarf 等 C 栈模式会改变准确性与开销,必须做同负载 A/B,不能把 unknown 统一归因于第三方库。
[frame_buffer_overflow] 是缓冲区不足的故障证据。先缩短窗口、增大采样间隔、减少线程或事件范围,再评估扩大缓冲区;缓冲区来自目标进程内存预算,不是免费的客户端配置。
项目接入、容量与长期治理
仓库只保存采样剧本和元数据模板,不提交 profiler 二进制、真实 JFR、HTML 或 SVG。一次证据绑定构建版本、JDK、实例、PID namespace、UID、CPU/内存限制、流量标签、完整命令、开始结束时间、总样本数、unknown/overflow 计数、文件哈希和关联指标。
“低开销”必须转化为该服务自己的预算。开销由事件频率、线程数、栈深、native 展开、输出格式和目标负载共同决定。团队在代表性压测环境做三段 A/B:相同流量下记录未采样基线,运行一个固定窗口,停止后继续观察。比较 CPU、P99、吞吐、错误率、GC 和内存;连续多轮后,采样段影响可解释且恢复段回到同负载基线,才能批准生产默认参数。
生产默认单实例、短窗口、单事件,逐实例扩大。持续 profiling 平台还要计算每实例 Agent CPU、事件吞吐、网络出口、中心存储、索引保留和查询并发成本;临时 CLI 采样不能被悄悄扩成无预算的常驻系统。应用 owner 负责问题假设,平台 owner 管理制品、内核与容器兼容,安全 owner 管理 Attach 和产物权限,性能 owner 复核事件与图形解释。
async-profiler 不需要业务令牌,但产物会暴露类名、方法名、线程名、库、文件路径和基础设施结构,JFR 还可能包含更多事件元数据。输出目录使用 0700,文件使用 0600,不由 Web Server 托管;转存后设置 owner、访问组、哈希、保留期限和销毁记录。
清理本地演练与生产临时改动
本地演练先确认没有活动会话,再停止目标进程,并只删除本次生成的受控目录:
"$ASPROF" status "$APP_PID" || true
"$ASPROF" stop "$APP_PID" 2>/dev/null || true
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
case "$LAB_DIR" in
"${TMPDIR:-/tmp}"/asprof-lab.*) rm -rf -- "$LAB_DIR" ;;
*) printf 'refuse to delete unexpected path: %s\n' "$LAB_DIR" >&2 ;;
esacstop 返回“没有活动会话”与随后 status 的停止状态是一致证据,不应写成采样成功。生产清理还要恢复临时 sysctl、seccomp、capability、挂载和 sidecar,检查工作负载声明已经回到基线,并在删除结果前完成受控转存与哈希登记。
最终结论必须写明事件、计数单位、窗口负载、样本质量和交叉证据。CPU 热点与实例 CPU、吞吐和代码路径核对;wall 等待与线程池、队列和下游时延核对;alloc 与分配率、GC 和存活堆核对;lock 与线程状态和同步路径核对。火焰图回答“样本落在哪里”,架构判断还要回答为什么在那里、改变它会把成本转移到哪里,以及优化后系统不变量是否真的改善。
