Envoy 上游连接优化:Happy Eyeballs 地址列表排序改为"创建时一次性完成"
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
导读
本文解读 Envoy 上游(upstream)连接路径中的一项重要行为变更:Happy Eyeballs 对多地址主机地址列表的排序,从"每次上游连接尝试时都重新排序"改为"地址列表创建或刷新时只排序一次"。这一改动消除了连接热路径上的重复计算,同时严格保证了连接尝试的顺序与原先完全一致。读完本文,你将理解 Envoy 中 Happy Eyeballs 的完整实现链路(从 DNS 解析结果到连接池建连)、排序算法依据的 RFC 8305 规则、happy_eyeballs_config配置项的语义,以及本次改动在静态 Host 与动态 LogicalHost 两种场景下的落地方式。
变更背景:Envoy 中的 Happy Eyeballs
Happy Eyeballs 算法(RFC 6555 / RFC 8305)解决的是一个经典问题:当一个主机名同时解析出 IPv6 与 IPv4 多个地址时,如何避免因首选地址族不可达而白白等待(例如 IPv6 路由黑洞导致 TCP 握手超时),同时又不浪费两端能力。其核心思想是对多个地址族并行发起连接尝试,谁先成功就用谁,从而把连接延迟降低到"最快可用地址族"的水平。
在 Envoy 中,该能力由 happy_eyeballs_connection_impl.cc 中的HappyEyeballsConnectionProvider与HappyEyeballsConnectionImpl实现:
HappyEyeballsConnectionProvider::sortAddresses()是一个静态方法,负责按 RFC 8305 第 4 节的要求把地址列表按地址族交错重排;HappyEyeballsConnectionImpl是一个透明的ClientConnection,它拿着排好序的地址列表,按顺序逐个发起连接尝试,并使用第一个成功建立连接的地址。
实现细节决定了排序结果被"谁"消费:HappyEyeballsConnectionImpl构造函数接收的地址列表必须已经排好序。因此,排序发生在哪里、多久发生一次,直接决定了建连热路径的开销。
本次变更:排序时机从"每次连接"前移到"创建/刷新时"
变更条目位于 happy_eyeballs__sort-address-list-once.rst,原文核心表述为:
The happy eyeballs sorting of a multi-address host's address list now happens once when the address list is created or refreshed, instead of on every upstream connection attempt. The order in which connection attempts are made is unchanged.
即:
- 变更前:每次创建上游连接(
createConnection)时,都要对地址列表重新执行一次 Happy Eyeballs 排序; - 变更后:排序只在地址列表创建或刷新(如 EDS 动态更新)时执行一次,结果被缓存复用;
- 不变:实际发起连接尝试的地址顺序与之前完全一致,行为语义无任何变化。
这是一个典型的"消除重复计算"式优化:排序结果只取决于地址列表本身和happy_eyeballs_config配置,两者在主机生命周期内不变(或仅在刷新时变化),因此每次建连都重新排序纯属浪费。
源码剖析:排序如何从热路径上"摘除"
排序入口:makeSortedAddressListOrNull
排序的核心逻辑收敛在 upstream_impl.cc 的HostDescriptionImplBase::makeSortedAddressListOrNull():
HostDescription::SharedConstAddressVector HostDescriptionImplBase::makeSortedAddressListOrNull(const ClusterInfo& cluster, const AddressVector& address_list) { if (address_list.size() <= 1) { return {}; } const envoy::config::cluster::v3::UpstreamConnectionOptions::HappyEyeballsConfig& happy_eyeballs_config = cluster.happyEyeballsConfig().has_value() ? *cluster.happyEyeballsConfig() : defaultHappyEyeballsConfig(); return std::make_shared<AddressVector>( Network::HappyEyeballsConnectionProvider::sortAddresses(address_list, happy_eyeballs_config)); }两点值得注意:
- 少于 2 个地址直接跳过:当地址列表只有 0 或 1 个地址时返回空指针,Happy Eyeballs 不适用,建连走普通路径(对应 upstream_impl.h 中"happy eyeballs does not apply"的注释);
- 配置缺省自动补齐:如果集群没有配置
happy_eyeballs_config,则使用defaultHappyEyeballsConfig()——该默认配置位于 upstream_impl.cc,即first_address_family_version = DEFAULT(跟随地址列表首地址的地址族)、first_address_family_count = 1。
结果缓存:sorted_address_list_or_null_
排序结果被存入主机对象的成员变量sorted_address_list_or_null_。在静态主机HostDescriptionImpl中(upstream_impl.h),该字段的注释明确写道:
Happy eyeballs sorted copy of the address list, or nullptr if the host does not have multiple addresses.Set at construction and never changed; read by
HostImpl::sortedAddressListOrNull().
对应构造函数(upstream_impl.cc)在初始化列表中一次性完成排序:
sorted_address_list_or_null_(makeSortedAddressListOrNull(*cluster, address_list)),由于静态主机的地址列表在构造后不可变,排序结果天然只需计算一次。
消费端:createConnection 直接复用
建连入口 upstream_impl.cc 在构造连接时不再执行任何排序,而是直接读取缓存:
} else if (sorted_address_list != nullptr && sorted_address_list->size() > 1) { ENVOY_LOG(debug, "Upstream using happy eyeballs config."); connection = std::make_unique<Network::HappyEyeballsConnectionImpl>( dispatcher, sorted_address_list, source_address_selector, socket_factory, transport_socket_options, host, options); }即:只要主机持有"多于 1 个地址的已排序列表",就创建HappyEyeballsConnectionImpl,把已排好序的列表交给它按序尝试。这正是 happy_eyeballs_connection_impl.h 中HappyEyeballsConnectionProvider类注释所要求的契约——"The address list passed to the constructor must already be sorted withsortAddresses(); the host computes this once when its address list is created or refreshed rather than on every connection attempt."
动态场景:LogicalHost 的地址刷新同步重排
对于 EDS 等动态发现场景,主机地址会随LbEndpoint更新而刷新,此时使用的是 logical_host.cc 中的LogicalHost。其构造时同样调用一次makeSortedAddressListOrNull()(第 42 行),而在地址刷新入口setNewAddresses()(logical_host.cc)中,每次刷新都会重新计算排序结果并原子性地与原始列表一同更新:
SharedConstAddressVector sorted_address_list = makeSortedAddressListOrNull(cluster(), address_list); { absl::MutexLock lock(address_lock_); address_ = address; address_list_or_null_ = std::move(shared_address_list); sorted_address_list_or_null_ = std::move(sorted_address_list); health_check_address_ = std::move(health_check_address); }对应 logical_host.h 中两个关键注释也印证了设计意图:
sortedAddressListOrNull()被设计为public,"so that tests can verify the sorted list is kept in sync with the raw list across address refreshes"(便于测试验证刷新后排序列表与原始列表保持一致);- 成员字段
sorted_address_list_or_null_是 "Happy eyeballs sorted copy ofaddress_list_or_null_, updated together with it so that the raw and sorted lists stay consistent"。
因此,无论静态还是动态主机,"创建或刷新时排序一次"都得到了严格保证——这正是本次变更的完整语义。
排序算法:RFC 8305 第 4 节的实现
sortAddresses()的完整实现位于 happy_eyeballs_connection_impl.cc,其注释直接引用了https://datatracker.ietf.org/doc/html/rfc8305#section-4。算法分三步:
第一步:确定首选地址族(preferred family)。默认取地址列表第一个地址的地址族(DEFAULT);若配置了first_address_family_version,则按枚举值覆盖为 V4 / V6 / PIPE / INTERNAL。
第二步:按地址族分组(bucket)。遍历输入列表,用AddressFamily{type, version}作为键分组,同时维护一个family_order序列——首选地址族被插入到序列最前面,其余按首次出现的顺序排列。地址族不止 IPv4/IPv6,还覆盖了 Pipe 与 EnvoyInternal 类型(对应 happy_eyeballs_connection_impl.cc 的getFamily(),以及 upstream_impl.h 中"happy eyeballs does not apply"仅针对少于 2 个地址的约束)。
第三步:交错输出。采用轮转(round-robin)方式逐族取地址:首选地址族每次取first_address_family_count个,其余地址族每次取 1 个,直到全部地址取完。源码注释给出了直观示例——若首选族为 v6、count 为 3,输出形如:
[3*v6, 1*v4, 3*v6, 1*v4, ...](前提是输入中存在足够多的 v6 地址;族内地址不足时自然缩短该轮取值。)
配置参数:happy_eyeballs_config 全解
排序行为由集群级配置upstream_connection_options.happy_eyeballs_config控制,其 proto 定义位于 cluster.proto:
message UpstreamConnectionOptions { enum FirstAddressFamilyVersion { DEFAULT = 0; // Use the first address family encountered in the address list. V4 = 1; V6 = 2; PIPE = 3; INTERNAL = 4; } message HappyEyeballsConfig { // Specify the IP address family to attempt connection first in happy // eyeballs algorithm according to RFC8305#section-4. FirstAddressFamilyVersion first_address_family_version = 1; // Specify the number of addresses of the first_address_family_version being // attempted for connection before the other address family. google.protobuf.UInt32Value first_address_family_count = 2 [(validate.rules).uint32 = {gte: 1}]; } HappyEyeballsConfig happy_eyeballs_config = 3; // ... 其他字段省略 }参数语义与默认值总结:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
first_address_family_version | 枚举 | DEFAULT | 指定首先尝试连接的地址族;DEFAULT表示以地址列表首地址的地址族为准。取值含 V4、V6、PIPE、INTERNAL |
first_address_family_count | UInt32Value | 1 | 首选地址族在切换到其他地址族前,连续尝试的地址数量;校验规则为>= 1 |
默认值同样体现在源码两处:一是defaultHappyEyeballsConfig()(upstream_impl.cc);二是排序实现中通过PROTOBUF_GET_WRAPPED_OR_DEFAULT(..., 1)对first_address_family_count的兜底(happy_eyeballs_connection_impl.cc)。对应的 YAML 配置写法为:
clusters: - name: example_cluster connect_timeout: 5s type: STRICT_DNS lb_policy: ROUND_ROBIN load_assignment: cluster_name: example_cluster endpoints: - lb_endpoints: - endpoint: address: socket_address: address: dualstack.example.com port_value: 443 upstream_connection_options: happy_eyeballs_config: first_address_family_version: V6 first_address_family_count: 2值得说明的是,本次排序时机变更不改变任何配置语义:排序算法、参数含义、默认值、输出顺序均保持原样,只是计算时点被提前并缓存。
测试验证:排序行为与默认值兜底
排序算法的行为由单元测试完整锁定,见 happy_eyeballs_connection_provider_test.cc:
SortAddresses(第 15-55 行):验证默认配置下,纯 v4 / 纯 v6 列表保持不变,[v6, v6, v4, v4]被交错为[v6, v4, v6, v4],混合列表按族轮转输出;SortAddressesWithFirstAddressFamilyCount(第 57-117 行):验证V4 + count=2时[v6, v4, v6, v4]变为[v4, v4, v6, v6](v4 族每次取 2 个);同时覆盖缺省分支——缺first_address_family_version时回落为DEFAULT语义、缺first_address_family_count时回落为 1;SortAddressesWithNonIpFamilies(第 119 行起):验证非 IP 地址族(Pipe 等)同样参与交错排序。
此外,logical_host.h 特意将sortedAddressListOrNull()从 protected 提升为 public,目的就是让测试可以跨地址刷新断言"排序列表与原始列表始终同步"——这直接为本次"刷新时重新排序、连接时不再排序"的新行为提供了可验证的测试入口。
影响评估与总结
从源码结构可以推断,本次变更带来的收益集中在连接热路径的算力削减:
- 消除重复计算:此前每次
createConnection都要执行一遍地址族分组与交错;现在排序结果以shared_ptr形式缓存于sorted_address_list_or_null_,建连时零排序开销(列表 < 2 个地址的普通路径更是不受影响); - 语义完全等价:由于排序纯函数依赖于(地址列表,配置)二元组,而两者在两次连接尝试之间保持不变,缓存结果与逐次重算结果严格一致——连接尝试顺序不变,这正是变更条目中特别声明"unchanged"的原因;
- 动态刷新正确性:
LogicalHost::setNewAddresses()在地址刷新时同步重排,保证 EDS 更新后缓存始终新鲜,不引入过期地址尝试。
对于运行着大量多地址(如 STRICT_DNS 双栈)上游集群的 Envoy 而言,该改动把排序成本从"每个连接尝试一次"降为"每个地址列表生命周期一次",属于典型的低风险高收益优化。理解这一变更,也有助于排查与上游连接顺序相关的疑难问题:当看到日志中 "Upstream using happy eyeballs config." 时,可以明确该排序列表是在主机创建/刷新时生成的,而非连接时生成。
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考