6.3 配套代码:火焰图的分析辅助
对应小节:6.3 火焰图读法 三件事:① 从 flat 输出快速找热点;② diff 两次火焰图;③ 自动识别形状特征。
一、从 flat 输出找热点(比读图更快)
#!/usr/bin/env bash
# tools/top-hotspots.sh <PID> [EVENT] [DURATION]
#
# 用 async-profiler 的 flat 输出,直接列出"自身耗时最多"的函数。
# 比在火焰图里肉眼找快得多。
set -uo pipefail
PID="${1:?usage: top-hotspots.sh <pid> [event] [duration]}"
EVENT="${2:-cpu}"
DURATION="${3:-30}"
TMP=$(mktemp)
asprof -d "$DURATION" -e "$EVENT" -o flat -f "$TMP" "$PID" > /dev/null 2>&1
echo "═══ $EVENT 事件的热点排名(采样 ${DURATION}s)═══"
echo
# flat 输出格式:通常每行是 "百分比% 栈帧"
head -25 "$TMP" | while IFS= read -r line; do
echo "$line"
done
echo
echo "─── 按包/模块聚合(Top 10)───"
awk '{
# 取倒数第一列作为函数名(格式因版本而异,这是容错处理)
n = split($0, a, " ");
frame = a[n];
# 提取包名(前两段)
split(frame, parts, ".");
pkg = parts[1];
if (length(parts) > 1) pkg = pkg "." parts[2];
# 提取百分比(第一个字段)
pct = a[1];
gsub(/[^0-9.]/, "", pct);
if (pct != "") sum[pkg] += pct;
} END {
for (p in sum) printf "%8.2f%% %s\n", sum[p], p;
}' "$TMP" | sort -rn | head -10
rm -f "$TMP"
二、diff 两次火焰图(证明优化有效)
#!/usr/bin/env bash
# tools/flamegraph-diff.sh <PID> <EVENT> <BEFORE_LABEL> <AFTER_LABEL>
#
# 分别在优化前/后采集,生成差量图。
# 差量图能直接显示"哪条链路变短了"。
set -uo pipefail
PID="${1:?usage: flamegraph-diff.sh <pid> <event> <before_label> <after_label>}"
EVENT="${2:-wall}"
BEFORE="${3:?}"
AFTER="${4:?}"
OUT_DIR="${OUT_DIR:-docs/experiments/flamegraph-diff}"
mkdir -p "$OUT_DIR"
echo "采集 $EVENT 火焰图:$BEFORE"
asprof -d 60 -e "$EVENT" -o collapsed -f "$OUT_DIR/$BEFORE.txt" "$PID"
echo
echo "⏸ 现在请应用你的优化(或切到优化后的版本),然后按回车继续..."
read -r _
echo "采集 $EVENT 火焰图:$AFTER"
asprof -d 60 -e "$EVENT" -o collapsed -f "$OUT_DIR/$AFTER.txt" "$PID"
echo
echo "生成差量图..."
if [ ! -d FlameGraph ]; then
echo "⚠️ 未找到 FlameGraph 工具集,正在克隆..."
git clone --depth 1 https://github.com/brendangregg/FlameGraph 2>/dev/null || {
echo "❌ 克隆失败,请手动克隆 FlameGraph 后重试"
exit 1
}
fi
FlameGraph/difffolded.pl "$OUT_DIR/$BEFORE.txt" "$OUT_DIR/$AFTER.txt" \
| FlameGraph/flamegraph.pl > "$OUT_DIR/diff-${BEFORE}-vs-${AFTER}.svg"
echo "✅ 差量图 → $OUT_DIR/diff-${BEFORE}-vs-${AFTER}.svg"
echo
echo "读法:"
echo " 颜色区分「变多」和「变少」的栈 —— 红色变少的部分就是你优化掉的开销"
echo " (具体配色取决于 flamegraph.pl 的参数,可用 --colors 调整)"
一个更简单的 diff 方法:比较 flat 输出
# tools/compare-hotspots.py <before-flat.txt> <after-flat.txt>
"""
比较两次 flat 输出的热点变化。
比 SVG 差量图更容易量化(能算出"减少了多少个百分点")。
"""
import re
import sys
def parse_flat(path):
"""解析 flat 输出:返回 {函数名: 百分比}"""
result = {}
with open(path, errors="ignore") as f:
for line in f:
# 格式容错:找行首/行中的百分比数字
m = re.match(r"\s*([\d.]+)\s*%?\s+(.*)", line)
if m:
try:
pct = float(m.group(1))
frame = m.group(2).strip()
# 取最后一段作为函数名
name = frame.split()[-1] if " " in frame else frame
result[name] = result.get(name, 0) + pct
except ValueError:
continue
return result
def main(before_path, after_path):
before = parse_flat(before_path)
after = parse_flat(after_path)
all_keys = set(before) | set(after)
print("═" * 92)
print("热点变化对比(按变化幅度排序)")
print("═" * 92)
print()
print(f"{'函数':<52}{'优化前':>10}{'优化后':>10}{'变化':>12}")
print("-" * 92)
rows = []
for k in all_keys:
b, a = before.get(k, 0), after.get(k, 0)
rows.append((a - b, k, b, a))
# 按"减少最多"排序(负数 = 减少了,好事)
rows.sort()
for delta, k, b, a in rows[:15]:
marker = "✅" if delta < -1 else ("❌" if delta > 1 else " ")
print(f"{k[:50]:<52}{b:>9.2f}%{a:>9.2f}%{delta:>+11.2f}% {marker}")
print()
print("─" * 92)
print("增加最多的(可能是新引入的开销):")
for delta, k, b, a in rows[-5:][::-1]:
if delta > 0.5:
print(f" ❌ {k[:60]:<62}{b:>7.2f}% → {a:>7.2f}% ({delta:+.2f}%)")
print()
print("═" * 92)
print("判读:")
print(" ✅ 表示该函数的耗占比下降了(优化生效)")
print(" ❌ 表示上升了(可能是新的瓶颈,或优化引入了副作用)")
print("═" * 92)
if __name__ == "__main__":
if len(sys.argv) < 3:
print("usage: compare-hotspots.py <before-flat.txt> <after-flat.txt>")
sys.exit(1)
main(sys.argv[1], sys.argv[2])
三、自动识别形状特征
# tools/analyze-flamegraph.py <collapsed.txt>
"""
从 collapsed 格式分析火焰图的形状特征。
collapsed 格式:每个栈一行
frame1;frame2;frame3 1234
(分号分隔栈,最后的数字是采样数)
"""
import sys
from collections import Counter, defaultdict
def load(path):
stacks = []
with open(path, errors="ignore") as f:
for line in f:
line = line.strip()
if not line:
continue
*frames, count = line.rsplit(" ", 1)
try:
stacks.append((frames[0].split(";"), int(count)))
except ValueError:
continue
return stacks
def analyze(stacks):
total = sum(c for _, c in stacks)
if total == 0:
print("❌ 没有采样数据")
return
print("═" * 76)
print(f"火焰图形状分析(总采样 {total},{len(stacks)} 条栈)")
print("═" * 76)
print()
if total < 100:
print("⚠️ 采样数很少 —— 可能是:① 采样时长太短 ② 权限问题 ③ 服务空闲")
print(" 建议延长 -d 时长或确认采样时正在施压\n")
# ① 自身耗时排名(叶子帧)
self_time = Counter()
for frames, count in stacks:
self_time[frames[-1]] += count
print("【① 自身耗时 Top 15】(叶子帧 = 真正在消耗时间的函数)")
for frame, c in self_time.most_common(15):
pct = c / total * 100
bar = "█" * int(pct / 2)
print(f" {pct:5.1f}% {bar} {frame[:60]}")
print()
# ② 栈深度分布
depths = [len(frames) for frames, _ in stacks]
avg_depth = sum(d * c for (f, c), d in zip(stacks, depths)) / total
print(f"【② 栈深度】平均 {avg_depth:.1f} 层(最深 {max(depths)})")
if avg_depth > 60:
print(" → 栈很深:可能是框架层级多,或递归")
print()
# ③ 关键模式检测
print("【③ 关键模式检测】")
patterns = {
"socketRead/socketWrite(等网络)": ("socketRead", "socketWrite", "SocketInputStream"),
"等数据库(JDBC/PG)": ("jdbc", "postgresql", "QueryExecutor", "PgConnection"),
"等连接池": ("HikariPool", "getConnection"),
"锁等待": ("park", "Unsafe.park", "LockSupport", "monitor"),
"文件 IO(日志)": ("FileOutputStream", "FileDispatcher", "Files.write"),
"序列化": ("Jackson", "serializ", "writeValue", "Json"),
"正则": ("Pattern.compile", "Matcher", "Regex"),
"GC": ("G1", "ZGC", "gc", "Collector"),
"协程调度": ("kotlinx.coroutines", "DispatchedTask"),
"字符串处理": ("StringBuilder", "String.format", "String.concat"),
}
for label, keywords in patterns.items():
hits = [(f, c) for f, c in self_time.items() if any(k.lower() in f.lower() for k in keywords)]
if hits:
total_pct = sum(c for _, c in hits) / total * 100
print(f" {total_pct:5.1f}% {label}")
print()
# ④ 形状判定
print("【④ 形状判定】")
top_frame, top_count = self_time.most_common(1)[0]
top_pct = top_count / total * 100
if top_pct > 25:
print(f" → 存在明显热点:{top_frame[:50]}({top_pct:.1f}%)")
print(f" 这是「宽而平」形状,是优化的好目标")
elif avg_depth > 40:
print(f" → 深调用链(平均 {avg_depth:.0f} 层),时间分散")
print(f" 如果底部是等待类调用(socketRead 等),问题在链的末端")
else:
print(f" → 分布较均匀,无明显单点热点")
print(f" 可能是多因素共同作用,或瓶颈在进程外(网络/网关/上游)")
if total < 100:
print(f"\n ⚠️ 采样太少({total}),以上判定不可靠")
print("═" * 76)
if __name__ == "__main__":
load_path = sys.argv[1] if len(sys.argv) > 1 else "cpu.collapsed"
analyze(load(load_path))
四、完整工作流
#!/usr/bin/env bash
# tools/flamegraph-workflow.sh <PID> <EXP_ID>
#
# 完整的火焰图分析工作流:采四类 + 分析 + 生成报告
set -uo pipefail
PID="${1:?usage: flamegraph-workflow.sh <pid> <exp_id>}"
EXP_ID="${2:?}"
DIR="docs/experiments/${EXP_ID}/results/flamegraph"
mkdir -p "$DIR"
echo "═══ 1. 采集四类火焰图(各 30 秒)═══"
for E in cpu wall alloc lock; do
echo " 采集 $E ..."
asprof -d 30 -e "$E" -f "$DIR/$E.html" "$PID" > /dev/null 2>&1
asprof -d 30 -e "$E" -o collapsed -f "$DIR/$E.collapsed" "$PID" > /dev/null 2>&1
done
echo
echo "═══ 2. 形状分析 ═══"
for E in cpu wall alloc lock; do
echo
echo "───────── $E ─────────"
python3 tools/analyze-flamegraph.py "$DIR/$E.collapsed" | head -40
done
echo
echo "═══ 3. 快速热点排名 ═══"
for E in cpu wall; do
echo
asprof -d 10 -e "$E" -o flat -f "$DIR/$E-flat.txt" "$PID" > /dev/null 2>&1
echo "── $E Top 10 ──"
head -12 "$DIR/$E-flat.txt"
done
echo
echo "✅ 完成 → $DIR"
echo
echo "下一步:"
echo " ① 打开 HTML 文件确认形状"
echo " ② 用 compare-hotspots.py 对比优化前后"
echo " ③ 填进实验档案的「证据」章节"
五、动手改造
| 改动 | 观察什么 |
|---|---|
对同一个接口采 cpu 和 wall 两份,都跑 analyze-flamegraph.py |
对比两份的「关键模式检测」结果——这是"两类现象两条路径"的实证 |
| 在优化前后各采一份 flat 输出并对比 | compare-hotspots.py 会显示哪条链路变短了 |
把 analyze-flamegraph.py 的 patterns 加上你自己的框架关键词 |
适配你的技术栈(比如你的 ORM、你的 HTTP 客户端) |
用 -e wall 采一份,看「等连接池」的占比 |
印证第 6.5 节的池化瓶颈 |
| 故意在采样期间停止施压 | analyze-flamegraph.py 会提示「采样数很少」——理解采样需要流量 |
六、这段代码的局限
analyze-flamegraph.py的关键词匹配是启发式的:可能误报(比如类名里恰好含 “gc”)或漏报(你的框架名字不在列表里)。- collapsed 格式因 async-profiler 版本而异:本脚本用
frame;frame count的通用格式,如果版本差异大需要调整解析。 - flat 输出格式也不统一:不同版本的表头与列顺序可能不同,
compare-hotspots.py用了容错解析但仍可能不准。 - 形状判定只是辅助:真正的判读还是要看 HTML 图——脚本只能提示"哪里可能值得看"。