文档目录

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 图——脚本只能提示"哪里可能值得看"。