登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

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
net.Resolver、Resolver.Dial、首选 DNS、备用 DNS 与应用层 fallback 的责任边界结构说明图
图1:Resolver.Dial 与查询级备用切换的静态边界说明图,不是运行截图。

因此,排查时先看自定义 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 // 业务代码继续使用解析结果建立连接。
resolverGroup 将 primary Resolver 和 backup Resolver 连接到独立超时、LookupIPAddr 与 errors.Join 的结构说明图
图2:查询级主备 Resolver 组与错误聚合的结构说明图,不是运行截图。

这里的时间关系只是预算示意:两个 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,为每次查询分配独立预算,并保留取消、超时和错误来源,才能让主备行为既可控又可排查。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>