IPv6 与 IPv4 双栈环境下的 Happy Eyeballs (RFC 8305) 竞争延迟与首包卡顿分析
一个双栈客户端打开网页,浏览器地址栏转圈 2 秒才出首字节,抓包却显示 TCP 握手在 40ms 内就完成了。这类"握手很快、首包很慢"的割裂现象,大量出现在 IPv6 出口路由绕道、隧道 MTU 塌陷、或者 DNS 双查询时间差被放大的环境里。Happy Eyeballs v2 (RFC 8305) 的设计目标本来是让用户在 IPv6 可用时尽快用上 IPv6、在 IPv6 不通时无感回落到 IPv4,但它的竞争逻辑一旦碰上劣质 IPv6 链路,就会把"无感回落"变成"反复摇摆",把连接首包拖成偶发超时。
下面按 RFC 8305 的状态机、地址排序、Connection Attempt Delay 三个维度拆开,再落到 Linux 内核与主流客户端实现的具体参数,最后给出可复现的基准测试与 ConnProof 诊断路径。
一、Happy Eyeballs 要解决的问题与它的边界
1.1 双栈客户端的原始困境
RFC 6555 在 2012 年定义了 Happy Eyeballs 的第一版。它要解决的场景非常具体:客户端同时拿到 A 记录和 AAAA 记录,如果严格按 RFC 3484(后被 RFC 6724 取代)的地址排序优先使用 IPv6,而这条 IPv6 路径实际不可达,用户就要等满 TCP 连接超时(典型 21s、75s 甚至 130s)才回落到 IPv4。RFC 6555 的解法是并行发起,先发 IPv6,等一小段时间没成功再发 IPv4,谁先握手成功用谁。
RFC 8305 在 2017 年重写了这套逻辑,核心变化有三点:
- 把"先 IPv6 后 IPv4"的硬编码顺序改成"按 RFC 6724 排序后的地址列表交错发起",不再假设 IPv6 一定优先。
- 引入明确的 Connection Attempt Delay 概念,默认建议 250ms,并允许实现自适应。
- 定义了 DNS 查询的并行化与"先到先用"策略,避免一个查询拖住整个连接建立。
RFC 8305 第 3 节明确写了它的非目标:它不解决 IPv6 路径质量问题,不解决 MTU 黑洞,不解决 DNS 解析器返回错误记录。它只保证"在给定地址集合里,尽快挑出一个能完成握手的地址"。这句话是理解后续所有首包卡顿的钥匙:Happy Eyeballs 优化的是"选路时间",不是"路径质量"。选出来的路如果 PMTU 塌陷,握手能过、首包照样卡。
1.2 竞争延迟的本质是"时间预算分配"
把连接建立拆成时间轴,客户端在 t=0 拿到地址列表,之后每个动作都消耗预算:
t=0ms DNS A/AAAA 返回,地址排序完成
t=0ms 发起第一个连接尝试(排序首位,通常 IPv6)
t=250ms 若首次尝试未完成握手,发起第二个尝试(下一个地址族)
t=500ms 仍未成功,继续交错发起后续地址
t=... 任一尝试完成 TCP 握手,取消其余尝试,进入 TLS/应用层2
3
4
5
250ms 这个数字不是拍脑袋。RFC 8305 第 5 节给出的建议区间是 100ms 到 300ms,默认 250ms,理由是:大多数正常 RTT 在 100ms 以内的链路上,首个尝试能在 250ms 内完成或明确失败;而 250ms 又短到用户感知不到明显停顿。问题在于,当首个 IPv6 尝试既没有完成也没有明确失败(比如 SYN 发出后进入黑洞,既无 SYN-ACK 也无 RST),客户端必须等满 250ms 才发 IPv4,这 250ms 就是用户可感知的固定迟滞。
更糟的是,如果 IPv6 路径的 SYN-ACK 在 300ms 才回来,而 IPv4 在 250ms 时已经发起并在 280ms 完成握手,客户端会选 IPv4 并取消 IPv6 尝试。下一次连接,排序可能因为缓存或 RTT 估计变化而不同,于是用户在 IPv4 和 IPv6 之间反复横跳。这种摇摆本身不致命,但它让连接复用、TLS session resumption、HTTP/2 连接池全部失效,首包延迟被反复放大。
二、RFC 6724 地址排序与 DNS 双查询时间差
2.1 排序规则决定了谁先上场
RFC 6724 的排序不是一个简单优先级列表,它按以下顺序逐级比较:
- 地址族与源地址的匹配度(是否有同族源地址可用)。
- 规则 5/6 的作用域比较(link-local、site-local、global)。
- 规则 7 的 IPv4-mapped 惩罚。
- 规则 8 的"最长前缀匹配"。
- 规则 9 的"首选源地址"。
- 最后才看 RFC 8305 引入的 RTT 估计。
关键点在规则 9 之后:如果两个地址在所有静态规则上打平,实现可以用实测 RTT 打破平局。Linux 的 getaddrinfo 在 glibc 层做排序,但真正的连接发起在应用层(浏览器、curl、Go runtime),它们各自维护 RTT 缓存。Chrome 的 HostResolver 会记录每个地址族的连接成功率和 RTT,并在排序时给近期失败的地址族降权。这就是"摇摆"的机制来源:一次 IPv6 慢成功,下一次可能就被降权到 IPv4 后面。
2.2 A 与 AAAA 查询的时间差被放大
RFC 8305 第 4 节要求客户端并行发起 A 和 AAAA 查询,并允许"先返回的先用于发起连接"。这条规则在理想 DNS 环境下没问题,但在真实环境里会制造不对称:
- 运营商递归解析器对 AAAA 的缓存命中率通常低于 A,因为 AAAA 记录变更更频繁、上游权威服务器对 AAAA 的响应更慢。
- 部分 CDN 的权威服务器对 AAAA 查询走不同的 anycast 节点,响应时间比 A 慢 20 到 80ms。
- 客户端本地 stub resolver(如 systemd-resolved)对 A 和 AAAA 的并发处理能力不一致,某些版本会串行化。
结果就是 A 先返回、AAAA 晚到。客户端按"先到先用"先发起 IPv4 连接,等 AAAA 到了再按 RFC 8305 交错发起 IPv6。此时 IPv4 已经领先 50 到 100ms,IPv6 尝试即使路径正常也很难追上,用户长期停留在 IPv4。反过来,如果 AAAA 先返回且 IPv6 路径有 MTU 问题,客户端先发 IPv6、250ms 后发 IPv4,首包迟滞就出现了。
一个真实的抓包序列(简化):
## tcpdump -ni eth0 'port 53 or (tcp and port 443)' -tttt
2026-10-21 10:12:03.114 DNS A example.com -> 93.184.216.34
2026-10-21 10:12:03.187 DNS AAAA example.com -> 2606:2800:220:1:248:1893:25c8:1946
2026-10-21 10:12:03.190 TCP SYN -> 93.184.216.34:443
2026-10-21 10:12:03.232 TCP SYN-ACK <- 93.184.216.34:443
2026-10-21 10:12:03.440 TCP SYN -> [2606:2800:220:1:...]:443
2026-10-21 10:12:03.690 TCP SYN -> [2606:2800:220:1:...]:443 (重传)2
3
4
5
6
7
注意 AAAA 只比 A 晚 73ms,但 IPv6 的 SYN 直到 250ms 后才发,且两次 SYN 都没收到 SYN-ACK。这就是典型的 IPv6 黑洞:SYN 发出后无响应,客户端在 250ms 的 Connection Attempt Delay 后已经用 IPv4 完成握手,IPv6 尝试被取消,但重传的 SYN 还在网络上飘。用户感知到的首包延迟是 232ms(IPv4 握手完成),看起来还行,但如果 IPv4 也被降权,或者应用层强制 IPv6,首包就会卡满 21s 的 SYN 重传周期。
三、Connection Attempt Delay 的实现差异
3.1 各客户端的默认值
RFC 8305 给的是建议值,各实现落地时差异明显:
| 实现 | 默认延迟 | 自适应 | 备注 |
|---|---|---|---|
| Chrome/Chromium | 300ms | 有,基于历史 RTT | 地址族降权后可能拉长到 500ms |
| Firefox | 250ms | 有限 | 可通过 network.dns.disableIPv6 关闭 |
| curl | 200ms | 无 | --happy-eyeballs-timeout-ms 可调 |
| Go net.Dialer | 300ms | 无 | FallbackDelay 字段 |
| glibc getaddrinfo | 不实现 | 无 | 只排序,不发起连接 |
curl 的 200ms 比 RFC 建议更激进,原因是 curl 主要面向命令行和脚本场景,用户对固定延迟更敏感。Chrome 的 300ms 加上自适应,在移动网络下可能实际执行到 500ms,这是移动端首包卡顿的一个隐藏来源。
3.2 自适应逻辑的副作用
Chrome 的 HostResolver 维护一个按地址族聚合的 RTT 和失败率。当 IPv6 的失败率超过阈值,它会把这个地址族的排序整体后移,并在 Happy Eyeballs 里给它更长的延迟。这个机制在 IPv6 长期不稳的网络里会退化成"事实上的 IPv4-only",用户以为自己在用 IPv6,实际所有连接都走了 IPv4。更隐蔽的问题是,当网络环境切换(比如从 Wi-Fi 切到蜂窝),缓存没有及时失效,客户端仍然按旧 RTT 排序,首包延迟出现一次性的跳变。
用 ConnProof 可以直接观察这种地址族偏好:
connproof diagnose example.com --verbose
connproof tcp example.com --port 443 --ipv6 --timeout 5
connproof tcp example.com --port 443 --ipv4 --timeout 52
3
第一条命令会同时解析 A 和 AAAA,并按 RFC 6724 排序输出,附带每个地址的实测握手 RTT。后两条分别强制单栈测试,对比两者的握手耗时差。如果 IPv6 的握手 RTT 比 IPv4 高出 100ms 以上,而 DNS 解析时间差不到 50ms,那么首包卡顿的主因就在路径质量,不在 Happy Eyeballs 的延迟参数。
四、MTU 塌陷如何把"握手成功"变成"首包卡死"
4.1 PMTUD 黑洞的经典形态
TCP 握手只交换 SYN 和 SYN-ACK,这两个包通常很小(60 到 80 字节),不会触发 MTU 问题。握手成功后,客户端发送 ClientHello,这个包在 TLS 1.3 下带 key share 和大量扩展,常见大小 1200 到 1500 字节。如果 IPv6 路径的 PMTU 实际是 1280(IPv6 最小 MTU),而中间设备丢弃了带 DF 标志的超大包且不回 ICMPv6 Packet Too Big,客户端就会陷入 PMTUD 黑洞:
客户端 中间路由器 服务器
|--- SYN (60B) -------------->| |
|<-- SYN-ACK (60B) -----------| |
|--- ClientHello (1400B) ---->| 丢弃,不回 ICMPv6 PTB |
| (等待 ServerHello,超时) | |
|--- ClientHello 重传 -------->| 再次丢弃 |
| ... 指数退避 ... | |2
3
4
5
6
7
用户看到的现象是:TCP 握手 40ms 完成,首字节 3 到 21 秒才到,或者直接超时。这正是"握手很快、首包很慢"的典型成因。IPv4 路径如果 PMTU 正常(1500),同样场景下首包 50ms 就到,于是客户端在 Happy Eyeballs 里选了 IPv6 却拿到最差体验。
4.2 Linux 侧的观测与缓解
Linux 内核在 net.ipv4.tcp_mtu_probing 控制 PMTUD 行为:
## 0 = 关闭(默认),1 = 启用,2 = 启用并使用更激进的探测
sysctl net.ipv4.tcp_mtu_probing2
设为 1 时,内核在检测到连续重传后会主动降低 MSS 并重试,能缓解部分黑洞,但只对本地发起的连接有效,对中间设备丢弃 ICMPv6 的场景帮助有限。IPv6 侧的 net.ipv6.route.mtu_expires 控制 PMTU 缓存过期时间,默认 600 秒,过长会导致路径变化后仍用旧 MTU。
抓包时用 Wireshark 过滤表达式定位:
ipv6 and tcp.flags.syn == 1
ipv6 and tcp.len > 1200
icmpv6.type == 22
3
第三条过滤 ICMPv6 Packet Too Big,如果客户端发了大包却从没收到 type 2,基本可以判定黑洞。ConnProof 的 tcp 子命令在 --verbose 下会报告握手后首个数据包的发送与 ACK 时间,如果这个时间差超过 1 秒而握手 RTT 正常,MTU 问题优先级最高:
connproof tcp example.com --port 443 --ipv6 --verbose --timeout 10输出里关注 handshake_rtt 与 first_byte_rtt 两个字段的差值。差值超过 500ms 就该怀疑 PMTU。
五、出口路由绕道与地址族摇摆
5.1 绕道如何制造不对称 RTT
IPv6 的出口路由绕道在国内网络里非常常见。一个典型的场景是:客户端有原生 IPv6 地址,但运营商的 IPv6 出口 peer 质量差,去往目标 CDN 的 IPv6 路径要绕经另一个城市甚至境外,单程 RTT 从 IPv4 的 15ms 涨到 120ms。此时:
- DNS 双查询几乎同时返回。
- RFC 6724 排序把 IPv6 放首位。
- 客户端发起 IPv6 连接,握手 RTT 240ms(往返)。
- 250ms 的 Connection Attempt Delay 到期时,IPv6 握手刚好完成或差一点,客户端可能已经并发发起 IPv4。
- 两个连接都成功,客户端按"先完成"选一个,结果取决于毫秒级抖动。
这种场景下每次连接的地址族选择都不稳定。用户感知是"有时快有时慢",慢的时候首包 300 到 500ms,快的时候 50ms。抓包会看到 IPv6 和 IPv4 的 SYN 交替出现,且 IPv6 的 SYN-ACK 总是比 IPv4 晚 100ms 以上。
5.2 用基准测试量化摇摆
要证明摇摆存在,需要连续多次测量同一目标的地址族选择与首包延迟。ConnProof 的 diagnose 子命令在 --verbose 下会输出每次连接的地址族和 RTT:
connproof diagnose example.com --verbose --timeout 5连续跑 20 次,统计 IPv6 与 IPv4 的选中比例和首包 RTT 分布。如果 IPv6 选中率在 30% 到 70% 之间波动,且 IPv6 的首包 RTT 方差明显大于 IPv4,摇摆成立。进一步用 dns 子命令对比 A 与 AAAA 的解析耗时:
connproof dns example.com --verbose如果 AAAA 的解析耗时比 A 高 50ms 以上,说明 DNS 层的非对称也在贡献首包延迟。
六、系统化的双栈基准测试方法
6.1 测试矩阵设计
单次测量没有意义,双栈问题必须用矩阵覆盖。建议的最小矩阵:
| 维度 | 取值 |
|---|---|
| 地址族 | IPv4-only、IPv6-only、双栈 |
| 目标 | 同城 CDN、跨省源站、境外站点 |
| 网络 | Wi-Fi、蜂窝、VPN |
| 时间 | 早高峰、午间、夜间各 3 次 |
每个单元格跑 20 次连接,记录握手 RTT、首包 RTT、地址族选择、失败率。ConnProof 的 --format 参数支持结构化输出,便于聚合:
connproof diagnose example.com --ipv6 --format json --timeout 5
connproof diagnose example.com --ipv4 --format json --timeout 52
6.2 关键指标与判据
- 握手 RTT 差:IPv6 握手 RTT 减去 IPv4 握手 RTT。超过 80ms 视为路径质量不对称。
- 首包 RTT 差:IPv6 首包 RTT 减去 IPv4 首包 RTT。超过 300ms 且握手 RTT 正常,判 MTU 问题。
- 地址族选中率:双栈下 IPv6 被选中的比例。长期低于 20% 说明客户端已事实降权。
- 失败率:IPv6 连接失败率超过 5%,Happy Eyeballs 的 250ms 延迟会被频繁触发。
6.3 失败边界的诚实说明
这套方法有两个覆盖不到的场景。第一,客户端侧的 Happy Eyeballs 实现差异无法从服务端观测,只能靠本地抓包或客户端日志。第二,中间设备的 ICMPv6 策略不可见,PMTUD 黑洞只能通过首包 RTT 异常间接推断。遇到这两种情况,需要结合 tcp 子命令的 --verbose 输出和本地 tcpdump 交叉验证。
七、ConnProof 定向诊断流程
7.1 从现象到根因的决策树
首包慢?
├─ 握手 RTT 正常(< 100ms)
│ ├─ 首包 RTT > 1s → 查 MTU/PMTUD
│ │ └─ connproof tcp <host> --port 443 --ipv6 --verbose
│ └─ 首包 RTT 200-500ms → 查地址族摇摆
│ └─ connproof diagnose <host> --verbose 连跑 20 次
├─ 握手 RTT 高(> 200ms)
│ ├─ IPv6 高 IPv4 正常 → 查 IPv6 出口路由
│ │ └─ connproof tcp <host> --port 443 --ipv6 --verbose
│ └─ 两者都高 → 查本地网络或目标侧
│ └─ connproof doctor --verbose
└─ 握手失败
└─ connproof diagnose <host> --verbose2
3
4
5
6
7
8
9
10
11
12
13
7.2 具体命令序列
第一步,确认双栈解析与排序:
connproof dns example.com --verbose第二步,分族测握手:
connproof tcp example.com --port 443 --ipv4 --timeout 5
connproof tcp example.com --port 443 --ipv6 --timeout 52
第三步,测首包:
connproof tcp example.com --port 443 --ipv6 --verbose --timeout 10第四步,TLS 层验证(排除证书或 SNI 问题):
connproof tls example.com --port 443 --ipv6 --verbose第五步,综合诊断:
connproof diagnose example.com --verbose --format json --timeout 5doctor 子命令用于排除本地环境问题(路由表、DNS 配置、防火墙),在怀疑客户端侧时应先跑:
connproof doctor --verbose7.3 与站内故障库的联动
如果 diagnose 报告 IPv6 地址可达但 TCP 握手失败,优先查 IPv6 解析但目标不可达。如果握手成功但首包异常,结合 网络不可达故障排查 里的分层方法定位。对 IPv4 与 IPv6 在协议栈层面的差异不熟悉的读者,先读 IPv4 与 IPv6 基础差异 建立背景。
八、工程侧的缓解与取舍
8.1 客户端侧
- 调整 Happy Eyeballs 延迟:curl 用
--happy-eyeballs-timeout-ms 150,Go 用Dialer.FallbackDelay = 150 * time.Millisecond。降低延迟能减少固定迟滞,代价是 IPv6 慢成功时更容易被 IPv4 抢跑,长期看会让 IPv6 使用率下降。 - 启用
tcp_mtu_probing=1,缓解本地 PMTUD 黑洞。 - 对已知 IPv6 劣化的目标做地址族白名单,这是最直接但最不优雅的做法。
8.2 服务端侧
- 确保 IPv6 路径的 PMTU 不小于 1280,并在负载均衡器上正确回 ICMPv6 Packet Too Big。
- 监控 IPv6 与 IPv4 的首包 RTT 差,超过阈值告警。
- 对 CDN 场景,确认 AAAA 记录的 anycast 节点与 A 记录的地理分布一致,避免 IPv6 绕道。
8.3 Trade-offs
降低 Connection Attempt Delay 提升响应速度,牺牲 IPv6 使用率。提高延迟保护 IPv6 优先,牺牲 IPv6 故障时的用户体验。启用 MTU 探测增加少量探测流量,换取黑洞场景的可用性。没有一组参数适合所有网络,基准测试是唯一可靠的调参依据。
九、结论性的工程判断
Happy Eyeballs v2 是一个选路算法,它把"用哪个地址族"的决策从静态排序改成动态竞争,但竞争的输入(DNS 时间差、路径 RTT、MTU 状态)全部来自网络环境,算法本身无法修正劣质路径。首包卡顿的根因通常不在 RFC 8305 的参数,而在 IPv6 路径的 PMTU 或出口路由。诊断的正确顺序是先分族测握手和首包,用 ConnProof 的 tcp 与 diagnose 拿到量化数据,再决定是调客户端参数、修服务端 MTU,还是接受 IPv6 降权。把 Happy Eyeballs 当成性能优化手段会失望,把它当成故障隔离机制,才能理解它为什么在 250ms 这个数字上做了那样的取舍。