HTTP/3 运行在 QUIC 与 UDP 之上,常用端口是 443,但协议不要求只能使用这个端口。HTTP/2 各流共用 TCP 的有序字节流,缺失片段可能阻塞无关请求的数据交付。QUIC 按流提供有序交付,某条流丢失数据不必阻塞另一条流;连接级拥塞控制与 HTTP 层依赖仍可能影响多个流,不能说每条流都有完全独立的拥塞控制或丢包恢复状态。
仅启用服务器模块还不够。客户端需要 HTTP/3 支持、可代表该源的端点、可验证的 TLS 身份,以及实际可用的 UDP 路径。发现机制和回退策略决定何时尝试它;不能把此前必须成功访问一次 TCP 服务当成所有客户端都要满足的协议前提。
两种传输,一个端口号
UDP:443 与 TCP:443 使用不同套接字,只是端口数字相同;并不是一个套接字同时监听两种传输。nginx 的 server 块可以并列配置 listen 443 quic reuseport; 与 listen 443 ssl; http2 on;。受支持系统上的 reuseport 用于每个 worker 的监听套接字,不是修复重复 server 声明或其他进程占用端口的万能选项。应先核对完整配置、实际错误与套接字归属,再判断是否需要修改。
QUIC 将 TLS 1.3 与传输建立结合,能够减少建立连接的往返次数。恢复会话时可能发送早期应用数据,但握手并未消失,服务器也不一定接受 0-RTT。Retry、丢包与验证都可能增加等待。连接 ID 可以在端点与路由设施支持时帮助连接跨地址变化延续;它本身不承诺所有漫游都成功,也不会自动修复 UDP 不通的路径。
Alt-Svc 是客户端得知的方式
源站可以通过已有 HTTP 连接发布 Alt-Svc: h3=":443"; ma=86400,表示协议 h3、同一主机的 443 端口,以及二十四小时的新鲜期。该连接不必是 HTTP/2;已知目标支持 HTTP/3 的客户端也可以直接尝试,不需要先访问一次 TCP 服务。客户端可以使用仍新鲜的替代服务,不必等待下次页面访问。收到通告只证明服务器发出了信息,不能证明该客户端的 UDP 路径真正可用。
新鲜期允许复用通告,不会强迫客户端每次新建连接都重复一个已经失败的 QUIC 尝试。客户端可以回退,也可以使用自己的失败处理策略。QUIC 探测定时器取决于往返时间估计及退避,而何时并行竞速、放弃尝试属于实现选择。早先所写的十五秒、每天三百秒和零点一秒是没有保留可运行模型或原始轨迹的示意假设,既不是实测,也不是协议规定的回退时间,不能据此估算真实用户损失。
0-RTT 与连接迁移是两处锋利的边缘
0-RTT 允许回访客户端在新握手完成前发送应用数据。早期数据存在重放风险,因此应用必须明确哪些处理能安全重放,并拒绝不安全的早期操作;持有旧票据也不保证服务器接受。未经保护的创建订单请求可能重复产生副作用,传输加密并不提供恰好一次执行保证。判断能否使用早期数据,应依据业务语义与服务端保护,不能只看请求是否已经加密。
客户端地址改变后,迁移可以保留 QUIC 连接,但受到对端策略与路径验证约束。按五元组选择后端的均衡器可能把报文发给没有该连接加密状态的另一台后端,造成中断或重置,而不是让它自然接管原连接。早先从七号落到四号的示例没有保留哈希模型或原始轨迹,不能当成测得的落点,也不能据此断言每次地址变化一定选中不同后端。
坏掉的样子
缓存通告与 UDP 阻断可能拖慢连接建立,但不能保证整整一天每次访问都慢,也不能仅凭 HTTP/3 失败断定中间盒故障。客户端缺少支持、TLS 错误、端点配置或服务器不可用,都可能导致失败。应对照客户端错误、分协议延迟与服务端观察;访问日志里没有请求并不足以定位丢包。保留可工作的 TCP 备用路径,并测量实际客户端的回退策略,才能判断等待发生在哪里。
地址变化还可能暴露错误的路由亲和性。应保持报文到达拥有该连接状态的 QUIC 端点;仅靠应用 Cookie,无法在 QUIC 终止之前为加密报文选择后端。需要显式验证部署中的连接 ID 处理与路径变化,也不能假定连接 ID 永远不变。此类问题不限于移动客户端;传输恢复成功之后,应用会话是否连续仍是另一层问题,不能把两种状态混为一个保证。
快速验证
使用 Bash、Python 3,以及编译时支持 HTTP/2 和 HTTP/3 的 curl 7.88 或更新版本。把 URL 设置为获准直接访问、GET 预期返回 200 的完整 HTTPS 地址,包含路径;这些请求不使用重定向或代理。目标应是允许重复读取的诊断资源,而不是有副作用的操作:
(
set -euo pipefail
: "${URL:?Set one authorized HTTPS URL with an expected 200 response}"
umask 077
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
probe() {
local label=$1 mode=$2 expected=$3 rc check_rc
if curl --disable --silent --show-error --fail --globoff \
--noproxy '*' --proto '=https' --connect-timeout 10 --max-time 30 \
"$mode" --output /dev/null --dump-header "$tmp/$label.headers" \
--write-out '%{json}\n' --url "$URL" > "$tmp/$label.json"; then
rc=0
else
rc=$?
printf '%s: curl failed (exit %s)\n' "$label" "$rc" >&2
fi
if python3 - "$tmp/$label.json" "$label" "$expected" "$URL" "$rc" <<'PY'
import json, math, pathlib, sys
path, label, expected, url, curl_exit = sys.argv[1:]
print(label, "curl_exit", curl_exit)
d, headers = None, None
try:
d = json.loads(pathlib.Path(path).read_text())
if not isinstance(d, dict):
raise ValueError("expected a JSON object")
except (OSError, UnicodeError, ValueError) as error:
d = None
print(label, "metadata unavailable:", str(error), file=sys.stderr)
keys = ("url_effective", "remote_ip", "remote_port", "http_version", "http_code",
"ssl_verify_result", "time_appconnect", "time_starttransfer", "time_total")
if d is not None:
print(label, json.dumps({k: d[k] for k in keys + ("exitcode", "errormsg") if k in d}))
missing = [k for k in keys if k not in d]
if missing:
print(label, "metadata fields unavailable:", ", ".join(missing), file=sys.stderr)
try:
headers = pathlib.Path(path).with_suffix(".headers").read_text(encoding="iso-8859-1")
print(label, "Alt-Svc header observations:",
[line for line in headers.splitlines() if line.lower().startswith("alt-svc:")])
except OSError as error:
print(label, "header observations unavailable:", str(error), file=sys.stderr)
if int(curl_exit) != 0:
# Observations from a failed transfer are not successful validation.
raise SystemExit(0)
try:
ok = (d is not None and headers is not None and d["http_code"] == 200 and
d["url_effective"] == url and d["http_version"] in ("1.1", "2", "3") and
d["remote_ip"] and d["remote_port"] > 0 and d["ssl_verify_result"] == 0 and
(expected == "any" or d["http_version"] == expected) and
all(math.isfinite(d[k]) and d[k] >= 0 for k in
("time_appconnect", "time_starttransfer", "time_total")))
except (KeyError, TypeError, ValueError):
ok = False
if not ok:
raise SystemExit(label + ": unavailable or unexpected endpoint, protocol, status or timing")
PY
then
check_rc=0
else
check_rc=$?
fi
if [ "$rc" -ne 0 ]; then
return "$rc"
fi
return "$check_rc"
}
failed=0
probe h2 --http2 2 || failed=1
probe h3-only --http3-only 3 || failed=1
probe fallback-enabled --http3 any || failed=1
exit "$failed"
)
每个成功探测都检查状态码、URL 与实际协议。失败传输仍输出已有的协议、状态、对端、计时与响应头观察,并保留原始 curl 退出码和错误输出;缺失或无法解析的元数据会明确标为不可用。失败观察不算验证成功,即使 h3-only 失败,也继续执行后续探测,最终汇总仍为非零。–http3-only 禁用回退,–http3 则允许与较旧协议竞速。结果为 2 或 1.1 只记录选择了 TCP,不代表完成可控 UDP 阻断实验;1.1 也不能证明 h2。time_appconnect 从开始计到握手完成,time_starttransfer 从开始计到收到首字节;两者不是同一指标。Alt-Svc 输出只是原始响应头观察,可能包含中间响应,不证明通告有效或路径可达。
在对应的获准服务器上,单独检查配置和本地套接字;不能把客户端的远端 IP 自动当成这台主机的身份:
(
set -euo pipefail
umask 077
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT
if nginx -T > "$tmp/nginx.txt" 2> "$tmp/nginx.err"; then
cat "$tmp/nginx.err" >&2
else
rc=$?
cat "$tmp/nginx.err" >&2
exit "$rc"
fi
if grep -nE 'listen .*quic|http2|http3|Alt-Svc' "$tmp/nginx.txt"; then
:
else
rc=$?
printf 'No matching directives, or grep failed (exit %s)\n' "$rc" >&2
exit "$rc"
fi
ss -H -lunp 'sport = :443' > "$tmp/udp.txt"
cat "$tmp/udp.txt"
if [ ! -s "$tmp/udp.txt" ]; then
printf 'No local UDP:443 listener observed\n' >&2
exit 1
fi
)
错误仍然可见。配置匹配可能包括注释或未加载的设置,套接字进程信息也可能受权限限制。没有进一步对应运行进程、部署配置与客户端端点之前,两者都不证明公网服务身份或可达性。配置输出可能敏感,审阅时应妥善保管。本轮只有 shell 语法与假命令/文件控制,没有执行真实 TLS、HTTP、NGINX 或回退验证,技术与运行检查仍暂缓。
来源
- RFC 9114 HTTP/3,HTTP/3 在 QUIC 之上的语义,已核验 2026-09-16。
- RFC 7838 HTTP 替代服务,Alt-Svc 头及其新鲜期,已核验 2026-09-16。
- ngx_http_v3 模块,nginx 的 QUIC 与 HTTP/3 指令,已核验 2026-09-16。
- RFC 9000 QUIC,按流交付、连接 ID、迁移与 0-RTT,已核验 2026-09-16。
- nginx core listen directive,独立监听及每个 worker 的 reuseport 语义,已核验 2026-10-18。
- curl manual,HTTP/3-only 模式、回退选择、错误与握手计时,已核验 2026-10-18。
- RFC 9002 QUIC recovery,依赖 RTT 的探测定时器与指数退避,已核验 2026-10-18。