9.8 配套代码:长稳浸泡与三种回归
对应小节:9.8 步骤七:长稳与回归
一、浸泡测试编排(1 小时)
#!/usr/bin/env bash
# tools/step7a-soak.sh —— 1 小时浸泡 + 全程采集
#
# ═══ 为什么浸泡是【不可省略】的 ═══
# 短时压测只能看到「稳态」,看不到:
# • 内存泄漏(每小时漏 20MB,3 分钟压测根本看不出来)
# • 连接/FD 泄漏(同上)
# • 缓存雪崩(固定 TTL 10min,至少跑 20min 才见两轮脉冲)
# • 计数器溢出、缓慢退化(P99 每小时涨 5%)
# 这些都是【上线后才炸】的问题,必须在浸泡里抓出来。
set -euo pipefail
OUT="perf/results/step7-soak"
mkdir -p "$OUT"
DURATION="${DURATION:-60m}"
# ── 负载取拐点的 60%~80% ──
# 为什么不用 100%:满负荷下所有问题都会暴露,但那不是生产常态;
# 60%~80% 是"生产高峰"的合理模拟,也是发现慢性问题的最敏感区间
RATE="${RATE:-420}"
echo "═══ 浸泡测试 ═══"
echo "时长:$DURATION 负载:${RATE} RPS(拐点 600 的 70%)"
echo "开始:$(date -Iseconds)"
echo
# ══════════════════════════════════════════════
# 采集器:每 10 秒一个点,覆盖四类指标
# ══════════════════════════════════════════════
collect() {
local f="$OUT/soak.csv"
echo "ts,heap_used_mb,heap_committed_mb,gc_pause_sum_s,gc_count,"\
"pool_active,pool_idle,pool_pending,pool_total,"\
"threads_total,fds,p99_ms,rps,cpu_pct,dirties_per_sec,cache_hit_rate" > "$f"
local t0 gc0
t0=$(date +%s)
gc0=0
while true; do
local ts m
ts=$(date +%s)
m=$(curl -sf http://localhost:8080/metrics 2>/dev/null) || { sleep 10; continue; }
local heap heapc gcsum gccnt pa pi pp pt thr fds p99 rps cpu dss hit
heap=$(echo "$m" | grep '^jvm_memory_used_bytes{.*area="heap"' | awk '{s+=$NF} END{printf "%.1f", s/1048576}')
heapc=$(echo "$m" | grep '^jvm_memory_committed_bytes{.*area="heap"' | awk '{s+=$NF} END{printf "%.1f", s/1048576}')
gcsum=$(echo "$m" | grep '^jvm_gc_pause_seconds_sum' | awk '{s+=$NF} END{printf "%.3f", s+0}')
gccnt=$(echo "$m" | grep '^jvm_gc_pause_seconds_count' | awk '{s+=$NF} END{print s+0}')
pa=$(echo "$m" | grep '^hikaricp_connections_active' | awk '{print $NF}')
pi=$(echo "$m" | grep '^hikaricp_connections_idle' | awk '{print $NF}')
pp=$(echo "$m" | grep '^db_pool_pending' | awk '{print $NF}')
pt=$(echo "$m" | grep '^db_pool_total' | awk '{print $NF}')
thr=$(echo "$m"| grep '^jvm_threads_live_threads' | awk '{print $NF}')
p99=$(echo "$m"| grep '^app_request_duration_seconds{.*quantile="0.99"' | awk '{print $NF}')
rps=$(echo "$m"| grep '^http_server_requests_seconds_count' | awk '{s+=$NF} END{printf "%.0f", s}')
cpu=$(echo "$m"| grep '^process_cpu_usage' | awk '{printf "%.1f", $NF*100}')
dss=$(echo "$m"| grep '^jvm_gc_memory_allocated_bytes_total' | awk '{print $NF}')
hit=$(echo "$m"| grep '^app_cache_hit_rate' | awk '{printf "%.3f", $NF}')
# 文件描述符(macOS/Linux 命令不同)
fds=$(lsof -p "${APP_PID:-0}" 2>/dev/null | wc -l | tr -d ' ')
[ -z "$fds" ] && fds=0
echo "$ts,${heap:-0},${heapc:-0},${gcsum:-0},${gccnt:-0},"\
"${pa:-0},${pi:-0},${pp:-0},${pt:-0},"\
"${thr:-0},${fds},${p99:-0},${rps:-0},${cpu:-0},${dss:-0},${hit:-0}" >> "$f"
sleep 10
done
}
collect &
COLLECT_PID=$!
trap 'kill $COLLECT_PID 2>/dev/null || true' EXIT
# ── 记录起始状态(用于算增长率)──
curl -sf http://localhost:8080/metrics > "$OUT/metrics-start.txt" || true
jcmd "${APP_PID:?请设置 APP_PID}" GC.heap_info > "$OUT/heap-start.txt" 2>/dev/null || true
jcmd "${APP_PID}" VM.native_memory summary > "$OUT/native-start.txt" 2>/dev/null || true
# ── 跑浸泡压测 ──
cat > /tmp/soak.js <<JS
import http from 'k6/http';
import { SharedArray } from 'k6/data';
export const options = {
scenarios: { soak: {
executor: 'constant-arrival-rate',
rate: ${RATE}, timeUnit: '1s',
duration: '${DURATION}',
preAllocatedVUs: 300, maxVUs: 1500,
}},
thresholds: {},
discardResponseBodies: true,
};
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 --summary-export="$OLDPWD/$OUT/summary.json" \
/tmp/soak.js 2>&1 | tee "$OLDPWD/$OUT/k6.log")
# ── 结束状态 ──
curl -sf http://localhost:8080/metrics > "$OUT/metrics-end.txt" || true
jcmd "${APP_PID}" GC.heap_info > "$OUT/heap-end.txt" 2>/dev/null || true
jcmd "${APP_PID}" VM.native_memory summary > "$OUT/native-end.txt" 2>/dev/null || true
kill $COLLECT_PID 2>/dev/null || true
sleep 1
echo
echo "✅ 浸泡完成:$(date -Iseconds)"
echo " 数据 → $OUT/soak.csv"
echo
echo "下一步:python3 tools/step7b-analyze-soak.py"
二、浸泡数据分析(四类趋势 + 缓存雪崩)
#!/usr/bin/env python3
"""tools/step7b-analyze-soak.py —— 分析浸泡数据,判定「稳定 / 泄漏 / 雪崩」
三类典型结论:
① 稳定:所有趋势线【斜率≈0】,且 GC 后的基线不上升
② 泄漏:某个指标持续单调上升,且 GC 后【基线】仍上升
③ 雪崩:某个指标呈【周期性尖峰】,周期与 TTL 吻合
关键:"GC 后基线"才是判断内存泄漏的正确指标 ——
堆使用量上升可能是正常的(缓存填充、JIT 编译),GC 后会下降。
只有 GC【之后】的谷底仍在上升,才是真正的泄漏。
"""
import csv
import pathlib
import statistics
import sys
def load(p: pathlib.Path):
rows = []
with p.open() as f:
for r in csv.DictReader(f):
rows.append(r)
return rows
def slope(xs, ys):
"""最小二乘斜率(单位:y 每秒)"""
n = len(xs)
if n < 3:
return 0.0
mx, my = statistics.mean(xs), statistics.mean(ys)
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
den = sum((x - mx) ** 2 for x in xs)
return num / den if den else 0.0
def trend(rows, key, label, unit="", warn=None):
xs, ys = [], []
for r in rows:
try:
v = float(r[key] or 0)
except ValueError:
continue
xs.append(float(r["ts"]))
ys.append(v)
if len(xs) < 5:
print(f" {label:<24s} 数据不足")
return None
s = slope(xs, ys)
per_hour = s * 3600
first, last = ys[0], ys[-1]
lo, hi = min(ys), max(ys)
# ── 判定 ──
if warn is not None and abs(per_hour) > warn:
icon = "❌"
elif abs(per_hour) > warn * 0.3 if warn else False:
icon = "🟡"
else:
icon = "✅"
print(f" {icon} {label:<22s} 首 {first:9.2f} → 末 {last:9.2f} {unit}"
f" 斜率 {per_hour:+9.2f} {unit}/小时 范围 [{lo:.2f}, {hi:.2f}]")
return {"slope_per_hour": per_hour, "first": first, "last": last,
"min": lo, "max": hi, "ys": ys, "xs": xs}
def gc_baseline(rows, key: str, gc_key: str):
"""计算「GC 之后」的基线:找每次 gc 计数增加后的第一个采样点"""
vals = []
prev_gc = None
for r in rows:
try:
g = float(r[gc_key] or 0)
v = float(r[key] or 0)
except ValueError:
continue
if prev_gc is not None and g > prev_gc:
vals.append(v) # 这一采样点紧跟一次 GC
prev_gc = g
return vals
def main(d: str) -> int:
d = pathlib.Path(d)
rows = load(d / "soak.csv")
if len(rows) < 30:
print(f"❌ 数据点太少({len(rows)})—— 浸泡时长不够")
return 1
dur_h = (float(rows[-1]["ts"]) - float(rows[0]["ts"])) / 3600
print(f"═══ 浸泡分析({len(rows)} 个点 / {dur_h:.2f} 小时)═══\n")
# ══════════════════════════════════════════
# ① 内存
# ══════════════════════════════════════════
print("① 内存")
heap = trend(rows, "heap_used_mb", "堆使用量", "MB", warn=50)
# ── 关键:GC 后基线 ──
base = gc_baseline(rows, "heap_used_mb", "gc_count")
if len(base) >= 3:
b_first, b_last = statistics.mean(base[:3]), statistics.mean(base[-3:])
b_delta = b_last - b_first
print(f" ⭐ GC 后基线 首 {b_first:.1f} → 末 {b_last:.1f} MB"
f" Δ {b_delta:+.1f} MB")
if b_delta > 20:
print(f" ❌ GC 后基线上升 —— 【真泄漏】")
print(f" 堆使用量上升 + GC 后基线上升 = 对象被持有")
print(f" 下一步:jmap -histo:live 对比两次,找增长的类型")
else:
print(f" ✅ GC 后基线稳定 —— 无泄漏")
print(f" (堆使用量上升但 GC 后回落 = 正常缓存/JIT)")
else:
print(" ⚠️ GC 次数太少,无法判断 GC 后基线")
print()
print("② GC")
trend(rows, "gc_pause_sum_s", "GC 停顿累计", "s", warn=3.0)
gc_rates = []
for i in range(1, len(rows)):
try:
dg = float(rows[i]["gc_count"]) - float(rows[i-1]["gc_count"])
dt = float(rows[i]["ts"]) - float(rows[i-1]["ts"])
if dt > 0:
gc_rates.append(dg / dt * 60)
except ValueError:
pass
if gc_rates:
early = statistics.mean(gc_rates[:len(gc_rates)//4])
late = statistics.mean(gc_rates[-len(gc_rates)//4:])
print(f" GC 频率 首段 {early:.1f} 次/分 → 末段 {late:.1f} 次/分")
if late > early * 2:
print(f" ❌ GC 频率翻倍 —— 分配速率上升或内存压力增大")
else:
print(f" ✅ GC 频率稳定")
print()
print("③ 连接与 FD")
trend(rows, "pool_active", "池活跃", warn=5)
trend(rows, "pool_pending", "池等待", warn=0.5)
trend(rows, "threads_total", "线程数", warn=5)
trend(rows, "fds", "文件描述符", warn=10)
print()
print("④ 延迟与吞吐")
trend(rows, "p99_ms", "P99", "ms", warn=5)
trend(rows, "cpu_pct", "CPU", "%", warn=10)
trend(rows, "cache_hit_rate", "缓存命中率", warn=0.05)
trend(rows, "heap_committed_mb", "堆已提交", "MB", warn=100)
# ══════════════════════════════════════════
# ⑤ 缓存雪崩检测(埋雷 ③)⭐
# ══════════════════════════════════════════
print()
print("⑤ 缓存雪崩检测(埋雷 ③)")
print(" 检测方法:看 DB 相关指标的【周期性】")
print(" 周期预期:约 10 分钟(= 缓存 TTL)")
print()
# 用 RPS 的差分找脉冲
rps_deltas = []
for i in range(1, len(rows)):
try:
dr = float(rows[i]["rps"]) - float(rows[i-1]["rps"])
dt = float(rows[i]["ts"]) - float(rows[i-1]["ts"])
if dt > 0:
rps_deltas.append((float(rows[i]["ts"]), dr / dt * 60))
except ValueError:
pass
if rps_deltas:
rates = [v for _, v in rps_deltas]
mean_r = statistics.mean(rates)
sd_r = statistics.stdev(rates) if len(rates) > 1 else 0
# ── 找超过 均值+3σ 的尖峰 ──
spikes = [(ts, v) for ts, v in rps_deltas
if sd_r > 0 and v > mean_r + 3 * sd_r]
print(f" DB 请求速率:均值 {mean_r:.0f}/分 标准差 {sd_r:.0f}")
print(f" 超过 +3σ 的尖峰:{len(spikes)} 个")
if len(spikes) >= 2:
intervals = [spikes[i][0] - spikes[i-1][0]
for i in range(1, len(spikes))]
avg_interval = statistics.mean(intervals) / 60
print(f" 尖峰间隔:{[f'{i/60:.1f}分' for i in intervals]}")
print(f" 平均间隔:{avg_interval:.1f} 分钟")
print()
if 8 <= avg_interval <= 12:
print(f" ❌ 尖峰周期 ≈ {avg_interval:.0f} 分钟,与缓存 TTL(10min) 【吻合】")
print(f" → 确认埋雷 ③:固定 TTL 导致集体失效")
print(f" → 修复:TTL 加 ±20% 抖动(见 07-step6-optimize.md 优化 4)")
else:
print(f" ⚠️ 有周期性尖峰但与 TTL 不吻合 —— 排查其他周期性任务")
else:
print(f" ✅ 未发现周期性尖峰(或 TTL 抖动已生效)")
else:
print(" ⚠️ 数据不足")
# ── 写报告 ──
(d / "soak-report.md").write_text(f"""# 浸泡测试报告
- 时长:{dur_h:.2f} 小时
- 数据点:{len(rows)}
## 结论
(请根据上面的输出填写)
## 判读要点
1. **GC 后基线**才是泄漏判据,不是堆使用量
2. 任何【单调上升】的指标都要解释:
- 缓存填充 → 正常,会收敛
- 线程/FD/连接 → 泄漏
3. 周期性尖峰要【与已知周期对齐】才能归因
""")
print()
print(f"✅ 报告 → {d}/soak-report.md")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1
else "perf/results/step7-soak"))
预期输出:
═══ 浸泡分析(361 个点 / 1.00 小时)═══
① 内存
✅ 堆使用量 首 412.30 → 末 438.10 MB 斜率 +25.80 MB/小时 范围 [401.20, 461.50]
⭐ GC 后基线 首 398.2 → 末 401.1 MB Δ +2.9 MB
✅ GC 后基线稳定 —— 无泄漏
(堆使用量上升但 GC 后回落 = 正常缓存/JIT)
② GC
✅ GC 停顿累计 首 0.00 → 末 1.42 s 斜率 +1.42 s/小时 范围 [0.00, 1.42]
GC 频率 首段 2.1 次/分 → 末段 2.3 次/分
✅ GC 频率稳定
③ 连接与 FD
✅ 池活跃 首 6.00 → 末 6.00 斜率 +0.00 /小时 范围 [5.00, 7.00]
✅ 池等待 首 0.00 → 末 0.00 斜率 +0.00 /小时 范围 [0.00, 0.00]
✅ 线程数 首 42.00 → 末 42.00 斜率 +0.00 /小时 范围 [41.00, 43.00]
✅ 文件描述符 首 313.00 → 末 315.00 斜率 +2.00 /小时 范围 [311.00, 317.00]
④ 延迟与吞吐
✅ P99 首 18.10 → 末 19.40 ms 斜率 +1.30 ms/小时 范围 [17.20, 21.30]
✅ CPU 首 22.30 → 末 22.10 % 斜率 -0.20 %/小时 范围 [20.10, 24.50]
✅ 缓存命中率 首 0.751 → 末 0.749 斜率 -0.00 /小时 范围 [0.741, 0.758]
⑤ 缓存雪崩检测(埋雷 ③)
检测方法:看 DB 相关指标的【周期性】
周期预期:约 10 分钟(= 缓存 TTL)
DB 请求速率:均值 8420/分 标准差 312
超过 +3σ 的尖峰:5 个
尖峰间隔:['10.0分', '10.2分', '9.8分', '10.1分']
平均间隔:10.0 分钟
❌ 尖峰周期 ≈ 10 分钟,与缓存 TTL(10min) 【吻合】
→ 确认埋雷 ③:固定 TTL 导致集体失效
→ 修复:TTL 加 ±20% 抖动(见 07-step6-optimize.md 优化 4)
关键:只有浸泡能看到埋雷 ③:
短时压测(3.5 分钟)→ 一个 TTL 周期都不到 → 完全看不到脉冲
浸泡 1 小时 → 看到 5 个脉冲,间隔精确 10 分钟 → 铁证 ✅
这就是"浸泡不可省略"的最强论据:
它抓到了一个【其他所有步骤都抓不到】的问题。
三、三种回归验证
#!/usr/bin/env bash
# tools/step7c-regression.sh —— 三种回归
set -euo pipefail
echo "═══ 三种回归验证 ═══"
echo
# ══════════════════════════════════════════════
# ① 功能正确性(优化不能改行为)
# ══════════════════════════════════════════════
echo "① 功能正确性"
echo
echo "── 单元 + 集成测试 ──"
./gradlew test --quiet && echo " ✅ 测试全绿" || { echo " ❌ 测试失败"; exit 1; }
echo
echo "── 端到端冒烟(关键路径)──"
BASE="http://localhost:8080"
# 1) 创建短链
CODE=$(curl -sf -X POST "$BASE/links" \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/regression-check","userId":1}' \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["code"])')
echo " 创建短链:$CODE"
# 2) 跳转(不跟随)
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/$CODE")
LOC=$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE/$CODE")
echo " 跳转状态:$STATUS Location:$LOC"
[ "$STATUS" = "302" ] && [ "$LOC" = "https://example.com/regression-check" ] \
&& echo " ✅ 跳转正确" || { echo " ❌ 跳转错误"; exit 1; }
# 3) 计数(埋雷 ② 改异步后的关键验证点)
# ⚠️ 如果做了"异步批量 UPDATE",这里必须容忍延迟
sleep 2
echo " 计数已累加(异步方案需要等待 2s)"
# 4) 不存在的短链
S404=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/doesnotexist")
[ "$S404" = "404" ] && echo " ✅ 未知短链返回 404" || echo " ❌ 应为 404,实际 $S404"
# 5) 短码冲突重试(业务正常路径)
echo " (短码冲突重试需专门的注入测试,此处略)"
echo
# ══════════════════════════════════════════════
# ② 性能回归(⭐ 最容易忘)
# ══════════════════════════════════════════════
echo "② 性能回归"
echo
echo "── 对照:优化后基线 vs 当前 ──"
python3 - <<'PY'
import json, pathlib, statistics
cur = pathlib.Path("perf/results/step7-soak/summary.json")
base = pathlib.Path("perf/results/step6-optimize/opt-1/summary-B.json")
if not cur.exists() or not base.exists():
print(" ⚠️ 缺少对比数据")
raise SystemExit(0)
c = json.loads(cur.read_text())["metrics"]["http_req_duration"]
b = json.loads(base.read_text())["metrics"]["http_req_duration"]
nf = pathlib.Path("perf/results/step3-baseline/noise-floor.json")
mdd = json.loads(nf.read_text())["mdd_pct"] if nf.exists() else 11.8
checks = [
("P50", b["p(50)"], c["p(50)"]),
("P95", b["p(95)"], c["p(95)"]),
("P99", b["p(99)"], c["p(99)"]),
]
print(f" {'指标':<6s} {'优化后基线':>12s} {'当前':>12s} {'变化':>10s} 判定")
allok = True
for name, bv, cv in checks:
d = (cv - bv) / bv * 100
if d > mdd:
verdict, ok = "❌ 回归", False
allok = False
elif d > mdd / 2:
verdict, ok = "🟡 需观察", True
else:
verdict, ok = "✅ 无回归", True
print(f" {name:<6s} {bv:12.1f} {cv:12.1f} {d:+9.1f}% {verdict}")
print()
print(f" 判据:回归 = 变化超过 MDD(±{mdd:.1f}%)")
if not allok:
print(" ❌ 存在性能回归 —— 必须定位原因后才能交付")
else:
print(" ✅ 无性能回归")
PY
echo
# ══════════════════════════════════════════════
# ③ 长稳回归(发布后持续观察)
# ══════════════════════════════════════════════
echo "③ 长稳回归"
echo
echo "── 灰度发布期间的对比 ──"
cat > /tmp/canary_check.py <<'PY'
import json, pathlib, sys
# 灰度:新旧版本各 1 台,对比同样的流量下关键指标
# 注意:这里读的是【生产监控】的数据,不是压测数据
snap = pathlib.Path("perf/results/step7-soak/canary.json")
if not snap.exists():
print(" (灰度阶段执行:对比新旧版本的 P99 / 错误率 / GC 频率)")
print(" 判据:")
print(" • 新版本 P99 <= 旧版本 × 1.1")
print(" • 新版本错误率 <= 旧版本")
print(" • 新版本 GC 频率 <= 旧版本 × 1.2")
print(" 任一不满足 → 暂停灰度 / 回滚")
sys.exit(0)
d = json.loads(snap.read_text())
ok = True
for k, (new, old, limit) in d.items():
ratio = new / old if old else 9.99
if ratio > limit:
print(f" ❌ {k}: 新 {new} / 旧 {old} = {ratio:.2f} > {limit}")
ok = False
else:
print(f" ✅ {k}: 新 {new} / 旧 {old} = {ratio:.2f} <= {limit}")
sys.exit(0 if ok else 1)
PY
python3 /tmp/canary_check.py
echo
echo "═══════════════════════════════════════════"
echo "三种回归的职责分工:"
echo " ① 功能正确性 —— 优化没有改变行为(必须有)"
echo " ② 性能回归 —— 优化没有引入新的慢(最易忘)"
echo " ③ 长稳回归 —— 上线后持续观察(灰度期)"
echo
echo "缺任何一个都会出事:"
echo " 缺 ① → 功能坏了,性能再好也没用"
echo " 缺 ② → 优化 A 引入了回归 B,上线才发现"
echo " 缺 ③ → 灰度期的问题(内存缓涨)被全量放大"
四、动手改造
| 改动 | 观察什么 |
|---|---|
| 把浸泡从 60m 改成 10m | 完全看不到缓存雪崩脉冲——理解埋雷 ③ 为什么只在浸泡可见 |
| 只看"堆使用量"不看"GC 后基线" | 会把正常的缓存填充误判为泄漏——判据选错会得出相反结论 |
| 把负载从 70% 提到 100% | 所有指标都会抖动,问题被噪声掩盖——浸泡要选"生产高峰"而非"极限" |
| 关掉 TTL 抖动,浸泡 1 小时 | 看到 5 个精确 10 分钟的脉冲——这是埋雷 ③ 的铁证 |
| 只做 ① 功能回归 | 之后会发现 /health 又慢了——性能回归最容易漏 |
| 浸泡时不采 FD/线程数 | 泄漏了也看不出来——采集维度决定能发现什么问题 |
五、这段代码的局限
gc_baseline用"GC 计数增加的下一采样点"近似 GC 后时刻:采样间隔 10s,可能错过真正的谷底——精度有限,只用于"是否单调上升"的粗判。- 缓存雪崩检测用"RPS 累计差分":不是严格的频域分析(应该用 FFT 或自相关)——工程近似。
lsof -p在容器里可能不可用:应该读/proc/<pid>/fd。- 浸泡 1 小时不足以发现"每小时漏 2MB"的慢泄漏:这类问题需要 24h 以上的浸泡(或发到真实预发环境跑几天)。
- 没有做内存 dump 对比:真正的泄漏定位需要
jmap -histo:live两次对比找增长的类型——本脚本只告诉你"有没有",不告诉你"是什么"。 step7c-regression.sh的端到端冒烟很浅:只覆盖了创建/跳转/404,没有覆盖短码冲突、非法 URL、并发写等——生产应该跑完整的契约测试套件。