文档目录

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

这个推算很有价值:它把「疑似泄漏」变成了「预计多久出问题」,从而能判断紧急程度。


六、本节小结

  1. 浸泡测试是唯一能发现缓慢泄漏的测试,因为泄漏需要时间才能显现。
  2. 判定泄漏的方法是看「GC 后基线的趋势」,而不是瞬时值——只看瞬时会被锯齿骗到。
  3. 四类泄漏:内存、连接、线程/协程、文件描述符。Kotlin 的两个常见源是 GlobalScope 和无界缓存。
  4. 至少跑 1 小时,并覆盖一个业务周期与一个缓存过期周期。
  5. 负载用目标值的 60%–80%,保持恒定,不要用满负载。
  6. 用「泄漏速率」推算出「预计多久 OOM」——这是决定紧急程度的依据。

七、自测

  1. 你跑了 1 小时浸泡,内存从 400 MB 涨到 520 MB。能不能直接判定「有内存泄漏」?还需要看什么?
  2. 一个服务每小时内存涨 30 MB,堆上限 4 GB,当前基线 500 MB。粗算多久会 OOM?这个估算有哪些不确定性?
  3. 为什么浸泡测试要用「中高负载」而不是「满负载」?如果用满负载会有什么问题?
  1. 不能直接判定,需要看趋势的形状:① 每次 GC 后的最低点是否在抬升——如果最低点稳定在 400 MB 附近、只是峰值到了 520 MB,那可能是正常的缓存增长或临时对象,不是泄漏;② 如果最低点从 400 涨到 480,那基线抬升就是泄漏的强信号;③ 还需要看其他指标是否同步恶化:连接数、线程数、FD 数是否也在涨;④ 检查期间是否有环境事件(日志轮转、定时任务、备份)造成的正常内存增长;⑤ 最好再跑一次,看是否可复现同样的斜率。一句话:单看内存总量不够,要看「GC 后基线趋势 + 其他资源是否同向 + 是否可复现」。
  2. 总可用空间 = 4096 − 500 = 3596 MB。按 30 MB/小时计算:3596 / 30 ≈ 120 小时(约 5 天)。不确定性:① 泄漏速率可能不是线性的(有些泄漏在特定路径触发才增长);② GC 可能无法把堆回收到「基线」,实际可用空间更小;③ 缓存增长等其他因素也会占用内存,可能加速;④ 堆外内存(直接内存、元空间、线程栈)不在这 4 GB 里,也可能是泄漏源;⑤ 如果服务有重启/发布,实际不会累积到 OOM。所以这个估算的用途是判断紧急程度,不是精确预测。
  3. 因为满负载会让排队效应主导一切:延迟飙升、连接池 pending 上升、GC 频繁——这些都会掩盖缓慢泄漏的信号。你会看到内存波动很大,无法判断基线是否在抬升。而且满负载本身就接近崩溃点,测试很难稳定跑几小时。中高负载(60%–80%)的好处:① 所有代码路径都被执行到(泄漏才会触发);② 系统处于健康区间,指标基线稳定,泄漏造成的抬升清晰可见;③ 能稳定跑很长时间。