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 | 域名 → IPv4 | github.com → 140.82.121.4 |
| AAAA | 域名 → IPv6 | github.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 有两条硬规则,踩坑率极高:
根域名(
example.com本身)不能用 CNAME。因为根域必须有 NS 和 SOA 记录,而 CNAME 规定"有 CNAME 就不能有其他记录",冲突。想让根域指向 CDN 怎么办?各家 DNS 服务商提供了非标准的 ALIAS / ANAME / CNAME Flattening——表面像 CNAME,实际由服务商在后台解析成 A 记录返回。这是绕过标准限制的实用方案。
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 的缓存。但要注意别缓存太久,否则故障切换失效。