你是不是也遇到过这种情况:明明域名解析是正常的,但页面就是打不开,换个网络却好了;或者刚切完CDN,本地怎么刷新都是老IP。这些问题绕来绕去,根子多半都在DNS解析过程上。我先直接说结论:在浏览器里敲下一个域名,到真正建立连接,中间至少要经过缓存检查、hosts文件检查、系统DNS缓存检查、递归查询、迭代查询这样一长串流程,任何一个环节卡住,表象都一样——“网页打不开”。
DNS(Domain Name System,域名系统)做的是把 www.example.com 这种人类友好名字翻译成 93.184.216.34 这样的机器地址。因为TCP/IP协议栈只认IP,不认名字。这篇文章不是讲概念,而是要带你亲手做三个实验:用 dig 命令看完整解析链路,用抓包工具看真实的DNS报文,最后用一段纯Python代码从零构造DNS请求,完整模拟一遍从根服务器到权威服务器的迭代查询。做完这套模拟,你对“域名访问”背后发生的事情,会比大多数人清晰得多。本文适合刚接触网络协议的开发、运维同学,也适合纯好奇“按下回车键后到底发生了什么”的读者。
1. 打开一个网页前,系统到底做了几次查询
1.1 浏览器是第一道关卡:本地缓存为何优先
浏览器里输入域名后,第一件事不是发起网络请求,而是先翻自己的“小本本”。以 Chrome 为例,它内部维护了一份独立的DNS缓存,你可以在地址栏输入 chrome://net-internals/#dns 查看当前缓存了哪些域名、对应的IP、以及过期时间。为什么浏览器要自己再存一层?操作系统也有DNS缓存,但浏览器想更快、更可控——它可以针对同一个域名做连接复用,可以在服务器返回多个IP时做负载均衡,甚至缓存策略也可以独立于系统。
这里有个很多人不知道的细节:浏览器的缓存时间不一定完全遵守DNS响应里的TTL(Time To Live,存活时间)。有的浏览器对解析失败的域名也会缓存一小段时间,这就是为什么你改了域名解析后,浏览器可能在一段时间内依然倔强地连接旧IP。如果线上切换服务器IP后,用户反馈“怎么还在访问老地址”,排查顺序是先清浏览器缓存,再清系统缓存,这个顺序别搞反。
1.2 操作系统接过第二棒:hosts文件与系统级缓存
浏览器缓存查不到,请求会落到操作系统。系统第一步是检查hosts文件,这个文件的优先级在所有操作系统上都是最高的。Linux 和 macOS 是 /etc/hosts,Windows 是 C:\Windows\System32\drivers\etc\hosts。如果你在里面写了 127.0.0.1 www.example.com,那么不管网络上的DNS服务器返回什么,系统都会直接用这个地址。
hosts文件在开发环境里用来做本地联调非常方便,但也经常制造灵异事件。我见过不少同事排查半天“为什么DNS解析还是旧IP”,最后发现hosts里残留了一行几年前的测试配置。所以当你觉得DNS“不生效”的时候,第三步就去看hosts文件里有没有中奖。确认完hosts,系统继续查自己的DNS缓存——Windows上可以执行 ipconfig /displaydns 查看缓存内容,Linux上主要通过 systemd-resolved 或 dnsmasq 来看,macOS 则隐藏在 mDNSResponder 里。这些缓存全部落空,系统才会真正向外发出网络请求。
1.3 本地DNS服务器:处理递归查询的“总客服”
系统把查询请求交给本地配置的DNS服务器——可能是路由器下发的、可能是公司内网指定的、也可能是你手动填的 8.8.8.8 / 114.114.114.114 / 223.5.5.5。对操作系统来说,这一步非常简单:把域名发给它,让它去查,查到结果再告诉我。这种“你帮我查到底”的方式,在DNS术语里叫递归查询(Recursive Query)。
本地DNS服务器接单后,同样先查自己的缓存。如果缓存里没有,它就替你去问别人。这里要理解一个关键点:对普通用户而言,通常只需要和这一台本地DNS服务器打交道,它会负责把后面所有复杂的迭代过程全部消化掉,最后只给你一个简洁的答案。所以从体验上看,你发出去一次查询,很快就收到了结果,你完全看不到背后发生了什么。想看到,就需要用到后面章节里的 +trace 参数和手写解析器。
1.4 根、顶级域、权威域:三层迭代查询的分工
本地DNS服务器串起三股查询:先去问根服务器(Root Server),全球目前有13组根服务器,由数百台物理节点组成。根服务器不认识具体的 www.example.com,但它知道“com 这个顶级域的服务器在哪”。于是本地DNS拿到了顶级域服务器(TLD Server)的地址,再去问它,得到的回答是“example.com 的权威域名服务器(Authoritative Server)在哪”。最后再去问权威服务器,这才拿到具体的 A 记录(IPv4地址)或 CNAME 记录(别名)。
这种“一层层问下去”的方式叫迭代查询(Iterative Query)。递归查询是“我替别人跑腿”,迭代查询是“我只告诉你下一步去哪儿,你自己跑”。一次完整的解析,用户侧通常只发了一个请求,但背后大概率经历了多轮接力。好在每一层都有缓存兜底,否则全球几十亿用户的查询量早就把根服务器打爆了。理解了这三层结构,后面所有模拟实验才有意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. dig +trace:顺着根服务器跑一遍完整迭代
2.1 dig基础输出:一条记录的五个段落
学习DNS,最趁手的命令行工具就是 dig。Linux和macOS自带,Windows用户可以在WSL里使用,或者安装 bind-utils / 使用nslookup凑合。执行一条最简单的 dig www.example.com,输出会分成几个段落:最上面是全局统计,告诉我们用的哪个版本、查询了哪个服务器;接着是 QUESTION SECTION,表示“你问了什么”;然后是 ANSWER SECTION,这是真正想要的答案;再往下是 AUTHORITY SECTION(权威服务器信息)和 ADDITIONAL SECTION(附加信息)。
看答案的时候,每行记录除了IP地址,还会显示TTL——就是这个记录还能在缓存里存活多少秒;以及CLASS(通常是IN,表示Internet)和TYPE(记录类型)。如果你只想快速拿到IP,用 dig +short www.example.com 就会只输出一个IP地址列表。但想看完整解析链,得用 +trace 参数。
2.2 +trace参数下看到的完整链路
dig +trace www.example.com 的输出非常直观,它会从根服务器开始,逐级往下走,把每一层返回的NS记录(域名服务器记录)和对应IP都打出来。输出会分几大块:第一块是根服务器返回的 com. 的NS记录,第二块是 com 顶级域服务器返回的 example.com. 的NS记录,第三块才是 www.example.com 的A记录。
以 example.com 为例,你会看到类似这样的结构:
code复制. 518021 IN NS a.root-servers.net.
. 518021 IN NS b.root-servers.net.
...
com. 172800 IN NS a.gtld-servers.net.
com. 172800 IN NS b.gtld-servers.net.
...
example.com. 86400 IN NS a.iana-servers.net.
...
www.example.com. 86400 IN A 104.16.132.229
注意 +trace 会直接忽略本地配置的DNS服务器,强制从根开始逐层迭代,所以它展示的就是本地DNS服务器在“冷缓存”状态下替你做的那些事。很多人用 dig 查了很久,却从没注意过这个参数,我强烈建议你在这里停两分钟,观察一下三层查询的衔接逻辑——尤其是每一层返回的NS记录,其实就是下一层服务器的“门牌号”。
2.3 实战:指定DNS服务器与查询不同记录类型
dig @8.8.8.8 www.example.com 表示向指定服务器发查询,而不是用系统默认配置。这个功能在做对比排查时很有用:如果本地DNS解析结果和公共DNS不一致,往往是缓存配置或者内网策略导致的。加参数 -t 可以查不同类型记录:dig -t A example.com、dig -t MX example.com、dig -t NS example.com、dig -t CNAME www.example.com。
CNAME场景值得多说几句。如果某个域名配置了CDN加速,它的A记录查询返回的往往不是IP,而是另一个域名。系统拿到这个别名后,会立刻对新域名发起二次查询,最终才拿到IP。很多工具不会主动告诉你这个过程,你用 +trace 一看,会发现解析链路上多出了一层“跳转”。这就是为什么同一个域名,在不同地区、不同运营商网络下解析出的IP可能完全不一样——CDN节点分配逻辑是动态的。后面第五章我会再展开讲这个坑。
3. 抓DNS网络包:看清一次请求从发起到响应的全过程
3.1 tcpdump抓包:不到100字节的UDP报文
命令行的抽象能力再强,也不如抓包来得实在。在Linux或者WSL终端里执行下面的命令监听网卡上的DNS流量:
bash复制sudo tcpdump -i eth0 -s 0 port 53 -vv
然后另开一个终端执行 nslookup example.com 触发一次真实查询。正常情况你会看到一行UDP报文:源端口是随机的高位端口,目的端口是53,数据区只有几十到一百来字节。和HTTP动辄几KB的包相比,DNS报文堪称小巧精致。
这里解释一个很多人误解的点:DNS默认跑在UDP上,不是因为UDP“快”,而是因为这类查询本身就是一次请求一次响应,包又特别小,没有必要走TCP三次握手建立连接。只有在响应包过大被截断(truncated)时,或者做区域传送(zone transfer)这种大批量数据传输时,才切换到TCP。你抓包时如果看到TCP 53端口的流量,大概率不是普通查询,要留个心眼。
3.2 Wireshark看交互:事务ID如何配对请求与响应
tcpdump 在终端里看个热闹,想看更清晰的交互,推荐用 Wireshark。打开捕获,设置过滤器 dns.qry.name == "example.com",只保留这个域名的查询和响应。点开任意一条UDP报文,DNS层会显示一个 Transaction ID——事务ID。这个16位ID在每次查询时随机生成,响应报文必须原样回填。
为什么要设计这个字段?因为客户端发出去的查询可能因网络抖动而重发,或者同时发起多个域名的查询。有了事务ID,客户端才能一一对账:哪个响应对应哪个请求,哪个响应已经来晚了可以丢弃。你可以试着在Wireshark里同时发起多个不同域名的查询,观察每个请求和它的响应确实保持着相同的事务ID,这种“对账机制”在网络协议里随处可见。
3.3 报文结构解析:头部三段话说明一切
不管请求还是响应,DNS报文都是固定骨架:12字节的Header加若干个分区。Header里六个字段:事务ID、Flags(标志位)、QDCOUNT(问题数)、ANCOUNT(答案数)、NSCOUNT(权威记录数)、ARCOUNT(附加记录数)。请求通常QDCOUNT为1,其余为0;响应里则根据实际携带的数据填充。
Flags标志位里值得记住几个:QR表示“这是请求还是响应”;RD表示“我要求递归”;RA表示“服务器是否支持递归”;AA表示“这个回答是不是来自权威服务器”;TC表示“响应被截断,请用TCP重试”。Wireshark会把标志位图解成一行行复选框,非常直观。你亲手抓一次包,再对照这个结构看一遍,对递归和授权的理解会瞬间清晰很多——尤其是你手动模拟迭代查询的时候,会发现很多问题就出在这些标志位没设对。
4. 手写一个迷你解析器:用Python从头模拟DNS查询
4.1 用Python构造最原始的DNS请求
命令工具看完了,现在动手写代码。我用Python构造一个最小的DNS查询报文,发到DNS服务器并接收响应。先看请求报文的构造逻辑:
python复制import socket
import struct
import random
def build_query(domain, qtype=1):
# qtype: 1=A记录, 2=NS记录, 5=CNAME, 15=MX
txid = random.randint(0, 65535)
flags = 0x0100 # 标准查询,开启递归
header = struct.pack('>HHHHHH', txid, flags, 1, 0, 0, 0)
qname = b''
for part in domain.split('.'):
qname += bytes([len(part)]) + part.encode()
qname += b'\x00' # 域名结束标志
question = qname + struct.pack('>HH', qtype, 1)
return txid, header + question
def send_query(domain, server='223.5.5.5', port=53, qtype=1):
txid, packet = build_query(domain, qtype)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
sock.sendto(packet, (server, port))
data, _ = sock.recvfrom(4096)
recv_txid = struct.unpack('>H', data[0:2])[0]
if recv_txid != txid:
raise RuntimeError('事务ID不匹配,响应包可能不是我的')
return data
这段代码里最核心的是 struct.pack('>HHHHHH'),把头部六个字段按网络字节序(大端序)拼进12字节。接下来把域名按“长度+内容”的格式编码——这是DNS协议一个很有意思的设计。www.example.com 在报文里不是直接复制字符串,而是先放一个字节表示下一段长度3,再放 www,再放长度7和 example,以此类推,最后以 0x00 收尾。这样既节省空间,又能方便解析器逐段读取。
4.2 从根出发:完整模拟递归查询的骨架
上面的代码做的是递归查询——把问题丢给一台服务器,让它替我们跑腿。但“模拟DNS通过域名访问”最完整的姿势,是手动跑一遍迭代查询:从根服务器开始,逐层往下问。下面这段代码就是个简化但完全能跑的版本:
python复制import socket
import struct
import random
import time
TYPE_A = 1
TYPE_NS = 2
ROOT_SERVERS = [
'198.41.0.4', # 根服务器 A
'199.9.14.201', # 根服务器 B
'192.33.4.12', # 根服务器 C
]
def parse_name(data, offset):
labels = []
end = offset
jumped = False
while True:
length = data[offset]
if length == 0:
if not jumped:
end = offset + 1
break
if length & 0xC0 == 0xC0:
# 遇到压缩指针,跳转到报文其他位置
ptr = struct.unpack('!H', data[offset:offset + 2])[0] & 0x3FFF
if not jumped:
end = offset + 2
jumped = True
offset = ptr
continue
labels.append(data[offset + 1:offset + 1 + length].decode())
offset += length + 1
return '.'.join(labels), end
def parse_response(data):
ancount = struct.unpack('>H', data[6:8])[0]
offset = 12
_, offset = parse_name(data, offset)
offset += 4 # 跳过 QTYPE + QCLASS
records = []
for _ in range(ancount):
name, offset = parse_name(data, offset)
rtype, rclass, ttl, rdlen = struct.unpack('>HHIH', data[offset:offset + 10])
offset += 10
rdata_start = offset
if rtype == TYPE_A and rdlen == 4:
ip = '.'.join(str(b) for b in data[rdata_start:rdata_start + 4])
records.append((rtype, name, ip, ttl))
elif rtype == TYPE_NS:
ns_name, _ = parse_name(data, rdata_start)
records.append((rtype, name, ns_name, ttl))
offset = rdata_start + rdlen
return records
def raw_query(domain, server, qtype=TYPE_A):
txid, packet = build_query(domain, qtype)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(5)
start = time.time()
sock.sendto(packet, (server, 53))
data, _ = sock.recvfrom(4096)
cost = time.time() - start
return parse_response(data), cost
def resolve_from_root(domain):
server = ROOT_SERVERS[0]
for depth in range(1, 8):
records, cost = raw_query(domain, server, TYPE_A)
a_records = [r for r in records if r[0] == TYPE_A]
if a_records:
print(f'第{depth}轮: 从 {server} 直接拿到 A 记录 {a_records[0][2]}')
return a_records
ns_records = [r for r in records if r[0] == TYPE_NS]
if not ns_records:
print(f'第{depth}轮: 既没有A记录也没有NS记录,解析终止')
return None
ns_domain = ns_records[0][2]
ns_a, _ = raw_query(ns_domain, '223.5.5.5', TYPE_A)
next_server = [r[2] for r in ns_a if r[0] == TYPE_A][0]
print(f'第{depth}轮: {server} 把我引到了 {ns_domain} -> {next_server}')
server = next_server
return None
if __name__ == '__main__':
resolve_from_root('www.example.com')
这段代码模拟的流程是:轮询根服务器,拿到com顶级域的NS域名;再通过公共DNS把这个NS域名解析成IP;把IP设为下一轮要查询的服务器;继续问,直到有人直接返回A记录。运行之后,你会看到控制台打印出“第1轮被引到com服务器”“第2轮被引到example.com权威服务器”“第3轮拿到A记录”这样的日志。这就是完整迭代查询的骨架。
有个细节要说明:真实解析器在拿到NS记录后,会优先读取响应报文Additional区域里附带的“胶水记录”(Glue Record),直接拿到NS服务器的IP,不用再额外查一轮。我的简化版本用公共DNS做二次解析,逻辑等价,但少了一个优化点,理解原理时二维即可。
4.3 用现成库快速验证思路
工作中一般不会手写这么多底层代码,用 dnspython 库就能快速验证解析思路:
bash复制pip install dnspython
python复制import dns.resolver
answers = dns.resolver.resolve('www.example.com', 'A')
for rdata in answers:
print(rdata)
换用不同解析服务器可以用 Resolver 对象:
python复制import dns.resolver
resolver = dns.resolver.Resolver()
resolver.nameservers = ['8.8.8.8']
answers = resolver.resolve('www.example.com', 'A')
for rdata in answers:
print(rdata)
dnspython封装好了所有报文细节,适合生产脚本使用。但我还是建议每个人都手写一遍上面的迷你解析器,因为只有亲自拼过一次DNS报文、亲自解析过一次带压缩指针的响应包,你才会对“DNS协议为什么设计成现在这样”有体感——比如压缩指针机制,就是为了减少UDP包大小而生的。
4.4 把模拟过程加上日志:每一步的耗时和命中率
在上面的代码基础上,我们可以顺手加上耗时统计,跑100次解析求出平均耗时。我实测下来,直接查本地DNS(有缓存)通常需要1到10毫秒;走完整迭代链路,平均需要60到120毫秒;如果某层服务器响应慢,总耗时会被放大到几百毫秒。这些数字看着不大,但在高并发场景下会被急剧放大。
这就是缓存的巨大价值。如果没有缓存,每次访问都要从根开始跑三轮以上,全球网民光等待时间就是天文数字。所以在模拟实验里加日志、统计命中率,不是无聊的额外工作——它会帮你建立对“为什么DNS要分层缓存”的直观认知,而不是停留在书本上的“因为有缓存所以快”。
5. 模拟过程中最容易踩的坑:缓存、CNAME与TTL的相爱相杀
5.1 改完DNS不生效:八成是缓存在作怪
我曾遇到过最典型的线上问题是:把A记录从一台服务器切到另一台,过了很久,电脑上访问还是老IP。完整的排查链路应该是这样的:
nslookup 域名 8.8.8.8,让Google公共DNS直接解析,验证源站解析结果是否已经变了;nslookup 域名 你本地的DNS,看本地DNS返回的是新IP还是旧IP,这一步能区分问题在公共解析还是内网缓存;- 查看返回值里的TTL字段,如果它还很大,说明这条记录是近期刚解析的,缓存策略强制让它活到了现在;
- 到本机清缓存。Chrome 用
chrome://net-internals/#dns清除浏览器缓存,Windows 用ipconfig /flushdns,macOS 用sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder,Linux 用sudo resolvectl flush-caches; - 顺便检查hosts文件有没有残留配置。
我遇到过一次比较难缠的情况:改了解析五分钟后,手机4G网络已经访问到新IP,但办公室Wi-Fi下的一批电脑还连在旧IP上。最后查明原因是公司内部DNS服务器把TTL手动改成了14400秒,也就是4个小时。所以改完解析后,等一个TTL周期才是正常预期,某些内网策略会故意放大TTL来减少出口请求量,但这会给变更操作带来很大困扰。
5.2 CNAME连环套与CDN:解析结果为什么总在变
模拟CNAME解析时会发现一个有趣的现象:dig www.cdn-site.com 返回的不是IP,而是另一个域名,继续查这个域名才拿到IP,如果CDN根据地理位置或运营商选择节点,同一时刻不同位置查询结果会完全不同。这也是“为什么同一个域名,大家解析出的IP不一样”的根源。
多做几轮模拟,你还会看到某些域名是CNAME套CNAME:www 指向 edge.example.net,edge.example.net 又指向 cdn.example2.com。每一层都要额外消耗一次查询。在抓包或者写解析器的时候,如果不处理CNAME记录类型,你会看到明明“解析失败”的域名实际上只是多拐了一道弯。
日常运维中,CNAME还有一个容易踩的坑:它不能和其他记录类型共存于同一个节点。你可以把 www.example.com 配CNAME指向CDN,但不能再给它配MX记录或A记录,否则在权威服务器上会产生冲突配置,部分DNS解析器会返回异常结果。我自己就帮人排查过这种“一会儿能解析一会儿不能”的诡异问题,最后发现就是同一主机名下混用了A和CNAME。
5.3 模拟实验中的抓包前提
做抓包模拟前,先确认几件小事,否则容易白忙活:
- 要有权限。
tcpdump在Linux下需要root或者sudo,Wireshark在Windows/macOS下需要安装时勾选抓包服务组件; - 选对网卡。如果有多个网卡,用
ip addr或ifconfig确认流量走的是哪块,WSL里注意用的是虚拟网卡而不是物理网卡; - 过滤条件用
port 53,最好别额外加host,否则可能漏掉本机发给不同DNS服务器的流量; - 如果浏览器开启了加密DNS,53端口上可能抓不到流量。Chrome的“安全DNS”默认开启时,DNS查询会被封装成HTTPS请求,走443端口,而且报文内容是加密的,你抓包只能看到一堆TLS握手,看不到明文域名。
确认完这些前提,再回到第3章的实验,你看到的才是一次干净的、真实的DNS交互过程。
5.4 一个实战排错案例:解析正常却上不了网的完整排查链路
最后分享一个真实的排错经历。某个下午接到反馈,说公司一台测试服务器访问外部接口偶发超时。我用 dig 查域名,解析正常;用 ping 测IP,通了;再用 curl 访问,偶尔连接超时。我在服务器上抓包,发现DNS请求发出去后,响应有时候要3秒多才回来。一开始以为是网络抖动,后来注意到这台服务器的 /etc/resolv.conf 里配置了两个nameserver,第一个是已经下线的内网DNS,第二个才是正常的公网DNS。系统在发完第一个查询后,等它超时(默认5秒)才去问第二个,于是偶发超时就这么产生了。
修复很简单:删掉第一个nameserver配置,问题立刻消失。但这个案例让我印象深刻,因为它完美演示了“解析正常但实测异常”的排查死角——单看 dig 的结果没问题,可系统实际的解析路径里藏着一个早已失效的节点。后来我把这套排查流程固定成了习惯:先明确当前机器到底在用哪个DNS服务器,再看配置的顺序和优先级,最后才考虑解析结果正确与否。模拟DNS解析的过程中,每一次实验都值得问一句“当前这个请求实际发给了谁”,这会帮你省下大量排查时间。
最后再分享一个小技巧:写模拟脚本时,把每一步的查询目标、耗时、返回记录都打印出来,就像上面代码里那样。这样跑几次之后,你对“域名解析到底经历了多少跳”会形成非常稳定的直觉。以后无论遇到网页打不开、接口超时、CDN回源异常,只要症状和域名相关,你都能瞬间判断出问题最可能出在哪一层——这比任何监控告警都来得快。
