Go DNSResolver 超时后没有切换备用服务器怎么办
来源:17golang原创
时间:2026-10-18 07:56:18 157浏览 收藏
Go 社区里常说的“DNSResolver”通常不是一个具体类型,实际落到标准库时,多数场景指的是 net.Resolver。如果首选 DNS 超时后没有切到备用服务器,先不要急着加重试:最常见的根因是把 Resolver.Dial 当成了主备调度器,回调里始终连同一个固定地址。
可落地的处理方式是把责任拆开:Resolver.Dial 只负责为某一个解析器建立 DNS 连接,查询级代码负责在多个 Resolver 之间切换。每个候选服务器拥有独立的超时预算,调用方的取消信号仍然保留,最后把每次失败组合成可以排查的错误。
官方资料:https://pkg.go.dev/net#Resolver
先区分 Resolver.Dial 和备用切换
net.Resolver 的 Dial 字段是 Go 内置 DNS 解析器的可选连接函数。标准库会把 DNS 服务地址传给它,但如果自定义回调忽略这个地址、无条件拨到 10.0.0.53:53,那么解析器内部就没有机会知道“下一台服务器”应该是谁。
另外,Dial 建立的是 DNS 传输连接,超时也可能发生在连接建立之后的响应读取阶段。单纯在 Dial 内重试一次,并不能完整表达“这个查询失败后换另一台服务器”的策略。更稳定的边界是:
| 位置 | 负责什么 | 不负责什么 |
|---|---|---|
Resolver.Dial | 按当前 Resolver 的配置建立 UDP/TCP DNS 连接 | 不负责业务层的多 Resolver 轮询 |
LookupIPAddr | 执行一次主机名查询并返回地址或错误 | 不保证自定义 Resolver 组自动切换 |
| 查询级 fallback | 根据超时、临时错误和空结果选择下一候选 | 不应吞掉调用方已取消的 context |

因此,排查时先看自定义 Dial 是否把传入的 address 丢掉,再看查询调用是否只有一个总超时。如果所有请求都固定发往首选服务器,备用配置即使写在代码里,也只是没有被读取的字符串。
为每个 DNS 服务创建独立 Resolver
下面的工厂函数把一个明确的 DNS 地址绑定到一个 net.Resolver。它坚持使用 Go 内置解析器,并保留标准库传入的网络类型:通常是 udp,遇到截断响应时也可能需要 tcp。不要为了省事把所有网络类型硬编码成 UDP。
package dnsfailover
import (
"context"
"errors"
"fmt"
"net"
"time"
)
// newResolver 把一个 DNS 服务地址封装成独立的 net.Resolver。
func newResolver(server string) *net.Resolver {
dialer := &net.Dialer{Timeout: 350 * time.Millisecond}
return &net.Resolver{
// PreferGo 让下面的 Dial 回调在支持的平台上生效。
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
address := net.JoinHostPort(server, "53")
// 沿用解析器给出的 udp/tcp,避免截断响应时丢失 TCP 能力。
conn, err := dialer.DialContext(ctx, network, address)
if err != nil {
return nil, fmt.Errorf("dial DNS %s: %w", address, err)
}
return conn, nil
},
}
}
这个函数只解决“一个 Resolver 应该连谁”的问题。它没有承诺主备,也没有把系统的 /etc/resolv.conf 列表和应用配置混在一起。每个解析器都可以单独记录名称、地址和健康状态,后续的切换逻辑更容易审查。
用查询级主备循环接住超时
主备切换应该发生在一次查询的边界上。下面给每个 Resolver 分配独立的 lookupTimeout,首选失败后才尝试备用;如果调用方 context 已经取消,就立即停止,不因为 fallback 把请求生命周期延长。
type resolverGroup struct {
resolvers []*net.Resolver
lookupTimeout time.Duration
}
// Lookup 只在当前候选失败时切换,不把调用方取消伪装成 DNS 故障。
func (g resolverGroup) Lookup(ctx context.Context, host string) ([]net.IPAddr, error) {
if len(g.resolvers) == 0 {
return nil, fmt.Errorf("no DNS resolver configured")
}
failures := make([]error, 0, len(g.resolvers))
for index, resolver := range g.resolvers {
// 每个候选使用自己的时间片,避免首选耗尽全部请求预算。
lookupCtx, cancel := context.WithTimeout(ctx, g.lookupTimeout)
addrs, err := resolver.LookupIPAddr(lookupCtx, host)
cancel()
if err == nil && len(addrs) > 0 {
return addrs, nil
}
if ctx.Err() != nil {
// 上游已取消时保留取消原因,不再无意义地访问备用服务器。
return nil, ctx.Err()
}
if err == nil {
err = fmt.Errorf("resolver returned no address")
}
failures = append(failures, fmt.Errorf("resolver[%d]: %w", index, err))
}
// errors.Join 保留每台服务器的失败信息,便于日志和指标按地址聚合。
return nil, errors.Join(failures...)
}
示例使用了 errors.Join,因此项目应使用支持它的 Go 版本;如果旧项目不能升级,可以定义自己的错误类型保存失败列表,但不要只返回最后一条错误。对于生产服务,还应把候选名称、DNS 地址、查询耗时和最终命中位置写入结构化日志或指标。
组装时可以把首选和备用明确写出来:
group := resolverGroup{
resolvers: []*net.Resolver{
newResolver("10.0.0.53"), // 首选:内网 DNS 服务。
newResolver("10.0.0.54"), // 备用:同一网络域内的第二服务。
},
lookupTimeout: 700 * time.Millisecond, // 每台服务器单独的查询预算。
}
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
defer cancel() // 调用方结束时释放整个查询链路的计时器。
addrs, err := group.Lookup(ctx, "service.internal")
if err != nil {
// 这里可以按 errors.Is、errors.As 或日志字段继续分类处理。
return fmt.Errorf("lookup service.internal: %w", err)
}
_ = addrs // 业务代码继续使用解析结果建立连接。

这里的时间关系只是预算示意:两个 700ms 的候选时间片并不意味着请求一定允许等待 1400ms,因为外层 context 还可能更早结束。真实服务要根据上游 SLA、网络 RTT 和连接建立开销调整,并观察首选失败比例后再决定是否需要健康探测或短暂熔断。
把错误、缓存和健康信号分开
主备代码上线后,最容易出现的第二个问题是“看起来能切换,但无法判断为什么切换”。建议至少区分三类信号:
- 请求取消:上游已经放弃请求,直接返回
context.Canceled或context.DeadlineExceeded,不要继续探测备用。 - 解析器失败:记录是哪一个 Resolver、哪种网络类型和哪一段预算耗尽,保留聚合错误。
- 解析成功但连接失败:DNS 只负责得到地址,后续 TCP/TLS 失败不应反过来伪装成 DNS 超时。
缓存也要放在清楚的边界上。若应用层有正缓存和负缓存,缓存命中时可以不访问 Resolver;但缓存失效后的那一次查询仍应走同样的主备策略。健康探测可以把最近连续超时的首选暂时降权,却不要在每次请求里无条件并发轰炸所有 DNS 服务,否则会把单点故障放大为流量问题。
常见问题
为什么把 Dial 里的地址改成备用地址还不够?
因为那通常只改变了一个 Resolver 的固定连接目标,不能表达“本次 Lookup 失败后换下一台”。而且超时可能发生在连接建立之后的读取阶段,连接函数返回成功并不等于 DNS 查询已经成功。
设置 StrictErrors 能自动完成主备吗?
不能。StrictErrors 主要影响 A/AAAA 等子查询出现临时错误时是否放弃整个查询,它不是候选 DNS 服务器列表的调度开关。是否切换应由查询级策略明确决定。
为什么不直接依赖系统解析器?
如果应用只需要遵守系统网络配置,直接使用默认 Resolver 往往更简单。只有在服务确实需要独立的内网 DNS、可观测的主备顺序或特殊超时策略时,才值得维护显式 Resolver 组;否则自定义连接层会增加配置和运维成本。
总结
Go DNSResolver 超时后不切备用,通常不是“少写了一个重试”,而是把两个层次混在了一起:net.Resolver.Dial 负责建立某个 DNS 连接,查询级 fallback 才负责在多个 Resolver 之间做选择。将每台服务器封装成独立 Resolver,为每次查询分配独立预算,并保留取消、超时和错误来源,才能让主备行为既可控又可排查。
-
364 收藏
-
447 收藏
-
109 收藏
-
418 收藏
-
238 收藏
-
241 收藏
-
372 收藏
-
413 收藏
-
209 收藏
-
430 收藏
-
211 收藏
-
248 收藏
-
393 收藏
-
280 收藏
-
148 收藏
-
117 收藏
-
378 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习