调试内存泄漏

在 Scrapy 中,请求(requests)、响应(responses)和项(items)等对象的生命周期是有限的:它们被创建、使用一段时间,最后被销毁。

在所有这些对象中,Request 可能具有最长的生命周期,因为它会一直在调度器(Scheduler)队列中等待,直到需要处理它。更多信息请参阅 架构概述

由于这些 Scrapy 对象具有(相当长的)生命周期,因此始终存在内存中累积它们而未正确释放的风险,从而导致所谓的“内存泄漏”。

为了帮助调试内存泄漏,Scrapy 提供了一个内置的跟踪对象引用的机制,称为 trackref;你还可以使用一个名为 muppy 的第三方库进行更高级的内存调试(更多信息请参阅下文)。这两种机制都必须通过 Telnet 控制台 使用。

内存泄漏的常见原因

Scrapy 开发者经常(有时是偶然,有时是故意)在请求(Requests)中传递引用对象(例如,使用 cb_kwargsmeta 属性或请求回调函数),这实际上会将这些引用对象的生命周期绑定到请求的生命周期。这是迄今为止 Scrapy 项目中最常见的内存泄漏原因,并且对于新手来说调试起来相当困难。

在大型项目中,爬虫通常由不同的人编写,其中一些爬虫可能“泄漏”,从而在它们并发运行时影响其他(编写良好的)爬虫,进而影响整个抓取过程。

泄漏也可能来自你编写的自定义中间件(middleware)、管道(pipeline)或扩展(extension),如果你没有正确释放(之前分配的)资源。例如,如果在 spider_opened 上分配资源但未在 spider_closed 上释放它们,那么如果你运行 每个进程多个爬虫,可能会导致问题。

请求过多?

默认情况下,Scrapy 将请求队列保留在内存中;它包括 Request 对象以及 Request 属性中引用的所有对象(例如在 cb_kwargsmeta 中)。虽然不一定是泄漏,但这会占用大量内存。启用 持久化作业队列 可能有助于控制内存使用。

使用 trackref 调试内存泄漏

trackref 是 Scrapy 提供的一个模块,用于调试最常见的内存泄漏情况。它主要跟踪所有存活的 Request、Response、Item、Spider 和 Selector 对象的引用。

你可以进入 Telnet 控制台,使用 prefs() 函数(它是 print_live_refs() 函数的别名)检查当前有多少上述类的对象处于活动状态。

telnet localhost 6023
>>> prefs()
Live References

ExampleSpider                       1   oldest: 15s ago
HtmlResponse                       10   oldest: 1s ago
Selector                            2   oldest: 0s ago
Request                           878   oldest: 7s ago

如你所见,该报告还显示了每个类中最旧对象的“年龄”。如果你在每个进程中运行多个爬虫,很有可能通过查看最旧的请求或响应来找出哪个爬虫正在泄漏。你可以使用 get_oldest() 函数(从 telnet 控制台)获取每个类中最旧的对象。

哪些对象被跟踪?

trackrefs 跟踪的对象都来自以下类(及其所有子类)

一个真实示例

让我们看一个假设的内存泄漏案例的具体示例。假设我们有一个爬虫,其中包含类似于这样的一行代码

return Request(f"http://www.somenastyspider.com/product.php?pid={product_id}",
               callback=self.parse, cb_kwargs={'referer': response})

那一行代码在请求中传递了一个响应引用,这实际上将响应的生命周期绑定到了请求的生命周期,这肯定会导致内存泄漏。

让我们看看如何使用 trackref 工具来发现原因(当然,在事先不知情的情况下)。

在爬虫运行几分钟后,如果我们注意到其内存使用量大幅增长,我们可以进入其 telnet 控制台并检查活动引用。

>>> prefs()
Live References

SomenastySpider                     1   oldest: 15s ago
HtmlResponse                     3890   oldest: 265s ago
Selector                            2   oldest: 0s ago
Request                          3878   oldest: 250s ago

存在如此多的活动响应(并且它们如此陈旧)无疑是可疑的,因为与请求相比,响应的生命周期应该相对较短。响应的数量与请求的数量相似,因此它们看起来以某种方式绑定在一起。我们现在可以去检查爬虫的代码,以发现导致泄漏的讨厌行(在请求中传递响应引用)。

有时,关于活动对象的额外信息会很有帮助。让我们检查最旧的响应

>>> from scrapy.utils.trackref import get_oldest
>>> r = get_oldest("HtmlResponse")
>>> r.url
'http://www.somenastyspider.com/product.php?pid=123'

如果你想遍历所有对象,而不是获取最旧的对象,可以使用 scrapy.utils.trackref.iter_all() 函数。

>>> from scrapy.utils.trackref import iter_all
>>> [r.url for r in iter_all("HtmlResponse")]
['http://www.somenastyspider.com/product.php?pid=123',
'http://www.somenastyspider.com/product.php?pid=584',
...]

爬虫过多?

如果你的项目有太多爬虫并行执行,prefs() 的输出可能难以阅读。因此,该函数有一个 ignore 参数,可用于忽略特定类(及其所有子类)。例如,这不会显示任何对爬虫的活动引用

>>> from scrapy.spiders import Spider
>>> prefs(ignore=Spider)

scrapy.utils.trackref 模块

以下是 trackref 模块中可用的函数。

class scrapy.utils.trackref.object_ref[源代码]

如果你想使用 trackref 模块跟踪活动实例,请继承此此。

scrapy.utils.trackref.print_live_refs(class_name, ignore=NoneType)[源代码]

打印一份按类名分组的活动引用报告。

参数:

ignore (类型元组) – 如果给定,则指定类(或类元组)中的所有对象将被忽略。

scrapy.utils.trackref.get_oldest(class_name)[源代码]

返回具有给定类名的最旧活动对象,如果未找到则返回 None。请先使用 print_live_refs() 获取按类名分类的所有被跟踪活动对象的列表。

scrapy.utils.trackref.iter_all(class_name)[源代码]

返回一个迭代器,遍历具有给定类名的所有活动对象,如果未找到则返回 None。请先使用 print_live_refs() 获取按类名分类的所有被跟踪活动对象的列表。

使用 muppy 调试内存泄漏

trackref 提供了一种非常方便的机制来追踪内存泄漏,但它只跟踪那些更有可能导致内存泄漏的对象。然而,在其他情况下,内存泄漏可能来自其他(或多或少不明显的)对象。如果这是你的情况,并且你无法使用 trackref 找到泄漏,你还有另一个资源:muppy 库。

你可以使用 Pympler 中的 muppy。

如果你使用 pip,可以使用以下命令安装 muppy

pip install Pympler

以下是使用 muppy 查看堆中所有可用 Python 对象的示例

>>> from pympler import muppy
>>> all_objects = muppy.get_objects()
>>> len(all_objects)
28667
>>> from pympler import summary
>>> suml = summary.summarize(all_objects)
>>> summary.print_(suml)
                               types |   # objects |   total size
==================================== | =========== | ============
                         <class 'str |        9822 |      1.10 MB
                        <class 'dict |        1658 |    856.62 KB
                        <class 'type |         436 |    443.60 KB
                        <class 'code |        2974 |    419.56 KB
          <class '_io.BufferedWriter |           2 |    256.34 KB
                         <class 'set |         420 |    159.88 KB
          <class '_io.BufferedReader |           1 |    128.17 KB
          <class 'wrapper_descriptor |        1130 |     88.28 KB
                       <class 'tuple |        1304 |     86.57 KB
                     <class 'weakref |        1013 |     79.14 KB
  <class 'builtin_function_or_method |         958 |     67.36 KB
           <class 'method_descriptor |         865 |     60.82 KB
                 <class 'abc.ABCMeta |          62 |     59.96 KB
                        <class 'list |         446 |     58.52 KB
                         <class 'int |        1425 |     43.20 KB

有关 muppy 的更多信息,请参阅 muppy 文档

假性内存泄漏

有时,你可能会注意到 Scrapy 进程的内存使用量只会增加,但从不减少。不幸的是,即使 Scrapy 和你的项目都没有内存泄漏,这种情况也可能发生。这是由于 Python 一个(不太为人所知的)问题,在某些情况下它可能不会将释放的内存返回给操作系统。有关此问题的更多信息,请参阅

Evan Jones 提出的改进(详见 这篇论文)已合并到 Python 2.5 中,但这只是减轻了问题,并未完全解决。引用该论文的内容:

不幸的是,此补丁只能在竞技场(arena)中不再分配任何对象时才能释放它。这意味着碎片化是一个大问题。一个应用程序可能拥有数兆字节的可用内存,分散在所有竞技场中,但它无法释放其中任何一部分。这是所有内存分配器都会遇到的问题。解决它的唯一方法是转向紧凑型垃圾收集器,该收集器能够移动内存中的对象。这将需要对 Python 解释器进行重大更改。

为了保持内存消耗在合理范围内,你可以将作业拆分为几个较小的作业,或者启用 持久化作业队列 并不时停止/启动爬虫。