preconnect 走 http3
想让 <link rel="preconnect"> 强制走 HTTP/3,能做到吗?
preconnect 是什么
<link rel="preconnect" href="https://example.com">
浏览器看到这个标签,会立刻去做这几件事:
- 解析
example.com域名 - 建立一个连接
- 完成 TLS 握手(如必要)
- 保持连接并等着,不发送任何请求正文
等真正要请求资源时,这个连接已经热好了。这就是 提前连接 preconnect
HTTP/3 跑在 UDP 上(QUIC 协议),HTTP/1.1 和 HTTP/2 跑在 TCP 上。
HTML 层面没有任何办法声明"这个 preconnect 要走 h3"。
这个连接最终用什么协议,HTTP/1.1、HTTP/2、HTTP/3,浏览器有一系列决定流程
触发 h3 preconnect 的三个条件
网络层有两条实现路径(Chromium 正在迁移,两条并存)
条件 ①:浏览器缓存了这个站点的 Alt-Svc
服务端某次响应头带上:
Alt-Svc: h3=":443"; ma=86400
浏览器把它记下来(存进 profile 磁盘缓存),之后所有对该站点的 preconnect 都会走 QUIC。
- 传统路径:
http_stream_factory_job_controller.cc:915-919, 950-968 - 新路径(HttpStreamPool):
http_stream_pool_job_controller.cc:283-288
// If the preconnect explicitly requests QUIC, start preconnecting before
// checking existing SpdySession and idle streams.
if (origin_quic_version_.IsKnown()) {
preconnect_callback_ = std::move(callback);
StartAltSvcQuicPreconnect();
}
条件 ②:DNS 的 HTTPS 记录(RFC 9460 SVCB)里 ALPN 含 h3
example.com. IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443
浏览器判定逻辑:
- 传统路径:
http_stream_factory_job_controller.cc:925-932 - 新路径:
http_stream_pool_attempt_manager.cc:1333-1361→quic_session_pool.cc:1687SelectQuicVersion()
关键规则:
// net/quic/quic_session_pool.cc:1691
if (metadata.supported_protocol_alpns.empty()) {
// 纯 A/AAAA 记录 → 除非已知 QUIC 版本,否则这个 endpoint 不合格
return svcb_optional ? known_quic_version : Unsupported();
}
DNS 只有 A/AAAA 记录时,浏览器根本不会尝试 QUIC,必须 DNS 里明确写 h3。
条件 ③:force_quic_everywhere(理论上行,Chrome 里做不到)
net/quic/quic_context.h:131-133 的 QuicParams 确实能全局强制 QUIC,代码路径也支持:
http_stream_factory_job.cc:145-177(using_quic_含ShouldForceQuic)http_stream_pool_job.cc:43-47(group->force_quic()时只允许 QUIC)
但我把 net/base/switches.h、content_switches.h、chrome_switches.h 都翻了一遍,Chrome 没有任何命令行开关能设置它(老版本的 --force-quic-everywhere 已移除)。只有 Cronet 或自己编译的 embedder 直接构造 QuicContext 才能用。
→ 实际可用方案只有 ① 和 ②。
实施细节
不考虑 Alt-Svc,细说冷连接直接 h3
example.com. IN HTTPS 1 . alpn="h3,h3-29,h2,h1" port=443
必要条件,缺一不可:
- 必须开启 Secure DNS(DoH) —— 这是最容易踩的坑。
Chromium 里 NetworkService 用默认配置创建 DNS 解析器:
// services/network/network_service.cc:483
host_resolver_manager_ = std::make_unique<net::HostResolverManager>(
net::HostResolver::ManagerOptions(), ...);
默认 insecure_dns_mode = kDisabled(net/dns/host_resolver.h:354),意思是不走 Chromium 内置的明文 DNS 客户端,只走系统 resolver(getaddrinfo)。而系统 resolver 无法查询 type 65。
// net/dns/host_resolver_dns_task.cc:378
if (types.Has(DnsQueryType::HTTPS)) {
if (!secure() && !client_->CanQueryAdditionalTypesViaInsecureDns(ech_mode_)) {
https_disabled_ = true; // 直接把 HTTPS 查询删掉
types.Remove(DnsQueryType::HTTPS);
}
}
所以要去 chrome://settings/security → 使用安全 DNS,或用企业策略 SecureDnsMode 配自定义 DoH。你用的 DoH 服务器必须支持 type 65(Cloudflare / Google / Quad9 都支持)。
-
QUIC 不能被关:
chrome://flags/#enable-quic保持默认(Default = 开)。 -
origin 未被标记 QUIC broken。
-
不能走代理。源码两处都要求直连:
session_->IsQuicEnabled() && proxy_info_.is_direct()
(http_stream_factory_job_controller.cc:925-932)。代理下 preconnect 只会走 TCP。
- 必须是 https:HTTP/3 只用于加密连接。
type 65 的两个时序问题
查询顺序上 type 65 排第一(kPrioritizeHttpsResourceRecord 默认开),然后才是 AAAA、A。但实际上 DoH 下三个查询是并发发出的(host_resolver_manager_job.cc:864):
// DoH queries can bypass the dispatcher and start all of their transactions immediately.
if (secure) {
while (dns_task_->num_additional_transactions_needed() >= 1) {
dns_task_->StartNextTransaction();
}
}
所以理论上不存在 HTTPS 单独慢的问题。
type 65 来迟了怎么办? 只有 20% × 50ms 的窗口:
// host_resolver_dns_task.cc:1165
timeout = total_time_for_other_transactions * extra_time_percent / 100; // 20%
timeout = std::min(timeout, timeout_max); // 50ms
timeout = std::max(timeout, timeout_min); // 5ms
举例:A/AAAA 用了 30ms,type 65 只多等 6ms。超时就按"没这条记录"处理。
为什么不等更久?因为三个查询并发在飞,type 65 只要不比 A/AAAA 慢太多就来得及。
降级是静默的
如果 DNS 里没返回 h3 ALPN,QUIC 侧会返回 ERR_DNS_NO_MATCHING_SUPPORTED_ALPN,然后自动回落到 TCP preconnect:
// http_stream_factory_job_controller.cc:586
if (result == ERR_DNS_NO_MATCHING_SUPPORTED_ALPN && preconnect_backup_job_) {
main_job_ = std::move(preconnect_backup_job_);
main_job_->Preconnect(num_streams_);
所以配错不会报错,只会静默降级成 TCP。 只能靠 netlog 确认。
同样的降级也发生在 type 65 查询超时、SERVFAIL、REFUSED 时 —— IsFatalTransactionFailure() 对 HTTPS 查询永远返回 false,失败时合成一个空的合法响应,效果等同"这条记录不存在"。preconnect 照样成功,只是走 TCP。
唯一的例外是 DoH + UseDnsHttpsSvcbEnforceSecureResponse=true(默认 false),或 ECH 严格模式,此时失败是致命的 —— 因为必须拿到 SVCB 信息才能继续。普通用户碰不到。
DNS 侧的四个额外坑
非 443 端口不支持。 源码里直接写了注释:
// http_stream_factory_job_controller.cc:113
// Note: Chrome does not yet support different port DNS alpn.
所以 h3 preconnect 实际上只对 443 有效。
ALPN 写了 h2 但没写 h3 反而会堵死 h3。 SelectQuicVersion 一旦看到 SVCB 记录就只认列表里的 h3,找不到直接返回 Unsupported,不会退回"忽略 SVCB 走 QUIC"。也就是说只要发了 HTTPS 记录,就必须在里面写 h3。
HTTPS 记录的 TTL 会拉低整个域名的缓存寿命。 TtlFromInternalResults 取所有查询结果里最短的 TTL(host_cache.cc:729),而 HTTPS metadata 也在这份结果里。别把 HTTPS TTL 单独调小于 A/AAAA,建议保持一致。
ipv4hint/ipv6hint 在稳定版不生效。 代码支持,但受 kUseDnsHttpsSvcbAddressHints 控制,默认关闭(net_base_features.cc:85)。好消息是它不是必需的,A/AAAA 会正常返回 IP。
一个容易误判的点
源码里有"流式返回中间 DNS 结果"的机制(DnsTaskResultsManager),但它的三个开关默认全是关的:
// host_resolver_manager_job.cc:856
if (kEnableIntermediateDnsResults || // 默认关
kAsyncDnsQuicJob || // 默认关
resolver_->IsHappyEyeballsV3Enabled()) { // 默认关
dns_task_results_manager_ = ...;
}
所以当前稳定版 Chrome 实际是"要么全等到齐、要么超时降级",没有真正的流式中间结果。 容易误以为存在该机制。
局限性
| 坑 | 说明 |
|---|---|
| 粒度只能是 origin 级 | Alt-Svc 和 SVCB 都是站点级信息。一旦生效,后续真实请求也会一起走 h3;没法只让 preconnect 走 h3 而普通请求走 h2。 |
| UDP 443 被封 | QUIC 握手失败 → 标记 QUIC broken → 之后连 Alt-Svc 都不看,直接退 TCP,还多等一个握手超时。表现为首帧变慢而不是报错。 |
| 代理环境下基本失效 | 方案 B 完全失效(要求直连);方案 A 需要 alt-svc + QUIC 代理同时具备。 |
| 第一次 preconnect 不生效 | 方案 A 必须先有一次真实响应。方案 B 没有这个问题(DNS 直接可查)。 |
| 非 https 目标 | http:// 永远不会走 h3(GURL::SchemeIsCryptographic 判定)。 |
crossorigin 属性无用 |
<link rel=preconnect crossorigin> 只影响 credentials mode 和连接分组键,不影响协议选择。 |
| 连接数会被合并/限流 | 同一 origin 的多个 preconnect 合并到同一个连接池,受上限约束(kDefaultMaxStreamSocketsPerGroup = 6,http_stream_pool.h:110)。 |
| QUIC 只能开 1 条 | h3 preconnect 只建 1 个 QUIC session。TCP 侧在服务端支持优先级时也会把预连接数压到 1(http_stream_factory_job.cc:255-270)。 |
| QUIC 被禁后不可恢复 | profile 偏好 kQuicAllowed 一旦被企业策略置 false,代码里明确注释 "re-enabling QUIC is not supported",只能重启浏览器或换 profile。 |
检查清单
- 抓 netlog(最权威)
# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--log-net-log --net-log-file=/tmp/netlog.json \
--net-log-capture-mode=IncludeSensitive
打开 https://netlog-viewer.appspot.com/ 拖进去,搜:
HTTP_STREAM_JOB且 type =preconnect_dns_alpn_h3→ 方案 B 生效HTTP_STREAM_POOL_JOB_ALIVE里allowed_alpns含h3→ 方案 A 或 B 生效QUIC_SESSION/QUIC_VERSION_NEGOTIATION→ 确认真的握手了HOST_RESOLVER_DNS_TASK的transactions_needed里有没有HTTPS类型 → 没有就说明压根没发这个查询
- 确认 QUIC 没被关
chrome://version→ 命令行里没有--disable-quicchrome://policy→ 没有禁用 QUIC 的企业策略
- 确认 DNS 记录正确
# 直接问你的 DoH(推荐,能验证真实链路)
dig @your-doh-host example.com HTTPS
看 ANSWER 里 alpn= 是否含 h3。注意直接 dig HTTPS example.com 走的是系统 resolver,即使能查到也不代表 Chrome 会用。
- 按顺序排查
| # | 检查项 | 怎么查 |
|---|---|---|
| 1 | DoH 开了吗 | chrome://settings/security → "使用安全 DNS" 非"关闭" |
| 2 | DoH 服务器支持 type 65 吗 | dig @doh-host example.com HTTPS 要有 ANSWER |
| 3 | Secure DNS 模式 | 必须"自动"或"安全"。"关闭" = 完全不发 type 65 |
| 4 | 端口是 443 吗 | 非 443 不支持 |
| 5 | ALPN 串里有 h3 吗 | 注意是 h3 不是 h3-30,建议 h3,h3-29,h2,h1 |
| 6 | 是直连吗 | 走代理时 DNS-ALPN-h3 被禁用 |
| 7 | QUIC 没被关吗 | flags 默认 + policy 无禁用 |
| 8 | TTL 合理吗 | HTTPS TTL 别短于 A/AAAA |
| 9 | origin 没被标 QUIC broken | 之前握手失败过会被标记,之后退 TCP 直到重启 |
源码文件参考
| 文件 | 作用 |
|---|---|
third_party/blink/renderer/core/loader/preload_helper.cc |
Blink 侧:把 <link rel=preconnect> 变成 preconnect 指令 |
services/network/network_context.cc |
转发给 HttpStreamFactory::PreconnectStreams |
net/http/http_stream_factory.cc |
传统路径入口,创建 JobController |
net/http/http_stream_factory_job_controller.cc |
核心判定:何时走 DNS-ALPN-H3 / Alt-Svc,失败如何回落 TCP |
net/http/http_stream_factory_job.cc |
单个连接任务,using_quic_ 判定 |
net/http/http_stream_pool_job_controller.cc |
新路径(HEv3)preconnect 逻辑 |
net/http/http_stream_pool_attempt_manager.cc |
新路径:QUIC/TCP 连接尝试、竞速 |
net/quic/quic_session_pool.cc |
SelectQuicVersion():SVCB ALPN 匹配逻辑 |
net/http/http_network_session.cc |
ShouldForceQuic() / IsQuicEnabled() |
net/quic/quic_context.h |
force_quic_everywhere 等参数定义 |
net/dns/host_resolver.h |
ManagerOptions.insecure_dns_mode(为什么必须 DoH) |
net/dns/host_resolver_dns_task.cc |
HTTPS RR 查询条件、查询顺序、等待超时预算 |
net/dns/host_resolver_manager_job.cc |
DoH 下多查询并发发起 |
net/dns/host_cache.cc |
TtlFromInternalResults():缓存 TTL 取各结果最小值 |
net/dns/public/util.cc |
GetNameForHttpsQuery():非 443 端口的查询域名格式 |
Posted
stdout


