Scrapy隐藏的内存泄漏

Scrapy hidden memory leak

背景 - TLDR:我的项目有内存泄漏

花了几天时间用scrapy查看内存泄漏文档,没找到问题所在。 我正在开发一个中等规模的 scrapy 项目,每天约 40k 个请求。

我正在使用 scrapinghub 的预定 运行s 来托管这个。

scrapinghub,每月支付 9 美元,您基本上可以获得 1 台具有 1GB RAM 的虚拟机,供 运行 您的爬虫使用。

我已经在本地开发了一个爬虫并上传到scrapinghub,唯一的问题是运行快结束时,我超出了内存。

本地设置 CONCURRENT_REQUESTS=16 工作正常,但会导致 scrapinghub 上的内存超过 50%。当我设置 CONCURRENT_REQUESTS=4 时,我在 95% 点超出了内存,所以减少到 2 应该可以解决问题,但是我的爬虫变得太慢了。

另一种解决方案是支付 2 个 VM 的费用,以增加 RAM,但我感觉我设置爬虫的方式导致了内存泄漏。

对于这个例子,该项目将抓取一个在线零售商。 当 运行 在本地时,我的 memusage/max 是 2.7gb CONCURRENT_REQUESTS=16.

我现在运行通过我的scrapy结构

  1. 获取要抓取的页面总数
  2. 循环浏览所有这些页面使用:www.example.com/page={page_num}
  3. 在每个页面上,收集 48 种产品的信息
  4. 对于这些产品中的每一个,请转到其页面并获取一些信息
  5. 使用该信息,为每个产品直接调用 API
  6. 使用项目管道保存这些(我在本地写入 csv,但不在 scrapinghub 上)
    class Pipeline(object):
        def process_item(self, item, spider):
            item['stock_jsons'] = json.loads(item['stock_jsons'])['subProducts']
            return item
    class mainItem(scrapy.Item):
        date = scrapy.Field()
        url = scrapy.Field()
        active_col_num = scrapy.Field()
        all_col_nums = scrapy.Field()
        old_price = scrapy.Field()
        current_price = scrapy.Field()
        image_urls_full = scrapy.Field()
        stock_jsons = scrapy.Field()
    
    class URLItem(scrapy.Item):
        urls = scrapy.Field()
    class ProductSpider(scrapy.Spider):
        name = 'product'
        def __init__(self, **kwargs):
            page = requests.get('www.example.com', headers=headers)
            self.num_pages = # gets the number of pages to search
    
        def start_requests(self):
            for page in tqdm(range(1, self.num_pages+1)):
                url = 'www.example.com/page={page}'
                yield scrapy.Request(url = url, headers=headers, callback = self.prod_url)

        def prod_url(self, response):
            urls_item = URLItem()
            extracted_urls = response.xpath(####).extract() # Gets URLs to follow
            urls_item['urls'] = [# Get a list of urls]
            for url in urls_item['urls']:
                    yield scrapy.Request(url = url, headers=headers, callback = self.parse)

        def parse(self, response) # Parse the main product page
            item = mainItem()
            item['date'] = DATETIME_VAR
            item['url'] = response.url
            item['active_col_num'] = XXX
            item['all_col_nums'] = XXX
            item['old_price'] = XXX
            item['current_price'] = XXX
            item['image_urls_full'] = XXX

            try:
                new_url = 'www.exampleAPI.com/' + item['active_col_num']
            except TypeError:
                new_url = 'www.exampleAPI.com/{dummy_number}'
        
            yield scrapy.Request(new_url, callback=self.parse_attr, meta={'item': item})


        def parse_attr(self, response):
        ## This calls an API Step 5
            item = response.meta['item']
            item['stock_jsons'] = response.text
            yield item

到目前为止我尝试了什么?

HtmlResponse                       31   oldest: 3s ago
mainItem                            18   oldest: 5s ago
ProductSpider                       1   oldest: 3321s ago
Request                            43   oldest: 105s ago
Selector                           16   oldest: 3s ago

问题

如果需要更多信息,请告诉我

需要更多信息

如果需要 scrapinghub 的输出,请告诉我,我认为它应该是相同的,但是完成原因的消息是内存超出。

1.Log 行开始(从信息:Scrapy xxx 开始到蜘蛛打开)。

2020-09-17 11:54:11 [scrapy.utils.log] INFO: Scrapy 2.3.0 started (bot: PLT)
2020-09-17 11:54:11 [scrapy.utils.log] INFO: Versions: lxml 4.5.2.0, libxml2 2.9.10, cssselect 1.1.0, parsel 1.6.0, w3lib 1.22.0, Twisted 20.3.0, Python 3.7.4 (v3.7.4:e09359112e, Jul  8 2019, 14:54:52) - [Clang 6.0 (clang-600.0.57)], pyOpenSSL 19.1.0 (OpenSSL 1.1.1g  21 Apr 2020), cryptography 3.1, Platform Darwin-18.7.0-x86_64-i386-64bit
2020-09-17 11:54:11 [scrapy.crawler] INFO: Overridden settings:
{'BOT_NAME': 'PLT',
 'CONCURRENT_REQUESTS': 14,
 'CONCURRENT_REQUESTS_PER_DOMAIN': 14,
 'DOWNLOAD_DELAY': 0.05,
 'LOG_LEVEL': 'INFO',
 'NEWSPIDER_MODULE': 'PLT.spiders',
 'SPIDER_MODULES': ['PLT.spiders']}
2020-09-17 11:54:11 [scrapy.extensions.telnet] INFO: Telnet Password: # blocked
2020-09-17 11:54:11 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.corestats.CoreStats',
 'scrapy.extensions.telnet.TelnetConsole',
 'scrapy.extensions.memusage.MemoryUsage',
 'scrapy.extensions.logstats.LogStats']
2020-09-17 11:54:12 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
 'scrapy.downloadermiddlewares.retry.RetryMiddleware',
 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware',
 'scrapy.downloadermiddlewares.stats.DownloaderStats']
2020-09-17 11:54:12 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
 'scrapy.spidermiddlewares.referer.RefererMiddleware',
 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
 'scrapy.spidermiddlewares.depth.DepthMiddleware']
=======
17_Sep_2020_11_54_12
=======
2020-09-17 11:54:12 [scrapy.middleware] INFO: Enabled item pipelines:
['PLT.pipelines.PltPipeline']
2020-09-17 11:54:12 [scrapy.core.engine] INFO: Spider opened

2.Ending 日志行(信息:转储 Scrapy 统计信息结束)。

2020-09-17 11:16:43 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 15842233,
 'downloader/request_count': 42031,
 'downloader/request_method_count/GET': 42031,
 'downloader/response_bytes': 1108804016,
 'downloader/response_count': 42031,
 'downloader/response_status_count/200': 41999,
 'downloader/response_status_count/403': 9,
 'downloader/response_status_count/404': 1,
 'downloader/response_status_count/504': 22,
 'dupefilter/filtered': 110,
 'elapsed_time_seconds': 3325.171148,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2020, 9, 17, 10, 16, 43, 258108),
 'httperror/response_ignored_count': 10,
 'httperror/response_ignored_status_count/403': 9,
 'httperror/response_ignored_status_count/404': 1,
 'item_scraped_count': 20769,
 'log_count/INFO': 75,
 'memusage/max': 2707484672,
 'memusage/startup': 100196352,
 'request_depth_max': 2,
 'response_received_count': 42009,
 'retry/count': 22,
 'retry/reason_count/504 Gateway Time-out': 22,
 'scheduler/dequeued': 42031,
 'scheduler/dequeued/memory': 42031,
 'scheduler/enqueued': 42031,
 'scheduler/enqueued/memory': 42031,
 'start_time': datetime.datetime(2020, 9, 17, 9, 21, 18, 86960)}
2020-09-17 11:16:43 [scrapy.core.engine] INFO: Spider closed (finished)
  1. self.num_pages 变量使用什么值?

我正在抓取的网站有大约 20,000 个产品,每页显示 48 个。所以它去网站,看到20103的产品,然后除以48(然后math.ceil)得到页数。

  1. 更新中间件后添加抓取中心的输出
downloader/request_bytes    2945159
downloader/request_count    16518
downloader/request_method_count/GET 16518
downloader/response_bytes   3366280619
downloader/response_count   16516
downloader/response_status_count/200    16513
downloader/response_status_count/404    3
dupefilter/filtered 7
elapsed_time_seconds    4805.867308
finish_reason   memusage_exceeded
finish_time 1600567332341
httperror/response_ignored_count    3
httperror/response_ignored_status_count/404 3
item_scraped_count  8156
log_count/ERROR 1
log_count/INFO  94
memusage/limit_reached  1
memusage/max    1074937856
memusage/startup    109555712
request_depth_max   2
response_received_count 16516
retry/count 2
retry/reason_count/504 Gateway Time-out 2
scheduler/dequeued  16518
scheduler/dequeued/disk 16518
scheduler/enqueued  17280
scheduler/enqueued/disk 17280
start_time  1600562526474

1.Scheruler queue/Active 请求
self.numpages = 418.

此代码行将创建 418 个请求 objects(包括 -to ask OS 以委托内存持有 418 objects)并将它们放入调度程序 queue :

for page in tqdm(range(1, self.num_pages+1)):
                url = 'www.example.com/page={page}'
                yield scrapy.Request(url = url, headers=headers, callback = self.prod_url)

每个“页面”请求生成 48 个新请求。
每个“产品页面”请求生成 1 个“api_call”请求
每个“api_call”请求 returns 项 object.
由于所有请求具有相同的优先级 - 在最坏的情况下,应用程序将需要内存一次在 RAM 中保存 ~20000 request/response objects。

为了排除这种情况,可以在scrapy.Request中添加priority参数。 并且您可能需要将蜘蛛配置更改为如下内容:

    def start_requests(self):
        yield scrapy.Request(url = 'www.example.com/page=1', headers=headers, callback = self.prod_url)

    def prod_url(self, response):
        #get number of page
        next_page_number = int(response.url.split("/page=")[-1] + 1
        #...
        for url in urls_item['urls']:
                yield scrapy.Request(url = url, headers=headers, callback = self.parse, priority = 1)

        if next_page_number < self.num_pages:
            yield scrapy.Request(url = f"www.example.com/page={str(next_page_number)}"

    def parse(self, response) # Parse the main product page
        #....
        try:
            new_url = 'www.exampleAPI.com/' + item['active_col_num']
        except TypeError:
            new_url = 'www.exampleAPI.com/{dummy_number}'
    
        yield scrapy.Request(new_url, callback=self.parse_attr, meta={'item': item}, priority = 2)

使用此爬虫配置 - 爬虫仅在处理完前一页的产品后才会处理下一页的产品页面,您的应用程序将不会收到 requests/response 的长 queue。

2.Http压缩

很多网站压缩 html 代码以减少流量负载。
例如亚马逊网站使用 gzip 压缩它的产品页面。
亚马逊产品页面压缩后的平均大小 html ~250Kb
未压缩的大小 html 可以超过 ~1.5Mb。

如果您的网站使用压缩并且未压缩的响应大小 html 与亚马逊产品页面的大小相似 - 应用程序将需要花费大量内存来保存压缩和未压缩的响应主体。 填充 downloader/response_bytes 统计参数的 DownloaderStats 中间件不会计算解压缩响应的大小,因为它是 process_response 方法在 HttpCompressionMiddleware 中间件的 process_response 方法之前调用。

为了检查它,您需要通过将此添加到设置来更改下载器统计中间件的优先级:

DOWNLOADER_MIDDLEWARES = {
'scrapy.downloadermiddlewares.stats.DownloaderStats':50
}

在这种情况下: downloader/request_bytes 统计参数 - 将减少,因为它不会计算中间件填充的某些 headers 的大小。
downloader/response_bytes 统计参数 - 如果网站使用压缩,将会大大增加。