DNS 解析到底慢在哪?缓存、超时和重试的取舍
连接池配好了,并发也上了,协程写得漂漂亮亮,一压测——还是慢。翻日志、看耗时,最后发现时间压根没花在请求上,而是卡在请求之前那一步:把域名翻成地址。
这一步平时就几十毫秒,存在感低得可怜,可量一大,它就成了那个躲在角落拖后腿的家伙。我踩过不止一次,这篇把这条路上常见的坑拆开讲。
一、为什么它总被忽略
因为单看一次,它实在太不起眼。一次解析几毫秒到几十毫秒,跟一次完整请求几百毫秒比,谁会在意。
问题出在它会重复。假设一个任务要发一千次请求,每次都重新解析一遍,一次算它二十毫秒,光解析就白扔掉二十秒。要是正好赶上解析抖动的时段,单个拖到一两秒,你的超时直接被撞爆。
更憋屈的是,默认这条路上,应用层几乎拿不到缓存。系统调用把域名翻成地址,用完就丢,下一次请求照样从头走一遍。底层到底有没有缓存、缓存多久,你说了不算。
二、一条解析请求,走了哪些路
按顺序,大概这么几层:
现实中常见的画面是:前面几层全没命中,每次都老老实实走到第 4 步往后。而这几步靠的都是网络往返,慢起来没边。
这里有条分界线得划清楚:你能动手的只有第 1 层。 底下的改不动,那就把自己这一层的缓存攥紧。别指望操作系统替你兜底。
顺带把第 4、5 步说透一点。本地 DNS 服务器不是凭空知道答案的,它会替你一路往上问:先问根服务器,再问顶级域,最后问到权威服务器,拿到答案后缓存一份再回给你。这条链路只要有一段慢,你这边感受到的就是解析慢。
还有个少有人提的点,叫负缓存。查不到的域名同样会被缓存一小段时间,所以有时候你改完解析,短时间内还是失败,先别急着怀疑人生,等过了那段缓存时间再看。
三、慢下来的几个来源
重复解析。 同一个域名在一轮任务里被解析几十上百次,每次都往外跑一趟,成本全落在等待上。这是最普遍的一种。
解析超时被当成连接超时。 不少人设了个 timeout 就觉得稳妥了,其实那个值管的是连接和读取,跟解析半点关系没有。解析卡住的时候,请求会停在"还没发出去"的位置,报错信息里甚至看不出跟解析有关,特别容易查错方向。
失败重试在悄悄叠加。 解析出问题时,底层可能依次去试好几个 DNS 服务器、好几个地址,每一个都要等一轮超时,加起来的耗时相当可观。
解析服务器的物理距离。 用的 DNS 服务器离你很远,或者权威服务器响应本来就慢,往返时间会被放大。这种时候本地再怎么优化也有限,得换成近一点的解析服务。
解析出多个地址,却只有一个能用。 这种更隐蔽。域名解析回了好几个 IP,客户端从第一个开始挨个试,前面几个都连不上,试到最后一个才通。表面上一次就成了,实际上一半时间都耗在无效的尝试上。
四、能落地的提速做法
自己加一层缓存。 这是最直接也最有效的。把解析结果连同过期时间存起来,命中就不问出去。
import socket
import time
_cache = {} # host -> (过期时间戳, 解析结果)
_TTL = 300 # 缓存 5 分钟,按业务调
def cached_getaddrinfo(host, port, *args, **kwargs):
now = time.time()
hit = _cache.get(host)
if hit and hit[0] > now:
return hit[1]
info = socket.getaddrinfo(host, port, *args, **kwargs)
_cache[host] = (now + _TTL, info)
return info
TTL 这个值得拿捏:太短等于没缓存,太长则会在目标换了地址之后,你还傻乎乎地连旧地址。
靠连接复用顺手省掉解析。 长连接被复用的时候,域名早就解析过了,根本不会再走一遍。这也是为什么用好 Session、别每个请求都新开连接,本身就是一种提速。
import requests
s = requests.Session()
for url in urls:
s.get(url, timeout=(3, 10)) # 连接被复用,不会重复解析
强制解析协议,躲开 IPv6 的坑。 有些环境里,getaddrinfo 会同时返回 IPv4 和 IPv6 地址,客户端倾向于先试 IPv6。万一这台机器的 IPv6 是"能解析、连不通"的半吊子状态,就会变成每次先等 IPv6 超时、再退回 IPv4,白白多耗几秒。
# 明确只要 IPv4,避免在 IPv6 上半死不活地等
info = socket.getaddrinfo(host, port, family=socket.AF_INET)
把解析提前,挪出热路径。 批处理任务可以在开跑前,把要用的域名一次性解析好、灌进缓存。任务真正跑起来时,命中的都是内存里的结果,热路径上就不会卡这一下。
给缓存加个锁,防缓存击穿。 高并发下,同一个域名的缓存刚过期,几十个线程会同时发现"没缓存",然后一起去解析,等于把一次请求放大成几十次。加把锁或者用个 future 去重,就能把这类重复挡在外面。
import threading
_locks = {}
_guard = threading.Lock()
def locked_getaddrinfo(host, port):
with _guard:
lock = _locks.setdefault(host, threading.Lock())
with lock:
return cached_getaddrinfo(host, port)
优先复用现成的机制。 不少 HTTP 客户端本身就带连接池和 DNS 缓存。把连接池开好、把会话复用起来,顺手就能挡掉一大半重复解析。与其自己造一套解析缓存,不如先把客户端自带的这些用到位,再考虑补应用层缓存。顺序反了,就是重复劳动。
五、容易反被聪明误的坑
缓存没有失效机制,等于给自己埋雷。目标换了地址,你手里还是旧结果,请求自然连不上。所以缓存必须带 TTL,而且这个 TTL 最好做成可配置,别写死在代码里。
TTL 给多少,看场景。目标地址很少变的,给几分钟到十几分钟都行;地址经常变的,宁可短一点,几秒到几十秒,牺牲一部分命中率换及时性。拿不准就从一分钟起步,观察一阵再调。
也别在解析上搞太多花活。见过有人叠了两三级缓存、又套了队列,结果查一次比不缓存还慢。缓存这东西够用就行,简单可靠远比炫技重要。
还有个高频操作问题:改完 DNS 配置记得清缓存。 很多人换了 DNS 服务器却没生效,不是配置写错,是旧缓存还没过期,白折腾半天。
六、怎么确认慢真的出在解析
别凭感觉。把解析这一步单独拎出来计时就行:
import socket
import time
def timed_resolve(host):
t = time.perf_counter()
socket.getaddrinfo(host, 80)
return (time.perf_counter() – t) * 1000 # 毫秒
for _ in range(5):
print(round(timed_resolve("example.com"), 1), "ms")
第一次通常会慢,后面因为系统缓存会快一些,这很正常。多跑几轮,再换几个域名对比,就能看出是普遍慢,还是只有某个域名慢。要是每次稳定都在几百毫秒以上,那基本能确定解析就是瓶颈,前面那些缓存和复用的手段,该上了。
小结
DNS 这条路,提速的核心就俩字——少跑。能缓存的别重复问,能复用的连接别重开。
至于慢了怎么定位,是缓存没命中、超时没设对,还是掉进了 IPv6 的坑,这套排查顺序我写在下一篇,顺着走能省不少时间。
互动时间:你有没有遇到过"代码看着没问题、就是慢",最后查到 DNS 头上的情况?评论区讲讲当时怎么定位的 👇
声明:本文为原创技术分享,请遵守相关网络协议与法律法规,勿将技术用于任何违规场景。
网硕互联帮助中心



评论前必须登录!
注册