解决 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 解析的过程,可以作为你思考的基础。