云计算百科
云计算领域专业知识百科平台

DNS 解析到底慢在哪?缓存、超时和重试的取舍

DNS 解析到底慢在哪?缓存、超时和重试的取舍

连接池配好了,并发也上了,协程写得漂漂亮亮,一压测——还是慢。翻日志、看耗时,最后发现时间压根没花在请求上,而是卡在请求之前那一步:把域名翻成地址。

这一步平时就几十毫秒,存在感低得可怜,可量一大,它就成了那个躲在角落拖后腿的家伙。我踩过不止一次,这篇把这条路上常见的坑拆开讲。

一、为什么它总被忽略

因为单看一次,它实在太不起眼。一次解析几毫秒到几十毫秒,跟一次完整请求几百毫秒比,谁会在意。

问题出在它会重复。假设一个任务要发一千次请求,每次都重新解析一遍,一次算它二十毫秒,光解析就白扔掉二十秒。要是正好赶上解析抖动的时段,单个拖到一两秒,你的超时直接被撞爆。

更憋屈的是,默认这条路上,应用层几乎拿不到缓存。系统调用把域名翻成地址,用完就丢,下一次请求照样从头走一遍。底层到底有没有缓存、缓存多久,你说了不算。

二、一条解析请求,走了哪些路

按顺序,大概这么几层:

  • 应用自己有没有缓存(多数项目没有);
  • 系统 hosts 文件;
  • 系统级 DNS 缓存或服务(各平台不一样);
  • 你配置的那个本地 DNS 服务器;
  • 本地 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 头上的情况?评论区讲讲当时怎么定位的 👇


    声明:本文为原创技术分享,请遵守相关网络协议与法律法规,勿将技术用于任何违规场景。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » DNS 解析到底慢在哪?缓存、超时和重试的取舍
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!