preconnect 走 http3

想让 <link rel="preconnect"> 强制走 HTTP/3,能做到吗?

答案是:HTML 层面没有任何办法声明"这个 preconnect 要走 h3"。能不能走 h3 完全由网络栈根据服务端信息和本地配置自动决定。你能控制的只有两个旋钮:响应头 Alt-Svc 和 DNS 的 HTTPS(SVCB) 记录。

preconnect 是什么

<link rel="preconnect" href="https://example.com">

浏览器看到这个标签,会立刻去做这几件事:

  1. 解析 example.com 域名
  2. 建立一个连接
  3. 完成 TLS 握手(如必要)
  4. 保持连接并等着,不发送任何请求正文

等真正要请求资源时,这个连接已经热好了。这就是 提前连接 preconnect

这个连接最终用什么协议,HTTP/1.1、HTTP/2、HTTP/3,浏览器有一系列决定流程

HTTP/3 跑在 UDP 上(QUIC 协议),HTTP/1.1 和 HTTP/2 跑在 TCP 上。

触发 h3 preconnect 的三个条件

网络层有两条实现路径(Chromium 正在迁移,两条并存)

条件 ①:浏览器缓存了这个站点的 Alt-Svc

服务端某次响应头带上:

Alt-Svc: h3=":443"; ma=86400

浏览器把它记下来(存进 profile 磁盘缓存),之后所有对该站点的 preconnect 都会走 QUIC。

// 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

浏览器判定逻辑:

关键规则:

// 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,代码路径也支持:

但我把 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

必要条件,缺一不可:

  1. 必须开启 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 都支持)。

  1. QUIC 不能被关:chrome://flags/#enable-quic 保持默认(Default = 开)。

  2. origin 未被标记 QUIC broken。

  3. 不能走代理。源码两处都要求直连:

session_->IsQuicEnabled() && proxy_info_.is_direct()

(http_stream_factory_job_controller.cc:925-932)。代理下 preconnect 只会走 TCP。

  1. 必须是 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。

检查清单

  1. 抓 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/ 拖进去,搜:

  1. 确认 QUIC 没被关
  1. 确认 DNS 记录正确
# 直接问你的 DoH(推荐,能验证真实链路)
dig @your-doh-host example.com HTTPS

看 ANSWER 里 alpn= 是否含 h3。注意直接 dig HTTPS example.com 走的是系统 resolver,即使能查到也不代表 Chrome 会用。

  1. 按顺序排查
# 检查项 怎么查
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 端口的查询域名格式

Comments