如何修复 Django 测试中的内存泄漏?

How do you fix a memory leak within Django tests?

最近我开始在 Django (3.1) 测试中遇到一些问题,我最终查明是某种内存泄漏。 我通常 运行 我的套件(目前大约 4000 个测试) --parallel=4 导致大约 3GB 的高内存水印(从 500MB 左右开始)。 不过,出于审计目的,我偶尔会 运行 它与 --parallel=1 - 当我这样做时,内存使用量不断增加,最终超过 VM 分配的 6GB。

我花了一些时间查看数据,很明显罪魁祸首不知何故是 Webtest - 更具体地说,它的 response.htmlresponse.forms:测试用例期间的每次调用都可能分配一些 MB(通常是两个或三个)在测试方法结束时不会释放,更重要的是,甚至在 TestCase.

结束时也不会释放

我已经尝试了所有我能想到的方法 - gc.collect()gc.DEBUG_LEAK 向我展示了很多可收集的物品,但它根本没有释放内存;在各种 TestCaseTestResponse 属性上使用 delattr() 等导致根本没有变化,等等

我实在是无能为力,所以任何解决这个问题的建议(除了编辑使用 WebTest 响应的数千个测试之外,这实际上是不可行的)将非常感激。

(请注意,我也尝试过使用 guppytracemallocmemory_profiler,但都没有给我任何可操作的信息。)


更新

我发现我们的一个 EC2 测试实例没有受到该问题的影响,因此我花了更多时间试图解决这个问题。 最初,我试图找到“合理”的潜在原因——例如,缓存的模板加载器,它在我的本地 VM 上启用而在 EC2 实例上禁用——但没有成功。 然后我全力以赴:我复制了 EC2 virtualenv(使用 pip freeze)和设置(复制 dotenv),并检查了相同的提交,其中测试在 EC2 上正常 运行ning。

瞧瞧! 内存泄漏仍然存在

现在,我正式放弃,并将使用 --parallel=2 进行未来的测试,直到一些绝对的大师可以为我指明正确的方向。


第二次更新

现在即使 --parallel=2 也存在内存泄漏。我想这在某种程度上更好,因为它看起来越来越像是系统问题而不是应用程序问题。没有解决问题,但至少我知道这不是我的错。


第三次更新

感谢 Tim Boddy 对 this question 的回复,我尝试使用 chap 来弄清楚是什么让内存增长。不幸的是,我无法正确“读取”结果,但看起来某些非 python 库实际上是导致问题的原因。 所以,这就是我在 运行 测试我知道导致泄漏的几分钟后分析核心的结果:

chap> summarize writable
49 ranges take 0x1e0aa000 bytes for use: unknown
1188 ranges take 0x12900000 bytes for use: python arena
1 ranges take 0x4d1c000 bytes for use: libc malloc main arena pages
7 ranges take 0x3021000 bytes for use: stack
139 ranges take 0x476000 bytes for use: used by module
1384 writable ranges use 0x38b5d000 (951,439,360) bytes.
chap> count used
3144197 allocations use 0x14191ac8 (337,189,576) bytes.

有趣的一点是,非泄漏 EC2 实例显示的值与我从 count used 获得的值几乎相同 - 这表明那些“未知”范围是实际的猪。 summarize used 的输出也支持这一点(显示前几行):

Unrecognized allocations have 886033 instances taking 0x8b9ea38(146,401,848) bytes.
   Unrecognized allocations of size 0x130 have 148679 instances taking 0x2b1ac50(45,198,416) bytes.
   Unrecognized allocations of size 0x40 have 312166 instances taking 0x130d980(19,978,624) bytes.
   Unrecognized allocations of size 0xb0 have 73886 instances taking 0xc66ca0(13,003,936) bytes.
   Unrecognized allocations of size 0x8a8 have 3584 instances taking 0x793000(7,942,144) bytes.
   Unrecognized allocations of size 0x30 have 149149 instances taking 0x6d3d70(7,159,152) bytes.
   Unrecognized allocations of size 0x248 have 10137 instances taking 0x5a5508(5,920,008) bytes.
   Unrecognized allocations of size 0x500018 have 1 instances taking 0x500018(5,242,904) bytes.
   Unrecognized allocations of size 0x50 have 44213 instances taking 0x35f890(3,537,040) bytes.
   Unrecognized allocations of size 0x458 have 2969 instances taking 0x326098(3,301,528) bytes.
   Unrecognized allocations of size 0x205968 have 1 instances taking 0x205968(2,120,040) bytes.

那些单实例分配的大小与我在 starting/stopping 测试时在测试 运行 中添加对 resource.getrusage(resource.RUSAGE_SELF).ru_maxrss 的调用时看到的增量非常相似 -但他们不被认为是 Python 分配,因此我的感觉。

首先,深表歉意:我错误地认为 WebTest 是造成这种情况的原因,而原因确实在我自己的代码中,而不是库或其他任何东西。

真正的原因是 mixin class,我不假思索地添加了一个字典作为 class 属性,比如

class MyMixin:
    errors = dict()

由于这个 mixin 以几种形式使用,并且测试产生了相当多的形式错误(添加到 dict 中),这最终占用了内存。

虽然这本身并不是很有趣,但有一些要点可能对未来遇到同类问题的探索者有所帮助。除了我和其他开发人员之外,他们可能对每个人来说都是显而易见的 - 在这种情况下,您好其他开发人员。

  1. 同一个提交在EC2机器和我自己的虚拟机上有不同行为的原因是远程机器中的分支还没有被合并,所以提交导致泄漏的原因并没有毒害环境。 这里的要点是:确保您正在测试的代码是相同的,而不仅仅是提交。
  2. 低级内存分析在某些情况下可能会有所帮助,但这不是半天就能学会的技能:我花了很长时间试图理解分配和对象等等,但没有更接近解决方案.
  3. 这种错误的代价可能非常高 - 如果我少进行几百次测试,我就不会以 OOM 错误告终,而且我可能根本不会注意到这个问题。也就是说,直到它投入生产。 这也可以通过某种 linter/static 分析来解决,如果有一种分析将这种结构标记为潜在有害的话。不幸的是,没有(我能找到)。
  4. git bisect是你的朋友,只要你能找到真正有效的提交。