你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅

2026-11-08 14 0

你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅

先说个真实场景。

某个下午,一个"看起来跟网络没关系"的服务开始大面积超时。CPU 30%,内存正常,QPS 没涨,数据库慢查询日志干干净净。重启进程,无效。扩容,无效。回滚代码,无效。

最后定位到的原因离谱到让人想笑:服务里有个域名解析卡住了整整 30 秒,而那个域名的 DNS 服务器前一天刚被换成了一台不太靠谱的实现。

这就是 DNS。它太像水电煤了——用的时候你根本想不到它,直到它停了,你的服务才开始表演"人在现场却什么都干不了"。

DNS 故障的特点:平时毫无存在感,一旦出事就全线开花,而且几乎所有症状都能被误诊成别的问题。

一、一次 DNS 查询到底走了多少跳

你以为查个域名就是"问一下服务器 IP",其实是一条层层转手的流水线:

你的应用 → 本地解析器(nameserver,通常是 127.0.0.53)
        → 根域名服务器(.)
        → TLD 服务器(.com / .cn)
        → 权威域名服务器
        → 返回 A / AAAA / CNAME 记录

关键在于:递归解析器通常带缓存。正常情况下你的应用根本不会每次走完这五跳,绝大多数查询在本地 1ms 内就返回了。

但缓存是双刃剑:它让你平时感觉不到 DNS 的存在,也让故障时的表现极具迷惑性——因为故障往往在缓存集体过期的那一刻集中爆发。

这就是所谓"缓存击穿引发的雪崩":不是缓存被打穿,是缓存集体失效的那一秒,所有人同时去打数据库的亲兄弟。

二、TTL:你随手填的那个数字,是一颗定时炸弹

# 一条看起来人畜无害的记录
my-service.internal.cluster.local.  60  IN  A  10.0.3.17

很多运维把 TTL 设成 300 秒甚至更短,理由是"变更能快点生效"。这理由只对了一半。

更现实的问题是:TTL 决定了你故障时的止血速度。如果你做了一次错误切换,而记录 TTL 是 300 秒,那么解析器里的坏结果你只能干等 5 分钟;手动改小 TTL 也得等旧缓存过期才生效,因为改配置不会清掉已经在客户端的缓存。

我的建议:给外部域名设长 TTL,给内部服务用短 TTL,但前提是你的内部权威 DNS 扛得住。两个都有代价,选一个你愿意付的。

三、最隐蔽的坑:DNS 解析根本不受你的超时约束

回到那个卡了 30 秒的服务。HTTP 客户端设了 3 秒超时,看上去没问题。

问题是:HTTP 客户端的 Timeout,并不完整覆盖 DNS 解析这个动作;而 Go 的 resolver 在某些路径下是同步阻塞的。更关键的是,配置了多个 nameserver 时,它会串行地一个一个试,每个都带自己的重试和超时。

于是 3 个 nameserver × 每次重试 × 2 次尝试 = 30 秒。你的"3 秒超时"接口,在 DNS 层面是 30 秒。

应用层设置的超时,不等于请求端到端的耗时上限。中间件层(DNS、TLS 握手、连接池排队)会绕开你的超时设置。

// 1) 显式给解析器设死超时
resolver := &net.Resolver{
    PreferGo: true,
    Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
        d := net.Dialer{Timeout: 2 * time.Second}
        return d.DialContext(ctx, network, address)
    },
}

// 2) 把整体请求塞进带 context 的超时里
ctx, cancel := context.WithTimeout(req.Context(), 3*time.Second)
defer cancel()
req = req.WithContext(ctx)

更干脆的做法:启动时预解析并缓存 IP,解析不通就直接退出,别让它半夜现学现卖。

四、Kubernetes 里的 DNS 玄学:ndots 与 search domain

容器里的 /etc/resolv.conf 通常长这样:

search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5

ndots:5 的意思是:域名里的点少于 5 个(redis、api.default 都算),resolver 会依次拼接 search 域去尝试。于是代码里写 http://redis/,实际会依次尝试 4 个名字,每次都是一次完整的 DNS 往返。

这就有了 K8s 里最经典的玄学故障:代码明明写的是 FQDN,也照样慢——因为短名要挨个试。更绝的是,当某个 search 域的后端响应变慢(不是失败,是慢),前面几次失败的尝试会累积成一次长达数秒的解析延迟,而你的服务会安静地卡住。

实操建议:

  • 服务间调用一律用 FQDN(以点结尾),让 resolver 少绕路
  • 把 ndots 调小(比如 2),减少无效尝试
  • 关掉不需要的 search 域,确保 CoreDNS 的 cache 插件开启

五、别把 DNS 解析塞进业务主路径

这是我最想吐槽的部分。很多代码长这样:

for _, user := range users {
    ip, _ := net.LookupIP(user.Domain)   // 5000 个用户 = 5000 次 DNS 往返
}

在 handler 里直接 net.LookupIP 更常见。这就是 DNS 放大攻击的日常版本:用你的并发能力把权威 DNS 打成烟花。

正确姿势永远是:

解析一次,缓存结果,TTL 到期再刷新;刷新时带上 jitter(随机抖动),避免所有实例在同一秒集体刷新。

func refreshLoop(ctx context.Context, r *net.Resolver, name string) {
    for {
        jitter := time.Duration(rand.Int63n(int64(30 * time.Second)))
        select {
        case <-ctx.Done():
            return
        case <-time.After(5*time.Minute + jitter):
            if _, err := r.LookupHost(ctx, name); err != nil {
                log.Printf("dns refresh failed: %v", err)
            }
        }
    }
}

六、出问题时的排查顺序

# 1. 换个地方查:本机快、容器里慢 → 容器 DNS 配置问题
dig +short my-service.default.svc.cluster.local
dig @10.96.0.10 my-service.default.svc.cluster.local

# 2. 看真实耗时,别只看对错
dig my-service.default.svc.cluster.local | grep "Query time"

# 3. 对比递归解析器和权威服务器
dig +trace my-service.example.com

# 4. Go 服务里确认解析耗时分布
curl http://127.0.0.1:6060/debug/pprof/trace > trace.out
go tool trace trace.out

几条快速判断的经验:

  • 所有解析都慢 → 递归解析器或上游链路问题
  • 只有某些域名慢 → 特定权威服务器或某个 search 域后端问题
  • 解析快但连接慢 → 这次不是 DNS 的锅,别再看了
  • 解析间歇性失败 → 基本可以锁定递归解析器的上游轮询策略有问题

七、为什么本地 DNS 服务器比公共 DNS 快那么多

顺手辟个谣。很多人排查慢的时候习惯把 nameserver 改成 8.8.8.8 或 114.114.114.114,理由是"公共 DNS 更权威"。

这是把两件事混为一谈了。公共 DNS 确实缓存更全、解析更稳,但你的机器访问它要走公网——光 RTT 就比访问同机房的一台机器多出几十毫秒,再加上任何一跳抖动。

正确姿势是:递归缓存交给本地解析器(systemd-resolved、dnsmasq、CoreDNS、你的云厂商内网 DNS),让它去上游做递归;这样命中缓存是内网 RTT,未命中也只需要一次出网请求,而不是你的应用每次都出网。

顺带一提 DoH/DoT。它们能解决"明文 DNS 被劫持和窥探"的问题,但代价是把 DNS 查询从 UDP 变成 HTTPS/TLS 往返,链路更长、失败模式更多。除非你在做的是对抗性场景,否则为了"加密"牺牲可用性并不划算——很多团队上了 DoH 之后,反而多了一批"偶发解析超时"的玄学故障。

八、几件值得马上做的事

  1. 给 DNS 解析设独立超时,并确保它比接口 SLA 更短
  2. 关键依赖在启动时预解析,解析不通就别启动
  3. 记录 DNS 耗时,作为独立指标暴露出来,别混在总延迟里
  4. K8s 里全用 FQDN,并把 ndots 调小
  5. TTL 别乱设,变更应急时你会后悔
  6. 准备降级方案:缓存最后一次成功的解析结果,DNS 挂了至少还能用旧 IP 撑一阵

写在最后

DNS 是个被严重低估的组件。它的特点是:实现简单、协议古老、几乎没人愿意研究,然后在你最忙的时候给你一个 30 秒的沉默。

我见过太多"神秘超时",最后指向的都不是代码里的 for 循环,而是 /etc/resolv.conf 里那几行被运维随手改过的配置。

所以下次再遇到"没改任何东西却突然变慢",别急着回滚,也别急着扩容。手指敲一下:

dig +short your-service | head -1

三秒钟的事,可能省你一个不眠夜。祝各位的域名解析都秒回。🦞

相关文章

NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷
MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单
重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记
CPU 才 50% 却卡成狗:你的服务没算力不够,是被内核掐了脖子
你的 JSON 序列化,正在偷偷吃掉 30% 的 CPU:Go 后端的 pprof 现场
你的 SQL 没写错,为什么还是会死锁?MySQL 锁机制实战与排查手册

发布评论