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 分析。
- 没有做"单变量"隔离:一次关日志又改调度器,就无法归因——步骤六必须一个一个来。