文档目录

3.5 配套代码:浸泡测试与泄漏判定

对应小节:3.5 浸泡测试 两份东西:① 浸泡压测 + 指标采样脚本;② 判定「是泄漏还是正常锯齿」的分析脚本。

一、浸泡测试脚本

// loadtest/soak.js
import http from 'k6/http';
import { check } from 'k6';

/**
 * 浸泡测试:恒定中高负载长时间运行。
 *
 * 三条关键设计:
 *   1. 负载用目标容量的 60%~80%,不要用满负载(否则排队会掩盖泄漏信号)
 *   2. 恒定到达率,不要做阶梯
 *   3. 时长至少 1 小时;覆盖一个业务周期 + 一个缓存过期周期
 */
export const options = {
  scenarios: {
    soak: {
      executor: 'constant-arrival-rate',
      rate: Number(__ENV.RATE || 400),      // = 目标容量的约 70%
      timeUnit: '1s',
      duration: __ENV.DURATION || '1h',
      preAllocatedVUs: 500,
      maxVUs: 3000,
    },
  },
  discardResponseBodies: true,
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
};

function zipf(max) {
  return Math.max(1, Math.floor(max * Math.pow(Math.random(), 3)) + 1);
}

export default function () {
  const res = http.get(`${__ENV.BASE_URL}/orders/${zipf(1_000_000)}`, {
    tags: { name: 'GET /orders/{id}' },
  });
  check(res, { ok: (r) => r.status === 200 });
}
#!/usr/bin/env bash
# tools/run-soak.sh <EXP_ID> [duration]
set -euo pipefail

EXP_ID="${1:?usage: run-soak.sh <EXP_ID> [duration]}"
DURATION="${2:-1h}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results"

# ① 环境元数据(第 1 章 1.7 节)
tools/collect-env.sh "$EXP_ID"

# ② 指标采样:每 30 秒记录一次资源与延迟(浸泡的价值全在趋势)
echo "开始采样资源指标(每 30 秒一个点)..."
(
  echo "timestamp,heap_used_mb,heap_max_mb,gc_count,gc_time_ms,pool_active,pool_idle,pool_pending,pool_total,threads,fd_count,p99_ms"
  while true; do
    TS=$(date +%s)
    M=$(curl -s --max-time 5 http://127.0.0.1:8080/metrics 2>/dev/null || echo "")
    HEAP_USED=$(echo "$M" | awk '/jvm_memory_used_bytes.*heap/{printf "%.0f", $2/1048576; exit}')
    HEAP_MAX=$(echo "$M"  | awk '/jvm_memory_max_bytes.*heap/{printf "%.0f", $2/1048576; exit}')
    GC_COUNT=$(echo "$M" | awk '/jvm_gc_pause_seconds_count/{s+=$2} END{printf "%.0f", s}')
    GC_TIME=$(echo "$M"  | awk '/jvm_gc_pause_seconds_sum/{s+=$2} END{printf "%.0f", s*1000}')
    P_ACT=$(echo "$M" | awk '/db_pool_active/{printf "%.0f", $2; exit}')
    P_IDLE=$(echo "$M" | awk '/db_pool_idle/{printf "%.0f", $2; exit}')
    P_PEND=$(echo "$M" | awk '/db_pool_pending/{printf "%.0f", $2; exit}')
    P_TOT=$(echo "$M" | awk '/db_pool_total/{printf "%.0f", $2; exit}')
    THREADS=$(echo "$M" | awk '/jvm_threads_live_threads/{printf "%.0f", $2; exit}')
    FD=$(ls /proc/$(jcmd 2>/dev/null | grep app.jar | awk '{print $1}')/fd 2>/dev/null | wc -l | tr -d ' ')
    P99=$(echo "$M" | awk '/app_request_duration_seconds/{v=$2} END{printf "%.0f", v*1000}')
    echo "$TS,$HEAP_USED,$HEAP_MAX,$GC_COUNT,$GC_TIME,$P_ACT,$P_IDLE,$P_PEND,$P_TOT,$THREADS,$FD,$P99"
    sleep 30
  done
) > "$DIR/results/soak-metrics.csv" &
MONITOR_PID=$!

# ③ 压测
BASE_URL=http://127.0.0.1:8080 \
k6 run --out json="$DIR/results/k6-soak-raw.json" \
       --summary-export="$DIR/results/k6-soak-summary.json" \
       loadtest/soak.js

kill "$MONITOR_PID" 2>/dev/null || true
echo "✅ 浸泡完成 → $DIR/results/soak-metrics.csv"

FD 那一行的 /proc/<pid>/fd 只适用于 Linux。macOS 上可以改用 lsof -p <pid> | wc -l。

二、泄漏判定:区分「正常锯齿」与「真泄漏」

关键:不要看瞬时值,要看每次 GC 之后的最低点是否抬升。

# tools/analyze_soak.py <soak-metrics.csv>
"""
判定浸泡测试中是否存在泄漏。

核心方法:
  1. 对内存:取「滚动窗口内的最小值」作为 GC 后基线,看它是否上升
  2. 对其他资源(连接、线程、FD):直接看是否单调上升
  3. 用线性回归算斜率,并外推「多久会 OOM」
"""
import csv, sys
from statistics import mean

def load(path):
    with open(path) as f:
        return list(csv.DictReader(f))

def rolling_min(values, window=10):
    """滚动最小值:近似每次 GC 之后的基线"""
    out = []
    for i in range(len(values)):
        lo = max(0, i - window + 1)
        out.append(min(values[lo:i + 1]))
    return out

def slope(values):
    """最小二乘斜率(单位/采样点)"""
    n = len(values)
    if n < 2:
        return 0.0
    xs = list(range(n))
    mx, my = mean(xs), mean(values)
    num = sum((x - mx) * (y - my) for x, y in zip(xs, values))
    den = sum((x - mx) ** 2 for x in xs)
    return num / den if den else 0.0

def main(path):
    rows = load(path)
    if len(rows) < 10:
        print("❌ 采样点太少,无法判断趋势(至少需要 10 个点)")
        return

    n = len(rows)
    hours = n * 30 / 3600          # 每 30 秒一个点
    print(f"样本点 {n} 个,约 {hours:.2f} 小时\n")

    # ── ① 内存:看 GC 后基线(滚动最小值) ────────────────────
    heap = [float(r["heap_used_mb"] or 0) for r in rows]
    baseline = rolling_min(heap, window=10)
    first_q = mean(baseline[:max(1, n // 4)])
    last_q = mean(baseline[-max(1, n // 4):])
    heap_slope = slope(baseline) * n / max(hours, 1e-9)     # MB/小时

    print("内存")
    print(f"  前 1/4 时段的 GC 后基线 : {first_q:8.1f} MB")
    print(f"  后 1/4 时段的 GC 后基线 : {last_q:8.1f} MB")
    print(f"  变化                    : {last_q - first_q:+8.1f} MB")
    print(f"  斜率                    : {heap_slope:+8.1f} MB/小时")

    heap_max = max(float(r["heap_max_mb"] or 0) for r in rows)
    if heap_slope > 5 and (last_q - first_q) > 20:
        remaining = heap_max - last_q
        eta = remaining / heap_slope if heap_slope > 0 else float("inf")
        print(f"  ❌ 疑似内存泄漏:基线持续抬升")
        print(f"     按当前速率,约 {eta:.1f} 小时后触顶(上限 {heap_max:.0f} MB)")
    else:
        print("  ✅ 内存基线稳定,未见泄漏迹象")

    # ── ② 其他资源:看是否单调上升 ────────────────────────────
    print("\n其他资源(首尾对比)")
    for col, label, unit in [
        ("pool_active", "连接池 active", ""),
        ("pool_pending", "连接池 pending", ""),
        ("threads", "线程数", ""),
        ("fd_count", "文件描述符", ""),
        ("gc_count", "GC 次数(累计)", ""),
    ]:
        vals = [float(r[col] or 0) for r in rows]
        head = mean(vals[:max(1, n // 5)])
        tail = mean(vals[-max(1, n // 5):])
        s = slope(vals) * (3600 / 30)          # 每小时变化
        flag = "❌ 持续上升" if s > 0.5 and tail > head * 1.2 else "✅"
        print(f"  {label:<18} 首 {head:8.1f} → 尾 {tail:8.1f}   斜率 {s:+7.2f}/小时  {flag}")

    # ── ③ 延迟趋势 ────────────────────────────────────────────
    print("\n延迟趋势(缓慢恶化往往是泄漏的最终表现)")
    p99 = [float(r["p99_ms"] or 0) for r in rows]
    p99 = [v for v in p99 if v > 0]
    if p99:
        head = mean(p99[:max(1, len(p99) // 5)])
        tail = mean(p99[-max(1, len(p99) // 5):])
        print(f"  P99 首 {head:8.1f} ms → 尾 {tail:8.1f} ms")
        if tail > head * 1.5:
            print("  ⚠️  P99 明显恶化,可能与其他指标同源(泄漏或缓存无界增长)")
        else:
            print("  ✅ P99 稳定")

    print("\n判读要点:")
    print("  1. 内存要看【GC 后基线】的趋势,不要看瞬时值(锯齿会骗人)")
    print("  2. 连接池 pending 长期 > 0 说明一直在排队(不一定是泄漏,但一定是问题)")
    print("  3. 任何指标『首尾差异 > 20% 且斜率为正』都值得追查")

if __name__ == "__main__":
    main(sys.argv[1])

三、预期输出

样本点 120 个,约 1.00 小时

内存
  前 1/4 时段的 GC 后基线 :    420.3 MB
  后 1/4 时段的 GC 后基线 :    486.1 MB
  变化                    :    +65.8 MB
  斜率                    :    +61.2 MB/小时
  ❌ 疑似内存泄漏:基线持续抬升
     按当前速率,约 26.4 小时后触顶(上限 2048 MB)

其他资源(首尾对比)
  连接池 active      首      8.0 → 尾      9.0   斜率   +0.83/小时  ✅
  连接池 pending     首      0.0 → 尾      0.0   斜率   +0.00/小时  ✅
  线程数             首     42.0 → 尾     43.0   斜率   +0.87/小时  ✅
  文件描述符         首    312.0 → 尾    314.0   斜率   +1.75/小时  ✅
  GC 次数(累计)     首   1204.0 → 尾   4892.0   斜率 +3688.00/小时  ✅

延迟趋势(缓慢恶化往往是泄漏的最终表现)
  P99 首     42.1 ms → 尾     48.9 ms
  ✅ P99 稳定

这份报告的结论:内存基线每小时抬升 61 MB(疑似泄漏),其他资源正常,延迟尚未受影响。

注意「延迟尚未受影响」这一句:说明这个泄漏还没有到影响用户的阶段——但按当前速率 26 小时后会 OOM。这就把「疑似问题」变成了可排期的修复任务,而不是半夜的紧急事故。

四、发现泄漏后怎么定位

# ① 堆直方图:看哪类对象最多
jcmd <pid> GC.class_histogram | head -30

# ② 堆快照:用 MAT / VisualVM 分析引用链
jcmd <pid> GC.heap_dump docs/experiments/E0x/results/leak.hprof

# ③ 分配火焰图:看谁在持续分配(这些分配为什么没被回收)
asprof -d 120 -e alloc -f docs/experiments/E0x/results/alloc-leak.html <pid>

# ④ 如果是连接泄漏,看连接池与数据库侧
curl -s localhost:8080/metrics | grep db_pool
psql -c "select count(*), state from pg_stat_activity group by state"

Kotlin 里最常见的三个泄漏源:

// ① GlobalScope:请求结束后协程还在跑,持有引用
GlobalScope.launch { ... }                  // ❌
coroutineScope { launch { ... } }           // ✅

// ② 无界缓存:key 无限增长
val cache = mutableMapOf<String, Order>()   // ❌
val cache = Caffeine.newBuilder().maximumSize(100_000).build<String, Order>()   // ✅

// ③ 监听器/回调未注销:被长期持有的注册表引用
eventBus.register(listener)                 // ❌ 没有对应的 unregister

五、动手改造

改动 观察什么
故意引入一个泄漏(无界缓存)再跑浸泡 分析脚本能否检出?速率是否可预测?
把采样间隔从 30 秒改成 5 分钟 泄漏信号会变得模糊——采样太稀疏会漏掉趋势
把负载从 70% 提到 100% 到处都是抖动,判断泄漏变得困难(这就是「不要用满负载」的原因)
把 rolling_min 的窗口从 10 改成 3 基线会更贴近瞬时值,容易被锯齿干扰
跑 2 小时而不是 1 小时 斜率估算更准,OOM 外推更可信

六、这段代码的局限

  • heap_used_mb 来自 /metrics,采样间隔 30 秒,会漏掉两次采样之间的 GC 细节。要精确定位请用 GC 日志(第 2 章 2.5 节)。
  • 线性外推假设泄漏速率恒定,实际可能不是线性的(某些路径触发才增长)。
  • 堆外内存(直接内存、元空间、线程栈)不在这份分析里,如果堆稳定但 RSS 持续上升,要另外排查堆外。
  • 1 小时的浸泡只是最低要求。要发现慢速泄漏(例如每小时 5 MB),需要更长时间。