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),需要更长时间。