Summary

在网络中发送消息又一个前提:需要知道对方的 IP 地址。TCP 要 IP 才能握手,TLS 要 IP 才能连接。

可你在浏览器里敲的是 github.com,在代码里写的是 https://eth-mainnet.g.alchemy.com——这些名字对网络层毫无意义,IP 包的头部里只有 140.82.121.4 这样的数字。

DNS(Domain Name System,域名系统)就是干这个翻译的。 一句话:把人记得住的名字,变成机器认得的地址。

为什么需要这一层?两个理由:

  • 好记:没人愿意背 140.82.121.4。
  • 解耦:服务器换 IP、加机器、切机房,域名不用变。用户和你的代码永远只认那个名字,底下的 IP 想怎么改就怎么改——这才是 DNS 真正的工程价值,它是互联网上第一层、也是最基础的一层间接寻址。

而这个"间接层"顺带给了它一堆额外能力:负载均衡、就近调度(CDN)、故障切换——因为翻译这一步是可控的,你可以对不同的人给出不同的答案。

DNS 也是互联网上最古老、规模最大的分布式数据库(1983 年至今),它的设计思路对做分布式系统的人很有启发。


域名的层级结构:一棵倒过来的树

DNS 能扛住全球规模,靠的是分层 + 分权——没有任何一台机器知道全部答案。

                    .  (根,root)
                    │
      ┌─────────────┼─────────────┐
     com           org            cn        ← 顶级域 TLD
      │             │              │
   ┌──┴──┐          │          ┌───┴───┐
 google github    wikipedia   com     edu   ← 二级域(你注册的那层)
   │      │                    │
  www    api                 taobao

一个完整域名从右往左读,层级从高到低:

    api.github.com.
    └┬┘ └──┬─┘ └┬┘└─ 根(通常省略不写)
     │     │    └─── 顶级域 TLD
     │     └──────── 二级域(你花钱注册的)
     └────────────── 子域(你自己随便定,不用注册)

关键点:每一层只知道"下一层归谁管",不知道完整答案。

  • 根服务器不知道 github.com 的 IP,但它知道所有 .com 域名去问谁。
  • .com 的服务器不知道 api.github.com 的 IP,但它知道 github.com 归哪台服务器管。
  • GitHub 自己的权威服务器才知道最终答案。

这就是"分权":任何一层都不需要掌握全局。GitHub 想加一个 test.github.com,只需要改自己的服务器,不用通知任何上级。全球几亿个域名的变更,不会给上层造成任何压力。

用邮政系统类比:总局只管把信分到各省,省局分到各市,市局才知道具体门牌号。 没有任何一级需要掌握全国所有地址。

这个"分层委派"的思路值得记住,它是超大规模系统的通用解法:不要试图让一个中心掌握全部,而是让每一层只负责"往下一层转交"。你在设计跨链路由表、多链适配架构时会遇到同样的问题。


一次完整解析

假设你的电脑第一次要访问 api.github.com,而所有缓存都是空的。

            递归查询                       迭代查询
 你的电脑  ─────────→  本地 DNS  ─────────→  根服务器
          ←─────────  服务器     ←─────────  "去问 .com 的"
          (最终答案)    │        ─────────→  .com 顶级域服务器
                      │        ←─────────  "去问 example.com 的"
                      │        ─────────→  example.com 权威服务器
                      │        ←─────────  "IP 是 93.184.x.x"

两种查询模式,区别在"谁跑腿"

递归查询(你 → 本地 DNS):"我不管你怎么查,给我最终答案。" 你的电脑发一个请求就等着,所有中间步骤都由本地 DNS 服务器代劳。

迭代查询(本地 DNS → 各级权威):"你知道就告诉我,不知道就告诉我该问谁。" 本地 DNS 服务器自己一级一级跑腿。

为什么这么分工? 因为如果每台终端设备都自己迭代查询:

  • 每台电脑都要直接压根服务器 → 根服务器立刻被压垮。
  • 缓存无法共享 → 同一个办公室 100 台电脑访问同一个网站,要查 100 次。

集中到本地 DNS 服务器之后,一次查询的结果能被成千上万个用户复用。 这就是典型的**“把重复工作集中起来做,顺便建立共享缓存”**——和 CDN、连接池是同一个思路。

用点外卖类比:你(递归)对外卖员说"我要一份宫保鸡丁",然后你就等着;外卖员(迭代)去问商场前台"川菜馆在几楼"、再上楼找店、再等出餐。 你只发一次话,跑腿的活全是他干的。

根服务器:13 个名字,上千台机器

全球有 13 组根服务器(a.root-servers.net 到 m.root-servers.net)。为什么只有 13 个?因为早期 DNS 响应要塞进一个 512 字节的 UDP 包,13 个是能装下的上限。

但"13"只是名字的数量,不是机器的数量。 实际上每一组都由成百上千台物理机器组成,分布在全球各地,通过 任播(Anycast) 技术共用同一个 IP 地址——你的请求会自动被路由到地理上最近的那台。

任播是个很妙的技术:多台服务器公布同一个 IP,由 BGP 路由自动把流量导向"最近"的一台。它同时实现了就近访问和抗 DDoS(攻击流量被分散到全球各节点),根服务器和主流公共 DNS 都靠它。


缓存与 TTL:DNS 能扛住全球流量的真正原因

上面那套完整流程看着挺长,但实际上 99% 的查询根本走不到那么远——因为每一层都有缓存。

浏览器缓存      (几分钟)
    ↓ 没有
操作系统缓存    (systemd-resolved / DNS Client 服务)
    ↓ 没有
本地 hosts 文件 (优先级最高,直接命中就不查了)
    ↓ 没有
本地 DNS 服务器缓存  ← 【命中率最高的一层】,服务着成千上万用户
    ↓ 没有
才真正走那套递归流程

每条 DNS 记录都带一个 TTL(Time To Live),单位是秒,表示"这个答案可以缓存多久"。

TTL 的取舍

TTL 是一个非常实际的运维权衡:

TTL好处代价
短(60s)改 IP 后几分钟就全网生效,故障切换快查询量大,DNS 服务器压力大,解析延迟影响更多请求
长(86400s)缓存命中率高,又快又省改了 IP 要等一天才全网生效

实战套路(很实用,记住它):

计划要迁移 IP 之前,提前 24 小时把 TTL 从 3600 调到 60。

等旧的长 TTL 缓存全部过期后,再执行切换——这时全网都在用 60 秒的缓存,切换后一分钟内生效。切换完成、稳定运行后,再把 TTL 调回去。

不这么做的话,你改了 IP,部分用户可能一整天还在访问旧服务器,而你在监控上看到流量诡异地不下降。

缓存带来的两个典型困惑

① “我明明改了 DNS,为什么还是访问旧地址?”

缓存没过期。而且是多层缓存,任何一层没过期都会挡住:

# macOS 清 DNS 缓存
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
# Linux (systemd)
sudo systemd-resolve --flush-caches
# Windows
ipconfig /flushdns
# Chrome 自己还有一层
chrome://net-internals/#dns → Clear host cache

② 负缓存(Negative Caching)

“查不到"这个结果本身也会被缓存。如果你刚注册域名、还没配好解析就去访问,得到 NXDOMAIN(域名不存在),这个"不存在"会被缓存一段时间——即使你几秒后配好了,还是访问不了,得等负缓存过期。

新域名刚配置时"死活不生效”,十有八九是踩了这个。别反复刷新,等就完了。


记录类型:不只是域名转 IP

DNS 存的不只是 IP,而是一张"域名 → 各种信息"的表。常用的几种:

类型作用例子
A域名 → IPv4github.com → 140.82.121.4
AAAA域名 → IPv6github.com → 2606:50c0::
CNAME域名 → 另一个域名(别名)www.example.com → example.cdn.com
NS这个域归哪些服务器管github.com → ns1.p16.dynect.net
MX邮件服务器发邮件给 @example.com 时用
TXT任意文本域名所有权验证、SPF 反垃圾邮件、ACME 挑战
SRV服务发现(协议+端口)微服务、SIP
CAA限定哪些 CA 能给这个域名签证书

几个值得单独说的:

CNAME 有两条硬规则,踩坑率极高:

  1. 根域名(example.com 本身)不能用 CNAME。因为根域必须有 NS 和 SOA 记录,而 CNAME 规定"有 CNAME 就不能有其他记录",冲突。

    想让根域指向 CDN 怎么办?各家 DNS 服务商提供了非标准的 ALIAS / ANAME / CNAME Flattening——表面像 CNAME,实际由服务商在后台解析成 A 记录返回。这是绕过标准限制的实用方案。

  2. CNAME 会多一次解析。查 www.example.com 拿到 CNAME 后,还得再查目标域名,多一个往返。链太长会明显拖慢首次访问。

CAA 记录值得配:它明确声明"只有 Let’s Encrypt 能给我签证书"。这样即使别的 CA 被攻破或被骗,合规的 CA 会拒绝签发

example.com.  CAA  0 issue "letsencrypt.org"

DNS 使用 UDP,以及它带来的安全问题

为什么用 UDP

UDP查询与响应比TCP节省至少一个RTT,一次 DNS 查询就几十字节,为它建立连接的开销比数据本身大好几倍。 丢了?客户端超时重发一次就行,三行代码的事。

什么时候会切到 TCP:

  • 响应超过 512 字节(现在有 EDNS0 扩展可以协商更大的 UDP 包,但仍有上限)
  • DNSSEC 签名数据很大,经常超限
  • 区域传送(主从服务器同步整个域的数据)

无连接 + 明文 = 攻击面

DNS 设计于 1983 年,当时完全没考虑安全:明文、无认证、基于 UDP。这三点凑一起,后果很严重。

① DNS 劫持 / 缓存投毒

因为 UDP 无连接、DNS 响应又没有签名,攻击者只要抢在真正的响应之前,伪造一个响应发给你,你就信了。

要伪造成功需要猜对:源端口 + 那个 16 位的 Transaction ID。16 位只有 65536 种可能,暴力猜是可行的——这就是著名的 Kaminsky 攻击(2008 年),它一度威胁到整个互联网。

缓解手段是源端口随机化(把可猜空间从 2^16 扩大到 2^32),但这只是提高了成本,没有根本解决——因为根源是"响应没有身份认证"。

② 中间人篡改 / 网络审查

明文传输意味着路径上任何设备都能看到你在查什么域名,也能直接返回一个假的答案。这是网络审查最常用的手段之一(DNS 污染),也是运营商插广告的老办法。

③ DDoS 放大攻击

这条是 DNS 被"武器化"的方式:攻击者伪造源 IP(填成受害者的 IP),向大量开放的 DNS 服务器发送小查询。DNS 服务器把大得多的响应发给受害者。

攻击者发出:  60 字节的查询(源 IP 伪造成受害者)
DNS 回复:    4000 字节的响应 → 打向受害者
放大倍数:    约 70 倍

攻击者用 1MB 的上行带宽,能打出 70MB 的攻击流量——而且流量来自成千上万台正常的 DNS 服务器,难以封禁。这也是为什么有些网络会限制 UDP。

现代解法:加密与签名

加密查询(防偷看、防篡改)

方案原理特点
DoT(DNS over TLS)DNS 跑在 TLS 上,专用 853 端口端口独立,容易被网络管理员识别和封禁
DoH(DNS over HTTPS)DNS 封装进 HTTPS 请求,443 端口和普通 HTTPS 流量混在一起,难以区分和封禁
DoQ(DNS over QUIC)跑在 QUIC 上新标准,兼具加密和低延迟

DoH 的争议很值得一提:它把 DNS 藏进 HTTPS,中间设备再也分不出来——这既保护了用户隐私,也让企业的内容过滤失效,还把大量 DNS 查询集中到了几家大公司(Google、Cloudflare)手里。隐私和可管控性在这里是直接冲突的,至今仍有争论。

签名验证(防伪造)

DNSSEC 给 DNS 记录加数字签名,形成一条从根开始的信任链。

但 DNSSEC 部署率一直很低(全球不到 10% 的域名启用),原因很现实:配置复杂、密钥轮换麻烦、签名让响应变大(更容易触发 TCP 回退,也加剧了放大攻击)。这是个"理论上正确但工程成本过高"的方案。

注意 DoH/DoT 和 DNSSEC 解决的是不同问题:前者保证"传输过程没被人偷看/篡改",后者保证"数据本身是真的"。理论上应该两个都要,实践中大部分人只用了 DoH。


DNS 的另一面:它是全球负载均衡器

回到开头说的"间接层"价值——因为翻译这一步可控,你可以对不同的人给出不同的答案。这让 DNS 成了互联网上最基础的调度工具。

① 轮询负载均衡:一个域名配多条 A 记录,DNS 每次返回时轮换顺序。

example.com. → 1.1.1.1, 2.2.2.2, 3.3.3.3   (顺序轮换)

最原始的负载均衡,零成本。缺点是没有健康检查——某台机器挂了,DNS 照样把用户导过去,而且缓存让它撤不下来。

② 就近调度(GeoDNS):根据查询来源的地理位置返回不同 IP。

北京的用户查 example.com  → 返回北京机房 IP
纽约的用户查 example.com  → 返回美东机房 IP

CDN 的核心机制就是这个。

③ 故障切换:配合健康检查,把挂掉的 IP 从解析结果里摘掉。但受 TTL 限制,切换不可能秒级生效——这也是为什么关键业务不能只靠 DNS 做故障切换,还需要四层/七层负载均衡。

一句话总结 DNS 在架构里的定位:它是最粗粒度、最上游的流量调度层,好处是零成本、全局生效,代价是受缓存和 TTL 限制,不精确、不实时。DNS系统里面的很多设计理念目前都有在大范围使用,理解它,并熟练的掌握它的设计理念,会对以后的编程生活有很大的帮助。


从 Go 的视角看

Go 有两个解析器

Go 的 DNS 解析有个容易踩坑的设计:它有两套实现。

  • 纯 Go 解析器:Go 自己实现的 DNS 客户端,直接读 /etc/resolv.conf 发查询。跨平台一致、不阻塞 goroutine。
  • cgo 解析器:调用系统的 getaddrinfo。能支持系统特有的配置(如 macOS 的 DNS 设置、NIS、mDNS),但每次调用会占用一个 OS 线程。

Go 会自动选择,但可以强制:

GODEBUG=netdns=go    # 强制纯 Go
GODEBUG=netdns=cgo   # 强制 cgo
GODEBUG=netdns=1     # 打印调试信息,看它到底用了哪个

什么时候需要关心? 当你的服务出现"DNS 解析偶发很慢"或"容器里解析行为和宿主机不一致"时,先用 netdns=1 看用的是哪个解析器——这两者读取配置的方式不同,行为可能不一样。

给 DNS 解析加超时(容易被漏掉)

http.Client 的 Timeout 覆盖整个请求,包括 DNS。但如果你想单独控制 DNS,或者在自定义 Dialer 里做解析:

dialer := &net.Dialer{
    Timeout:   5 * time.Second,   // 覆盖 DNS 解析 + TCP 连接
    KeepAlive: 30 * time.Second,
    Resolver: &net.Resolver{
        PreferGo: true,           // 用纯 Go 解析器
        Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
            d := net.Dialer{Timeout: 2 * time.Second}
            return d.DialContext(ctx, network, "8.8.8.8:53") // 指定 DNS 服务器
        },
    },
}
tr := &http.Transport{
    DialContext:       dialer.DialContext,
    ForceAttemptHTTP2: true,      // 别忘了这个
}

为什么要指定 DNS 服务器? 在容器里,/etc/resolv.conf 可能指向一个不靠谱的解析器;在跨境场景下,本地 DNS 可能被污染。明确指定可信的解析器,能避开一整类诡异问题。

手动查询各种记录

r := &net.Resolver{PreferGo: true}
ctx := context.Background()

ips, _ := r.LookupIPAddr(ctx, "github.com")     // A + AAAA
cname, _ := r.LookupCNAME(ctx, "www.example.com")
txts, _ := r.LookupTXT(ctx, "example.com")      // 验证记录、SPF
nss, _ := r.LookupNS(ctx, "example.com")

DNS 缓存:Go 不缓存!

这是个非常重要、又经常被忽略的事实:Go 标准库不做 DNS 缓存。

每次 Dial 都会重新解析。好处是改了 DNS 立刻生效;坏处是高频建连的场景下,DNS 查询会成为可观的开销和故障点。

不过实践中这个问题被 HTTP 连接池掩盖了,不重新建连就不会重新解析。所以:

全局共享 http.Client(而不是每次 new 一个)不只是省了 TCP 握手,还省掉了 DNS 查询。 这是同一条建议的又一个理由。

如果确实需要缓存(比如大量短连接、或者要防 DNS 抖动),可以用第三方库,或者自己在 Resolver 外面包一层带 TTL 的缓存。但要注意别缓存太久,否则故障切换失效。