bigywater.tech
网络协议 · 27

双栈与 Happy Eyeballs:先连 IPv6 还是 IPv4

IPv4 和 IPv6 并存的时候,应用先连哪个?IPv6 连不通为什么会让整个网页卡几十秒?Happy Eyeballs 的 250 毫秒是怎么回事?这篇用 netlab 给 IPv6 加延迟、用一个真实的 IPv6 黑洞地址,实测顺序回退与 Happy Eyeballs 的差别。

801 ms → 255 ms
IPv6 往返 800 ms 时,顺序回退与 Happy Eyeballs(250 ms)的连接用时
5 秒仍卡死
IPv6 黑洞时 asyncio 关闭 Happy Eyeballs 的结果;打开 0.25 则 254 ms 连上 IPv4
+2~4 ms
每个 Happy Eyeballs 延迟值之上的额外开销:连上用时就是延迟值加这一点

双栈

实测 · 本机执行输出
  net.inet6.ip6.v6only: 0(0 = 默认双栈)
  IPV6_V6ONLY=0:连 ::1 成功,服务端看到的对端地址是 ::1;连 127.0.0.1 成功,服务端看到的对端地址是 ::ffff:127.0.0.1
  IPV6_V6ONLY=1:连 ::1 成功,服务端看到的对端地址是 ::1;连 127.0.0.1 失败:ConnectionRefusedError: Connection refused
  IPv4 映射地址:::ffff:127.0.0.1 → Python 的 .ipv4_mapped = 127.0.0.1;一个 AF_INET6 的监听套接字因此可以同时服务两种协议

监听 :: 的 AF_INET6 套接字默认同时收 IPv4 与 IPv6,IPv4 客户端以 ::ffff:a.b.c.d 的映射地址出现;IPV6_V6ONLY=1 则只收 IPv6。

地址顺序

实测 · 本机执行输出
  getaddrinfo("localhost") 默认:IPv6 ::1 → IPv4 127.0.0.1
  getaddrinfo("localhost") AI_ADDRCONFIG:IPv6 ::1 → IPv4 127.0.0.1
  按 RFC 6724 的默认策略,IPv6 优先;所以连 localhost 时应用先试 ::1,失败(被拒绝)再试 127.0.0.1

getaddrinfo 按 RFC 6724 把 IPv6 排在前面,应用依次尝试。被拒绝时瞬间回退,而地址挂住时每一项都要等到超时。

IPv6 慢

实测 · 本机执行输出
  没有延迟时(基线):顺序连接(IPv6 先)用时中位 0.10 ms;Happy Eyeballs 用时中位 0.12 ms,5 次里 IPv6 赢了 5 次
  IPv6 加了 400 ms 单向延迟:顺序连接(IPv6 先,等它连上才算):用时中位 801 ms,每次最终连上的是IPv6
  Happy Eyeballs(250 ms 后启动 IPv4):用时中位 255 ms,每次最终连上的是IPv4(5 次里 IPv4 赢了 5 次)
  Happy Eyeballs 不是放弃 IPv6:基线里 IPv6 本来就快,它赢了(IPv4 的尝试没来得及启动或被放弃);只有 IPv6 太慢才让 IPv4 去抢

往返 800 ms 的 IPv6 让顺序回退花 801 ms;Happy Eyeballs 在 IPv6 起步 250 ms 后并行启动 IPv4,用时 255 ms。IPv6 正常时 IPv6 照样赢。

IPv6 黑洞

实测 · 本机执行输出
  happy_eyeballs_delay=None(asyncio 默认,关闭):等待上限 5 秒,结果 [(5.0, 'TimeoutError'), (5.0, 'TimeoutError')]——一直卡在 IPv6 黑洞上,根本没尝试 IPv4(超时 = 5 秒的人为上限)
  happy_eyeballs_delay=0.25:[(254, '127.0.0.1'), (253, '127.0.0.1'), (252, '127.0.0.1')](毫秒,连上的对端地址)——约 250 ms 后启动 IPv4,立刻成功

目标是不存在的链路本地地址时,SYN 发不出去。asyncio 关闭 Happy Eyeballs 卡死,打开 0.25 就在 254 ms 内连上 IPv4。

延迟值

实测 · 本机执行输出
  happy_eyeballs_delay=0.05 → 连上用时中位 52 ms(额外开销 1.8 ms)
  happy_eyeballs_delay=0.1  → 连上用时中位 102 ms(额外开销 1.9 ms)
  happy_eyeballs_delay=0.25 → 连上用时中位 252 ms(额外开销 2.5 ms)
  happy_eyeballs_delay=0.5  → 连上用时中位 502 ms(额外开销 1.7 ms)
  happy_eyeballs_delay=1.0  → 连上用时中位 1004 ms(额外开销 3.8 ms)
  RFC 8305 的建议:先解析 AAAA 再等 A(解析延迟 50 ms)、连接尝试延迟 250 ms(可根据历史 RTT 在 100 ms 到 2 s 间调整);asyncio 的建议值同样是 0.25

延迟值就是黑洞时用户多等的时间上限(实测连上用时 = 延迟 + 2~4 ms)。RFC 8305 建议 250 ms,可在 100 ms 到 2 s 之间调整。

分步演示

双栈与 Happy Eyeballs
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。本机网络没有公网 IPv6,所以“IPv6 慢”由 netlab 在 lo0 上对 ::1 的端口加延迟模拟,“IPv6 黑洞”是对一个不存在的链路本地地址的真实 TCP 连接;顺序回退与 Happy Eyeballs 的线程实现是自己写的简化版,asyncio 部分用 getaddrinfo 的替换函数喂入“黑洞 + 127.0.0.1”两项
    • 没有验证:公网上的真实双栈主机;系统解析器并行查询 A / AAAA 的“解析延迟”;浏览器与 curl 的 Happy Eyeballs 实现(版本 2 的历史 RTT 记忆等);IPv6 连通但路径 MTU 黑洞的情形;macOS 的 getaddrinfo 在有全球 IPv6 地址时的排序
    京ICP备2026067502号-1京公网安备11010502063487号