3.5 浸泡测试:唯一能发现泄漏的测试
上一节:3.4 负载曲线 | 下一节:3.6 破坏性测试 配套代码:03-test-pyramid/05-soak-testing
一句话结论
浸泡测试(Soak Test)是唯一能发现「缓慢泄漏」的测试——内存泄漏、连接泄漏、文件描述符泄漏、缓存无界增长。它也是最少人做、回报最高的一种测试,因为它发现的问题往往是半夜 OOM 的直接原因。
一、用「耐久路试」理解浸泡测试
汽车厂商会让一台车连续跑 10 万公里,穿越各种气候与路况。
为什么不跑 1000 公里就够了?
- 有些问题是随里程累积的:密封圈老化、机油消耗、变速箱磨损。
- 短途试驾永远发现不了这些——因为它们需要时间。
后端完全一样:
| 短时间压测(10 分钟) | 长时间浸泡(数小时) |
|---|---|
| 发现「慢」和「错」 | 发现「缓慢变坏」 |
| 内存看起来稳定 | 内存每小时涨 50 MB → 12 小时后 OOM |
| 连接数正常 | 连接数每小时涨 3 个 → 一天后池子耗尽 |
| 文件描述符正常 | FD 缓慢泄漏 → 一天后 Too many open files |
关键:泄漏类问题不会在 10 分钟内显现。它们的特点是「每天的增量很小,但永不回落」——只有拉长时间轴才能看出来。
二、泄漏的四种典型形态
| 泄漏类型 | 表现 | 常见原因 |
|---|---|---|
| 内存泄漏 | 堆使用持续上升,Full GC 后也不回落 | 静态集合持有对象、监听器未注销、ThreadLocal 未清理、缓存无界 |
| 连接泄漏 | 池中 active 持续上升,idle 下降,total 触顶 |
异常路径未归还连接、事务未关闭、use {} 漏写 |
| 线程/协程泄漏 | 线程数或协程数持续上升 | 未取消的 GlobalScope、未关闭的线程池、阻塞操作堆积 |
| 文件描述符泄漏 | FD 数持续上升 | 未关闭的文件/套接字、日志句柄泄漏 |
Kotlin 特有的两个泄漏源:
// ❌ GlobalScope:请求已结束,协程还在跑
GlobalScope.launch { heavyWork() }
// ❌ 无界缓存:key 无限增长
val cache = mutableMapOf<String, Order>() // 没有淘汰策略
cache[key] = order
三、该盯哪些指标
核心原则:看趋势,不看瞬时值。
| 指标 | 判定 | 危险信号 |
|---|---|---|
| 堆使用(每次 Full GC 后) | 看「基线」是否抬升 | 每次 GC 后的低点越来越高 |
| RSS(进程常驻内存) | 看是否单调上升 | 持续上升且不回落 |
连接池 active / idle / total |
看是否回到基线 | active 持续高位、idle 持续下降 |
| 线程数 / 协程数 | 看是否单调上升 | 持续增长 |
| 文件描述符数 | 看是否单调上升 | 持续增长 |
| GC 频率与停顿 | 看是否逐渐恶化 | 频率上升、停顿变长 |
| P99 延迟 | 看是否逐渐抬升 | 缓慢恶化(泄漏的最终表现) |
| 缓存大小与命中率 | 看是否无界增长 | 大小持续涨、命中率反而下降 |
怎么区分「正常的锯齿」和「泄漏」
✅ 正常:宏观平坦,微观锯齿
内存
↑ ╱╲ ╱╲ ╱╲ ╱╲
╱ ╲╱ ╲╱ ╲╱ ╲
└──────────────────→ 时间
(GC 周期性回收,基线稳定)
❌ 泄漏:宏观单调上升
内存
↑ ╱
╱
╱
╱
└──────────────────→ 时间
(每次回收后的低点也在抬高)
判定方法:把「每次 GC 之后的最低点」连成一条线,看这条线是否上升。只看瞬时值会被锯齿骗到。
四、该跑多久
| 场景 | 最短时长 | 说明 |
|---|---|---|
| 快速排查 | 30 分钟 | 能发现快速泄漏 |
| 常规验证 | 1–2 小时 | 能发现大部分常见泄漏 |
| 上线前长稳 | 4–12 小时 | 覆盖更多周期(定时任务、缓存过期、连接回收) |
| 关键系统 | 24–72 小时 | 覆盖日周期(比如每日对账、日志轮转) |
关键考虑:
- 至少覆盖一个「业务周期」:如果有每小时执行的定时任务,至少要跑 2 小时。
- 至少覆盖一个「缓存过期周期」:如果缓存 TTL 是 30 分钟,至少跑 1 小时。
- 至少覆盖多个 GC 周期:这样基线抬升才能显现。
1 小时是最低要求。 30 分钟的浸泡只能算「加长版负载测试」。
五、浸泡测试的执行要点
❶ 负载水平:中高但不必到拐点
- 不要用满负载——那会变成压力测试,掩盖缓慢泄漏。
- 建议用目标负载的 60%–80%:既能让所有代码路径跑起来,又不会因为排队而干扰观察。
- 保持恒定负载(恒定到达率),不要做阶梯。
❷ 数据要能被绘图
浸泡测试的价值全在趋势,所以必须把指标按时间序列记录下来。最小要求:
时间戳 | QPS | P50 | P99 | 错误率 | 堆使用 | GC次数 | 连接active | 连接idle | 线程数 | FD数
建议间隔:10–60 秒一个点。间隔太长会漏掉细节,太短会产生大量数据。
❸ 记录「环境影响」
长时间测试期间可能会发生:日志轮转、备份任务、系统更新、其他服务抢占资源。这些都要记录,否则你会把环境干扰误判为泄漏。
❹ 结束时做一次「对照」
测试开始时的堆基线(GC 后最低点): 420 MB
测试结束时的堆基线(GC 后最低点): 480 MB
→ 1 小时涨了 60 MB
粗算:如果这个速率持续,24 小时后是 420 + 60×24 = 1860 MB
如果堆上限是 2 GB → 大约 26 小时后 OOM
这个推算很有价值:它把「疑似泄漏」变成了「预计多久出问题」,从而能判断紧急程度。
六、本节小结
- 浸泡测试是唯一能发现缓慢泄漏的测试,因为泄漏需要时间才能显现。
- 判定泄漏的方法是看「GC 后基线的趋势」,而不是瞬时值——只看瞬时会被锯齿骗到。
- 四类泄漏:内存、连接、线程/协程、文件描述符。Kotlin 的两个常见源是
GlobalScope和无界缓存。 - 至少跑 1 小时,并覆盖一个业务周期与一个缓存过期周期。
- 负载用目标值的 60%–80%,保持恒定,不要用满负载。
- 用「泄漏速率」推算出「预计多久 OOM」——这是决定紧急程度的依据。
七、自测
- 你跑了 1 小时浸泡,内存从 400 MB 涨到 520 MB。能不能直接判定「有内存泄漏」?还需要看什么?
- 一个服务每小时内存涨 30 MB,堆上限 4 GB,当前基线 500 MB。粗算多久会 OOM?这个估算有哪些不确定性?
- 为什么浸泡测试要用「中高负载」而不是「满负载」?如果用满负载会有什么问题?
- 不能直接判定,需要看趋势的形状:① 每次 GC 后的最低点是否在抬升——如果最低点稳定在 400 MB 附近、只是峰值到了 520 MB,那可能是正常的缓存增长或临时对象,不是泄漏;② 如果最低点从 400 涨到 480,那基线抬升就是泄漏的强信号;③ 还需要看其他指标是否同步恶化:连接数、线程数、FD 数是否也在涨;④ 检查期间是否有环境事件(日志轮转、定时任务、备份)造成的正常内存增长;⑤ 最好再跑一次,看是否可复现同样的斜率。一句话:单看内存总量不够,要看「GC 后基线趋势 + 其他资源是否同向 + 是否可复现」。
- 总可用空间 = 4096 − 500 = 3596 MB。按 30 MB/小时计算:
3596 / 30 ≈ 120 小时(约 5 天)。不确定性:① 泄漏速率可能不是线性的(有些泄漏在特定路径触发才增长);② GC 可能无法把堆回收到「基线」,实际可用空间更小;③ 缓存增长等其他因素也会占用内存,可能加速;④ 堆外内存(直接内存、元空间、线程栈)不在这 4 GB 里,也可能是泄漏源;⑤ 如果服务有重启/发布,实际不会累积到 OOM。所以这个估算的用途是判断紧急程度,不是精确预测。 - 因为满负载会让排队效应主导一切:延迟飙升、连接池 pending 上升、GC 频繁——这些都会掩盖缓慢泄漏的信号。你会看到内存波动很大,无法判断基线是否在抬升。而且满负载本身就接近崩溃点,测试很难稳定跑几小时。中高负载(60%–80%)的好处:① 所有代码路径都被执行到(泄漏才会触发);② 系统处于健康区间,指标基线稳定,泄漏造成的抬升清晰可见;③ 能稳定跑很长时间。