DNS 解析优化完全指南:从一次"接口偶发卡顿几秒钟"看懂为什么 DNS 不能甩给操作系统

2023 年我维护一个后端服务后面要调多个第三方 API 上线之后接口大部分时候都很快可 p99 偶尔会冒出几秒钟的尖刺我盯了一周代码层面没看出任何问题第一版我做得很顺手发请求就用 requests.get HTTP 客户端我心里很笃定 DNS 解析嘛操作系统底层的事我又不用管把域名甩给它它会自己查会自己缓存我应用层只管发请求就行可等我真去抓包定位一串麻烦冒了出来第一种最先把我打懵抓包发现那几次卡几秒钟的请求 TCP 握手前有一段长长的空白时间真正卡住的根本不是 HTTP 也不是 TCP 而是 DNS 解析它走了超时重试整整等了 5 秒第二种最难缠我把客户端封装好以为加了连接池就万事大吉结果在容器里跑同一个域名每次新建客户端都要重新解析一次 DNS 解析缓存压根没生效第三种最离谱我配置文件里只写了一个 nameserver 那台 DNS 服务器某天抽风我的服务跟着抽风每次解析都要等十几秒第四种最莫名其妙同一个域名解析出来的多个 IP 我永远只用第一个有一天那个 IP 所在节点坏了我的服务跟着挂了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为 DNS 解析是操作系统底层自己处理的瞬间完成的可这个认知是错的本文从头梳理为什么甩给操作系统会出事一次 DNS 解析到底经过哪几层缓存藏在哪几层为什么容器里常常失效 resolv.conf 的关键参数怎么配连接复用为什么是性价比最高的一招以及一些把 DNS 解析做扎实要避开的工程坑

2023 年我维护一个后端服务,它的核心工作是给前端提供一个"下单"接口,接口里要顺序调好几个第三方:支付网关查余额、短信网关发通知、地图服务算配送时间。上线后,服务的整体响应"看起来还行",可监控曲线一直有一个甩不掉的毛病:p99 延迟时不时被一根细细的尖刺顶到几秒,完全和并发量对不上。我查了下游每一个第三方,人家自己的接口监控很平稳;我查了我自己的业务逻辑,没什么可疑的。第一版我对这件事完全没多想,DNS 这两个字我连提都没提到过。我心里很笃定:DNS 解析嘛,不就是把域名翻成 IP——这是操作系统和网络库底层在干的活,我代码里 requests.get('https://api.xxx.com') 一写完事,域名怎么变成 IP 根本不用我操心;就算要花时间,也就几毫秒,在一个动辄几百毫秒的接口里,这点开销完全可以忽略,根本不值得我去关注它。可等我真去定位那些尖刺,一串问题冒了出来。第一种最先把我打懵:我抓包,把那些卡了几秒的请求一个一个挑出来看,发现真正卡的不是对方接口的响应,而是请求发出之前的那段时间——握手前面那一截,DNS 查询包发出去后,要等好几秒才有回应,有时干脆等到客户端超时重试。第二种最难缠:我以为操作系统会替我缓存 DNS,可我的服务跑在容器里,而且每次业务调用都新建一个 HTTP 客户端,客户端用完即弃;DNS 解析的结果根本没机会被复用,每个请求都是从零开始重新解析。第三种最头疼:我去看容器里的 /etc/resolv.conf,里面只有一个 nameserver,而且 timeout、attempts 全是默认值——一旦这唯一一个 DNS 服务器抖一下,解析就只能干等 5 秒、重试、再等 5 秒,一次失败的解析能拖十几秒进请求里。第四种最莫名其妙:同一个域名,我手动多解析几次,会拿到一组不同的 IP;可我的代码永远只用 getaddrinfo 返回的第一个,撞上一个就近坏掉的或者新故障的 IP,整条调用就在那里干耗,而 DNS 明明已经把别的好 IP 一起返给我了。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为 DNS 解析就是一个由操作系统在底层自动完成的、几毫秒就过去了的小事,我业务代码里完全不用去操心它;它不会失败、不会慢、不会有重试,反正网络库一调就有 IP 回来,这一步对我服务的性能和稳定性几乎没有影响。可这个认知是错的。DNS 解析不是一次本地查表,它是一次真正的网络往返——你的机器把"域名是什么 IP"这个问题问出去,问到 DNS 服务器那里,等它回话;DNS 服务器自己往往还要继续问别的服务器(递归解析),整条链路上任何一环慢、抖、挂,都会把这次解析的耗时直接灌进你那次请求的总耗时里。它有缓存,但缓存分布在好几层(应用层、系统层、递归服务器),任何一层失效都意味着这次解析要走真实的网络;它有配置,/etc/resolv.conf 里 timeout、attempts、ndots、rotate 这几个参数,你不去精调,默认值就在替你"用最糟糕的方式"工作着;它和你的网络库是一对组合拳——你不会复用连接,DNS 解析的所有优化都白搭,因为每次新建连接都意味着重新解析一次。所以做 DNS 优化,根上不是"这是底层的事我不用管"这一个心态,而是一整套设计:要知道一次解析究竟经过了哪几层,要在应用层加一层带 TTL 的 DNS 缓存,要把 resolv.conf 的几个关键参数调对,要在多个 IP 之间会选会绕开坏的,要用 Session 把连接长久地复用起来,还要把 DNS 解析耗时本身监控起来。本文从头梳理:为什么"DNS 解析不用管"会出事,一次 DNS 解析究竟经过了哪几层,DNS 缓存藏在哪几层、为什么容器里常常失效,resolv.conf 的几个关键参数怎么调,连接复用为什么是真正治本的,以及一些把 DNS 这件事做扎实要避开的工程坑。

问题背景

先把 DNS 解析这件事说清楚。你的服务要调 api.pay.example.com,可网络上传的是 IP 包,不是域名,所以在 TCP 握手开始之前,客户端得先把"api.pay.example.com 对应的 IP 是什么"这个问题问清楚,这一步就是 DNS 解析。它通常的样子是:你的进程调 getaddrinfo,内核 resolver 按 /etc/resolv.conf 配置去问指定的 nameserver,nameserver 自己若没有缓存,会代你递归地去问根服务器、顶级域、权威服务器,一层层问出最终的 IP,再把结果一路返回。这里的关键在于:这一整条链路上,任何一段都是真实的网络通信,任何一段的卡顿都会变成你这次请求的卡顿;而它的多级缓存、配置参数、连接复用方式,共同决定了"这个卡顿在大多数情况下能不能被避开"。第一版的错,不在于"用了 DNS",而在于它把 DNS 当成了一个"瞬间完成、永远成功"的本地函数调用——这个心态,正是它所有麻烦的根。

错误认知是:DNS 解析是操作系统底层的事,瞬间完成、不会失败,我业务代码完全不用管。真相是:它是一次真正的网络往返,有缓存(分布在多层)、有配置(resolv.conf)、有故障模式(超时、解析到坏 IP),而且和连接复用强绑定——你不复用连接,DNS 优化全是空话。把这一点摊开,第一版的几类问题就都能解释了:

  • p99 尖刺找不到原因:卡顿不在业务和下游接口,而在请求发出前的 DNS 解析这一段。
  • 缓存没生效:每次新建客户端,操作系统层的 DNS 缓存对短命客户端根本没起到作用。
  • 一次失败拖十几秒:单 nameserver + 默认 timeout 5 秒,一次抖动就被放大。
  • 撞上坏 IP 整条卡死:DNS 返回多个 IP 却只用第一个,没有选优和绕开机制。

所以让 DNS 这条链真正可靠,核心不是"交给操作系统就行",而是一整套工程:看清解析经过的层级、加应用层缓存、调对 resolv.conf、做多 IP 选优、复用连接、把解析耗时监控起来。下面六节,就从第一版"DNS 不用管"的想当然讲起。

一、为什么"DNS 解析不用管"会出事

第一版我写网络调用的方式,核心就是一句 requests.get,域名怎么变 IP 的事完全交给了操作系统。

# 反面教材:第一版 —— 每次请求都新建客户端,DNS 也跟着每次重解析
import requests

def call_payment_api(order_id):
    # 每调一次就 requests.get 一次,代码看起来简洁、漂亮
    return requests.get(
        f'https://api.pay.example.com/order/{order_id}',
        timeout=10,
    ).json()

def call_sms_api(phone):
    return requests.get(
        f'https://api.sms.example.com/send?to={phone}',
        timeout=10,
    ).json()

# 本地一测飞快,就上线了。可上线后:
# 大部分请求很快,可时不时一个请求要卡好几秒、甚至超时;
# 抓包才发现 —— 卡的根本不是对方的接口,
# 是握手之前那段 DNS 解析:把 api.pay.example.com 翻成 IP,
# 这一步偶尔要等 5 秒(查询超时后又重试一次)才有结果。

问题就藏在这段代码"看起来很简洁"的假象之下。它隐含了一个极其乐观的假设:requests.get 那一刻,域名到 IP 这件事会在零时间内、零失败率地完成,我业务代码完全不用关心它。可现实里,这一行简洁的代码下面,藏着一段完整的、可以失败、可以变慢、可以拖垮整个请求的网络协议交互。

这一节要建立的认知是:第一版最深的想当然,是把 DNS 解析当成了一次"本地函数调用",而它真实的身份是"一次网络请求"——这两件事虽然在代码里看起来都是一句 getaddrinfo,可它们在性能、可靠性、失败模式上是完全不同的两类东西;只要你把 DNS 解析的真实身份从"本地操作"扶正到"网络请求",你对它的整个态度——会不会缓存、会不会监控、会不会担心它失败——就会跟着翻盘。本地函数调用和网络请求,是两种你心里早就分得很清的东西。一个排序函数,你不会担心它"网络抖动了一下没排出来",你也不会想到要给它加重试、加超时、加监控;反过来,任何一个调下游服务的 HTTP 请求,你都会本能地配上超时、配上重试、画上监控曲线,因为你心里清楚它是网络调用,网络是会出问题的。可 DNS 解析非常容易被人放进第一类:它的 API 长得像本地函数(socket.getaddrinfo 看起来和 sorted 没什么两样,一调就有返回值),它的失败又通常很安静(超时也只是返回慢,不会抛出醒目的网络错误),它的耗时在日志里也不会单独出现一行——这三个表面特征加起来,让 DNS 解析非常容易被当成"本地的、稳定的、零耗时的东西"。第一版的错,就错在它接受了这个表象,从来没把 DNS 当成一次需要被严肃对待的网络请求。一旦你把它的身份扶正,所有第一版踩的坑都会立刻变得"理所当然":它是网络请求,所以它会慢——上游 nameserver 卡一下,你这次解析就慢一下;它是网络请求,所以它会失败和超时——你那个 timeout 5 秒 attempts 2 的默认配置,意味着最差情况下一次解析就要耗 20 秒;它是网络请求,所以它的结果应该被缓存——而不是每次都重头来一遍;它是网络请求,所以它的延迟应该被监控——而不是埋在某次接口耗时里看不见;它是网络请求,所以它在容器里会受到容器网络的影响——resolv.conf 的内容、上游 DNS 的可达性、ndots 的搜索行为,都会让它的表现和你本地机器上完全不同。把这一切倒过来,第一版那种"DNS 不用管"的心态,就是在用对待本地操作的方式,去对待一个本质上是网络请求的东西——所有问题都从这里长出来。摆正了身份,第一步要做的事,是看清楚这个网络请求究竟经过了哪几段路,下一节讲。

二、一次 DNS 解析,到底经过了哪几层

要优化 DNS,先得知道一次解析到底走了哪几段。它远不止"我问一下、对面答一下"这么简单,它是一条多层级、多级缓存的链路。先用一段代码,把"DNS 解析究竟花了多少时间"这件事从黑箱里拎出来。

# 把 DNS 解析这一步单独测出来:它根本不是"零耗时"的本地操作
import socket, time

def resolve_timed(host, port=443):
    t0 = time.time()
    # getaddrinfo 就是底层"把域名翻成 IP"的那个调用
    infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC,
                               socket.SOCK_STREAM)
    cost = (time.time() - t0) * 1000
    ips = sorted({i[4][0] for i in infos})
    print(f'{host:30s} 解析耗时 {cost:7.1f} ms  -> {ips}')
    return cost, ips

for h in ['api.pay.example.com', 'api.sms.example.com',
          'api.map.example.com']:
    resolve_timed(h)

# 你会看到非常戏剧的两种数字:
# 第一次解析 —— 几十到几百毫秒,赶上 DNS 抖动甚至到几秒;
# 紧接着再解析同一个 —— 可能只要零点几毫秒(命中了缓存)。
# 这两个数量级的差距,本身就是 DNS 是网络请求最直观的证据。

看清了耗时,再来看这条链。一次"从代码里调 getaddrinfo"到"拿到 IP",它至少经过这样几层:你的进程里(应用层缓存,如果你建了的话)、你机器上的系统缓存(nscd / systemd-resolved 之类,如果在跑的话)、你 resolv.conf 配的递归 DNS 服务器(它自己也有缓存)、再到根服务器 / 顶级域 / 权威服务器(若递归服务器没缓存)。任何一层命中,后面就都省了;任何一层没命中,就要继续往下走真实的网络。

这一节的认知是:DNS 解析的耗时不是一个固定数,它是一个"取决于哪一层命中"的阶梯函数——命中应用层,几乎零耗时;命中系统层或递归服务器缓存,几毫秒到几十毫秒;一层都不命中,要走到权威服务器,几百毫秒到几秒都正常;再加上 nameserver 抖动、ndots 搜索这些坑,最坏情况能拖到十几秒;所以你不能用某一次"很快"的测量,就笃定 DNS 不会慢——它的"快"和"慢"取决于此刻命中了哪一层。第一版对 DNS 耗时的判断,几乎全部来自"我在本地测了一下,几毫秒就过去了"这种印象。这个印象不是错的,但它非常有迷惑性:你本地机器上,这个域名你刚解析过、系统缓存还在、上游 DNS 也活得好好的,所以你这一次确实就是几毫秒。问题是,你拿这个"几毫秒"作为对 DNS 的总体认知,就等于用最幸运的一种情况去估计了它的全部行为。真实生产环境里,你的请求面对的是各种各样的"层级命中状况":一个长期没人访问的域名,所有缓存都过期了,这次请求触发的就是一次走到权威服务器的完整解析,几百毫秒起步;一个上游 DNS 偶尔抖动的下午,某个解析正好赶上 nameserver 没响应,你那个 timeout 5 秒就要原原本本耗光、再重试;一个容器刚启动、本地什么缓存都没有的瞬间,你的服务初始几次解析全是"冷的";一个 ndots 默认 5 的 K8s 环境,你每次解析都先去试几个注定失败的搜索后缀。这些场景下的耗时,和你本地那个"几毫秒"完全不在一个量级上。理解了 DNS 的耗时是个阶梯函数,你就明白了两件事:第一,你的 p99 延迟尖刺非常可能就来自"这次正好掉到了阶梯下面的某一档";第二,你做 DNS 优化的全部努力,本质上就是在尽可能让请求命中靠上的、更便宜的那一档——加应用层缓存,就是在保证最常用的那几个域名永远命中第一档;复用连接,就是在保证一次解析的结果能在大量后续请求里被反复用上,等于把"那一档命中"的收益放大一万倍;调 resolv.conf,就是在缩短"万一掉到最差档时"的代价。下一节先讲缓存——它是这条链上你最能掌握的那部分。

三、DNS 缓存:它藏在哪几层,为什么容器里常常失效

第一版那个"DNS 反复重解析"的问题,根子在这里。DNS 缓存并不是只有一处,它分布在好几层,而每一层都有自己的失效条件——尤其是在容器里。你要的不是"指望某一层替你扛着",而是在应用层自己也加一层。

# 应用层 DNS 缓存:别指望操作系统的缓存替你扛,自己也存一份
import socket, time, threading

class DnsCache:
    def __init__(self, ttl=60):
        self.ttl = ttl
        self._data = {}                  # host -> (ips, expire_at)
        self._lock = threading.Lock()

    def resolve(self, host, port=443):
        now = time.time()
        with self._lock:
            hit = self._data.get(host)
            if hit and hit[1] > now:     # 还没过期 —— 直接用缓存
                return hit[0]
        # 缓存未命中或已过期,才真去解析一次
        infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
        ips = [i[4][0] for i in infos]
        with self._lock:
            self._data[host] = (ips, now + self.ttl)
        return ips

# 这一层缓存挂在你自己的进程里,不依赖任何下游 ——
# 哪怕系统层、递归服务器全挂了,只要 TTL 没到,
# 你的服务还是能拿到 IP 接着干活。

讲清了应用层这一层,再回过头去看其它几层为什么"靠不住"。系统层(nscd、systemd-resolved)在很多容器基础镜像里根本没装、没开;DNS 协议自带的 TTL 字段,libc resolver 本身并不会按它去缓存——getaddrinfo 调一次,内核就老老实实去问 DNS 一次;递归服务器有缓存,但它是为所有客户端服务的,你的服务命中与否取决于刚才有没有别人问过同一个域名;而容器调度起新实例时,容器内的一切都是从零开始,连一个热身的窗口都没有。

这一节的认知是:不要指望"某一层会替你做 DNS 缓存"——多层缓存听起来层层兜底,可在你的容器环境里,这几层往往一层接一层地不生效,最终能落到你头上的兜底,常常一层也没有;所以应用层那一层 DNS 缓存,不是"锦上添花的优化",而是"在多层缓存集体失效的最坏情况下,你唯一的安全垫"。第一版的乐观假设,是"操作系统总会缓存的吧"。这个假设本身没错,问题是它对的是某些场景——你 ping 一个网站,过一会儿再 ping,确实快;你 dig 一个域名,过一会儿再 dig,确实快。可这些场景里,"过一会儿"那个间隔,要么有 nscd 在做用户态缓存,要么是同一个进程多次解析、底层 stub resolver 内部短暂复用,要么是同一台机器上别的进程已经问过同样的域名、递归服务器替你缓存住了。这些条件,任何一个在容器里都可能不成立。容器镜像往往是精简的,没有 nscd 也没有 systemd-resolved;一个新拉起来的容器,本机什么缓存都没有,它问的递归 DNS 是集群里独立的服务,这个服务此刻有没有你那个域名的缓存,完全靠运气;你的应用每次请求新建客户端、用完即弃,进程级别那点短暂的复用也享受不到。一层一层失效下来,你以为有四层缓存兜底,实际可能一层都靠不住。应用层那一层,是唯一你能完全自己掌控、不依赖任何外部条件的缓存。它装在你的进程里,你启动它,它就在;你给它的 TTL 是多少,它就帮你扛多久。它的 TTL 该设多大,是要拿"缓存可能过时"和"解析压力"换的——TTL 太长,域名后台切了 IP 你这边迟迟感知不到;TTL 太短,缓存的作用就被削弱了。对绝大多数后端服务,30 秒到 5 分钟是个合理的起点,真正长尾的、IP 几乎不变的内部服务可以更长。还要顺便点出来:应用层缓存解决了"反复重解析"的问题,但解决不了"我每次都新建一个客户端、所以每次都要在新连接上重新触发 getaddrinfo"——那个问题归连接复用管,后面会讲。掌控了缓存,再来看你能掌控的另一处——resolv.conf,下一节。

四、resolv.conf:DNS 行为一大半由这个文件决定

第一版"一次失败拖十几秒"的问题,几乎全藏在 /etc/resolv.conf 里。它是一个非常小、非常容易被忽略的文件,可它里面的几个参数,几乎决定了你的 DNS 在最差情况下能糟糕到什么程度。

# /etc/resolv.conf —— DNS 解析行为一大半由它决定

# 第一版的配置:只有一个 nameserver,参数全是默认值
nameserver 10.0.0.2

# 默认行为的几个隐藏成本:
#   timeout 默认  5 秒  —— 一次查询没回应,要等满 5 秒才算超时
#   attempts 默认 2     —— 每个 nameserver 试 2 轮
#   只有 1 个 nameserver,它一抖动,你所有解析就只能干等

# 调整后:多个 nameserver + 更短超时 + 轮询
nameserver 10.0.0.2
nameserver 10.0.0.3
options timeout:1 attempts:2 rotate single-request-reopen
#   timeout:1   —— 1 秒没响应就算超时,不要再等满 5 秒
#   attempts:2  —— 每个 nameserver 试 2 轮,过了就走下一个
#   rotate      —— 在多个 nameserver 间轮询,把压力分摊开
#   single-request-reopen —— 解决某些 NAT 下 IPv4/IPv6
#                            两条查询用同一个 socket 互相干扰的老毛病

resolv.conf 里还有一个"最常被忽略、可能也最坑"的参数,叫 ndots。在 K8s 这种容器环境里,它几乎默认就是埋雷的姿势。

# 容器里最坑的一个参数:ndots

# 一份典型的 K8s Pod 内 /etc/resolv.conf:
# search default.svc.cluster.local svc.cluster.local cluster.local
# options ndots:5

# ndots:5 的意思:你要查的域名里,如果"点"的个数小于 5,
# 就先把它和上面 search 里的每一个后缀拼起来挨个试一遍,
# 都失败了,才回头查这个域名本身。

# 于是你查 api.pay.example.com(只有 3 个点,小于 5):
#   先查 api.pay.example.com.default.svc.cluster.local  -> NXDOMAIN
#   再查 api.pay.example.com.svc.cluster.local          -> NXDOMAIN
#   再查 api.pay.example.com.cluster.local              -> NXDOMAIN
#   最后才查 api.pay.example.com                        -> OK
# 一次解析凭空多出 3 次注定失败的查询 ——
# 这是容器/K8s 环境里 DNS 慢、压力大的头号元凶。

# 修法两种:
#   1) 把域名结尾带一个点 "api.pay.example.com." 写成全名,
#      告诉解析器"别再拼 search 后缀了,这就是 FQDN";
#   2) Pod 里把 ndots 调小,比如 options ndots:2。

这一节的认知是:resolv.conf 是一个看似无关紧要的小文件,它的全部内容加起来可能就那么几行,可它里面每一个默认值,都是"以稳妥为名、以延迟为代价"在替你做选择——timeout 默认 5 秒是为了网络极差时也别误判,可在你的局域网里它意味着"一次抖动就被放大 10 倍";ndots 默认 5 是为了让短名搜索"尽量找得到",可在你的容器里它意味着"每次解析都先扔出几次注定失败的查询";这些默认值都不是"错的",它们只是默认的——你要的从来不是默认的稳妥,你要的是和你的环境匹配的、风险/收益重新算过账的配置。第一版对 resolv.conf 的态度,是一种隐性的尊重:这文件长得很底层、很 Linux、看起来是运维该管的,我业务开发不该乱动它。这种"敬而远之"的心态,正好让默认值悄无声息地替你做了主。可一旦你停下来,把每一个默认参数都当成一次"为什么是这个值"的提问,你就会发现这些默认值都在为某种"和你的环境不一样的世界"做兜底:timeout 5 秒,是上世纪 DNS 在公网里偶尔丢包的兜底,在如今你局域网里相互信任、毫秒级响应的 DNS 之间,它已经过保护性的好几倍了——只要你的环境里 DNS 平时响应都在 50 毫秒以内,把 timeout 调到 1 秒,既能在 99% 的情况下完全无差别地工作,又能在那 1% 真的卡了的时候,把"放弃并切到下一个 nameserver"的时间从 5 秒缩到 1 秒。attempts 也是同理:重试本身是好东西,但它和 timeout 是乘法关系,timeout=5 attempts=2 + 单个 nameserver,意味着最差情况是"等 5 秒、再试一次等 5 秒、整整 10 秒后才放弃",而 timeout=1 attempts=2 + 两个 nameserver + rotate,意味着同样的兜底兜得快多了。rotate 这一行单独提一下:不加它,resolver 默认是"按 nameserver 顺序一条条试",一旦第一个 nameserver 抖动,你的请求总是先被它拖一下;加了 rotate,在多个 nameserver 间轮询,压力分摊、抖动也被摊薄。ndots 是最容易上手就翻盘的一个——你的代码里如果调的全是 api.xxx.com 这种"超过两个点的真实域名",那 ndots 默认 5 对你完全没好处,只有坏处;把它调到 2,或者更省事一点,把代码里所有外部域名都写成 FQDN("api.xxx.com.",末尾带点),立刻就能把每次解析里那两三个注定失败的查询全省掉。理解了 resolv.conf 是个"为不匹配的环境兜底的默认值",你就不再会"敬而远之",而是把它当成一组要为自己环境重新算账的旋钮。掌控了解析这一段,下一段要补上的,是"为什么我做了这一切,效果依然没有想象中大"——那是因为没复用连接,下一节讲。

五、连接复用:让 DNS 解析"一次解决,长期省心"

第一版里我加过应用层 DNS 缓存,效果有,但远不如预期。后来才想明白:DNS 缓存优化的是"解析",可解析这一步只是连接建立的入场票——真正昂贵的是建立连接整套动作。如果你每次请求都新建连接,那"DNS 这一步省下来的时间"在 TCP 握手 + TLS 握手面前,只是九牛一毛。连接复用,才是把 DNS 优化的成本真正摊薄的关键。

# 真正治本:复用连接 —— 连接复用了,DNS 解析的成本就被摊到 0
import requests

# 反面:每次请求隐式新建一个 Session(等于每次都从头来一遍)
def call_bad(url):
    # 每一次都触发:DNS 解析 + TCP 握手 + TLS 握手 + 业务请求
    return requests.get(url, timeout=(3, 10)).json()

# 正解:一个长期存活的 Session,在所有调用间共享
session = requests.Session()

def call_good(url):
    # 同一个 host 的后续请求,会复用已经建立好的 keep-alive 连接:
    # 不用再 DNS 解析、不用再 TCP 握手、不用再 TLS 握手 ——
    # DNS 解析在连接最初建立时只发生过一次。
    return session.get(url, timeout=(3, 10)).json()

# 一条 keep-alive 的连接活着,这条连接对应的 IP 就一直在用,
# 这条连接上后面成千上万次请求,全部省掉了 DNS 这一步。

光建一个 Session 还不够,要让连接池"够用"——池子小了,并发一上来,连接不够就会被迫现场再建,新建就要重新解析、重新握手,优化等于白做。

# 给 Session 配一个够用的连接池,别让连接不够而被迫重建
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
adapter = HTTPAdapter(
    pool_connections=20,           # 缓存多少个不同 host 的连接池
    pool_maxsize=50,               # 单个 host 最多保持多少条连接
    max_retries=Retry(
        total=2, backoff_factor=0.3,
        status_forcelist=[502, 503, 504],
    ),
)
session.mount('https://', adapter)
session.mount('http://', adapter)

# 池子配多大,要看下游的并发预算:
# 池子太小 —— 并发一上来连接不够,新请求只能现场新建连接,
#             又是一次 DNS + TCP + TLS,前面的优化全白做;
# 池子太大 —— 每个连接都长时间占着对方一个 socket,
#             下游会被你这一个客户端撑爆。配到刚好够用就行。

[mermaid]
flowchart TD
A[请求偶发卡顿 p99 尖刺] --> B{抓包看卡在哪一段}
B -->|卡在 DNS 查询| C{为什么这次解析慢}
B -->|卡在对方接口| Z[不在 DNS 去查下游]
C -->|每次都重新解析| D[复用 Session 加应用层 DNS 缓存]
C -->|一次解析拖十几秒| E[改 resolv.conf timeout 与多 nameserver]
C -->|容器里凭空多次查询| F[修 ndots 或域名结尾带点写全名]
D --> G[把 DNS 解析耗时单独监控起来]
E --> G
F --> G

这一节的认知是:连接复用和 DNS 优化不是两件事,它们是同一件事在不同尺度上的两个侧面——你不复用连接,无论你把 DNS 缓存做得多漂亮,每次新建的连接都还是要触发底层的 getaddrinfo,你那点应用层缓存的命中,救得了一次解析却救不了每次都从头开始的握手开销;你复用了连接,这条连接对应的 IP 就在那条 socket 上稳定地活着,DNS 这一步连"被命中缓存"的机会都不需要,因为它根本就没被再次调起。第一版的一个隐藏假设,是把"DNS 解析"和"建立连接"看成了两个独立的优化目标:既然 DNS 慢,那我就专门优化 DNS,加缓存、改 resolv.conf;TCP 握手 TLS 握手该花多少花多少,反正没办法。这个分割看上去合理,实际上忽略了一个最朴素的事实:DNS 解析在你的代码路径里,永远是"为了建立一个连接"才发生的。没有人会去解析一个域名然后就把结果扔掉——你解析它,是因为你接下来要往它对应的 IP 上发起 TCP 连接、做 TLS 握手、走 HTTP 请求。这一整套动作是一个不可分割的"建立通信"序列,DNS 是序列的第一步、TCP 是第二步、TLS 是第三步、业务请求才是第四步。前三步的每一步都要花时间,而且越靠后越花时间(TLS 握手通常比 DNS 解析慢得多)。在这种情况下,你只优化第一步,等于在改善一个总账里占比并不那么大的项;而连接复用,做的是另一件根本不同的事——它把整个"前三步序列"在一次成功建立后,长期保留下来,后续所有请求都直接从第四步开始。这就是为什么"建一个 Session 长期用"会带来比"加一层 DNS 缓存"大得多的收益:一次握手覆盖了 DNS + TCP + TLS 三步,被复用上千次就把这三步的总成本摊到了几乎为零。理解了这一点,你对"DNS 优化"的全部理解就该重新校准:它不是一项"独立的、为了让 getaddrinfo 更快"的工作,它是"让一整套连接建立序列的成本能被尽可能复用、并在不得不重做时也尽量快"的整体工程的一部分。Session、连接池、应用层缓存、resolv.conf,这几样东西不是各管一摊,它们是从不同尺度共同保护"建立通信"这件事不发生在你的关键路径上。把这一切串起来——一次请求遇到偶发卡顿,该按什么样的路径定位 DNS 是不是元凶,可以画成上面的流程图。掌控了 DNS 的整条链路之后,还有几个工程坑要单独点一下,下一节讲。

六、把 DNS 这件事做扎实,要避开的工程坑

前面五节讲清了 DNS 的核心:把它当网络请求看待、看清解析的多层级、加应用层缓存、调 resolv.conf、复用连接。但要在生产里真正用稳,还有几个工程坑得专门讲。第一个,是把 DNS 解析挪出关键路径——预解析。

# 坑一:首次解析永远是冷的 —— 用预解析 + 后台刷新挪出关键路径
import threading, time

class DnsPrefetcher:
    def __init__(self, hosts, cache, interval=30):
        self.hosts = list(hosts)
        self.cache = cache             # 复用前面的 DnsCache
        self.interval = interval

    def _loop(self):
        while True:
            for h in self.hosts:
                try:
                    self.cache.resolve(h)   # 提前解析、写进缓存
                except Exception as e:
                    print(f'预解析 {h} 失败: {e}')
            time.sleep(self.interval)

    def start(self):
        t = threading.Thread(target=self._loop, daemon=True)
        t.start()

# 服务一启动就把要用的域名先解析一遍、并定时刷新缓存。
# 真实请求来的时候,解析结果早就躺在应用层缓存里 ——
# DNS 这一步的耗时对请求来说被完全压到 0。

第二个坑,是 DNS 经常会返回多个 IP,而你大概率只用了第一个——撞上一个就近坏掉或正在抽风的 IP,整条调用就在那里干等。

# 坑二:多 IP 不选优,撞上坏 IP 整条卡死
import socket, time

def pick_fastest_ip(host, port=443, timeout=1.0):
    infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
    ips = sorted({i[4][0] for i in infos})
    best, best_cost = None, float('inf')
    for ip in ips:
        t0 = time.time()
        try:
            # 实测这个 IP 的 TCP 连接耗时
            s = socket.create_connection((ip, port), timeout=timeout)
            s.close()
            cost = time.time() - t0
            if cost < best_cost:
                best, best_cost = ip, cost
        except OSError:
            continue                   # 这个 IP 连不上,直接跳过
    return best

# DNS 返回的多个 IP 里,可能有的就近、有的远、甚至已经挂了。
# 挨个连一下、挑最快的活的那个,才不会被某一个坏 IP 拖死。
# 这套逻辑接近 RFC 8305 Happy Eyeballs 的思路。

第三个坑,也是最容易被忽略的:DNS 解析耗时本身,必须被监控起来,不然它"慢慢变差"你永远发现不了。

# 坑三:DNS 解析耗时本身要监控 —— 它变慢往往是故障前兆
import socket, time

DNS_SLOW_MS = 200             # 解析超过这个毫秒数就算异常

def resolve_with_metric(host, port=443):
    t0 = time.time()
    ok = True
    try:
        socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
    except socket.gaierror:
        ok = False
    cost_ms = (time.time() - t0) * 1000

    emit_metric('dns.resolve.ms', cost_ms, tags={'host': host})
    if (not ok) or cost_ms > DNS_SLOW_MS:
        alert(f'DNS 异常: host={host} 耗时={cost_ms:.0f}ms ok={ok}')
    return ok

# 把每个域名的解析耗时画进监控曲线。某个域名解析耗时
# 突然抬头,常常是上游 DNS 在出问题、或递归服务器过载的
# 第一个信号 —— 它比"接口大面积超时"那条告警要早得多。

还有几个坑值得点一下。其一,容器镜像里如果没装 ca-certificates、解析了 IP 也要看 TLS 链:别因为业务做了 DNS 优化,却忘了基础证书库,变成 DNS 没问题但 TLS 失败。其二,某些库或 SDK 内置了自己的 DNS 解析路径(尤其是各种 RPC 客户端),你在 requests 那一层做的优化不一定覆盖它们,要单独确认。其三,DNS over HTTPS / DNS over TLS 是另一回事,优化思路类似但配置点完全不同,别在传统 DNS 的思路下套它们的结论。下面把"请求偶发卡顿,该往 DNS 哪一层查"这件事集中对照一下:

请求偶发卡顿?DNS 这条链先查这几个地方

  这一层               常见的坑                  该怎么改
  ------------------------------------------------------------
  应用 每次新建连接    DNS 跟着每次重解析        复用 Session 与连接池
  应用 无 DNS 缓存     解析结果反复丢弃          加一层带 TTL 的 DNS 缓存
  resolv.conf 单 NS    一个挂了所有解析都卡      配置多个 nameserver
  resolv.conf 默认值   timeout 5 秒 一拖十几秒   timeout 1 attempts 2 rotate
  容器 ndots 默认 5    凭空多出几次失败查询      改小 ndots 或域名结尾带点
  多 IP 不选优         撞上坏 IP 整条卡死        实测连一下挑最快的活 IP
  无监控               DNS 慢慢变差无人知        把解析耗时画进监控曲线

  原则 DNS 解析不是零耗时的底层细节
       它是每次请求真正的第一步 要缓存 要监控 要复用

这一节这几个坑,串起来是同一个意思:DNS 是一个"做对一次就可以悄悄省你一万次成本"的东西,可它同时也是一个"出问题时表现得不像 DNS 问题"的东西——它的失败常常表现为接口超时、p99 尖刺、偶发抖动,没有一个征兆上直接写着"我是 DNS";所以你必须主动把它从黑箱里拎出来,给它单独的监控、单独的预解析、单独的多 IP 选优,而不是等它"哪天爆给你看"。第一版的整个心态,是把 DNS 放进了一个"如果出问题它会自己叫"的盒子里。这个心态在很多基础设施上是成立的——数据库连接断了会抛异常、HTTP 请求超时了会有错误码、磁盘满了会有 I/O 报错;可 DNS 的失败模式天生就是"安静的":一次解析慢了 4 秒,它不抛异常,只是 getaddrinfo 慢了一点,然后返回成功,你拿到 IP 接着走下面的逻辑,这慢了的 4 秒就这样一声不响地灌进了你这次请求的总耗时里。如果你只盯着"接口耗时"这一个数字,你看到的现象会是"某些接口偶尔变慢",而看不到这慢的来源——所以你会去查代码、查下游、查数据库、查 GC,绕了一大圈才回到 DNS。预解析、多 IP 选优、监控,这三件事的共同主题,是把 DNS 这一段从黑箱里强行拎出来变成白箱:预解析,是不让 DNS 出现在请求的关键路径上,让它的耗时被提前到服务启动和后台刷新里;多 IP 选优,是承认"DNS 返回的多个 IP 不是等价的",主动挑出活的、近的那个,而不是听天由命;监控,是给 DNS 单独装一个传感器,让它的状态变化能被你独立看到,而不是混在接口耗时这个综合指标里。这三件事合起来,本质上是把"原本你以为不归你管的 DNS",纳入了你能可观测、可控制、可干预的工程范围。把 DNS 这件事做扎实,根上就是这一步心态上的转变:它不归操作系统全权代办,它归你管,只不过你管它的方式,是设计、配置、缓存和监控,而不是手动写解析代码。

关键概念速查

概念 说明
DNS 解析 把域名翻译成 IP 的过程,本质是一次网络请求而非本地操作
递归解析 recursive 客户端委托一个 DNS 服务器代为问根/顶级域/权威服务器,直到拿到 IP
权威 DNS 服务器 真正持有某个域名 IP 记录的服务器,递归解析的最终目的地
TTL Time To Live DNS 记录的有效期,各层缓存根据它决定多久重新去问一次
多层 DNS 缓存 缓存分布在应用层、系统层、递归服务器,各层都有自己的失效条件
resolv.conf Linux 客户端的 DNS 配置文件,决定 nameserver、超时、重试、ndots 等
ndots 域名中"点"少于此值时先拼搜索后缀挨个试,容器里默认 5 是头号慢源
timeout 与 attempts resolv.conf 的两个关键参数,默认值会把一次抖动放大成十几秒
连接复用 keep-alive 同一连接上多次发请求,只需一次解析+握手,DNS 成本被摊到几乎为零
Happy Eyeballs DNS 返回多 IP 时并行试连、挑最快活的 IP,避免被坏 IP 拖死

避坑清单

  1. 不要把 DNS 解析当成零耗时的本地操作:它是一次网络请求,会慢、会超时、会失败。
  2. 不要每次请求新建 HTTP 客户端:DNS 跟着每次重解析,所有优化都被掏空。
  3. 不要指望操作系统替你缓存 DNS:容器里 nscd / systemd-resolved 往往根本没装。
  4. 不要让 resolv.conf 保持默认 timeout:5 秒的默认值在你的局域网里被过度保护了好几倍。
  5. 不要只配一个 nameserver:它一抖动你所有 DNS 解析就只能干等。
  6. 不要在容器里忽视 ndots:默认 5 会让每次解析凭空多几次注定失败的查询。
  7. 不要只用 DNS 返回的第一个 IP:多 IP 要会选会绕开坏的,接近 Happy Eyeballs。
  8. 不要把 DNS 解析放在请求关键路径上:服务启动就预解析、后台定时刷新。
  9. 不要忘了把 DNS 解析耗时单独监控:它变慢往往是大故障的最早征兆。
  10. 不要分别优化 DNS 与连接:它们是一体的,复用连接才是真正治本的那一刀。

总结

回头看第一版那个"requests.get 一调就完事、DNS 是底层自己会搞定的事"的方案,它的失控很典型。它不在某一行代码,而在一个对 DNS 的根本误解:以为它是一次本地操作,瞬间完成、不会失败,业务代码完全不用关心。真相是,DNS 解析是一次真正的网络往返,经过应用层 / 系统层 / 递归服务器 / 权威服务器这一整条多级链路,任何一层不命中、任何一段抖动,都会原原本本灌进你这次请求的耗时里;它的缓存分布在多层,但每一层在容器里都可能失效;它的配置 resolv.conf 默认值,都是为不匹配的环境兜底的、以延迟换稳妥的选择;而它的优化,只有和"连接复用"绑在一起做,才真正能被摊薄。第一版把这一切都当成"我不用管"的黑箱,于是 p99 莫名尖刺、缓存形同虚设、一次抖动拖十几秒、撞上坏 IP 整条卡死,全都顺理成章。

而把 DNS 这件事做对,工程量并不小。它不是"等出问题再说"那么简单,而是要把 DNS 重新认作一次网络请求来对待,看清它经过的多层级与多级缓存,在应用层加一层带 TTL 的 DNS 缓存,把 resolv.conf 的 timeout / attempts / rotate / ndots 几个参数为自己的环境重新算账,会在多 IP 之间选优、绕开坏的,用 Session + 连接池把连接长期复用起来,再把 DNS 解析耗时本身画进监控曲线、做预解析挪出关键路径。一套真正可靠的对外网络调用,是这些环节一个不少地拼起来的。

这件事其实很像每次打电话之前都要先翻一下通讯录查号码。第一版的做法,是把"翻通讯录"看作一瞬间就能完成、永远不会出错的事——"反正号码就在那儿,翻一下就好"。可现实远不是这样。有时通讯录就在手边一翻就有(这就是命中应用层缓存);有时手边没有,得问家里人(系统层缓存);有时家里人也不知道,得打电话给查号台(递归服务器);有时查号台一时半会儿翻不到,得让它去问别的查号台(走到权威服务器)。这一长串里任何一环慢下来,你"打电话"这件事就被拖住了。一个聪明的人是怎么打电话的?第一,他常打的几个号码自己抄在便签上贴墙上,过段时间核一次(应用层缓存 + TTL)。第二,他不会每次打电话都重新查号——号码记下后,下次直接拨(连接复用)。第三,他知道一个号码可能对应多个分机,某个分机占线就换另一个试(多 IP 选优)。第四,他会留意"最近怎么查号台老是占线",及早换一家(监控 DNS 解析耗时)。第五,他不会忍受"打过去等 5 秒没接才挂",会更快地放下重新拨(把 timeout 调小)。一通电话能不能打得通、打得快,从来不是"我嗓门多大"决定的,而是"我查号、记号、拨号、挑分机这一整套流程顺不顺"决定的。DNS 之于你的服务,完全是一回事。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你本地机器上,常用的域名早被你各种工具反复解析过,系统层、recursive 服务器都缓存得满满当当,你的代码哪怕每次新建客户端,DNS 解析也都是"几毫秒"级别的快;你的本机网络稳定、上游 DNS 健康,所有那些"timeout 5 秒"、"撞上坏 IP"、"ndots 拼后缀"的坏情况,你一个都撞不上;你的并发也低,连接池小不小、有没有被迫现场重建,你也看不出来。你测的那几次请求恰好都飞快,你就会觉得"DNS 嘛,操作系统会处理"。真正会把问题撑爆的,是上线后的真实环境:真实环境里,服务跑在容器里,基础镜像没有 nscd,resolv.conf 是 K8s 注入的 ndots:5 默认值,递归 DNS 是集群里独立的一两个 Pod;真实的并发会让你那个偷懒的 requests.get 每秒触发上千次新连接,把上游 DNS 压出尖刺;真实的网络会有抖动,会让你那个"timeout 5 秒、单 nameserver"的默认配置在某一天的某个十分钟把你的服务拖到投诉电话打进来。这些场景,你本地一个都模拟不到。所以如果你正在维护一个频繁向外发请求的服务,别等接口 p99 尖刺到引发告警、别等用户因为一次解析卡 10 秒投诉到你头上,才回头怀疑你当初那一行简洁的 requests.get。在你写下第一行外部网络调用之前就想清楚:这个域名怎么被解析、解析结果在哪一层被缓存、resolv.conf 是什么配置、连接有没有被复用、解析耗时有没有监控——把"让代码本地跑通"和"让它在容器、上游 DNS 抖动、坏 IP 出现时依然守住延迟和稳定"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

LLM Agent 提示词工程完全指南:从一次"规则写得越全模型越不听话"看懂系统提示词设计

2026-5-23 0:15:24

技术教程

Agent 多 Agent 协作 · 5 个常见坑与对策 完全指南:速查、踩坑与最佳实践

2026-5-19 1:01:45

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索