文档目录

9.6 配套代码:剖析定位与假设台账

对应小节:9.6 步骤五:剖析定位

一、在拐点处取证(五步法的前四步)

#!/usr/bin/env bash
# tools/step5a-collect-at-knee.sh
#
# ═══ 关键纪律 ═══
# 取证必须在【拐点负载下】做,不能停压测再取。
# 因为:停压测后线程池空了、GC 停了、锁不争了 ——
#       你看到的是一个"没有压力的系统",什么都看不出来。
set -euo pipefail

OUT="perf/results/step5-diagnose"
mkdir -p "$OUT"

PID="${APP_PID:?请设置 APP_PID(服务进程号)}"
KNEE_RPS="${KNEE_RPS:-600}"       # 步骤四测出的拐点

echo "═══ 在拐点负载(${KNEE_RPS} RPS)下取证 ═══"
echo

# ── 0. 先把压测跑起来,并保持压力 ──
cat > /tmp/knee-load.js <<JS
import http from 'k6/http';
import { SharedArray } from 'k6/data';
export const options = {
  scenarios: { knee: {
    executor: 'constant-arrival-rate',
    rate: ${KNEE_RPS}, timeUnit: '1s',
    duration: '3m', preAllocatedVUs: 500, maxVUs: 2000,
  }},
  thresholds: {},
};
const codes = new SharedArray('codes', () =>
  open('/tmp/hot-codes-copy.txt').split('\n').filter(Boolean));
export default function () {
  const c = codes[Math.floor(Math.random() * codes.length)];
  http.get(\`http://localhost:8080/\${c}\`, { redirects: 0 });
}
JS
cp perf/data/hot-codes.txt /tmp/hot-codes-copy.txt
(cd perf/k6 && k6 run --quiet /tmp/knee-load.js > "$OLDPWD/$OUT/k6-knee.log" 2>&1) &
LOAD_PID=$!
echo "压测已启动(PID $LOAD_PID),等待 40s 让它进入稳态..."
sleep 40

# ══════════════════════════════════════════════
# 第 1 步:看「整体形态」—— 哪一层慢?
# ══════════════════════════════════════════════
echo
echo "① 四层指标快照"
curl -sf http://localhost:8080/metrics > "$OUT/metrics-knee.txt"

python3 - "$OUT/metrics-knee.txt" <<'PY'
import re, sys, pathlib

txt = pathlib.Path(sys.argv[1]).read_text()

def g(pat, default=0.0):
    m = re.search(pat, txt, re.M)
    return float(m.group(1)) if m else default

# ── 计算「时间都花在哪」──
e2e_sum = g(r'^app_request_duration_seconds_sum\s+([\d.eE+-]+)')
dep_sum = g(r'^app_db_query_seconds_sum\s+([\d.eE+-]+)')
req_cnt = g(r'^app_request_duration_seconds_count\s+([\d.eE+-]+)')
dep_cnt = g(r'^app_db_query_seconds_count\s+([\d.eE+-]+)')

print(f"  请求数              {req_cnt:,.0f}")
print(f"  端到端总耗时        {e2e_sum*1000:,.0f} ms")
print(f"  依赖(DB)总耗时    {dep_sum*1000:,.0f} ms")
if e2e_sum > 0:
    print(f"  依赖占比            {dep_sum/e2e_sum*100:.1f}%  "
          f"{'← 瓶颈在依赖层' if dep_sum/e2e_sum > 0.6 else ''}")
if req_cnt > 0:
    print(f"  平均端到端          {e2e_sum/req_cnt*1000:,.1f} ms")
print()

# ── 饱和度信号 ──
pend = g(r'^db_pool_pending\s+([\d.eE+-]+)')
act  = g(r'^db_pool_active\s+([\d.eE+-]+)')
tot  = g(r'^db_pool_total\s+([\d.eE+-]+)')
run  = g(r'^jvm_threads_states_threads\{state="runnable"\}\s+([\d.eE+-]+)')
cpu  = g(r'^process_cpu_usage\s+([\d.eE+-]+)')

print("  饱和度信号:")
print(f"    连接池 等待/活跃/总数  {pend:.0f} / {act:.0f} / {tot:.0f}")
print(f"    RUNNABLE 线程数        {run:.0f}")
print(f"    进程 CPU               {cpu*100:.1f}%")
print()
print("  判读:")
if pend > 0 and act >= tot:
    print("    ⚠️  池已满且有等待 —— 但【不要马上加池】")
    print("        先回答:为什么每个请求占用连接这么久?")
    print("        如果根因是慢查询,加池只是让更多慢查询并发去抢 DB")
if run > 0:
    import os
    ncpu = os.cpu_count() or 8
    if abs(run - ncpu) <= 1:
        print(f"    ⚠️  RUNNABLE ≈ 核数({ncpu}) —— 调度器污染的典型信号")
        print("        说明 Default 调度器被阻塞调用占满(埋雷 ④)")
PY

# ══════════════════════════════════════════════
# 第 2 步:wall-clock 火焰图(找「时间花在哪」)
# ══════════════════════════════════════════════
echo
echo "② wall-clock 火焰图(30s)"
asprof -d 30 -e wall -t -o collapsed -f "$OUT/flame-wall.collapsed" "$PID"
echo "   ✅ $OUT/flame-wall.collapsed"
echo "   用 https://www.speedscope.app 打开,或:"
echo "   asprof -o flamegraph.html -f $OUT/flame-wall.html ..."

# ══════════════════════════════════════════════
# 第 3 步:CPU 火焰图(对照:如果 wall 有内容但 cpu 很空 → 是等待)
# ══════════════════════════════════════════════
echo
echo "③ CPU 火焰图(30s)"
asprof -d 30 -e cpu -t -o collapsed -f "$OUT/flame-cpu.collapsed" "$PID"
echo "   ✅ $OUT/flame-cpu.collapsed"

# ══════════════════════════════════════════════
# 第 4 步:分类火焰图(锁 / 分配 / IO)
# ══════════════════════════════════════════════
echo
echo "④ 分类火焰图"
for ev in lock alloc; do
  echo "   - $ev"
  asprof -d 20 -e "$ev" -t -o collapsed -f "$OUT/flame-$ev.collapsed" "$PID" \
    2>/dev/null && echo "     ✅ flame-$ev.collapsed" \
    || echo "     ⚠️  $ev 事件不可用(需要 -XX:+UnlockDiagnosticVMOptions)"
done

# ══════════════════════════════════════════════
# 第 5 步:线程 / 协程快照
# ══════════════════════════════════════════════
echo
echo "⑤ 线程快照"
jcmd "$PID" Thread.dump_to_file -format=json "$OUT/threads.json" 2>/dev/null \
  && echo "   ✅ threads.json" \
  || { jstack "$PID" > "$OUT/threads.txt"; echo "   ✅ threads.txt"; }

echo
echo "⑥ JFR 采样(60s,含 FileWrite / SocketRead 事件)"
jcmd "$PID" JFR.start name=diag settings=profile duration=60s \
  filename="$OUT/diag.jfr" 2>/dev/null \
  && echo "   ✅ diag.jfr(60s 后完成,可稍后分析)" \
  || echo "   ⚠️  JFR 启动失败"

# ── 数据库侧取证(与 JVM 侧同时进行)──
echo
echo "⑦ 数据库侧"
{
  echo "## pg_stat_statements 慢查询 TOP 10"
  psql "$PG_CONN" -c "
    SELECT round(total_exec_time::numeric,1) AS total_ms,
           calls,
           round(mean_exec_time::numeric,2)  AS mean_ms,
           round((100*total_exec_time/sum(total_exec_time) OVER ())::numeric,1) AS pct,
           left(query, 70) AS query
    FROM pg_stat_statements
    ORDER BY total_exec_time DESC LIMIT 10;" 2>/dev/null \
    || echo "(需要 CREATE EXTENSION pg_stat_statements)"
  echo
  echo "## 当前等待事件"
  psql "$PG_CONN" -c "
    SELECT wait_event_type, wait_event, count(*)
    FROM pg_stat_activity
    WHERE state <> 'idle'
    GROUP BY 1,2 ORDER BY 3 DESC;" 2>/dev/null
  echo
  echo "## 表大小"
  psql "$PG_CONN" -c "
    SELECT pg_size_pretty(pg_total_relation_size('links')) AS total_size;" 2>/dev/null
} > "$OUT/db-snapshot.md"
echo "   ✅ db-snapshot.md"

# ── 收尾:停压测 ──
echo
echo "取证完成,停止压测..."
kill $LOAD_PID 2>/dev/null || true
wait $LOAD_PID 2>/dev/null || true

echo
echo "═══ 取证完成 ═══"
echo "产出物 → $OUT/"
ls -1 "$OUT/" | sed 's/^/  /'
echo
echo "下一步:python3 tools/step5b-analyze-flame.py"

二、火焰图的自动初筛

#!/usr/bin/env python3
"""tools/step5b-analyze-flame.py —— 从 collapsed 火焰图提取热点

collapsed 格式:a;b;c 123   (分号分隔的调用栈 + 采样数)
"""
import pathlib
import sys
from collections import defaultdict


def load(path: pathlib.Path):
    stacks = []
    total = 0
    for line in path.read_text().splitlines():
        line = line.strip()
        if not line:
            continue
        parts = line.rsplit(" ", 1)
        if len(parts) != 2:
            continue
        stack, cnt = parts
        try:
            cnt = int(cnt)
        except ValueError:
            continue
        stacks.append((stack.split(";"), cnt))
        total += cnt
    return stacks, total


def main(d: str) -> int:
    d = pathlib.Path(d)

    for name, desc in [
        ("flame-wall.collapsed", "wall(墙钟:时间到底花在哪)"),
        ("flame-cpu.collapsed", "cpu(CPU:真正在算什么)"),
        ("flame-lock.collapsed", "lock(锁:在等什么锁)"),
        ("flame-alloc.collapsed", "alloc(分配:谁在造垃圾)"),
    ]:
        p = d / name
        if not p.exists():
            continue

        stacks, total = load(p)
        if total == 0:
            print(f"⚠️  {name} 为空 —— {desc}")
            print(f"    空就有意义:说明这类事件在采样窗口内没发生")
            print()
            continue

        print(f"═══ {desc} ═══")
        print(f"总采样 {total:,}")
        print()

        # ── 顶层帧聚合(谁是最外层的等待者)──
        by_top = defaultdict(int)
        for stack, cnt in stacks:
            if stack:
                by_top[stack[0]] += cnt
        print("  最外层帧 TOP 5:")
        for frame, cnt in sorted(by_top.items(), key=lambda kv: -kv[1])[:5]:
            print(f"    {cnt/total*100:6.1f}%  {frame}")
        print()

        # ── 叶子帧聚合(真正在执行的代码)──
        by_leaf = defaultdict(int)
        for stack, cnt in stacks:
            if stack:
                by_leaf[stack[-1]] += cnt
        print("  叶子帧 TOP 10(真正的热点):")
        for frame, cnt in sorted(by_leaf.items(), key=lambda kv: -kv[1])[:10]:
            print(f"    {cnt/total*100:6.1f}%  {frame[:110]}")
        print()

        # ── 关键类别的出现频率 ──
        cats = {
            "JDBC / PG 驱动": ("org.postgresql", "java.sql", "PgConnection"),
            "socket 读等待": ("SocketRead", "socketRead", "read0", "epollWait"),
            "文件写入(日志)": ("FileOutputStream", "FileChannel", "Log4j", "Logback"),
            "锁竞争": ("ReentrantLock", "synchronized", "Unsafe.park", "AQS"),
            "缓存 / Caffeine": ("caffeine", "BoundedLocalCache"),
            "协程调度": ("Dispatchers", "CoroutineScheduler", "runBlocking"),
            "GC / 分配": ("G1", "gc", "allocate", "TLAB"),
        }
        print("  类别占比(按包含匹配整栈):")
        for cat, keys in cats.items():
            hit = sum(c for stack, c in stacks
                      if any(k in f for f in stack for k in keys))
            if hit:
                print(f"    {hit/total*100:6.1f}%  {cat}")
        print()
        print("─" * 60)
        print()


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1
                  else "perf/results/step5-diagnose"))

预期输出(关键部分):

═══ wall(墙钟:时间到底花在哪)═══
总采样 1,842

  最外层帧 TOP 5:
      71.3%  org.postgresql.core.v3.QueryExecutorImpl.execute
      12.4%  java.net.SocketInputStream.socketRead0
       6.1%  org.slf4j.Logger.info
       4.8%  com.github.benmanes.caffeine.BoundedLocalCache.getIfPresent
       2.9%  io.ktor.server.engine.DefaultEnginePipeline

  叶子帧 TOP 10(真正的热点):
      68.2%  java.net.SocketInputStream.socketRead0
       6.0%  java.io.FileOutputStream.writeBytes
       ...

  类别占比(按包含匹配整栈):
      89.4%  JDBC / PG 驱动
      71.3%  socket 读等待
       6.1%  文件写入(日志)
       3.2%  GC / 分配

═══ cpu(CPU:真正在算什么)═══
总采样 412
  叶子帧 TOP 10:
      18.2%  java.util.regex.Pattern$...
      11.4%  java.lang.String.format
       ...

判读:

关键对比:
  wall 总采样 1842,其中 89.4% 在 JDBC/PG 驱动、71.3% 在 socket 读等待
  cpu  总采样只有 412

→ 结论:**不是 CPU 问题,是在等数据库**
→ 这排除了 H1(CPU 饱和)、H4(GC 压力)
→ 把注意力收到 H2(慢查询)、H3(池等待)

三、数据库侧定位(核心一步)

#!/usr/bin/env bash
# tools/step5c-diagnose-db.sh —— 用 EXPLAIN ANALYZE 找到慢查询的根因
set -euo pipefail

OUT="perf/results/step5-diagnose"
PG="${PG_CONN:-postgresql://app:app@localhost:5432/shortlink}"
CODE=$(head -1 perf/data/hot-codes.txt)

echo "═══ 数据库侧定位 ═══"
echo "取样短码:$CODE"
echo

{
  echo "# 数据库诊断($(date -Iseconds))"
  echo
  echo "## 1. 查询计划(EXPLAIN ANALYZE BUFFERS)"
  echo
  echo '```'
  psql "$PG" -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id, code, url, user_id, created_at, hits FROM links WHERE code = '$CODE';" 2>&1
  echo '```'
  echo
  echo "## 2. 表统计"
  echo
  echo '```'
  psql "$PG" -c "SELECT count(*) AS rows, pg_size_pretty(pg_total_relation_size('links')) AS size FROM links;" 2>&1
  echo '```'
  echo
  echo "## 3. 索引清单"
  echo
  echo '```'
  psql "$PG" -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename='links';" 2>&1
  echo '```'
  echo
  echo "## 4. 表级 I/O 统计"
  echo
  echo '```'
  psql "$PG" -c "SELECT seq_scan, seq_tup_read, idx_scan, n_tup_upd, n_tup_hot_upd, n_dead_tup FROM pg_stat_user_tables WHERE relname='links';" 2>&1
  echo '```'
} > "$OUT/db-explain.md"

cat "$OUT/db-explain.md"
echo
echo "═══ 关键判读 ═══"
echo
echo "看 plan 里是否有:"
echo "  ✗ Seq Scan on links          → 全表扫描(埋雷 ①)"
echo "  ✗ Rows Removed by Filter     → 扫描了大量无用行"
echo "  ✗ Execution Time: 3xx ms     → 与观测到的 P99(380ms) 吻合"
echo
echo "三条对上 → 根因确认,不是猜测"
echo
echo "═══ 立即验证(做一次对照)═══"
echo
echo "在测试库上临时建索引,看同一个查询快多少:"
echo "  CREATE UNIQUE INDEX CONCURRENTLY tmp_idx ON links(code);"
echo "  EXPLAIN (ANALYZE) ..."
echo "  DROP INDEX tmp_idx;"
echo
echo "为什么先做对照再正式优化:"
echo "  这一步是【最小成本的假设验证】—— 不改服务代码、不改配置,"
echo "  只验证「索引能否解决」,避免优化方向错误"

预期输出(核心片段):

## 1. 查询计划(EXPLAIN ANALYZE BUFFERS)

 Seq Scan on links  (cost=0.00..22412.00 rows=1 width=48) (actual time=0.012..385.201 rows=1 loops=1)
   Filter: ((code)::text = 'aB3xK9'::text)
   Rows Removed by Filter: 999999
   Buffers: shared hit=772 read=9902
 Planning Time: 0.098 ms
 Execution Time: 385.456 ms

逐行解读:

Seq Scan on links
  → 全表扫描,不是索引扫描(埋雷 ① 确认)✅

Rows Removed by Filter: 999999
  → 扫了 100 万行,过滤掉 999999 行,只留 1 行
  → 这就是 380ms 的来源:99.9999% 的工作是浪费的 ✅

Execution Time: 385.456 ms
  → 与依赖级 P99(380ms)吻合 → 根因锁定 ✅

Buffers: shared hit=772 read=9902
  → 9902 个块从磁盘读(≈77MB)
  → 说明缓存没放下整张表(表约 100MB+,shared_buffers=2GB 但还有其他表)

四、假设台账

#!/usr/bin/env python3
"""tools/hypotheses.py —— 假设台账:每个假设都必须有「验证手段」和「结论」

为什么需要台账:
  性能分析最容易犯的错是「跳来跳去」——
  刚有个想法就去看,看一半被另一个想法打断,最后哪个都没验证完。
  台账强制你【一次只推进一个】,并且留下证据。
"""
import json
import pathlib
import sys

TEMPLATE = {
    "id": "H1",
    "hypothesis": "假设内容(可证伪的陈述)",
    "evidence_for": "支持它的证据",
    "evidence_against": "反对它的证据",
    "verify_by": "用什么手段验证",
    "status": "pending",       # pending / confirmed / rejected / partial
    "conclusion": "结论与数据",
}


def load(p: pathlib.Path):
    if p.exists():
        return json.loads(p.read_text())
    return {"hypotheses": []}


def render(hs):
    icons = {"pending": "⏳", "confirmed": "✅", "rejected": "❌", "partial": "🟡"}
    print(f"{'ID':<4} {'状态':<4} {'假设':<46} 验证手段")
    print("─" * 110)
    for h in hs:
        icon = icons.get(h["status"], "?")
        print(f"{h['id']:<4} {icon:<4} {h['hypothesis'][:44]:<46} {h['verify_by'][:30]}")
    print()
    for h in hs:
        if h["status"] in ("confirmed", "partial"):
            print(f"{h['id']} 结论:{h['conclusion']}")
        elif h["status"] == "rejected":
            print(f"{h['id']} 被排除:{h['evidence_against'][:80]}")


def main(d: str) -> int:
    d = pathlib.Path(d)
    p = d / "hypotheses.json"
    data = load(p)

    if not data["hypotheses"]:
        # 本 Lab 的初始假设集(来自步骤四的饱和度信号 + 步骤五的火焰图)
        data["hypotheses"] = [
            {"id": "H1", "hypothesis": "CPU 饱和(核数不够)",
             "evidence_for": "拐点处 CPU 81%",
             "evidence_against": "CPU 火焰图总采样仅 412,wall 1842;CPU 未打满",
             "verify_by": "对比 wall/cpu 火焰图总采样",
             "status": "rejected",
             "conclusion": "CPU 不是瓶颈;系统在等待"},

            {"id": "H2", "hypothesis": "DB 查询慢(code 无索引)",
             "evidence_for": "wall 89% 在 JDBC;依赖级 P99=380ms",
             "evidence_against": "无",
             "verify_by": "EXPLAIN ANALYZE 看执行计划",
             "status": "confirmed",
             "conclusion": "Seq Scan + Rows Removed by Filter: 999999 + "
                           "Execution Time 385ms —— 贡献约 90%"},

            {"id": "H3", "hypothesis": "连接池太小(等待)",
             "evidence_for": "db_pool_pending=3,池已满",
             "evidence_against": "等待是【结果】不是原因:每请求占用连接 380ms",
             "verify_by": "算「连接占用时长」= 380ms,"
                          "Little's Law 需 0.38×RPS 个连接",
             "status": "partial",
             "conclusion": "池不是根因;但修复慢查询后需重算池大小"},

            {"id": "H4", "hypothesis": "GC 停顿导致 P99",
             "evidence_for": "P99 有尖刺",
             "evidence_against": "GC 火焰图占比 3.2%;"
                                 "GC 停顿时刻与 P99 尖刺【未对齐】",
             "verify_by": "GC 日志时间戳 vs 慢请求时间戳对齐(第 6.6 节)",
             "status": "rejected",
             "conclusion": "GC 正常,不是 P99 尖刺的原因"},

            {"id": "H5", "hypothesis": "热路径日志开销",
             "evidence_for": "wall 中 FileOutputStream 占 6.1%",
             "evidence_against": "无",
             "verify_by": "关掉 INFO 日志做对照实验",
             "status": "confirmed",
             "conclusion": "贡献约 6%(sys CPU 与写盘)"},

            {"id": "H6", "hypothesis": "阻塞调用占用 Default 调度器(埋雷 ④)",
             "evidence_for": "/health 接口 P99 从 8ms 涨到 380ms;"
                             "RUNNABLE 线程数 == 核数",
             "evidence_against": "无",
             "verify_by": "wall 火焰图看是否卡在 Default;"
                          "切 Dispatchers.IO 后测 /health",
             "status": "confirmed",
             "conclusion": "贡献包括:所有接口一起慢;"
                           "优化后 /health P99 380→8ms"},

            {"id": "H7", "hypothesis": "每次跳转 UPDATE hits 的行锁竞争(埋雷 ②)",
             "evidence_for": "lock 火焰图有 ReentrantLock",
             "evidence_against": "占比 2.4%;幂律下热门前 10 只占 31%",
             "verify_by": "异步批量 UPDATE 的 A/B 对比",
             "status": "confirmed",
             "conclusion": "真实存在但贡献仅 2.4% < MDD(11.8%) "
                           "—— 【否决优化】"},

            {"id": "H8", "hypothesis": "缓存 TTL 无抖动(埋雷 ③)",
             "evidence_for": "DB QPS 有周期性脉冲",
             "evidence_against": "基线阶段(短时压测)观察不到",
             "verify_by": "浸泡 ≥1h,把脉冲时刻与 TTL(10min) 对齐",
             "status": "pending",
             "conclusion": "留到步骤七(长稳)验证"},
        ]

    render(data["hypotheses"])
    p.write_text(json.dumps(data, indent=2, ensure_ascii=False))
    print(f"\n✅ 台账 → {p}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1
                  else "perf/results/step5-diagnose"))

预期输出:

ID   状态 假设                                           验证手段
──────────────────────────────────────────────────────────────────────────
H1   ❌   CPU 饱和(核数不够)                          对比 wall/cpu 火焰图总采样
H2   ✅   DB 查询慢(code 无索引)                      EXPLAIN ANALYZE 看执行计划
H3   🟡   连接池太小(等待)                            算「连接占用时长」
H4   ❌   GC 停顿导致 P99                              GC 日志时间戳对齐
H5   ✅   热路径日志开销                               关掉 INFO 日志做对照
H6   ✅   阻塞调用占用 Default 调度器                   切 Dispatchers.IO 后测 /health
H7   ✅   每次跳转 UPDATE hits 的行锁竞争               异步批量 UPDATE 的 A/B
H8   ⏳   缓存 TTL 无抖动                              浸泡 ≥1h,脉冲对齐 TTL

H2 结论:Seq Scan + Rows Removed by Filter: 999999 + Execution Time 385ms — 贡献约 90%
H1 被排除:CPU 火焰图总采样仅 412,wall 1842;CPU 未打满
H4 被排除:GC 火焰图占比 3.2%;GC 停顿时刻与 P99 尖刺【未对齐】

注意 H1 和 H4 的 evidence_against:

「被排除的假设」比「确认的假设」更重要 ——
因为它证明了你没有漏掉其他可能,
而且省下了「去优化 CPU」和「去调 GC」的浪费。

五、瓶颈清单

<!-- perf/results/step5-diagnose/bottlenecks.md -->
# 瓶颈清单(步骤五产出)

## 按贡献占比排序

| # | 瓶颈 | 贡献占比 | 证据 | 位置 | 修复成本 |
| --- | --- | --- | --- | --- | --- |
| 1 | `code` 列无索引 → Seq Scan | **90%** | `EXPLAIN` 显示 Seq Scan + Rows Removed 999999 + Execution 385ms;wall 89% 在 JDBC | DB 索引 | 极低(一条 DDL) |
| 2 | 热路径 INFO 日志(含完整 UA/IP) | **6%** | wall 中 FileOutputStream 6.1%;关闭后 sys CPU 下降 | `Routes.kt` | 极低 |
| 3 | 每次跳转 `UPDATE hits` 行锁竞争 | **2.4%** | lock 火焰图;`n_tup_upd` 高 | `LinkRepository` | 中(需改异步聚合) |
| 4 | `synchronized` 生成短码 | **1.2%** | 写路径偶发 BLOCKED;wall 4.8% 在 Caffeine/SecureRandom | `CodeGenerator` | 低 |
| 5 | 缓存 TTL 无抖动 | **待测** | 需浸泡观察周期性脉冲 | `LinkCache` | 低 |
| 6 | 阻塞调用未切 `Dispatchers.IO` | **影响面**:所有接口(含 `/health`) | `/health` P99 8ms→380ms;RUNNABLE ≈ 核数 | `LinkRepository` | 低 |

## 贡献占比合计

- 已定位:90% + 6% + 2.4% + 1.2% ≈ **99.6%**
- 剩余:约 0.4%(噪声内)

**注意**:占比是【可叠加的独立贡献】,不是"修了 1 就快 90%"。
实际修完 1 之后,其余项的【绝对占比会上升】——
因为总耗时变小了。所以修复顺序要重算。

## 已排除的假设

| 假设 | 排除依据 |
| --- | --- |
| CPU 饱和 | CPU 火焰图采样仅 412 vs wall 1842;CPU 81% 但有大量等待 |
| GC 停顿导致 P99 | GC 占比 3.2%;GC 时间戳与 P99 尖刺未对齐 |
| 缓存过小 | 命中率 75% 符合幂律预期;扩容不改变 DB 查询成本 |
| 网络/压测机瓶颈 | 压测机 load average < 2;RPS 严格跟随设定值 |

## 下一步(步骤六)

1. **加索引**(贡献 90%,成本极低)→ 预期 P99 420 → 45ms
2. **调度器隔离**(影响所有接口)→ 预期 `/health` P99 380 → 8ms
3. **日志异步化 + 降级**(贡献 6%)
4. 修完后**重跑容量曲线**,再决定池大小
5. **否决** 异步批量 UPDATE(2.4% < MDD 11.8%)
6. **否决** Redis 缓存(未验证收益,成本高)

六、动手改造

改动 观察什么
停掉压测再取样 火焰图变空、指标全正常——理解"必须在压力下取证"
只跑 CPU 火焰图、不跑 wall 会得出"CPU 才 412 采样,没什么问题"的错误结论——wall 才是第一步
在 EXPLAIN 里丢掉 BUFFERS 看不到 read=9902(磁盘 I/O)——少了一个关键证据
台账里不写"被排除的假设" 之后会重复怀疑 CPU/GC——排除也是结论
把贡献占比当成"修复收益" 修完 #1 后发现只快了 89%,不是 90%——占比会重新分配

七、这段代码的局限

  • asprof -e wall 需要 async-profiler 2.x+:旧版本没有 wall-clock 模式。
  • -e lock / -e alloc 需要先在 JVM 上开诊断选项(本脚本没自动加)。
  • step5b-analyze-flame.py 的类别匹配是字符串包含:可能误判(比如 gc 会匹配到任何含 gc 的类名)——只做初筛,结论仍需人看火焰图。
  • hypotheses.json 里的结论是"教科书式"的:真实项目中你需要在跑完前四步后自己填,而不是直接抄。
  • 贡献占比的估算方式没有代码化:本脚本给出证据,占比是人工根据「延迟分解」推算的(第 6.1 节)。严格做法是用 trace 做 critical path 分析。
  • 没有做"单变量"隔离:一次关日志又改调度器,就无法归因——步骤六必须一个一个来。