解决 Scrapy 并发量高时 DNS 超时的问题

解决 Scrapy 并发量高时 DNS 超时的问题

当 Scrapy 对不同域名抓取的时候,如果并发量太高,经常会有 DNS 超时的问题。当然,这个是同类型爬虫都有的问题,本文只是在 Scrapy 框架的基础上描述下解决这个问题的过程。

分析 Scrapy DNS 解析过程

当我把 CONCURRENT_REQUESTS 调到 600 的时候,爬虫便出现大量的 DNS 超时错误。Scrapy 虽说总体上是以异步的方式实现的,但是在进行 DNS 解析的时候,使用的是多线程。

我们一般使用类似下面的命令运行爬虫

$ scrapy crawl spider_name

这其实都会用到 cmdline.py 中的 execute 方法

# scrapy/cmdline.py

def execute(argv=None, settings=None):
    # ...
    cmd.crawler_process = CrawlerProcess(settings)
    _run_print_help(parser, _run_command, cmd, args, opts)
    sys.exit(cmd.exitcode)

看下 CrawlerProcess 类

# scrapy/crawler.py

class CrawlerProcess(CrawlerRunner):

    def start(self, stop_after_crawl=True):
        # ...
        reactor.installResolver(self._get_dns_resolver())
        tp = reactor.getThreadPool()
        tp.adjustPoolsize(maxthreads=self.settings.getint('REACTOR_THREADPOOL_MAXSIZE'))
        reactor.addSystemEventTrigger('before', 'shutdown', self.stop)
        reactor.run(installSignalHandlers=False)  # blocking call

    def _get_dns_resolver(self):
        if self.settings.getbool('DNSCACHE_ENABLED'):
            cache_size = self.settings.getint('DNSCACHE_SIZE')
        else:
            cache_size = 0
        return CachingThreadedResolver(
            reactor=reactor,
            cache_size=cache_size,
            timeout=self.settings.getfloat('DNS_TIMEOUT')
        )

start 方法中初始化了 DNS 解析器,并根据 REACTOR_THREADPOOL_MAXSIZE 线程池数量配置开启了线程池,但是还没有使用。

_get_dns_resolver 方法中我们得知 Scrapy 使用的 DNS 解析器是 CachingThreadedResolver,看下代码

# scrapy/resolver.py

dnscache = LocalCache(10000)

class CachingThreadedResolver(ThreadedResolver):
    # ...
    def getHostByName(self, name, timeout=None):
        if name in dnscache:
            return defer.succeed(dnscache[name])
        timeout = (self.timeout,)
        d = super(CachingThreadedResolver, self).getHostByName(name, timeout)
        if dnscache.limit:
            d.addCallback(self._cache_result, name)
        return d

其实 CachingThreadedResolver 基本上就是添加了一个域名缓存功能。你看 getHostByName,查找时如果域名之前有缓存直接返回,没有就调用父类的方法进行查找,后面查找到了话又继续缓存。如我们爬取的域名数量不多,只是各个域名下的 URL 多的话,这个功能就很实用。但是,我们爬取的都是一大批不同的域名(首页),这个时候这个缓存用处就不大了。

当然,缓存的大小、超时时间等都是可以配置的,具体有哪些可以看下 _get_dns_resolver 方法,这里就不赘述了。

我们看下父类,也就是 Twisted 框架下的解析器

# twisted/internet/base.py

@implementer(IResolverSimple)
class ThreadedResolver(object):
    # ...
    def getHostByName(self, name, timeout = (1, 3, 11, 45)):
        if timeout:
            timeoutDelay = sum(timeout)
        else:
            timeoutDelay = 60
        userDeferred = defer.Deferred()
        lookupDeferred = threads.deferToThreadPool(
            self.reactor, self.reactor.getThreadPool(),
            socket.gethostbyname, name)
        cancelCall = self.reactor.callLater(
            timeoutDelay, self._cleanup, name, lookupDeferred)
        self._runningQueries[lookupDeferred] = (userDeferred, cancelCall)
        lookupDeferred.addBoth(self._checkTimeout, name, lookupDeferred)
        return userDeferred

由于 Scrapy 是基于 Twisted 异步框架实现的,所以里面有很多 defer 类的操作,有兴趣的可以了解下 Twisted 的简单使用,熟悉了这个也更容易理解 Scrapy 的源码。当然,简单的看下代码,也能大概知道解析的过程,也就是获取之前的线程池,去调用 socket.gethostbyname 方法。

PS:这里我们的目的是为了了解 Scrapy 的 DNS 是怎么解析的,这里我们已经大概清楚了,不用纠结 defer 啥的怎么理解。如果你想比较透彻的理解 Scrapy,就另当别论了。

因此,DNS 为什么超时也就好理解了。同一时间,大量请求涌入,由于 DNS 解析线程只有 10 个,就导致很多请求排队,也就容易超时了。线程数量虽说可以调,但是相对同一时间的请求量来说,还是少了不少。线程调多了,你还得看下 DNS 服务器响应如何,这又涉及本地 DNS 服务器的优化了,本文并不从这个方向考虑解决方案,也就不多说了。

DNS 的目的是啥,不就是根据域名找 IP 吗,那我就直接给它,于是便想到了缓存。所以,前提是你有了域名和对应 IP 的数据,本文的解决方案才可用。至于怎么得到,或者是从某个 DNS 拉取,或者是自己维护这么一批数据,结合你的实际情况进行选择。

PS:为了方便,后面「域名和对应 IP 的数据」我就叫作「DNS 数据」吧

但是,我们有了 DNS 数据怎么办?最直接的想法就是使用 Redis 缓存起来啊,嗯...我们算下,假设一个域名、IP 对的大小是 100 个字节,那么 100W 条就需要大约 100M 内存,1000W 就要 1G...嗯,我当前机器是 2 核 4G 的,还是先想想其它方法。

那我把它当 hosts 文件用呢,当域名数量过大时,我觉得查询速度还是会慢,而且还受到 Scrapy 线程的限制,我觉得问题还在。当然,我没有试过,你可以去试试水。

想来想去,最终还是觉得把 DNS 数据放 Redis 比较合适。不过,放入的数量其实是可以不用全量的。因为每次下载器的数量基本都是小于等于 CONCURRENT_REQUESTS,只有处于下载器的请求才会使用到 DNS,所以,我想,能不能在请求进入下载器之前缓存一下对应域名的 IP,然后离开下载器的时候就把缓存干掉。了解 Scrapy 框架的伙伴估计心里有谱了,这不就是下载器中间件的作用吗。是的,本文采用的方案就是基于这个思路。

因此,我们现在就有 2 个任务要做:一是定制 DNS 解析器,二是编写下载器中间件。

便于描述,假设项目目录如下

projects
├── project
│   ├── downloadermiddlewares
│   │   └── ...
│   ├── spiders
│   │   ├── ...
│   │   └── demo_spider.py
│   ├── ...
│   └── settings.py
├── ...
└── scrapy.cfg

定制 DNS 解析器

从文章开头我们知道,如果要定制 DNS 解析器,那么我们得覆盖 CrawlerProcess 中的 _get_dns_resolver 方法,返回我们自己的解析器。

但如果还是用类似的命令启动爬虫

$ scrapy crawl spider_name

使用的还是 CrawlerProcess 类,所以我们得写自己的启动脚本,在 projects 目录下新建 start_demo_spider.py

# coding=utf-8

from scrapy.crawler import CrawlerProcess
from scrapy.utils.project import get_project_settings
from twisted.internet import reactor

from domain_project.resolver import RedisResolver

class XCrawlerProcess(CrawlerProcess):

    def _get_dns_resolver(self):
        return RedisResolver(
            reactor=reactor,
            timeout=self.settings.getfloat('DNS_TIMEOUT')
        )

settings = get_project_settings()
process = XCrawlerProcess(settings)

process.crawl('demo_spider')
process.start()

我们新建了个进程类 XCrawlerProcess,继承自 CrawlerProcess,覆盖了 _get_dns_resolver 方法,返回了我们新建的解析器 RedisResolver 实例,并从新进程类中启动脚本。

看下 domain_project/resolver.py 中的 RedisResolver

# coding=utf-8

import redis
from scrapy.utils.project import get_project_settings
from twisted.internet import defer
from twisted.internet.base import ThreadedResolver

class RedisResolver(ThreadedResolver):

    redis_key_dns = 'domains_spider:dns'

    project_settings = get_project_settings()
    if not project_settings.get('REDIS_URL_DNS'):
        raise RuntimeError('Please set REDIS_URL_DNS first.')
    redis_client = redis.StrictRedis.from_url(project_settings['REDIS_URL_DNS'])

    def __init__(self, reactor, timeout):
        super(RedisResolver, self).__init__(reactor)
        self.timeout = timeout

    def getHostByName(self, name, timeout=None):
        ip = self.redis_client.hget(self.redis_key_dns, name)
        if ip:
            return defer.succeed(ip.decode('utf-8'))

        timeout = (self.timeout, )
        d = super(RedisResolver, self).getHostByName(name, timeout)
        return d

这个类模仿的 Scrapy 的 CachingThreadedResolver,继承自 Twisted 的 ThreadedResolver。我们主要看下 getHostByName,直接从 Redis 缓存里面读取域名的 IP,如果有就返回 IP,没有的话,继续走父类的逻辑。所以,这里如果有少量的域名没有 IP 的话,只要分布均匀,爬取应该没有多大问题。

对于缓存,其实你使用 Python 的字典代替 Redis 的哈希表也是可以的。这里为了方便查看缓存的情况,所以选择了 Redis。

编写下载器中间件

在生成请求的时候,我已经把域名的 IP 放到了请求的 meta 属性中。缓存中间件的逻辑很简单,请求进入时取出 meta 属性中的 IP 以域名为 Key 进行缓存,返回响应时,删除域名对应的 Key。不过,不要忘了发生异常的时候,也要把域名对应的 Key 删掉。

在 downloadermiddlewares 下面新建 cachedomainip.py

# coding=utf-8

from urllib.parse import urlparse

import redis
from scrapy.utils.project import get_project_settings

from domain_project.resolver import RedisResolver

class CacheDomainIpMiddleware:

    project_settings = get_project_settings()
    if not project_settings.get('REDIS_URL_DNS'):
        raise RuntimeError('Please set REDIS_URL_DNS first.')
    redis_client = redis.StrictRedis.from_url(project_settings['REDIS_URL_DNS'])

    def process_request(self, request, spider):
        if request.meta.get('ip'):
            domain = urlparse(request.url).netloc
            self.redis_client.hset(RedisResolver.redis_key_dns, domain, request.meta['ip'])

    def process_response(self, request, response, spider):
        domain = urlparse(request.url).netloc
        self.redis_client.hdel(RedisResolver.redis_key_dns, domain)
        return response

    def process_exception(self, request, exception, spider):
        domain = urlparse(request.url).netloc
        self.redis_client.hdel(RedisResolver.redis_key_dns, domain)

这样,缓存中间件就编写好了。

不过,这样做有个问题:如果允许重定向的话,可能会导致域名、IP 对应出错。比如,A 请求(域名是 a.com,IP 是 1.2.3.4)重定向后发起 B 请求(域名可能变成 b.com,IP 是 1.2.3.4)
这样缓存的域名和 IP 就会不一致。
解决方法有

1、禁用重定向

2、重写重定向中间件,处理响应时如果请求域名和响应域名不一致,删掉请求中的 meta 属性中的 IP

爬虫由于是爬取各域名的首页,不确定到底是 HTTP 还是 HTTPS,所以请求的 URL 直接是 http://domain。如果是 HTTPS 的话,就需要靠重定向来抓取(网站如果支持 HTTPS 的话,如果你访问 HTTP 一般会将你重定向到前者),所以第 1 种方案不适合当前场景。

第 2 种方案,实现起来其实也不难。我们只要继承 Scrapy 的 RedirectMiddleware,然后覆盖 process_response,在之前的代码基础上,插入一小段需要的逻辑就好。

在 downloadermiddlewares 下新建 redirect.py

# coding=utf-8

from urllib.parse import urlparse

from scrapy.downloadermiddlewares.redirect import RedirectMiddleware as BRedirectMiddleware
from six.moves.urllib.parse import urljoin
from w3lib.url import safe_url_string

class RedirectMiddleware(BRedirectMiddleware):

    def process_response(self, request, response, spider):
        if (request.meta.get('dont_redirect', False) or
                response.status in getattr(spider, 'handle_httpstatus_list', []) or
                response.status in request.meta.get('handle_httpstatus_list', []) or
                request.meta.get('handle_httpstatus_all', False)):
            return response

        allowed_status = (301, 302, 303, 307, 308)
        if 'Location' not in response.headers or response.status not in allowed_status:
            return response

        # 这段逻辑用于处理域名、IP 的缓存问题
        request_domain = urlparse(request.url).netloc
        response_domain = urlparse(response.url).netloc
        if request_domain != response_domain and request.meta.get('ip'):
            del request.meta['ip']

        location = safe_url_string(response.headers['location'])

        redirected_url = urljoin(request.url, location)

        if response.status in (301, 307, 308) or request.method == 'HEAD':
            redirected = request.replace(url=redirected_url)
            return self._redirect(redirected, request, spider, response.status)

        redirected = self._redirect_request_using_get(request, redirected_url)
        return self._redirect(redirected, request, spider, response.status)

现在就到了配置中间件的步骤了,注意 2 个点

1、我们需要关闭原有的重定向中间件,然后使用新的重定向中间件,优先级保持不变比较好

2、因为不完全清楚其它中间件的作用,我们的下载中间件应该越靠近下载器越好(也就是优先级越高越好),这样能保证进入下载器之前能够进行缓存,在离开时能够删掉缓存

PS:关于下载器中间件执行的优先级可以参考官方文档的 EXTENDING SCRAPY -> Downloader Middleware -> Activating a downloader middleware

看下下载器中间件的默认配置

# scrapy/settings/default_settings.py

DOWNLOADER_MIDDLEWARES_BASE = {
    # Engine side
    'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100,
    'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300,
    'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350,
    'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 400,
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500,
    'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550,
    'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560,
    'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580,
    'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590,
    'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600,
    'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700,
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
    'scrapy.downloadermiddlewares.stats.DownloaderStats': 850,
    'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,
    # Downloader side
}

关闭 Scrapy 的 RedirectMiddleware,打开新的 RedirectMiddleware 并保持优先级,然后将 CacheDomainIpMiddleware 的优先级调至最高。编辑 domain_project/settings.py 修改 DOWNLOADER_MIDDLEWARES 配置

DOWNLOADER_MIDDLEWARES = {
    'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': None,
    'domain_crawl.downloadermiddlewares.redirect.RedirectMiddleware': 600,
    'domain_crawl.downloadermiddlewares.cachedomainip.CacheDomainIpMiddleware': 950,
}

至此,解决方案基本完成。

爬虫如何启动?执行 projects 目录下的 start_domain_spider.py 就可以了。

另外注意下,我之前在开发机(macOS)上将 CONCURRENT_REQUESTS 设得太高的时候,出现过类似下面这样的错误

filedescriptor out of range in select()

Twisted 在 macOS 上默认使用 SelectReactor,我们修改为 PollReactor 一般就能解决。在 start_domain_spider.py 文件开头添加下面几行代码就行

# https://github.com/scrapy/scrapy/issues/2905#issuecomment-444525799
from twisted.internet import pollreactor
pollreactor.install()

小结

本文简单分析了下 Scrapy 进行 DNS 解析的过程,并结合实际情况给了一个解决方案。不过,这个方案有一定的特殊性。因为爬虫的目的就是抓取各种域名的首页,所以生成 Scrapy 请求的数据源就是 DNS 数据,这样比较容易的就把域名和 IP 联系起来了,然后将这两者直接存在请求中就可以了。如果你的场景不同,这个方案可能不大适用。不过,Scrapy 进行 DNS 解析的过程,可以作为你思考的基础。

参考

Comments are closed.