自建 DNS 实测:DoH、DoT 与 UDP 的延迟对比
前一篇讲 sing-box 的 DNS 配置怎么写才对,这一篇回答「到底配哪个上游、用哪种协议」。
本文所有数字来自本机真实运行:Apple M1 Pro / macOS,Python 3.14.7 手写 DNS 报文,2026-09-15 17:01 至 17:23 之间完成。脚本放在/tmp/dnsbench/,关键片段贴在文中。
DNS 延迟高度依赖你到上游 anycast 节点的物理路径,本文的数字只在这个网络环境下成立,最后有一节专门说明不能外推到哪。
1. 测量环境
延迟测试里,环境就是结论的一半。先把会影响数字的东西列清楚。
机器与负载
| 项 | 值 |
|---|---|
| 机型 | Apple M1 Pro,10 核 arm64 |
| 系统 | Darwin 25.6.0(macOS 15) |
| Python | 3.14.7(Homebrew) |
| OpenSSL | 3.6.4 |
| sing-box | 1.14.1(go1.26.8,官方 darwin-arm64 发行版) |
主矩阵跑在 17:01:13 到 17:04:29,开始时 load average 5.60 / 5.84 / 5.89,结束时 5.54 / 5.62 / 5.78。10 核机器上这个负载有其它进程在跑,但它主要是 CPU 密集型的,对网络往返的干扰有限。HTTP/2 那一轮重跑在 17:22,load average 4.62 / 5.01 / 5.54。sing-box 那轮在 17:09 到 17:15,load average 在 7.29 到 7.70 之间。
网络路径
1 | $ route -n get default | grep -E 'gateway|interface' |
这是一台接在办公室有线网里的机器,出网走 10.194.247.9 这个网关。没有 sing-box、clash 这类本机代理在跑,三个显式代理开关也全是 0:
1 | $ pgrep -l -f 'sing-box|clash|mihomo|surge|v2ray|xray|tun2socks' |
但没有本机代理不等于裸连。事后核对路由表发现,这台机器上跑着企业 VPN 客户端 GlobalProtect(Palo Alto),utun2 接口上有 960 条路由,而 en0 只有 4 条。被测算的端点因此分成两组:
| 出口 | 端点 |
|---|---|
| 走 utun2 隧道 | 8.8.8.8、8.8.4.4、9.9.9.9、104.16.248.249(cloudflare-dns.com 的 DoH 地址)、100.67.60.45 |
| 走 en0 直连 | 1.1.1.1、1.0.0.1、223.5.5.5、223.6.6.6、119.29.29.29、1.12.12.12、120.53.53.53、149.112.112.112 |
也就是说 Google 三个协议、Quad9 的 9.9.9.9、Cloudflare 的 DoH 域名地址,都是在隧道里测的:它们的 135 到 190 毫秒里含有隧道自身的往返与封装开销。只看同一组内部仍然可比(都加同样的隧道成本),跨组比较(例如拿隧道的 8.8.8.8 比直连的 223.5.5.5)会把网络路径的差别算到服务商头上。这是本文测量口径的一个已知缺陷,原文初稿写成「裸连」是错的。
系统自身的 DNS 指向 100.67.60.45 和 100.67.70.61,两个都在 100.64.0.0/10 这个运营商级 NAT 段里,应该是网关设备自己提供的解析。下面的测量不走系统解析器,直接对上游发查询。
2. 测量方法
2.1 端点矩阵
5 个上游 × 3 种协议。协议端口按各自的 RFC:UDP 用 53(RFC 1035),DoT 用 853(RFC 7858),DoH 用 443(RFC 8484)。
| 上游 | UDP/53 | DoT/853 连接地址(SNI) | DoH/443 URL |
|---|---|---|---|
| Cloudflare | 1.1.1.1 | 1.1.1.1(cloudflare-dns.com) |
https://cloudflare-dns.com/dns-query |
| 8.8.8.8 | 8.8.8.8(dns.google) |
https://dns.google/dns-query |
|
| AliDNS | 223.5.5.5 | 223.5.5.5(dns.alidns.com) |
https://dns.alidns.com/dns-query |
| DNSPod | 119.29.29.29 | 1.12.12.12(dot.pub) |
https://doh.pub/dns-query |
| Quad9 | 9.9.9.9 | 9.9.9.9(dns.quad9.net) |
https://dns.quad9.net/dns-query |
DoT 和 DoH 那一列用的是「域名解析出来的地址」,不是服务商的招牌 IP。这一步不是可有可无:DNSPod 的 dot.pub 实际解析到 120.53.53.53 和 1.12.12.12,并不包含 119.29.29.29;Cloudflare 的 cloudflare-dns.com 解析到 104.16.248.249,也不是 1.1.1.1。后面第 3 节会看到,这些地址的可达性差别很大。
2.2 手写 DNS 报文
三种协议用的都是同一个查询报文,用 struct 拼出来,不依赖任何 DNS 库:
1 | def build_query(name, qtype=1, qid=0): |
三种传输的计时口径:
1 | # UDP:sendto 到 recvfrom 返回,一个 RTT,没有握手 |
TCP 连接时间和 TLS 握手时间在构造函数里分别计时,查询时间只算「请求写完到响应读完」。这样能把「握手一次性的开销」和「每个查询的边际开销」分开,这两件事在选型时的意义完全不同。
dig 只用了一次,查 dot.pub 的 A 记录用来确认 DNSPod 的 DoT 到底该连哪个地址。
2.3 冷热两组域名控制缓存
「DNS 延迟」这个说法本身是模糊的,缓存命中与否能差一个数量级,所以分成两组分别测。
热组(hot):20 个常见域名(baidu.com、github.com、cloudflare.com……),跑 30 次查询。这些名字在公共解析器上几乎必然是缓存命中,测到的是「解析器自己的响应速度」,也是日常使用中最常见的路径。30 个样本 = 20 个不重复名字 + 前 10 个再来一遍,所以第 21 到 30 个查询本身就是缓存命中的。
冷组(cold):每次现生成一个随机二级域名,形如 <18 位随机串>.<6 位随机串>-probe.com,跑 20 次。这些名字必然返回 NXDOMAIN,而且因为二级域名是随机的,任何缓存都拦不住。这里用到的是 RFC 8020 的语义:一条 NXDOMAIN 应答意味着这个名字以及它下面的所有名字都不存在,所以解析器完全有权把整棵子树负缓存。把随机性放在二级域名而不是三级,就是绕开这个负缓存的剪枝。冷组测到的是「解析器做一次完整递归」的最坏路径。
1 | def cold_names(n): |
实测确认这些名字返回 NXDOMAIN(rcode = 3):
1 | dwtgmlqucavltalsfy.dwtgml-probe.com rcode=3 ans=0 222ms |
2.4 统计口径
分位数用线性插值,和 numpy 默认口径一致。样本量有限:热组 30 个样本时 p95 落在最大的两个样本之间,冷组 20 个样本时 p95 已经很接近最大值。这个 p95 不是生产环境意义上的尾部延迟,它只够看「有没有明显的抖动」。尾部延迟要上千个样本加持续观测,这里做不到,后面的置信度声明里会再提。
失败判定:UDP 3 秒内没收到应答算超时;DoT/DoH 的连接建立异常或响应读取异常算失败。每个端点的失败率按 失败次数 / 总请求次数 算。
3. 可达性:先看谁能连上
延迟数字只有在连得上的前提下才有意义。用 4 秒超时对所有 IP:端口组合做 TCP 连接探测:
1 | 1.1.1.1 :853 cloudflare-dns.com OPEN 220ms |
几条值得单独拎出来:
1.1.1.1 的 443 和 853 是不可用的,但 53 可用,而且 DoH 走 CDN 域名可用。 1.1.1.1:443 连续 8 次全部 3 秒超时,1.0.0.1:443、1.0.0.1:853 同样全超时,报错文本就是裸的 TimeoutError: timed out,不是 RST 也不是 ICMP 不可达。但 1.1.1.1:53 连续 8 次全部 OPEN(230 到 250 毫秒),cloudflare-dns.com 解析出来的 104.16.248.249:443 也正常。所以断的是到 1.1.1.1(1.1.1.0/24)与 1.0.0.1(1.0.0.0/24)这两个地址上 443/853 端口的路径。
1.1.1.1:853 的状态会变。 第一次预检时它是超时的,主矩阵里 TCP 却连上了、TLS 被重置,再连续探测 10 次以后又变成整条路径静默丢包:
1 | === 1.1.1.1:853 连续 10 次 TCP+TLS === |
前面 4 次 TCP 能连上但 TLS 立刻被 RST,之后 6 次连 TCP 都连不上了。TCP 层通、TLS 层被重置,这个组合通常指向基于 SNI 的中间设备;连接次数多了以后整条路径干脆静默丢包,像是触发了限速或临时封禁。不管是哪种,结论一样:这个端点在这条网络路径上不可用,不是「偶尔慢一下」。
119.29.29.29 的 853/443 超时,但 dot.pub 解析出来的地址两个端口都通。 这两个是同一个服务商的不同接入地址,可用性完全不同。如果你的配置里把 DoT 服务器写成 119.29.29.29:853(这是很多教程的写法),在这台机器上就是连不上。
149.112.112.112 全端口超时,9.9.9.9 两个端口都通。 Quad9 的两个 anycast 地址,一个通一个不通。
所以第一条实操建议:配 DoT/DoH 时要用域名,让客户端去解析,不要直接把服务商的招牌 IP 写死。 写死 119.29.29.29 或 149.112.112.112 会直接撞上不通的那个地址。
4. 延迟矩阵
4.1 UDP/53
热组 30 次、冷组 20 次:
| 上游 | 地址 | 组 | 成功/失败 | p50 | p95 | max | 失败率 |
|---|---|---|---|---|---|---|---|
| Cloudflare | 1.1.1.1 | hot | 30/0 | 235.7 | 286.1 | 331.2 | 0.0% |
| Cloudflare | 1.1.1.1 | cold | 20/0 | 243.4 | 676.6 | 819.6 | 0.0% |
| 8.8.8.8 | hot | 30/0 | 137.8 | 392.6 | 463.6 | 0.0% | |
| 8.8.8.8 | cold | 20/0 | 140.9 | 166.1 | 175.4 | 0.0% | |
| AliDNS | 223.5.5.5 | hot | 30/0 | 60.3 | 108.5 | 139.5 | 0.0% |
| AliDNS | 223.5.5.5 | cold | 19/1 | 308.7 | 544.7 | 743.4 | 5.0% |
| DNSPod | 119.29.29.29 | hot | 30/0 | 75.0 | 136.7 | 289.5 | 0.0% |
| DNSPod | 119.29.29.29 | cold | 20/0 | 523.5 | 589.5 | 826.6 | 0.0% |
| Quad9 | 9.9.9.9 | hot | 29/1 | 182.1 | 691.5 | 962.5 | 3.3% |
| Quad9 | 9.9.9.9 | cold | 20/0 | 153.3 | 317.1 | 326.8 | 0.0% |
(单位毫秒,后同。)
几个观察:
热组 p50 的排序是 AliDNS 60.3 < DNSPod 75.0 < Google 137.8 < Quad9 182.1 < Cloudflare 235.7。 两个国内上游比三个国外上游快一倍以上,符合物理距离的预期。Cloudflare 的 235.7 毫秒比 Google 的 137.8 还慢,但这不是「Cloudflare 更远」:第 3 节里到 1.1.1.1:53 的 TCP 连接本身就要 230 到 250 毫秒,也就是这条路径的一个 RTT 就值这么多。UDP 查询延迟几乎正好等于这一个 RTT,说明解析器在热路径上没有做任何额外的递归,收到就答。
冷组的数字远高于热组,但幅度不一致。 DNSPod 从 75.0 涨到 523.5(7 倍),AliDNS 从 60.3 涨到 308.7(5 倍),而 Google 只从 137.8 涨到 140.9,几乎没动。这里能确定的是事实部分:Google 处理一个从未见过的随机 .com 名字,耗时和它处理一个缓存命中的常见名字一样;国内两家则要多花 250 毫秒左右。机制我没有做分段验证去定位,可能是 Google 的任何一个本地节点都能就近拿到 .com 的 NXDOMAIN,也可能是它的递归路径本身就短。要注意别把这一条读成「Google 的冷查询更快」:它的热查询本来就慢(137.8 对 AliDNS 的 60.3),冷热几乎没差别只是说明它家热路径没有比冷路径快多少。
AliDNS 冷组有 1 次失败,报错是 TimeoutError: timed out,5% 失败率。30 次里出现 1 次不算高,但说明它不是零丢包。
Quad9 热组有 1 次失败,同样是超时,3.3%。它同时有一条 962.5 毫秒的最大值,尾部抖动比前面几家明显。
4.2 DoT/853
所有查询在一条已建立的 TLS 连接上串行发出,所以下表是「连接就绪后每个查询的边际延迟」,不含握手:
| 上游 | 连接地址 | 组 | 成功/失败 | p50 | p95 | max | 失败率 |
|---|---|---|---|---|---|---|---|
| Cloudflare | 1.1.1.1 | hot | 0/30 | — | — | — | 100% |
| Cloudflare | 1.1.1.1 | cold | 0/20 | — | — | — | 100% |
| 8.8.8.8 | hot | 30/0 | 142.3 | 342.0 | 1470.1 | 0.0% | |
| 8.8.8.8 | cold | 20/0 | 138.4 | 248.5 | 360.4 | 0.0% | |
| AliDNS | 223.5.5.5 | hot | 30/0 | 92.9 | 176.8 | 209.4 | 0.0% |
| AliDNS | 223.5.5.5 | cold | 20/0 | 144.2 | 393.6 | 1197.1 | 0.0% |
| DNSPod | 1.12.12.12 | hot | 14/16 | 92.8 | 155.6 | 155.9 | 53.3% |
| DNSPod | 1.12.12.12 | cold | 0/20 | — | — | — | 100% |
| Quad9 | 9.9.9.9 | hot | 6/24 | 155.8 | 282.2 | 316.1 | 80.0% |
| Quad9 | 9.9.9.9 | cold | 8/12 | 211.2 | 373.7 | 412.2 | 60.0% |
Cloudflare 那两行的失败发生在连接建立阶段:
1 | connect: ConnectionResetError: [Errno 54] Connection reset by peer |
DNSPod 和 Quad9 的失败不一样,是连接建立成功、查询还能跑几个之后才断,报错是 ConnectionError: EOF。下一节专门查这件事。
从能跑通的端点看,DoT 的 p50 和 UDP 基本持平,甚至更低:Google 是 142.3 对 137.8,AliDNS 是 92.9 对 60.3,Quad9 是 155.8 对 182.1。这个结果初看反直觉,因为 DoT 多了一层 TLS 记录层。原因是 TLS 记录层只增加几十微秒级的封装开销,而 UDP 那条路径上有一个隐性成本:解析器对 UDP 有响应大小限制(TC 标志触发 TCP 回退)和更激进的限速,走 TLS 的请求反而落在一个更稳定、更少被限速的处理队列上。另外热组样本量不大,几十毫秒的差异本来就在噪声范围内,不值得过度解读。
4.3 DoH/443
DoH 这里出现了本文最值得记一笔的坑,所以要分两张表。
HTTP/1.1
| 上游 | 连接地址 | 组 | 成功/失败 | p50 | p95 | 失败率 |
|---|---|---|---|---|---|---|
| Cloudflare | 104.16.248.249 | hot | 30/0 | 144.4 | 276.6 | 0.0% |
| Cloudflare | 104.16.248.249 | cold | 20/0 | 146.8 | 539.2 | 0.0% |
| 8.8.8.8 | hot | 30/0 | 143.4 | 466.1 | 0.0% | |
| 8.8.8.8 | cold | 20/0 | 143.6 | 182.6 | 0.0% | |
| AliDNS | 223.5.5.5 | hot | 30/0 | 66.2 | 94.9 | 0.0% |
| AliDNS | 223.5.5.5 | cold | 20/0 | 91.0 | 649.2 | 0.0% |
| DNSPod | 120.53.53.53 | hot | 30/0 | 108.8 | 164.0 | 0.0% |
| DNSPod | 120.53.53.53 | cold | 20/0 | 610.6 | 941.5 | 0.0% |
| Quad9 | 9.9.9.9 | hot | 0/30 | — | — | 100% |
| Quad9 | 9.9.9.9 | cold | 0/20 | — | — | 100% |
Quad9 的两行全军覆没,但问题出在我的客户端。TLS 握手是成功的(tcp_ms 139 到 169、tls_ms 146 到 192 都正常记录下来了),卡在 HTTP 层:
1 | ConnectionError: no Content-Length: b'HTTP/1.1 505 HTTP Version Not Supported\r\nConnection: Close\r\n...' |
505 HTTP Version Not Supported。RFC 8484 第 5.2 节的原文是「HTTP/2 is the minimum RECOMMENDED version of HTTP for use with DoH」,也就是 DoH 的最低推荐版本是 HTTP/2。多数实现为了兼容会额外接受 HTTP/1.1,Quad9 不接受。用 curl 单独验证:
1 | $ curl -s -o /dev/null -w "code=%{http_code} ver=%{http_version}\n" --http1.1 \ |
所以补一个 HTTP/2 客户端,用 h2 库直接说话,ALPN 只声明 h2:
1 | ctx.set_alpn_protocols(["h2"]) |
HTTP/2(同样的域名池、同样的样本数、重跑于 17:22,load average 4.62):
| 上游 | 组 | 成功/失败 | p50 | p95 | max | 失败率 |
|---|---|---|---|---|---|---|
| Cloudflare | hot | 30/0 | 142.8 | 251.2 | 396.3 | 0.0% |
| Cloudflare | cold | 20/0 | 149.7 | 175.4 | 185.2 | 0.0% |
| hot | 30/0 | 135.4 | 350.6 | 472.7 | 0.0% | |
| cold | 20/0 | 150.8 | 236.2 | 294.0 | 0.0% | |
| AliDNS | hot | 30/0 | 62.3 | 118.6 | 202.4 | 0.0% |
| AliDNS | cold | 20/0 | 121.4 | 147.0 | 186.5 | 0.0% |
| DNSPod | hot | 30/0 | 92.8 | 200.4 | 318.6 | 0.0% |
| DNSPod | cold | 20/0 | 594.6 | 807.4 | 812.5 | 0.0% |
| Quad9 | hot | 30/0 | 146.6 | 331.9 | 759.9 | 0.0% |
| Quad9 | cold | 7/13 | 187.8 | 316.2 | 361.9 | 65.0% |
Quad9 换成 HTTP/2 之后热组 30 次全部成功,p50 146.6 毫秒,和 Google 处在同一档。冷组 20 次里只成功了 7 次,剩下 13 次是 ConnectionError: EOF,也就是连接被对端关掉了。这个现象和它的 DoT 表现一致,放到下一节一起看。
DoH 和 UDP 的 p50 在能跑通的端点上基本一致:Google 135.4 对 137.8,AliDNS 62.3 对 60.3,Quad9 146.6 对 182.1,Cloudflare 142.8 对 235.7。Cloudflare 那一对差了 90 多毫秒,但那是 1.1.1.1:53 这条路径本身的 RTT 就高,跟 DoH 协议无关。
4.4 三种协议横向合并
同一批数据的汇总,热组 p50 / p95(每格 30 次查询;样本量到这个规模,p95 等于最大值,只当上界看):
| 上游 | UDP/53 | DoT/853 | DoH/443 (HTTP/1.1) | DoH/443 (HTTP/2) |
|---|---|---|---|---|
| Cloudflare | 235.7 / 286.1 | 失败 | 144.4 / 276.6 | 142.8 / 251.2 |
| 137.8 / 392.6 | 142.3 / 342.0 | 143.4 / 466.1 | 135.4 / 350.6 | |
| AliDNS | 60.3 / 108.5 | 92.9 / 176.8 | 66.2 / 94.9 | 62.3 / 118.6 |
| DNSPod | 75.0 / 136.7 | 92.8 / 155.6 | 108.8 / 164.0 | 92.8 / 200.4 |
| Quad9 | 182.1 / 691.5 | 155.8 / 282.2 | 客户端不支持 | 146.6 / 331.9 |
这张表要说的事很清楚:在连接已经建立的前提下,协议本身几乎不影响延迟。 同一家上游的三种协议 p50 差异大多在 30 毫秒以内,远小于不同上游之间的差异(AliDNS 62 对 Cloudflare 143,差 80 多毫秒)。挑上游比挑协议重要得多。
4.5 握手成本才是真正的差别
上面所有数字都在「连接已建立」的前提下。真实客户端冷启动第一个查询还要加上 TCP 和 TLS 握手,这部分 DoT/DoH 有、UDP 没有:
| 上游 | 协议 | TCP 连接 | TLS 握手 | 合计 | 首次查询 | 稳态 p50 |
|---|---|---|---|---|---|---|
| Cloudflare | DoH/443 | 286.6 | 216.7 | 503.3 | 282.0 | 144.4 |
| DoT/853 | 113.0 | 130.9 | 243.9 | 344.3 | 142.3 | |
| DoH/443 | 134.0 | 125.8 | 259.8 | 325.0 | 143.4 | |
| AliDNS | DoT/853 | 66.6 | 83.3 | 149.9 | 123.6 | 92.9 |
| AliDNS | DoH/443 | 79.8 | 74.8 | 154.6 | 161.5 | 66.2 |
| DNSPod | DoT/853 | 75.1 | 184.7 | 259.8 | 56.6 | 92.8 |
| DNSPod | DoH/443 | 89.3 | 271.2 | 360.5 | 125.9 | 108.8 |
| Quad9 | DoT/853 | 139.7 | 165.6 | 305.3 | 316.1 | 155.8 |
| Quad9 | DoH/443 | 169.3 | 191.8 | 361.1 | 130.3 | 146.6 |
(单位毫秒。TCP 和 TLS 是各自独立一次测量的值,合计是这一对之和。各行来自不同的单次运行,样本量在 6 到 30 之间不等;Quad9 的 DoH 一行取自 HTTP/2 那一轮,与本表其余行的轮次不同,跨行比较这两个列只能当参考。)
把这几个数字放到一起,「UDP 快」这个说法就有了准确的形状:UDP 省掉的是 150 到 500 毫秒的一次性建连成本,不是每个查询的成本。 一个长驻进程每小时解析几百个域名,这点成本摊下来可以忽略;一个每次调用都重新建连的短命脚本,这就是实打实的几百毫秒。
「首次查询」那一列还揭示了另一件事:建连之后第一个查询明显比稳态慢。Google DoH 是 325.0 对 143.4,Google DoT 是 344.3 对 142.3,Quad9 DoT 是 316.1 对 155.8,都慢了 2 倍左右。具体是哪一段慢的,这次没有做分段测量去定位,可能是服务端在首个请求上的会话建立开销,也可能是 TCP 慢启动还没走完。能确定的是「握手完成」不等于「连接可用」,真正的冷启动成本是握手加第一次查询,而不是只有握手。
5. DoT 长连接会被对端掐断
DoT 和 DoH 的成本优势建立在连接复用上,所以「一条连接能用多久」直接决定了这个优势成不成立。在一条连接上连续发 60 次查询,没断就记 60,各跑两轮:
1 | DoT 存活探针 Cloudflare |
Google 和 AliDNS 在 60 次查询后连接仍然活着,9 秒和 4 秒的耗时基本就是 60 个 RTT 的总和。这两个端点可以认为支持稳定的连接复用。
DNSPod 在两轮里分别撑了 2 次和 3 次,Quad9 撑了 17 次和 9 次,然后连接被对端关掉。补做的抓包给出了关闭方式:Quad9 先发 17 03 03 00 13(TLS 1.3 加密的 close_notify)再发 FIN,全程没有 RST;DNSPod 先发 15 03 03 00 1a(TLS 1.2 加密的 alert(close_notify))再发 FIN,之后另有一个 RST。两者都是对端 TLS 端点的优雅关闭,而且证书校验到真实主机名通过,说明 TLS 是端到端的,中间没有解密代理。次数在两个运行之间不稳定,但为什么关(限速、空闲回收还是负载)本文没有证据,不做猜测。对客户端的影响是:必须有重连逻辑。没有重连的话,一次连接被关掉之后所有后续查询都会失败;有重连的话,每次断开要付一次完整的 TCP + TLS 握手成本(DNSPod 是 259.8 毫秒,Quad9 是 305.3 毫秒),而这个成本会不定期地插进查询路径里。
这一点在 DoH 那边同样成立:Quad9 的 DoH 冷组 20 次里断了 13 次,也是 ConnectionError: EOF。同一家的 DoT 和 DoH 共享同一个连接管理策略,表现一致。
还有一个侧证。用 openssl s_client 看各端点协商出来的 TLS 版本:
1 | dot.pub:853 New, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384 |
DNSPod 的两个端点是唯二停在 TLS 1.2 的,而 TLS 1.2 的完整握手要在 TCP 之上花两个 RTT,TLS 1.3 只要一个。把握手时间和 TCP 连接时间的比值算出来,正好对上:
| 端点 | 协商版本 | TCP 连接 | TLS 握手 | TLS / TCP | 理论 RTT 数 |
|---|---|---|---|---|---|
| AliDNS DoT | TLS 1.3 | 66.6 | 83.3 | 1.25 | 1 |
| Google DoT | TLS 1.3 | 113.0 | 130.9 | 1.16 | 1 |
| Quad9 DoT | TLS 1.3 | 139.7 | 165.6 | 1.19 | 1 |
| DNSPod DoT | TLS 1.2 | 75.1 | 184.7 | 2.46 | 2 |
| DNSPod DoH | TLS 1.2 | 89.3 | 271.2 | 3.04 | 2 |
(TLS / TCP 这一列用两个实测值相除得到,理论 RTT 数是协议规定的。)
这解释了 DNSPod 那两个握手数字为什么明显偏高:问题不在路径远近,同一个 IP 上 TLS 1.2 比 TLS 1.3 就是要多花一个握手 RTT。如果客户端能选,优先挑支持 TLS 1.3 的服务端可以省掉这几十到一百毫秒。
6. sing-box 做本地 DNS 服务
前面的测量都是直连上游。真实部署里通常在本地跑一个转发器,把 DNS 收敛到一个入口,好处是统一出口、缓存复用、加上 FakeIP 之类的策略。多一层转发要付多少代价,也可以直接测。
6.1 配置
用 1.14.1 的 DNS 服务格式(dns.servers[].type 从 1.12.0 开始取代旧的 address 写法),入站用 direct 类型加 override_address,再用路由规则把 DNS 劫持进来:
1 | { |
override_address 只是让入站看起来指向某个正常的 DNS 地址;用哪个上游由 dns.servers 决定。换上游只需要改 type:
1 | { "type": "tls", "server": "223.5.5.5", "server_port": 853, |
6.2 踩到的两个坑
第一个坑:detour 指向空的 direct outbound 会让 sing-box 拒绝启动。
一开始我在 dns.servers 里写了 "detour": "direct",同时在 outbounds 里放了一个 {"type": "direct", "tag": "direct"}。sing-box check 通过了,sing-box run 直接拒绝:
1 | FATAL[0000] start service: start dns/udp[ali-udp]: detour to an empty direct outbound makes no sense |
原因在新版文档里写着:新格式的 DNS 服务器「uses dialer just like outbound, which is equivalent to using an empty direct outbound by default」,也就是默认行为已经等价于空的 direct outbound,再显式指过去就是空转。把 detour 整个删掉即可。
这里有个值得记住的教训:sing-box check 通过不代表 run 能起来。 上面那个配置 check 返回 0 并且不打印任何东西,run 才在启动阶段报错。改完配置一定要真的起一次。
第二个坑:5353 端口被 macOS 上的 mDNS 占着。
我原先给 Google DoH 那个实例分了 5353 端口,启动直接失败:
1 | FATAL[0000] start service: start inbound/direct[dns-in]: listen udp 127.0.0.1:5353: bind: address already in use |
查一下就知道是 Chrome:
1 | $ lsof -nP -iUDP:5353 | head -3 |
5353 是 mDNS 的标准端口,浏览器、投屏、Bonjour 都在用它。本地 DNS 服务别选这个端口,换成 5354 以上的任意空闲端口。
6.3 真实运行日志
debug 级别下,一次完整的转发长这样:
1 | +0800 2026-09-15 17:10:28 INFO [2223575326 0ms] inbound/direct[dns-in]: inbound packet connection from 127.0.0.1:49982 |
四步都在日志里:入站收到 UDP 包、路由匹配到 hijack-dns、发起上游查询、427 毫秒后拿到 NXDOMAIN 加一条负缓存 TTL 900 秒的 SOA 记录。
缓存命中时长这样,注意前缀是 cached,耗时是 0ms:
1 | +0800 2026-09-15 17:10:34 DEBUG [4020014095 0ms] dns: cached baidu.com NOERROR 270 |
上游不可达时 sing-box 不会在日志里报错。 把 DoH 上游配成一个不可达地址,客户端全部超时,日志里没有一行错误:debug 级别停在发起查询那一行,trace 级别除了同样的行之外,只在进程被结束的瞬间多两行 http-client 关闭记录。两组实验用的不可达地址不同(1.1.1.1:443 那组与 127.0.0.1:9 那组),四个组合重跑后行为一致:
1 | +0800 2026-09-15 17:13:46 INFO [2573956400 0ms] inbound/direct[dns-in]: inbound packet connection from 127.0.0.1:60229 |
客户端那一侧是 70 秒超时:
1 | 客户端超时: 70001 ms TimeoutError timed out |
也就是说,DNS 上游挂掉时,症状是「查询变慢直到超时」,日志里找不到任何线索。排查这种问题别只盯 sing-box 的日志,要同时从客户端侧看超时,再手动验证上游地址的可达性。
6.4 数字
用同一个 Python 客户端打 127.0.0.1 上的入站端口,域名池和样本数与前面一致。冷组 20 次、热组 30 次跑三轮(同一个 sing-box 实例,缓存逐渐填满):
| 配置 | 组 | 成功/失败 | p50 | p95 | max | rcode |
|---|---|---|---|---|---|---|
| UDP 上游 223.5.5.5 | cold | 20/0 | 334.61 | 585.42 | 602.33 | 3 |
| UDP 上游 223.5.5.5 | hot 第 1 轮 | 30/0 | 53.73 | 96.24 | 119.79 | 0 |
| UDP 上游 223.5.5.5 | hot 第 2 轮 | 30/0 | 0.19 | 0.33 | 1.59 | 0 |
| UDP 上游 223.5.5.5 | hot 第 3 轮 | 30/0 | 0.16 | 0.23 | 0.32 | 0 |
| DoT 上游 223.5.5.5:853 | cold | 20/0 | 346.51 | 450.21 | 479.36 | 3 |
| DoT 上游 223.5.5.5:853 | hot 第 1 轮 | 30/0 | 68.11 | 143.21 | 146.71 | 0 |
| DoT 上游 223.5.5.5:853 | hot 第 2 轮 | 30/0 | 0.16 | 0.32 | 0.41 | 0 |
| DoT 上游 223.5.5.5:853 | hot 第 3 轮 | 30/0 | 0.17 | 0.25 | 0.27 | 0 |
| DoH 上游 104.16.248.249 | cold | 20/0 | 144.37 | 270.12 | 428.03 | 3 |
| DoH 上游 104.16.248.249 | hot 第 1 轮 | 30/0 | 131.73 | 172.51 | 187.30 | 0 |
| DoH 上游 104.16.248.249 | hot 第 2 轮 | 30/0 | 0.16 | 0.33 | 0.48 | 0 |
| DoH 上游 104.16.248.249 | hot 第 3 轮 | 30/0 | 0.19 | 0.25 | 0.28 | 0 |
| DoH 上游 8.8.8.8 | cold | 20/0 | 140.19 | 325.45 | 591.54 | 3 |
| DoH 上游 8.8.8.8 | hot 第 1 轮 | 30/0 | 142.65 | 428.17 | 1535.26 | 0 |
| DoH 上游 8.8.8.8 | hot 第 2 轮 | 30/0 | 0.18 | 0.24 | 0.26 | 0 |
| DoH 上游 8.8.8.8 | hot 第 3 轮 | 30/0 | 0.17 | 0.39 | 1.03 | 0 |
| DoH 上游 1.1.1.1:443 | cold | 0/20 | — | — | — | — |
| DoH 上游 1.1.1.1:443 | hot 第 1 轮 | 0/30 | — | — | — | — |
(单位毫秒。热组第 1 轮里 30 个样本 = 20 个不重复域名 + 前 10 个再来一遍,所以 min 会出现 0.16 这种缓存命中值,p50 对应的是第 15 个样本,仍然落在未命中区间。)
对比直连上游的同一组数字:
| 上游 | 直连 hot p50 | sing-box 转发 hot 第 1 轮 p50 | 差值 |
|---|---|---|---|
| AliDNS UDP | 60.3 | 53.73 | −6.6 |
| AliDNS DoT | 92.9 | 68.11 | −24.8 |
| Cloudflare DoH | 144.4 | 131.73 | −12.7 |
| Google DoH | 143.4 | 142.65 | −0.8 |
多一层本地转发的代价测不出来。 四组对比里有三组是负数(转发反而更快),但差值都落在噪声范围里,唯一能说的结论是「转发这一层没有引入可测量的额外延迟」。原因也简单:本地 UDP 环回只有零点几毫秒,转发器的排队和处理开销远小于上游那 60 到 140 毫秒的往返。
量得到的本地跳成本只有缓存命中那一行:p50 落在 0.16 到 0.19 毫秒。这是「进程内 DNS 客户端 → 本地 sing-box → 返回」的完整往返,也是转发器能带来的最好情况。
于是本地转发的收益落在缓存上:同一个域名第二次查询从 53 到 142 毫秒掉到 0.17 毫秒左右,快了 300 到 800 倍。代价是上游不可达时整个解析链路一起不可用,而且如上一节所见,日志里看不到原因。
最后一组:DoH 上游 1.1.1.1:443 全部失败,冷组 20 次加第一轮 30 次没有一次拿到应答,客户端侧清一色 TimeoutError。这和前面直连测量到的可达性结论完全吻合,也说明这种配置错误不会在启动时暴露,只在客户端发起查询时才变成延迟。
7. 测量误差与不可外推的边界
7.1 我自己踩的三个坑
这三个都是测量代码的问题,写出来是因为读者照着自己写一遍大概率会撞上同一个。
HTTP/2 的流 ID 必须是奇数。 第一版客户端 sid += 1 从 1 开始递增,结果每个端点的失败率精确地卡在 50%。服务端返回的是 ProtocolError: Invalid stream ID for peer.,因为偶数流 ID 是留给服务端发起的流的,客户端用了就是协议违规。改成 sid += 2 之后失败率归零。这种「恰好一半失败」的对称症状,基本可以直接指向流 ID 或帧奇偶性。
不开 TCP_NODELAY,HTTP/2 客户端每个查询多等一个 RTT。 这是最隐蔽的一个。同一份代码、同一个端点,我自己的 HTTP/2 客户端 p50 是 283 毫秒,curl --http2 是 157 毫秒,差了一倍。逐项排除:
1 | 现状 n=15 min=139 p50=287 max=571 |
根因是 Nagle 算法。HTTP/2 客户端在响应处理完之后还要回一个小的 WINDOW_UPDATE 帧,这个小包会被 Nagle 压住等待前一个未确认的段被 ACK,于是每次查询凭空多了大约一个 RTT。加上 TCP_NODELAY 之后 p50 从 287 降到 139,和 curl 的 157 以及 HTTP/1.1 那一路的 143 都对上了。表格里 HTTP/2 的数字是修好之后重跑的。
另外也验证了这不是 DoT 或 HTTP/1.1 的问题:AliDNS DoT 是 67(关)对 74(开),Google DoH HTTP/1.1 是 173(关)对 152(开),都在噪声里。原因也说得通,这两个客户端的请求头和请求体是用一次 sendall 写完的,没有产生需要等待的小包。
HTTP/2 的那些坑会污染表格。 4.3 节里 HTTP/1.1 那部分 Quad9 全失败,我最初的结论会写成「Quad9 的 DoH 不可用」,但实际是我的客户端只会 HTTP/1.1,而 Quad9 按 RFC 8484 的推荐只收 HTTP/2。用 curl 交叉验证之后才排除掉。凡是「某个服务商 100% 失败」的结果,先怀疑自己的客户端。
7.2 误差来源
网络波动。 同一个端点、同一批域名,不同轮次之间 p50 能差出 20%。AliDNS UDP 冷组在主矩阵里是 308.7 毫秒,在更早的一次试跑里是 520.2 毫秒;DNSPod DoT 热组第一次是 3/20 成功,重跑是 14/30。这类重复性偏差说明单次的几十毫秒差异不该被当作结论。表里每一格都是单轮 30 或 20 个样本,够看趋势,不够看小数点。
机器负载。 主矩阵跑在 load average 5.6 前后,HTTP/2 重跑在 4.6 前后,sing-box 那轮在 7.3 前后。这台机器上同时有其它进程在吃 CPU,虽然网络 I/O 主要由内核处理,但 Python 侧的计时和事件循环还是会受到影响。这也是我把每一轮的 load average 都标出来的原因。
代理环境。 这一轮测量全程没有代理。如果你在 TUN 模式的代理下重跑,所有流量都会多一次用户态转发,UDP 可能被强制走代理的 TCP,DNSPod 那种「连接被对端掐断」的现象也会因为出口 IP 不同而完全不同。
服务端 anycast 的就近性。 这是最难控制也最容易被忽略的。1.1.1.1、8.8.8.8、9.9.9.9 都是 anycast 地址,你在同一个城市不同运营商、甚至同一个运营商不同时段,落到的是不同机房。本文里 1.1.1.1:53 的 235.7 毫秒明显高于 9.9.9.9:53 的 182.1 毫秒,但别把这读成 Cloudflare 比 Quad9 慢:这台机器到 Cloudflare 最近节点的路径就是这样。同一家服务的不同接入地址也会落到不同节点:9.9.9.9 通而 149.112.112.112 不通,1.12.12.12 通而 119.29.29.29 的 853 不通。
企业网络的中间设备。 1.1.1.1 走 en0 直连(不在 VPN 隧道里),它 443 稳定超时、853 前期 TCP 能连但 TLS 被 RST、连了 4 次之后连 TCP 也超时。控制实验标定了判据:真正的 RST 会抛 ConnectionResetError,而裸 FIN 会抛 SSLEOFError、close_notify 会正常读到 EOF,这里拿到的是前者,说明路径上有设备在 TLS 阶段发 RST。至于隧道里的那批端点端口是否也被策略拦,本文没有区分隧道策略与目标端策略,不能下结论。
7.3 置信度声明
可以信的结论(有明确数字支撑,且多次观测一致):
- 这台机器上三种协议的 p50 差异远小于不同上游之间的差异。
- UDP 相对 DoT/DoH 省掉的是一次性的 150 到 500 毫秒建连成本,不是每查询成本。
- 本地 sing-box 转发在缓存命中时是 0.16 到 0.19 毫秒,未命中时与直连无差别。
1.1.1.1:443在这条路径上不可用,119.29.29.29的 853/443 不可用,149.112.112.112不可用。- Quad9 的 DoH 拒绝 HTTP/1.1(505),只接受 HTTP/2。
- DNSPod 和 Quad9 的 DoT 长连接会被对端以 close_notify + FIN 优雅关闭(抓包确认,无 RST 注入);关闭原因不在本文测量范围内。
只能当参考的结论(样本量或重复性不够):
- 所有 p95。每组只有 20 到 30 个样本,热组的 p95 甚至等于最大值,它反映的是「有没有抖动」,不是生产环境的尾部延迟。
- 各组之间 30 毫秒以内的排序。AliDNS UDP 60.3 对 DNSPod 75.0,AliDNS DoH HTTP/2 62.3 对 HTTP/1.1 66.2,这些差值都在噪声范围内。
- DoT/DoH 握手时间的绝对值。每张表里这个值来自一次单独测量,
tcp_ms和tls_ms也没有做多次取样。 - 各端点的可用性比例。30 次里失败 1 次算出来的 3.3% 没有统计意义,只能说明「有失败」。
- 跨出口分组的比较。Google、Quad9 的
9.9.9.9、Cloudflare 的 DoH 地址走 VPN 隧道,其余端点走直连,两组之间的差值里含有隧道开销,不能直接当服务商之间的差距。
这次没测的:
- DNSSEC 验证、EDNS Client Subnet、DoQ(443/UDP)和 HTTP/3。这台机器上没装 QUIC 客户端,DoQ 一行都没测。
- 持续数小时的稳定性。整个测量窗口只有 22 分钟,长期限速、夜间路由变化、上游节点轮换都不在观测范围内。
- 解析结果正确性。本文只测延迟和可达性,没有核对各家返回的 IP 是否被污染、是否走了正确的 CDN 节点。这是选型的另一个维度,需要单独的方法验证。
- 移动网络、家庭宽带、不同运营商。只有这一条办公室有线网路径。
8. 取舍表
把测到的和测不到的放在一起,五个维度:
| 维度 | UDP/53 | DoT/853 | DoH/443 |
|---|---|---|---|
| 延迟(连接已建立) | 三者持平,本文实测差异 30ms 内 | 同上 | 同上 |
| 延迟(冷启动) | 无握手成本,最快 | +150~300ms | +155~500ms |
| 隐私 | 明文,路径上任何人可见可改 | 加密,查询内容不可见,但 853 端口特征明显、易被识别和阻断 | 加密,混在 443 流量里,最难被针对性阻断 |
| 抗污染 | 最弱,明文应答可被篡改,本文中 1.1.1.1:53 能通而它的 443/853 不通就是路径策略的证据 |
中等,防篡改但不防阻断 | 较强,防篡改且难阻断 |
| 客户端支持 | 全部支持,无需配置 | 需要客户端显式支持,本文中 DNSPod/Quad9 的连接复用还不可靠 | 支持面最广,浏览器、系统、路由器基本都支持;但 Quad9 这种只收 HTTP/2 的实现会卡住只会 HTTP/1.1 的客户端 |
| 自建/维护成本 | 最低,配个 IP 就完事 | 需要处理连接断开重连 | 需要证书校验、HTTP/2 栈、连接池,出问题时客户端只表现为超时 |
| 本文实测可用性 | 5 家全部可用 | 5 家里 3 家可用(Cloudflare 不可达,DNSPod、Quad9 不稳定) | 5 家全部可用,但 Quad9 要 HTTP/2 |
场景建议
在不可信网络里(公共 Wi-Fi、酒店、咖啡厅):用 DoH,而且优先选一个解析到 CDN 地址的域名而不是服务商的招牌 IP。443 端口的 HTTPS 流量是网络里最不显眼的东西,这是 DoH 相对 DoT 最实际的优势,本机实测也印证了:1.1.1.1 的 443 被挡,但 cloudflare-dns.com 解析出来的 CDN 地址 443 通。
在可控的办公网或自建 IDC 里,追求确定性:用 UDP 指向就近的国内上游。省掉 150 到 500 毫秒的建连成本,在容器启动、脚本冷启动这类场景里是能感觉到的。前提是链路可信,不需要担心污染。
需要长期稳定运行的本地上游(比如给路由器或者 sing-box 做上游):DoH 优先于 DoT。本文里 DoT 的可用性问题集中在连接复用上:DNSPod 撑 2 到 3 次查询就断,Quad9 撑 9 到 17 次,而同样这两家的 DoH 在热组 30 次里都是 0 失败。用 DoT 就一定要实现断线重连,而每次重连要付 260 到 305 毫秒的握手成本。
本机已经跑了 sing-box 或类似转发器:上游用 DoH,本地监听用一个空闲的高位端口。这一层的额外延迟测不出来(0.16 到 0.19 毫秒的缓存命中往返),换来的是重复查询 300 到 800 倍的加速。但一定要给上游配一个能连通的地址,并且清楚上游挂掉时日志里不会有任何提示。
只想让浏览器或系统「换个 DNS」:DoH,配置界面里填 URL 就行,不需要额外软件。
总结
把这次测出来的东西收成一份可以直接照做的清单:
- 协议先放一边,先挑上游。同一家上游三种协议的 p50 差异在 30 毫秒以内,不同上游之间能差 80 毫秒以上。
- 在本文这条网络路径上,DoT/DoH 的 853/443 端口是被有选择地拦的,而 53 端口仍然可用:
1.1.1.1:53稳定 OPEN,1.1.1.1:443稳定超时。这个结论只覆盖本文的测量环境,换网络要重测。 - 配 DoT/DoH 时用域名而不是 IP。服务商的招牌 IP 和它服务域名的实际解析结果经常不是同一批地址,可用性也不同。
- UDP 省掉的是一次 150 到 500 毫秒的建连成本,不是每个查询的成本。长驻进程不必为此牺牲加密。
- DoT 的连接复用不能假设。DNSPod 和 Quad9 都会在几十次查询之内主动断开,客户端必须有重连逻辑。
- 本地转发器(sing-box)不增加可测量的延迟,缓存命中是 0.16 到 0.19 毫秒,收益全在缓存上。
- 服务端的 TLS 版本会直接体现进握手时间。本文里停在 TLS 1.2 的两个端点,握手比 TLS 1.3 的多花约一个 RTT。
- 写完测量脚本一定要用第二个实现交叉验证。我用 Python 手写的 HTTP/2 客户端因为流 ID 奇偶性和 TCP_NODELAY 两个问题,差点把 Quad9 的 DoH 判成不可用、把 Google 的 DoH 判成慢一倍。
参考资料
- RFC 1035: Domain Names - Implementation and Specification
- RFC 7858: Specification for DNS over Transport Layer Security (TLS)
- RFC 8020: NXDOMAIN: There Really Is Nothing Underneath
- RFC 8484: DNS Queries over HTTPS (DoH),第 5.2 节 “HTTP/2 is the minimum RECOMMENDED version of HTTP for use with DoH”
- sing-box DNS Server 配置文档
- sing-box DoH 服务器 / DoT 服务器
系列索引:网络与自建服务







