Go 默认 DNS 缓存不生效,net.Resolver 每次调用都可能发真实查询;需手动实现带 TTL 的缓存(如 sync.Map + singleflight.Group),并注入 http.Transport.DialContext 才能生效。

Go 默认 DNS 缓存不生效,net.Resolver 每次调用都可能发真实查询
Go 的 net.DefaultResolver 在多数场景下**根本不会缓存 DNS 结果**——它只是透传给系统 libc(如 glibc 或 musl)或直接走 UDP 查询,而 Go 自身不维护 TTL 缓存。你看到的“缓存”往往来自操作系统或中间 DNS 代理(如 systemd-resolved),不是 Go 控制的。
这意味着:net.LookupIP、http.Client 发起请求时底层调用的解析,每次都可能触发新网络请求,尤其在容器环境(如 Alpine 镜像用 musl)或短连接高频场景下,DNS QPS 突增、延迟毛刺明显。
- Linux 上若用 glibc,会受
/etc/nsswitch.conf和systemd-resolved影响,但 Go 不读这些配置;musl(Alpine 默认)则完全无缓存,每次查都发包 -
net.Resolver的PreferGo: true字段启用纯 Go 解析器,但它仍不缓存结果,只绕过 libc - HTTP 客户端复用
http.Transport时,DialContext若没定制,依然走默认 resolver,无法复用解析结果
手动加一层内存缓存:用 singleflight.Group + time.Timer 控制 TTL
最轻量可控的做法是自己封装一个带 TTL 的 resolver,核心是避免并发重复查询 + 到期强制刷新。别用简单 map 加锁,要解决惊群问题。
推荐组合:sync.Map 存缓存项(key 是域名),singleflight.Group 拦截并发请求,每个值附带 time.Time 过期时间。查的时候先比对时间,过期就异步刷新(或阻塞重查)。
立即学习“go语言免费学习笔记(深入)”;
- 不要用
time.AfterFunc为每个记录启 goroutine 清理——万级域名会炸 - TTL 值建议取自 DNS 响应中的
RR.TTL,但 Go 标准库不暴露原始响应;退而求其次,统一设保守值(如 30s),或按业务容忍度设(API 依赖域名可设 5m) - 注意:缓存 key 必须包含查询类型(
AvsAAAA),否则 IPv4/IPv6 结果混用会出错
// 示例:简易带 TTL 的 LookupHost
func (r *cachedResolver) LookupHost(ctx context.Context, host string) ([]string, error) {
now := time.Now()
if ips, ok := r.cache.Load(host); ok {
if entry := ips.(cacheEntry); entry.expires.After(now) {
return entry.ips, nil
}
}
<pre class="brush:php;toolbar:false;">// 防并发查同一 host
resCh := r.group.DoChan(host, func() (interface{}, error) {
ips, err := r.resolver.LookupHost(ctx, host)
if err != nil {
return nil, err
}
entry := cacheEntry{ips: ips, expires: now.Add(30 * time.Second)}
r.cache.Store(host, entry)
return entry, nil
})
select {
case res := <-resCh:
if res.Err != nil {
return nil, res.Err
}
return res.Val.(cacheEntry).ips, nil
case <-ctx.Done():
return nil, ctx.Err()
}}
替换 http.Transport 的 DialContext 以复用缓存解析结果
HTTP 客户端不主动复用 DNS 结果,必须显式把自定义 resolver 注入到 http.Transport 才能生效。否则即使写了缓存逻辑,http.Get 仍走默认路径。
-
http.Transport.DialContext是唯一可控入口,需传入自定义net.Conn建立逻辑,其中调用你的缓存 resolver 获取 IP - 注意:若目标服务是 HTTPS,还要处理 SNI;
tls.ServerName应设为原始 host(不是 IP),否则 TLS 握手失败 - 别漏掉
KeepAlive和IdleConnTimeout配置,否则长连接复用率低,DNS 缓存价值打折扣 - 容器内若用 CoreDNS 或 kube-dns,TTL 往往设得很短(如 5s),这时客户端缓存 TTL 要比它略长,否则频繁穿透
调试 DNS 行为:怎么确认是不是真的在查 DNS?
光看日志没用,得抓包或打点验证。Go 程序是否真发了 DNS 查询,不能靠“好像变快了”判断。
- 用
tcpdump -i any port 53在宿主机或容器内抓包,过滤 UDP 53 端口,观察查询频次和目标 DNS 服务器 - 在自定义 resolver 的查询函数里加
log.Printf("DNS lookup: %s", host),比 HTTP 日志更早触发,能准确定位源头 - 检查
strace -e trace=connect,sendto,recvfrom -p $(pidof yourapp),看是否有 sendto 到 :53 的调用 - Alpine 容器中,
nslookup example.com返回的 TTL 是系统层缓存结果,不代表 Go 的行为;必须测 Go 进程本身
缓存逻辑写得再好,如果没被 HTTP 客户端实际调用,或者被 context.WithTimeout 频繁中断导致缓存 never hit,就等于没做。上线前务必用真实流量验证缓存命中率。











