bigywater.tech
爬虫工程 · 12

DNS 解析:缓存、阻塞线程与每次请求多花多少时间

爬虫碰到的 DNS 问题往往不是“解析失败”,而是“慢”和“被重复做”。这篇在本机用一个包装过的 getaddrinfo 看清:requests 和 aiohttp 各自什么时候解析、缓存多久、并发解析会被什么限制,以及 localhost 为什么比 127.0.0.1 慢一点。

20 对 1
20 个请求的 getaddrinfo 调用次数:每次新建连接对复用同一个 Session 的连接
14 / 100
100 个不同主机名同时解析时,默认线程池的并发峰值对换成 100 线程池后的峰值
0.21 ms
localhost 比 127.0.0.1 每次新连接多花的时间

requests:DNS 跟着新连接走

实测 · 本机执行输出
  20 次 requests.get(每次新连接):getaddrinfo 调用 20 次,总耗时 2091 ms
  一个 Session 发 20 次(复用 1 条连接):getaddrinfo 调用 1 次,总耗时 114 ms
  一个 Session 发 20 次,每次 Connection: close:getaddrinfo 调用 20 次,总耗时 2094 ms

urllib3 每建一条新连接就调用一次 getaddrinfo,自己不缓存(本机操作系统可能有缓存,这里看不到)。所以 Session 的连接复用同时也省掉了 DNS;而每次 Connection: close 或每次 requests.get,都会重新解析。上面的 100 ms 延迟是我模拟的,真实的解析耗时取决于你的 DNS 服务器。

aiohttp:自带缓存

实测 · 本机执行输出
  默认值:use_dns_cache=True ttl_dns_cache=10
  默认(缓存开):20 个新连接,getaddrinfo 调用 1 次,请求本身累计耗时(不含主动等待)约 120 ms
  use_dns_cache=False:20 个新连接,getaddrinfo 调用 20 次,请求本身累计耗时(不含主动等待)约 2099 ms
  ttl_dns_cache=1,每次请求后等 0.4 秒:10 个新连接,getaddrinfo 调用 4 次,请求本身累计耗时(不含主动等待)约 433 ms

TCPConnector 默认缓存解析结果 10 秒(use_dns_cache=True,ttl_dns_cache=10)。同一个主机的 20 个新连接只解析了一次;关掉缓存就是 20 次;把有效期调成 1 秒,10 个相隔 0.4 秒的连接解析了 4 次。

解析被线程池卡住

实测 · 本机执行输出
  本机 os.cpu_count() = 10,asyncio 默认线程池大小的文档公式 min(32, cpu+4) = 14
  默认线程池:同时在解析的数量峰值 14,100 个请求全部完成 1625 ms
  换成 100 个线程的线程池:同时在解析的数量峰值 100,100 个请求全部完成 232 ms

aiohttp 默认用 getaddrinfo,在 asyncio 的默认线程池里执行。实测同时在解析的数量峰值是 14,与 min(32, cpu+4) 的结果一致。解析慢(这里模拟 200 ms)又要抓大量不同域名时,100 个请求被分成约 8 批,用了 1625 ms;换成更大的线程池后 232 ms。另一种办法是使用异步 DNS 库,我没有安装和测试。

localhost 的额外开销

实测 · 本机执行输出
  getaddrinfo("localhost") 的顺序:::1, 127.0.0.1
  200 次新连接 × 5 轮,每次请求平均:localhost 0.92 ms,127.0.0.1 0.71 ms
  拆开测:getaddrinfo("localhost") 每次 0.121 ms,getaddrinfo("127.0.0.1") 每次 0.002 ms;对 ::1 发起连接被拒绝(服务器没听 IPv6)每次 0.041 ms

localhost 解析出 ::1 和 127.0.0.1,::1 在前。服务器只听 IPv4 时,客户端先试 ::1 被拒绝,再连 IPv4。每次新连接比直接用 127.0.0.1 多约 0.21 ms,其中解析名字 0.121 ms,被拒绝的 ::1 尝试 0.041 ms(两项相加约 0.16 ms,剩下的差额没有再拆)。差距很小,但在本机压测时别把它算进服务器耗时。

分步演示

DNS 解析
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。把 socket.getaddrinfo 换成包装函数:只对 *.test 主机名计数、按需 time.sleep 模拟 DNS 延迟(延迟是模拟的)、统计并发峰值,并全部解析到 127.0.0.1;aiohttp 的 force_close=True 让每个请求新建连接;线程池大小公式 min(32, cpu+4) 是从观察到的峰值 14 反推吻合,没有重新读文档;localhost 对比交替跑 5 轮,每轮 200 次新连接
    • 没有验证:真实 DNS 服务器的耗时与超时、失败重试;操作系统层面的 DNS 缓存;aiodns/c-ares 等异步 DNS 解析器;IPv6 优先的 Happy Eyeballs 并行连接(aiohttp 有这个选项,没测);DNS 记录的 TTL 与 aiohttp ttl_dns_cache 的关系;DNS 轮询(多个 A 记录)下的连接选择(后面有专门的文章)
    京ICP备2026067502号-1京公网安备11010502063487号