你的服务连不上数据库,可能是 DNS 在打盹:一次从 200ms 抖动到全站 502 的排障实录

2026-10-26 10 0

凌晨两点,全站 502,而所有人的第一反应都是「网络没问题」

凌晨两点零七分,值班手机把我震醒。监控大盘一片红:所有下游接口 502,数据库完全没压力,CPU、内存、磁盘全是绿的。查了一圈,服务器能 ping 通外网,数据库端口 telnet 得通,连 MySQL 客户端直连都秒回。

于是所有人都说了一句那句经典废话:「网络没问题」。

结果问题出在一个从来没人怀疑过的地方:DNS 解析。一次 200ms 的解析抖动,被三层重试、四层超时、以及一个没配超时的连接池放大成了全站故障。这篇文章不打算教你「什么是 DNS」,我想讲的是:DNS 是分布式系统里唯一一个「所有组件都默认它不会出事」的组件,以及我们到底该怎么防它。


一、解析链路解剖:一次 getaddrinfo 背后发生了什么

先建立正确的物理图像。应用调用 getaddrinfo("db.internal", 3306),在这条链路上会发生:

  1. 应用(glibc / musl / Go runtime / JVM)决定用哪种解析器;
  2. 解析器读 /etc/resolv.conf,决定去问谁、超时多久、重试几次;
  3. 向 stub resolver 发出 A 记录和 AAAA 记录查询(UDP/53);
  4. stub resolver(可能是 systemd-resolved、dnsmasq、unbound,也可能是运营商的 114)递归解析并按 TTL 缓存;
  5. 拿到 IP,返回给应用,应用才开始三次握手。

关键在于:第 5 步之前的所有耗时,都发生在「连接」之前。你的 HTTP 客户端连接池报 connection timeout,日志里却干干净净只有「dial tcp: i/o timeout」——因为它压根不知道是 DNS 卡住了。中间还有 DNS 缓存过期、递归服务器抖动、UDP 丢包重传这一堆东西,随时可能把 1ms 变成 500ms。

# 一条 A 记录查询,root 视角
dig @114.114.114.114 db.internal A +noall +answer +stats

;; Query time: 214 msec
;; SERVER: 114.114.114.114#53(114.114.114.114)

二、坑位一:resolv.conf 的 timeout 和 attempts 是「静默炸弹」

先看最容易被忽略、也最容易翻车的一个。

cat /etc/resolv.conf
nameserver 114.114.114.114
nameserver 8.8.8.8
options timeout:5 attempts:2 ndots:2

glibc 的默认值就是 timeout:5 attempts:2。含义是:一次查询超时 5 秒,失败后再换一个 nameserver 重试一次,总共可以卡 10 秒以上。而这个 10 秒发生在你的连接池拨号阶段——如果你的 HTTP 客户端只设了 Timeout: 3s,你会看到一个自相矛盾的现场:客户端说「我超时了」,DNS 却还在后台慢慢重试,socket 堆积,最终把文件描述符和连接池一起撑爆。

更狠的是 ndots:2 规则:当域名里的点少于 2 个时(也就是 db、api 这类短名),解析器会先把它拼上 search 域逐个尝试,再查绝对域名。search 域配了三条、每个上游又慢,你的启动时间就凭空多出好几秒,而且日志里一个字都看不到。

应急手段很简单:把 options timeout:1 attempts:1 写死,再配合一个本地缓存 DNS,把「慢」和「卡」这两件事解耦。

三、坑位二:AAAA 查询拖死 IPv4 请求

这是我最想吐槽的一个设计。

现代 Linux 主机默认开启 IPv6,于是 getaddrinfo 会同时查 A 和 AAAA。但在很多云环境、容器网络、公司内网里,IPv6 地址配了但根本不通——路由没有、网关是空的、防火墙只放行 v4。这时候灾难性的组合出现了:

解析器拿到 AAAA(IPv6 地址)和 A(IPv4 地址),两者都「解析成功」,于是按 RFC 6724 的地址排序策略先试 IPv6。IPv6 握手丢包(TCP 的 SYN 没回,SYN 重传要等 1 秒、2 秒、4 秒),等到放弃切换 IPv4 时,客户端的总超时已经被吃掉了大半。

# 抓一下,看你的服务是不是一直在走 IPv6
tcpdump -i any -n "port 53 or (host db.internal and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0)"

# 更直接:看看 DNS 查询了哪些类型
dig db.internal AAAA +short
dig db.internal A    +short

另一个重灾区是 musl libc(Alpine 容器的默认 libc)。musl 历史上是先查 AAAA 再查 A,串行执行,在「有 IPv6 但不通」的网络上会硬生生多等好几秒。较新版本已改为并行查询,但老镜像、老基础镜像里依然躺着一大批。两个立刻见效的缓解手段:

  • 在应用层显式指定只查 v4(Go 里用 net.Dialer 的 Network: "tcp4",或在 GODEBUG=netdns=go+1 下配合 resolver 配置);
  • 关掉容器里的 IPv6,或至少保证 /etc/resolv.conf 与网卡实际能力一致。

四、坑位三:并发解析风暴把 conntrack 表撑爆

这个坑高级,但一旦遇到,你查一整天都查不出来,因为它在网络层,不在应用层。

DNS 查询走 UDP/53,每一条查询在内核里都会创建一条 conntrack 表项。假设你有 200 个实例,每个实例每秒 30 次 DNS 查询,加上连接池里因为 TTL 到期而集中重建连接——每秒上万条 conntrack 记录在几秒内被创建,而 nf_conntrack_max 默认值往往只有几万。表一满,内核开始丢包:

# 三个检查动作
sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max
dmesg | grep -i conntrack

日志里会看到 nf_conntrack: table full, dropping packet。它的恐怖之处在于:应用层一切正常,DNS 查询「偶尔超时」,重试又好了;但当丢包率被重试放大,解析耗时开始出现长尾毛刺。用 conntrack -S 看 drop 计数,或者直接把 nf_conntrack_max 调到几十万并开启 CONFIG_NF_CONNTRACK_TIMEOUT 调优。

根治办法是本地 DNS 缓存层:每个节点跑 dnsmasq 或 systemd-resolved,本地命中就不出网,外部查询被合并成一份。这既能削掉 conntrack 压力,也是防 DNS 抖动最有效的手段。

五、坑位四:TTL 到底是谁在遵守

你以为自己给域名设了 30 秒 TTL,客户端就会 30 秒重新解析。现实是:

  • glibc 自己不缓存 DNS。它每次 getaddrinfo 都真发查询,除非你装了 nscd 或接了 systemd-resolved 的 stub。所以「DNS 有缓存」这件事,完全取决于你机器上装了什么。裸容器里往往什么都没有。
  • JVM 的 networkaddress.cache.ttl 默认是 30 秒(有 SecurityManager 时甚至更久),而 Guava 的 InetAddressCache 默认只有 5 秒。两者不一致,同一个 JVM 里不同库对同一个域名的新鲜度判断就不同,排查时非常迷惑。
  • Go 的纯 Go resolver(GODEBUG=netdns=go)不读 nsswitch.conf,且默认 timeout:5s、attempts:2,跟 glibc 行为基本对齐;一旦 DNS 有问题,它会安静地等满 10 秒。
  • 中间盒子(企业 DNS、运营商 DNS、云厂商内网 DNS)经常无视你的 TTL,缓存几分钟到几小时。你在权威 DNS 上把 TTL 改成 5 秒,故障时切换 IP 依然没生效——因为没人听你的。

所以,真正可靠的高可用手段不是 DNS 轮询。轮询的多个 IP 依然需要客户端先解析才能知道地址;只要解析这条路断了,切流量就是空谈。可靠的做法是:客户端侧维护一个「IP 列表 + 独立健康检查」的兜底表,解析失败时直接从表里挑一个探活通过的 IP 建连,Host 头仍然带域名。这是很多网关型中间件的必备能力,也是自建高可用服务时最容易被跳过的一环。

六、坑位五:容器里的 127.0.0.11

Docker/K8s 里,容器内的 /etc/resolv.conf 通常指向 127.0.0.11,那是 Docker 内嵌 DNS 代理。它监听容器内所有 127.x 地址,把请求转发给宿主机的上游。它的问题也很典型:

# 容器内
cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

内嵌代理对上游的失败处理得很保守,上游超时时会返回 server misbehaving,并且并发查询多时容易出现延迟放大。在 K8s 里,Pod 的 dnsPolicy 决定了 nameserver 列表(clusterFirst / ClusterFirstWithHostNet / Default),配置不当会拿到 127.0.0.53 这种在容器里根本不存在的地址,表现为「容器里偶发 DNS 失败,宿主机上却好好的」。

解法是给业务容器显式指定 dnsConfig(nameservers + options + searches),别指望默认行为。dnsmasq 那种带本地缓存的方案,在 Pod 里同样值得单独部署一份。

七、怎么查:三步定位法

下次再遇到「连接超时但服务是好的」,按这个顺序走,不要跳步:

# 1. 确认是解析慢,不是连接慢
dig db.internal +stats
time getent hosts db.internal      # 走系统解析器,最接近应用行为
strace -f -e trace=sendto,recvfrom,poll ./your_app 2>&1 | grep -A5 53

# 2. 看查询本身发得对不对
dig db.internal A +noall +stats
dig db.internal AAAA +noall +stats
dig db.internal +trace            # 逐级看谁慢

# 3. 看内核有没有丢包
dmesg | grep -i conntrack
sysctl net.netfilter.nf_conntrack_count

第 1 步最关键:getent hosts 会走与你应用完全相同的解析器路径(glibc 还是 musl、有没有 nscd、读的哪个 resolv.conf),而 dig 是独立实现,用它测出来的结果和你的应用可能完全不是一回事。我见过不止一次「dig 飞快,getent 卡 5 秒」的现场——因为 dig 不走 nsswitch,不受 search 域影响。

八、防御清单

把这些当成默认配置,而不是出事后才想起的补丁:

  1. 本地缓存 DNS 必装:systemd-resolved(开启缓存)或 dnsmasq(cache-size=1000 起步),让所有查询先命中本地。
  2. 收紧超时:options timeout:1 attempts:1,别让默认值 5×2 替你做决定。
  3. 对齐 IPv6 现实:不能真通就关掉,别留着「配了但不通」的半吊子状态。
  4. conntrack 容量:按峰值 QPS × 域名数量估算,把 nf_conntrack_max 调够,并监控 drop 计数。
  5. 客户端超时预算要分层:DNS 解析、连接、读写的超时分开设,总预算留出重试空间。HTTP 客户端的 dial timeout 必须小于上游整体超时,否则重试逻辑永远等不到第一轮失败。
  6. 兜底 IP 列表:域名解析失败时能退化成直连健康 IP,Host 头保留域名。
  7. 监控 DNS 耗时:把解析耗时单独打点,别让它混在「connect timeout」里背锅。

九、结语

这次的故障复盘结论写下来只有一句话:「网络没问题」这个判断,本身就是一个没有证据的猜测。

DNS 处在一条所有依赖它的组件都不会怀疑的隐式链路上。它平时快得像不存在,出问题的时候又慢得像故意跟你作对。而绝大多数团队的架构图上,DNS 是一个灰色小方块,日志里它是一个不出现的名字。

所以下次监控大盘红了,先别急着看数据库和 CPU。拿出 dig 和 getent,跑两秒钟,你会省下一个通宵。

相关文章

你的 SQL 为什么这么慢?——从一条慢查询开始的性能破案
Go 的 interface 为什么这么难用?——nil 陷阱、方法集与隐式契约,一次说清
缓存说旧了,数据库说新了:别再让这两个家伙互相甩锅
你的服务雪崩,不是因为流量大:超时与重试写成的死亡循环
你的 goroutine 明明很轻,为什么服务还是被压垮了?
别再用 REST 硬扛了:gRPC 与 Protobuf 的六个真实翻车现场

发布评论