工程
DNS 写着 h3,Chrome 为什么还在用 h2
Chrome 拿到 alpn=h3,并不代表请求会走 HTTP/3。从首次连接、已有连接复用到 QUIC 失败冷却,梳理 DNS 公告之后的决策链,以及强制刷新和重启为何未必有效。
上一篇排查了 CNAME 让 Chrome 用不上 DNS 里的 h3: HTTPS 记录已经返回,元数据却没能和地址配对。把名字对齐后,Chrome 终于显示出可用的 HTTP/3 候选端点。
但还有另一类问题:DNS 结果里明明写着 h3,DevTools 的 Protocol 列却一直是 h2。
强制刷新没用,清 DNS 缓存没用,甚至重启之后也没变化。
继续沿源码往下看,才发现 DNS 公告之后,还有连接复用、协议准入、竞速和失败冷却几层判断。
alpn=h3 让浏览器知道可以尝试 HTTP/3;真正使用它,还得等连接选择和握手完成。
本文整理自 2026 年 11 月 7~9 日的 Chromium 源码走读。最初的 net/dns 分析基于 main 快照 6fa9934b,
其他部分参考当时主干及后续核对的源码。除特别说明外,行为与参数属于源码结论,未逐项抓包或执行清理验证;
实际 Chrome 版本、企业策略与实验配置可能不同。
一、首次访问能不能直接走 h3
可以,不必先通过 TCP 访问成功一次。
常见的 HTTP/3 发现路径是:先建立 HTTP/2 或 HTTP/1.1 连接,服务器通过 Alt-Svc 响应头宣告 HTTP/3,
浏览器后续再尝试 QUIC。如果当前网络到站点的 TCP 路径不通,这条发现路径就卡在第一步。
DNS 的 HTTPS 记录提供了另一条路。比如解析器返回:
www.example.com. 60 IN HTTPS 1 . alpn="h3"
浏览器在连接前就知道站点支持 h3,因此可以直接尝试 QUIC,无需等待 Alt-Svc。
HTTPS 在这里是 DNS 记录类型,编号为 65;1 表示 ServiceMode,. 表示记录所属的名字。
在 Chromium 的新建连接路径中,端点声明的 ALPN 会参与 QUIC 版本选择;
当 QUIC 获准使用、端点信息可用时,浏览器可以先尝试 QUIC,再按延迟策略决定是否发起 TCP。
具体逻辑在
HttpStreamPool::AttemptManager
与 QuicSessionPool。
这意味着:即使站点的 TCP 443 被阻断,只要 HTTP/3 服务正常、UDP 路径可用、证书和握手通过, 首次访问仍有机会成功。把它理解成「DNS 先给了浏览器一条可用的 QUIC 发现路径」就够了, 不必把 TCP 延迟的某个默认毫秒数当作固定保证。
二、type 65 不一定要通过 DoH 查询
HTTPS 记录的名字很容易让人以为,它必须通过加密 DNS 才能获得。其实查询的记录类型与 DNS 的传输方式是两回事: 同一条 type 65,可以通过普通 UDP/TCP DNS 查询,也可以装进 DoH 请求。
桌面 Chrome 的常见默认路径使用内置 DNS 客户端,读取系统的 DNS 服务器配置,然后自行发送查询。
如果系统 DNS 指向 127.0.0.1:53,没有命中缓存、hosts 等前置结果,而且这次选择了普通 DNS 路径,
查询就可能发到本机解析器。它并不总是把域名交给操作系统 API,再只拿回一个 IP。
但「会使用内置解析器」不能写成「所有请求一定走内置解析器」。企业策略、平台能力、失败回退和请求本身的限制,
都可能改变路径。加入 HTTPS 查询还需要相关功能开启、请求携带支持的 URL scheme;
普通 DNS 路径也受 AdditionalDnsQueryTypesEnabled 控制。仅做一次裸域名解析,不代表浏览器一定同时查询 type 65。
入口见 HostResolverManager。
| 当前解析路径 | 在本机 UDP 53 上能否看到 type 65 |
|---|---|
| 普通 DNS,系统服务器为本机解析器 | 满足查询条件时可以看到;截断等情况可能转 TCP |
| DoH | 页面域名的 DNS 查询通过 HTTPS 发往 DoH 服务器 |
| 自动模式从 DoH 回退到普通 DNS | 回退后可能看到 |
| 命中缓存,或走系统解析器等其他路径 | 不能据此期待出现新的 UDP 查询 |
从普通 DNS 得到的 alpn=h3 也可以参与 HTTP/3 连接选择。DoH 与普通 DNS 在缓存信任等级、错误处理和回退上有区别,
但 HTTP 层并不要求「只有从 DoH 得到的 h3 才能用」。当然,公告被采用之后,仍然需要通过服务器证书验证。
这也是本地 clean-dns 有用的地方:它可以如实转发 HTTPS 记录,或在确认服务能力后合成记录。 浏览器到本机解析器、解析器到上游、浏览器到网站,是三段独立连接;上游 DNS 走 HTTP/3,不能证明网页也走 HTTP/3。
三、DoH 自己也有一个启动问题
如果在 Chrome 中填写:
https://doh.example.com:1443/dns-query
浏览器首先得知道 doh.example.com 的地址。它不能依赖这个尚未连上的 DoH 服务来解析服务自己,
否则就会循环依赖。
Chromium 给 DoH 内部请求设置了 SecureDnsPolicy::kBootstrap,使用专门的自举解析路径。
预配置地址、缓存、hosts 都可能提供结果;需要普通 DNS 查询时,则由这条路径完成。
DoH 请求还带有绕过代理的标记,不能假设系统 HTTP 代理会替它解决连接问题。
这些设置可以在
DnsHTTPAttempt
中看到。
DoH 使用 https:// 模板,并不意味着传输只能是 HTTP/2。它也能通过 HTTP/3 发送查询,
但首次启动仍需要 HTTP/3 的发现依据:已有的 Alt-Svc 记忆,或自举阶段获得的可用 HTTPS 记录。
非默认端口尤其容易漏配。对于上面的模板,HTTPS 记录查询名是:
_1443._https.doh.example.com.
只给 doh.example.com 发布一条 HTTPS 记录,并不能替代这个带端口的查询名。
规则见 RFC 9460 §9.1。
如果 DoH 端点的 TCP 路径也不通,首次连接又没有可用的 h3 公告或记忆,就无法指望先连 HTTP/2 再学习 Alt-Svc。 需要分别解决服务地址、自举阶段的 h3 发现、QUIC 准入和 UDP 连通性。 hosts 只能帮助固定地址,不能代替 HTTPS 记录宣告协议;它也不是所有部署都必须采用的方案。
四、有候选端点,不等于已经连上
chrome://net-internals/#dns 可能显示这样的结果:
Alternative endpoint: {"alpns":["h3","http/1.1"],"ip_endpoints":["198.18.2.51"]}
它说明解析阶段产出了一个支持 h3 的候选端点。它没有说明 QUIC 握手成功,更没有说明某一条网页请求用了 HTTP/3。
这里还列出了 http/1.1,因此也不能把这个端点看成只允许 QUIC。
实际协议要看 DevTools → Network 的 Protocol 列。要解释为什么没用 h3,
则继续用 chrome://net-export/ 抓 NetLog,沿解析结果、连接复用、QUIC 尝试和失败结果往下查。
这里的底层是我的 mihomo TUN 模式,198.18.2.51 是 mihomo 返回的 Fake-IP。
Chrome 没有通过显式 HTTP/SOCKS 代理发起请求,在浏览器的连接逻辑中仍按直连处理;
流量随后由 mihomo 的 TUN 接管,按规则转发。要让 HTTP/3 真正可用,匹配的出站路径仍须支持 UDP 转发。
已有 h2 连接会先被复用
Chrome 并不为每次刷新重新做一轮 TCP 与 QUIC 竞速。新请求可以直接借用已有会话:
在这里分析的普通连接路径中,先找可复用的 QUIC 会话,再找 HTTP/2 会话,然后找空闲 HTTP/1.1 连接;
没有合适连接才进入新建流程。源码入口是
MaybeCreateStreamFromExistingSession()。
所以,即使 DNS 刚刚补上 h3,当前请求也可能继续使用已建立的 h2。
强制刷新主要影响资源缓存,并不保证重新解析域名、关闭连接或重建会话。新开同站点标签页也可能复用原来的连接。
QUIC 也可能暂时失去尝试资格
另一道判断是 IsQuicBroken()。当对应目标和网络匿名分区命中失败记录时,
Chromium 会从这次连接允许的协议集合中移除 QUIC。相关代码见
CalculateAllowedAlpns()。
此时 DNS 即使再次返回 h3,也不会自动推翻失败记录。如果 TCP 可用,访问可能继续走 h2;
如果 TCP 不通,表现就可能从「刚才还能打开」变成「现在完全打不开」。
flowchart TD
Request[发起 HTTPS 请求] --> Existing{有可复用连接?}
Existing -->|有| Reuse[使用已有连接]
Existing -->|没有| Allowed{QUIC 获准使用?}
Allowed -->|否| TCP[尝试可用的 TCP 路径]
Allowed -->|是| Discovery{有可用的 h3 发现依据?}
Discovery -->|否| TCP
Discovery -->|是| QUIC[尝试 QUIC]
QUIC --> Handshake{握手与请求成功?}
Handshake -->|是| H3[这次请求使用 h3]
Handshake -->|否| Failure[按错误类型回退并判断是否记为 broken]
Failure --> TCP
图中省略了并行尝试与实验分支,用来区分几个判断层次。TCP 是否会启动、何时启动,还要看可用端点、ALPN 和连接调度。
五、失败冷却从五分钟开始
QUIC 的一次符合条件的失败,就可能让对应服务被标记为 broken。 例如 QUIC 失败而 TCP 成功时,浏览器可以记住这条替代路径暂时不可用; 但网络切换、断网等错误有特殊处理,不能把每次丢包、每次重传都算成一次 broken。
按本次核对的默认参数,从没有历史计数开始,反复重试并再次被标记 broken,会出现这样的退避序列:
| 第几次被标记 broken | 本轮冷却 |
|---|---|
| 1 | 5 分钟 |
| 2 | 10 分钟 |
| 3 | 20 分钟 |
| 4 | 40 分钟 |
| 5 | 80 分钟 |
| 后续 | 继续翻倍,上限 2 天 |
计时从本次标记开始。表格描述的是失败退避,不是刷新次数;成功确认、状态清理、历史计数淘汰和实验覆盖也会影响后续行为。
算法见
BrokenAlternativeServices,
默认的按初始值退避设置见
broken_alternative_services.h,
初始值与最大值参数见
net/base/features.cc。
冷却到期,只恢复了尝试资格。浏览器不会主动把正在使用的 h2 连接换成 h3。 后续请求需要进入连接选择流程,遇到有效的公告、通过准入检查并成功握手,才会实际使用 HTTP/3。
有些 broken 状态与默认网络绑定,可以在默认网络切换时提前解除;普通计时状态则不保证如此。 所以「等五分钟」「切一下网络」都只能解释某些情况,不能作为必然恢复的方法。
六、重启和清 DNS 缓存为什么没用
这里至少有三份不同状态:DNS 解析缓存、连接池,以及 HTTP 服务器属性中的 QUIC 失败记录。 清理其中一份,不会自动重置另外两份。
更容易忽略的是,broken 状态默认可以持久化。Chrome 会保存 broken_count 和 broken_until,
下次启动时恢复失败计数和剩余冷却。broken_until 是 Unix 秒时间戳字符串,
重启后继续等到同一个绝对截止时间,并不会因为进程重启而自动失效。
保存与恢复逻辑见
HttpServerPropertiesManager。
macOS 的默认 Chrome 个人资料中,本机已确认过的文件位置是:
~/Library/Application Support/Google/Chrome/Default/Network Persistent State
其他个人资料以 chrome://version 显示的路径为准;版本和目录迁移也可能影响文件位置。
其中的 JSON 路径是:
$.net.http_server_properties.broken_alternative_services
判断记录时,要同时看协议、主机、端口和 anonymization 分区,不能只搜索某个站点名字。
分区字段里出现 Reddit,不代表这一条记录的目标主机就是 Reddit。
只有 broken_count,说明存在历史计数;broken_until 仍在未来,才表示这份磁盘快照里尚有冷却。
下面的 Fish 命令只读文件,列出快照中仍在冷却的记录,截止时间以 UTC 显示:
jq '
now as $now
| (.net.http_server_properties.broken_alternative_services // [])
| [
.[]
| (.broken_until // "0" | tonumber) as $until
| select($until > $now)
| {
host, port, protocol: .protocol_str, broken_count,
remaining_seconds: (($until - $now) | ceil),
until_utc: ($until | todateiso8601), anonymization
}
]
| sort_by(.remaining_seconds) | reverse
' ~/Library/Application\ Support/Google/Chrome/Default/Network\ Persistent\ State
磁盘只是一份快照,运行时以内存状态为准。属性修改通常会延迟安排保存,退出时也会保存;
直接编辑正在运行的 Chrome 的 JSON 文件不会热更新 broken 表,还可能被后续写入覆盖。
内存查询、初始化和延迟写入见
HttpServerProperties。
七、恢复 h3,先分清要清什么
| 操作 | 对 QUIC broken 的作用 |
|---|---|
| 普通刷新、强制刷新 | 不清除 |
| 清 DNS 缓存 | 不清除 |
| Flush socket pools | 关闭连接,不清除 broken;冷却已到期时,可帮助后续请求重新建连 |
| 正常重启 Chrome | 可能恢复持久化状态,不能保证清除 |
| 运行中修改状态文件 | 不会热加载 |
| 浏览器设置中删除「缓存的图片和文件」,不限定站点 | 当前源码路径会一并清除 HTTP 服务器属性,包括 broken 与失败计数 |
最后一项乍看不直观。浏览数据清理代码在缓存清理分支、站点过滤条件允许时,调用
NetworkContext::ClearNetworkingHistoryBetween(),再调用 HttpServerProperties::Clear(),
最终清空 broken 状态和历史计数。调用链可以从
浏览数据清理入口
与 NetworkContext
追下去。
虽然入口带有时间范围,HttpServerProperties::Clear() 本身没有时间参数,清理的是整份属性。
因此按这里分析的实现,选「过去一小时」也会清除全部 broken 与计数,不要求选「所有时间」。
代价是 Alt-Svc、网络统计、QUIC 服务器信息等也会一起清掉,资源缓存则按所选范围处理;这不是单域名的轻量重置。
DevTools 的 Disable cache 或 Empty cache and hard reload 也不能直接等同于这条设置页清理路径。
如果需要精准清除磁盘中的某一项,应先完全退出 Chrome、备份文件, 再删除匹配目标主机、端口、协议与分区的整条记录,保存后启动。只改截止时间会留下退避计数, 也没有必要删除整个状态文件。本文没有执行这类清理操作。
解除 broken 后,已有 h2 仍可能继续复用。确实需要重新建连时,可以再考虑
chrome://net-internals/#sockets 的 Flush socket pools,但它会影响正在使用的连接。
八、排查时,把三种证据分开
遇到「DNS 里有 h3,页面却没用」时,我会按这个顺序看:
- 解析证据:Chrome 是否真的得到可用的 h3 端点,而不只是命令行工具查到一条 HTTPS 记录?有无 CNAME 配对问题?
- 连接证据:请求是否复用了已有 h2?QUIC 是否被策略禁用或命中 broken?NetLog 中有没有 QUIC 尝试,失败在哪里?
- 请求证据:DevTools 的 Protocol 列实际是什么?在我的 mihomo TUN 配置下,流量命中了哪条规则,所选出站能否正常转发 UDP?
DNS 解决的是连接前的发现,连接池决定是否需要重建,失败记录决定现在是否允许尝试。 这三层状态各自有缓存和生命周期。把它们分开之后,「强制刷新为什么没用」就不再是一个玄学问题。