前一篇讲 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
2
3
4
5
6
$ route -n get default | grep -E 'gateway|interface'
gateway: 10.194.247.9
interface: en0
$ ifconfig en0 | grep -E 'inet |status'
inet 10.194.247.161 netmask 0xffffff00 broadcast 10.194.247.255
status: active

这是一台接在办公室有线网里的机器,出网走 10.194.247.9 这个网关。没有 sing-box、clash 这类本机代理在跑,三个显式代理开关也全是 0:

1
2
3
4
5
6
$ pgrep -l -f 'sing-box|clash|mihomo|surge|v2ray|xray|tun2socks'
(无输出)
$ scutil --proxy | grep -E 'HTTPEnable|HTTPSEnable|SOCKSEnable'
HTTPEnable : 0
HTTPSEnable : 0
SOCKSEnable : 0

没有本机代理不等于裸连。事后核对路由表发现,这台机器上跑着企业 VPN 客户端 GlobalProtect(Palo Alto),utun2 接口上有 960 条路由,而 en0 只有 4 条。被测算的端点因此分成两组:

出口 端点
走 utun2 隧道 8.8.8.88.8.4.49.9.9.9104.16.248.249(cloudflare-dns.com 的 DoH 地址)、100.67.60.45
走 en0 直连 1.1.1.11.0.0.1223.5.5.5223.6.6.6119.29.29.291.12.12.12120.53.53.53149.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.45100.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
Google 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.531.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
2
3
4
5
6
def build_query(name, qtype=1, qid=0):
if qid == 0:
qid = random.randint(1, 0xFFFF)
header = struct.pack("!HHHHHH", qid, 0x0100, 1, 0, 0, 0) # RD=1
qname = b"".join(bytes([len(l)]) + l.encode() for l in name.split(".")) + b"\x00"
return header + qname + struct.pack("!HH", qtype, 1)

三种传输的计时口径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# UDP:sendto 到 recvfrom 返回,一个 RTT,没有握手
s.sendto(q, (server, port))
data, _ = s.recvfrom(4096)

# DoT:TCP + TLS 握手单独记时,之后一条连接上串行发 N 个查询(2 字节长度前缀)
self.sock.sendall(struct.pack("!H", len(q)) + q)
n = struct.unpack("!H", self._recv_exact(2))[0]
data = self._recv_exact(n)

# DoH:HTTP POST application/dns-message,一条 keep-alive 连接上串行发 N 个查询
req = (f"POST {self.path} HTTP/1.1\r\nHost: {self.host}\r\n"
"Content-Type: application/dns-message\r\n"
"Accept: application/dns-message\r\n"
f"Content-Length: {len(q)}\r\n\r\n").encode() + q

TCP 连接时间和 TLS 握手时间在构造函数里分别计时,查询时间只算「请求写完到响应读完」。这样能把「握手一次性的开销」和「每个查询的边际开销」分开,这两件事在选型时的意义完全不同。

dig 只用了一次,查 dot.pub 的 A 记录用来确认 DNSPod 的 DoT 到底该连哪个地址。

2.3 冷热两组域名控制缓存

「DNS 延迟」这个说法本身是模糊的,缓存命中与否能差一个数量级,所以分成两组分别测。

热组(hot):20 个常见域名(baidu.comgithub.comcloudflare.com……),跑 30 次查询。这些名字在公共解析器上几乎必然是缓存命中,测到的是「解析器自己的响应速度」,也是日常使用中最常见的路径。30 个样本 = 20 个不重复名字 + 前 10 个再来一遍,所以第 21 到 30 个查询本身就是缓存命中的。

冷组(cold):每次现生成一个随机二级域名,形如 <18 位随机串>.<6 位随机串>-probe.com,跑 20 次。这些名字必然返回 NXDOMAIN,而且因为二级域名是随机的,任何缓存都拦不住。这里用到的是 RFC 8020 的语义:一条 NXDOMAIN 应答意味着这个名字以及它下面的所有名字都不存在,所以解析器完全有权把整棵子树负缓存。把随机性放在二级域名而不是三级,就是绕开这个负缓存的剪枝。冷组测到的是「解析器做一次完整递归」的最坏路径。

1
2
3
4
5
6
def cold_names(n):
out = []
for _ in range(n):
r = "".join(random.choices(string.ascii_lowercase, k=18))
out.append(f"{r}.{r[:6]}-probe.com")
return out

实测确认这些名字返回 NXDOMAIN(rcode = 3):

1
2
3
dwtgmlqucavltalsfy.dwtgml-probe.com           rcode=3 ans=0 222ms
xaaoyjfkaflmggflha.xaaoyjfl-probe.com rcode=3 ans=0 228ms
voqezwdissykvrhpww.voqezw-probe.com rcode=3 ans=0 231ms

2.4 统计口径

分位数用线性插值,和 numpy 默认口径一致。样本量有限:热组 30 个样本时 p95 落在最大的两个样本之间,冷组 20 个样本时 p95 已经很接近最大值。这个 p95 不是生产环境意义上的尾部延迟,它只够看「有没有明显的抖动」。尾部延迟要上千个样本加持续观测,这里做不到,后面的置信度声明里会再提。

失败判定:UDP 3 秒内没收到应答算超时;DoT/DoH 的连接建立异常或响应读取异常算失败。每个端点的失败率按 失败次数 / 总请求次数 算。

3. 可达性:先看谁能连上

延迟数字只有在连得上的前提下才有意义。用 4 秒超时对所有 IP:端口组合做 TCP 连接探测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
1.1.1.1           :853   cloudflare-dns.com   OPEN   220ms
1.0.0.1 :853 cloudflare-dns.com FAIL 4001ms TimeoutError: timed out
1.1.1.1 :443 cloudflare-dns.com FAIL 4001ms TimeoutError: timed out
1.0.0.1 :443 cloudflare-dns.com FAIL 4001ms TimeoutError: timed out
104.16.248.249 :443 cloudflare-dns.com OPEN 133ms
8.8.8.8 :853 dns.google OPEN 133ms
8.8.8.8 :443 dns.google OPEN 124ms
8.8.4.4 :853 dns.google OPEN 140ms
8.8.4.4 :443 dns.google OPEN 122ms
223.5.5.5 :853 dns.alidns.com OPEN 42ms
223.5.5.5 :443 dns.alidns.com OPEN 59ms
223.6.6.6 :853 dns.alidns.com OPEN 42ms
223.6.6.6 :443 dns.alidns.com OPEN 37ms
119.29.29.29 :853 dot.pub FAIL 4001ms TimeoutError: timed out
119.29.29.29 :443 doh.pub FAIL 4001ms TimeoutError: timed out
1.12.12.12 :853 dot.pub OPEN 64ms
1.12.12.12 :443 doh.pub OPEN 103ms
120.53.53.53 :853 dot.pub OPEN 71ms
120.53.53.53 :443 doh.pub OPEN 80ms
9.9.9.9 :853 dns.quad9.net OPEN 161ms
9.9.9.9 :443 dns.quad9.net OPEN 170ms
149.112.112.112 :853 dns.quad9.net FAIL 4001ms TimeoutError: timed out
149.112.112.112 :443 dns.quad9.net FAIL 4001ms TimeoutError: timed out

几条值得单独拎出来:

1.1.1.1 的 443 和 853 是不可用的,但 53 可用,而且 DoH 走 CDN 域名可用。 1.1.1.1:443 连续 8 次全部 3 秒超时,1.0.0.1:4431.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
2
3
4
5
6
7
8
9
10
11
=== 1.1.1.1:853 连续 10 次 TCP+TLS ===
0: TCP OPEN/245ms TLS FAIL ConnectionResetError: [Errno 54] Connection reset by peer # RST 出现在 TLS 握手阶段,说明路径上有中间设备干预
1: TCP OPEN/218ms TLS FAIL ConnectionResetError: [Errno 54] Connection reset by peer
2: TCP OPEN/243ms TLS FAIL ConnectionResetError: [Errno 54] Connection reset by peer
3: TCP OPEN/262ms TLS FAIL ConnectionResetError: [Errno 54] Connection reset by peer
4: TCP FAIL TimeoutError: timed out (3001ms)
5: TCP FAIL TimeoutError: timed out (3001ms)
6: TCP FAIL TimeoutError: timed out (3001ms)
7: TCP FAIL TimeoutError: timed out (3001ms)
8: TCP FAIL TimeoutError: timed out (3001ms)
9: TCP FAIL TimeoutError: timed out (3001ms)

前面 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.29149.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%
Google 8.8.8.8 hot 30/0 137.8 392.6 463.6 0.0%
Google 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%
Google 8.8.8.8 hot 30/0 142.3 342.0 1470.1 0.0%
Google 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%
Google 8.8.8.8 hot 30/0 143.4 466.1 0.0%
Google 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
2
3
4
5
6
$ curl -s -o /dev/null -w "code=%{http_code} ver=%{http_version}\n" --http1.1 \
-H 'content-type: application/dns-message' --data-binary @q.bin https://9.9.9.9/dns-query
http1.1: code=505 ver=1.1
$ curl -s -o /dev/null -w "code=%{http_code} ver=%{http_version}\n" --http2 \
-H 'content-type: application/dns-message' --data-binary @q.bin https://9.9.9.9/dns-query
http2: code=200 ver=2

所以补一个 HTTP/2 客户端,用 h2 库直接说话,ALPN 只声明 h2

1
2
3
4
ctx.set_alpn_protocols(["h2"])
self.sock = ctx.wrap_socket(raw, server_hostname=sni)
self.conn = h2.connection.H2Connection(config=h2.config.H2Configuration(client_side=True))
self.conn.initiate_connection(); self.sock.sendall(self.conn.data_to_send())

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%
Google hot 30/0 135.4 350.6 472.7 0.0%
Google 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
Google 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
Google DoT/853 113.0 130.9 243.9 344.3 142.3
Google 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DoT 存活探针 Cloudflare
run0: connect FAIL [Errno 54] Connection reset by peer
run1: connect FAIL timed out
DoT 存活探针 Google
run0: 连续成功 60 次后 连接仍在 耗时 9.32s
run1: 连续成功 60 次后 连接仍在 耗时 9.65s
DoT 存活探针 AliDNS
run0: 连续成功 60 次后 连接仍在 耗时 4.56s
run1: 连续成功 60 次后 连接仍在 耗时 3.7s
DoT 存活探针 DNSPod
run0: 连续成功 2 次后 断开: ConnectionError: EOF 耗时 0.48s
run1: 连续成功 3 次后 断开: ConnectionError: EOF 耗时 0.62s
DoT 存活探针 Quad9
run0: 连续成功 17 次后 断开: ConnectionError: EOF 耗时 3.16s
run1: 连续成功 9 次后 断开: ConnectionError: EOF 耗时 1.7s

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
2
3
4
5
dot.pub:853            New, TLSv1.2, Cipher is ECDHE-ECDSA-AES256-GCM-SHA384
doh.pub:443 New, TLSv1.2, Cipher is ECDHE-ECDSA-AES128-GCM-SHA256
dns.alidns.com:853 New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
dns.google:853 New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
dns.quad9.net:853 New, TLSv1.3, Cipher is TLS_AES_256_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
2
3
4
5
6
7
8
9
10
11
12
{
"inbounds": [
{ "type": "direct", "tag": "dns-in", "listen": "127.0.0.1", "listen_port": 5354,
"network": "udp", "override_address": "223.5.5.5", "override_port": 53 }
],
"route": { "rules": [ { "inbound": ["dns-in"], "action": "hijack-dns" } ] },
"dns": {
"servers": [
{ "type": "udp", "tag": "ali-udp", "server": "223.5.5.5", "server_port": 53 }
]
}
}

override_address 只是让入站看起来指向某个正常的 DNS 地址;用哪个上游由 dns.servers 决定。换上游只需要改 type

1
2
3
4
{ "type": "tls",   "server": "223.5.5.5", "server_port": 853,
"tls": { "server_name": "dns.alidns.com" } }

{ "type": "https", "server": "8.8.8.8", "server_port": 443, "path": "/dns-query" }

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
2
3
$ lsof -nP -iUDP:5353 | head -3
COMMAND PID USER FD TYPE ... NODE NAME
Google 1695 liangliang.liu 51u IPv4 ... UDP *:5353

5353 是 mDNS 的标准端口,浏览器、投屏、Bonjour 都在用它。本地 DNS 服务别选这个端口,换成 5354 以上的任意空闲端口。

6.3 真实运行日志

debug 级别下,一次完整的转发长这样:

1
2
3
4
5
6
+0800 2026-09-15 17:10:28 INFO [2223575326 0ms] inbound/direct[dns-in]: inbound packet connection from 127.0.0.1:49982
+0800 2026-09-15 17:10:28 INFO [2223575326 0ms] inbound/direct[dns-in]: inbound packet connection to 1.1.1.1:53
+0800 2026-09-15 17:10:28 DEBUG [2223575326 0ms] router: match[0] inbound=dns-in => hijack-dns
+0800 2026-09-15 17:10:28 DEBUG [2223575326 0ms] dns: exchange sb220544227-cold.sbprobe.com. IN A
+0800 2026-09-15 17:10:29 DEBUG [2223575326 427ms] dns: exchanged sb220544227-cold.sbprobe.com NXDOMAIN 900
+0800 2026-09-15 17:10:29 INFO [2223575326 427ms] dns: exchanged SOA com. 900 IN SOA a.gtld-servers.net. nstld.verisign-grs.com. 1789463408 1800 900 604800 900

四步都在日志里:入站收到 UDP 包、路由匹配到 hijack-dns、发起上游查询、427 毫秒后拿到 NXDOMAIN 加一条负缓存 TTL 900 秒的 SOA 记录。

缓存命中时长这样,注意前缀是 cached,耗时是 0ms

1
2
3
+0800 2026-09-15 17:10:34 DEBUG [4020014095 0ms] dns: cached baidu.com NOERROR 270
+0800 2026-09-15 17:10:34 INFO [4020014095 0ms] dns: cached A baidu.com. 270 IN A 110.242.74.102
+0800 2026-09-15 17:10:34 INFO [4020014095 0ms] dns: cached A baidu.com. 270 IN A 111.63.65.103

上游不可达时 sing-box 不会在日志里报错。 把 DoH 上游配成一个不可达地址,客户端全部超时,日志里没有一行错误:debug 级别停在发起查询那一行,trace 级别除了同样的行之外,只在进程被结束的瞬间多两行 http-client 关闭记录。两组实验用的不可达地址不同(1.1.1.1:443 那组与 127.0.0.1:9 那组),四个组合重跑后行为一致:

1
2
3
4
5
+0800 2026-09-15 17:13:46 INFO [2573956400 0ms] inbound/direct[dns-in]: inbound packet connection from 127.0.0.1:60229
+0800 2026-09-15 17:13:46 INFO [2573956400 0ms] inbound/direct[dns-in]: inbound packet connection to 1.1.1.1:53
+0800 2026-09-15 17:13:46 DEBUG [2573956400 0ms] router: match[0] inbound=dns-in => hijack-dns
+0800 2026-09-15 17:13:46 DEBUG [2573956400 0ms] dns: exchange github.com. IN A
(之后没有任何输出)

客户端那一侧是 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
2
3
4
现状                               n=15 min=139 p50=287 max=571
先收 SETTINGS 并 ACK n=15 min=118 p50=273 max=616
TCP_NODELAY n=15 min=113 p50=152 max=431
先 ACK + TCP_NODELAY n=15 min=124 p50=139 max=200

根因是 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.18.8.8.89.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_mstls_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 就行,不需要额外软件。

总结

把这次测出来的东西收成一份可以直接照做的清单:

  1. 协议先放一边,先挑上游。同一家上游三种协议的 p50 差异在 30 毫秒以内,不同上游之间能差 80 毫秒以上。
  2. 在本文这条网络路径上,DoT/DoH 的 853/443 端口是被有选择地拦的,而 53 端口仍然可用:1.1.1.1:53 稳定 OPEN,1.1.1.1:443 稳定超时。这个结论只覆盖本文的测量环境,换网络要重测。
  3. 配 DoT/DoH 时用域名而不是 IP。服务商的招牌 IP 和它服务域名的实际解析结果经常不是同一批地址,可用性也不同。
  4. UDP 省掉的是一次 150 到 500 毫秒的建连成本,不是每个查询的成本。长驻进程不必为此牺牲加密。
  5. DoT 的连接复用不能假设。DNSPod 和 Quad9 都会在几十次查询之内主动断开,客户端必须有重连逻辑。
  6. 本地转发器(sing-box)不增加可测量的延迟,缓存命中是 0.16 到 0.19 毫秒,收益全在缓存上。
  7. 服务端的 TLS 版本会直接体现进握手时间。本文里停在 TLS 1.2 的两个端点,握手比 TLS 1.3 的多花约一个 RTT。
  8. 写完测量脚本一定要用第二个实现交叉验证。我用 Python 手写的 HTTP/2 客户端因为流 ID 奇偶性和 TCP_NODELAY 两个问题,差点把 Quad9 的 DoH 判成不可用、把 Google 的 DoH 判成慢一倍。

参考资料

系列索引:网络与自建服务