Hyper 与 Tower:工业级 HTTP 栈
第7章 Balance / Discover / ready_cache:负载均衡抽象
第7章 Balance / Discover / ready_cache:负载均衡抽象
7.1 从”一个服务”到”一群服务”
前六章我们讨论的所有中间件——Timeout、Retry、Buffer、LoadShed、ConcurrencyLimit——都围绕一个隐含假设:底层只有一个 Service。到这里我们要打破这个假设。
真实的分布式系统几乎总有多个同等能力的后端。你调用 user-service 其实是调用一个由十几个 Pod 组成的集群中的某一个;你的 gRPC 客户端背后是一个经过 DNS 轮询或 service mesh 发现的服务端点池。
这就引出三个相互咬合的问题:
- 怎么表达”后端集合”?Pod 可能随时被调度、新 Pod 可能随时被加进来、旧 Pod 可能因为 health check 失败被剔除——这是动态的。
- 怎么选一个后端?随机?轮询?按负载?按延迟?
- 怎么保证选到的那个后端”现在”能接请求?已经满了的后端要跳过,已经挂了的要不再选。
Tower 给这三件事提供了三个抽象:
| 抽象 | 作用 |
|---|---|
Discover trait | 动态后端集合的”变更流” |
ReadyCache<K, S, Req> | 维护”就绪 + 待就绪”两个池子 |
Balance<D, Req> | 在 ready 池子里选一个(默认 P2C 算法) |
这一章我们把三者串起来读。真实源码全在 tower/src/discover/、tower/src/ready_cache/、tower/src/balance/p2c/,版本 0.5.3。
7.2 Discover:服务集合的”变更流”
// tower/src/discover/mod.rs:55-74
pub trait Discover: Sealed<Change<(), ()>> {
type Key: Eq;
type Service;
type Error;
fn poll_discover(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Result<Change<Self::Key, Self::Service>, Self::Error>>>;
}
语义:poll_discover 每次 poll 返回一个”集合变更事件”。它不是 “Vec<Service>”,也不是 “HashMap<Key, Service>”——而是流式的增量:
// tower/src/discover/mod.rs:100-107
pub enum Change<K, V> {
Insert(K, V), // 新来了一个 K 对应的服务
Remove(K), // K 对应的服务消失了
}
为什么这样设计?因为服务发现在现代系统里从来不是”一次性拿到全量列表”的。Kubernetes 的 Endpoints API 用 watch 模式返回一串增量事件;Consul 的 service discovery 用 long polling;DNS SRV 记录有 TTL 要不断重查。把这些异构来源抽象成”一个流”,下游(balancer)就不用关心”这些变化从哪来”——它只看到 Insert / Remove 的连续事件。
Discover trait 的另一个妙处是:它通过 blanket impl 自动覆盖了所有 TryStream<Ok = Change<K, S>>:
// tower/src/discover/mod.rs:83-98
impl<K, S, E, D: ?Sized> Discover for D
where D: TryStream<Ok = Change<K, S>, Error = E>, K: Eq,
{
type Key = K;
type Service = S;
type Error = E;
fn poll_discover(self: Pin<&mut Self>, cx: &mut Context<'_>)
-> Poll<Option<Result<D::Ok, D::Error>>>
{
TryStream::try_poll_next(self, cx)
}
}
这意味着你只要有一个能产生 Stream<Item = Result<Change, Error>> 的东西——无论是 async_stream! 包的 coroutine、是 mpsc receiver、是从 Kubernetes API watch 出来的事件流——都自动满足 Discover trait。这种”把一个新抽象嫁接到已经成熟的 Stream 生态上”的设计非常体贴:你不需要为 Tower 专门实现什么,现成的东西就能用。
7.2.1 最简单的 Discover:ServiceList
如果你不需要真正的动态发现——只是想把一个固定列表喂给 balancer——tower 提供了 ServiceList:
// tower/src/discover/list.rs 精简版
pub struct ServiceList<T> { inner: std::vec::IntoIter<T>, i: usize }
impl<T> ServiceList<T> {
pub fn new<I: IntoIterator<Item = T>>(services: I) -> Self {
Self { inner: services.into_iter().collect::<Vec<_>>().into_iter(), i: 0 }
}
}
它的 poll_discover 就是不停地 Insert(i, next_svc),遍历完之后 stream 结束——对 balancer 来说等价于”这 N 个端点进来,之后永不变化”。适合配置写死的多实例、或者测试场景。
7.2.2 Sealed<Change<(), ()>> 让 trait 无法被外部 impl
看 Discover trait 的真实定义(discover/mod.rs:55):
pub trait Discover: Sealed<Change<(), ()>> {
type Key: Eq;
// ...
}
Sealed<Change<(), ()>> 是一个私有 trait——定义在 tower 内部的 mod、用户无法 impl。这就形成了 sealed trait 模式:
- 用户能使用
Discovertrait(写泛型约束、调poll_discover) - 用户无法实现
Discovertrait(因为 Sealed 是私有的) - 但 Tower 的 blanket impl
impl<D: TryStream<..>> Discover for D让所有 TryStream 自动成为 Discover
为什么要 seal?——因为 Tower 想控制 Discover 的扩展边界——如果任何用户都能 impl Discover、未来 Tower 想加新方法(比如 poll_discover_many 批量)就是 breaking change。seal 让扩展权保留给 Tower 自己、用户只能通过 blanket impl 的 TryStream 路径 “曲线救国”。
Change<(), ()> 作为类型参数——是一个技巧:让 Sealed 带上 “关于 Discover 的标记信息”、区分不同的 sealed trait。Sealed<Change<(), ()>> 只给 Discover 用、不和其他 sealed trait 冲突。
这种 sealed trait 是 Rust 库设计的惯用模式——和 Python 的 _underscore_name 私有约定、Java 的 package-private 机制类似、但用类型系统强制。代表作是 std 的 Error trait、Box 的 TryFrom 等——都用 Sealed 保留内部演进空间。
7.3 ReadyCache:两个池子的协同
Balance 真正干活的地方在 ReadyCache——整整 500 行代码。但它的核心思想可以用一张图说明:
┌──────────────────────────────────────────────┐
│ ReadyCache<K, S, Req> │
│ │
│ ┌──────────────┐ ┌────────────────┐ │
│ │ pending │ ---> │ ready │ │
│ │ │ │ │ │
│ │ K→Pending<S> │ │ K→(S, cancel) │ │
│ └──────────────┘ └────────────────┘ │
│ ↑ ↓ │
│ push(key, svc) call_ready_index() │
│ │
└──────────────────────────────────────────────┘
两个池子:
pending:FuturesUnordered<Pending<K, S, Req>>——每一个 pending 是一个 future,它内部反复 poll 自己那个 service 的poll_ready,直到 Ready 了就”晋升”到 ready 池子。ready:IndexMap<K, (S, CancelPair)>——已经就绪的 service,按 key 索引。pending_cancel_txs:IndexMap<K, CancelTx>——每个 pending service 有一个 cancel 通道,可以在它还在 pending 期间把它剔除。
这里有两个数据结构值得拎出来讲。
7.3.0 ReadyCache 的 6 个字段构成状态机
ReadyCache 的实际字段(ready_cache/cache.rs):
pub struct ReadyCache<K, S, Req>
where K: Eq + Hash,
{
pending_cancel_txs: IndexMap<K, CancelTx>,
pending: FuturesUnordered<Pending<K, S, Req>>,
ready: IndexMap<K, (S, CancelPair)>,
_pd: PhantomData<fn(Req)>,
}
4 个字段(加上 _pd)表达了一个状态机:
pending_cancel_txs——还在 pending 的那些 service 的 cancel sender(按 key 索引)pending——未就绪的 service 的 future 集合ready——已就绪的 service(key → (service, cancel_pair))_pd: PhantomData<fn(Req)>——让编译器 “看到 Req 作为 contravariant 位置的类型参数”
PhantomData<fn(Req)> 的设计极其精妙——fn(Req) 是contravariant(逆变)的位置、这让 ReadyCache<K, S, Req> 对 Req 也是逆变的。具体意义:ReadyCache<K, S, SubType> 能被用作 ReadyCache<K, S, SuperType>——子类型关系”反转”。
对 Service<Req> 这种泛型接口、逆变 Req 意味着 “接受 SubType 的 balancer 能被当成接受 SuperType 的 balancer”——类型系统里的 Liskov 替换。普通 PhantomData<Req> 会让 Req 协变、语义错误。
多数用户不会遇到 variance 陷阱——但 tower 作为基础库必须正确建模、避免未来某个高级用户因为 variance 问题写出不编译的代码。这种 “看不见但重要” 的细节是 Rust 基础库的共同特征——Cell、RefCell、各种智能指针都为 variance 精心设计。
7.3.1 FuturesUnordered:不分先后的并发 poll
FuturesUnordered 是 futures-util 的一个核心工具(回想卷四《Tokio》第 14 章讨论 select! 时也出现过类似的思想)。它把多个 future 装到同一个集合里,每次 poll 会同时推进所有内部 future——哪个先 Ready 就先产生 output。
对 ReadyCache 来说,这完美贴合”推动多个 pending service 的 poll_ready”这个需求——一次 pending.poll_next(cx) 就能让所有还在 pending 的 service 各被 poll 一次,任何一个 Ready 了就被产出。这是很典型的”数据结构即设计”——选对集合类型,算法几乎自动成立。
7.3.2 indexmap::IndexMap:保持插入顺序的 HashMap
ready 字段是 IndexMap<K, (S, CancelPair)> 而不是 HashMap——关键差别是 IndexMap 既支持按 key 查,也支持按 index 查,而且插入顺序稳定。
为什么需要 by-index?因为 P2C 算法要”随机挑两个端点”——最高效的做法是 rng.gen_range(0..n) 两次生成两个 index,然后按 index 直接拿。HashMap 没法按 index 取,需要先 .values().nth(i) 线性扫描。IndexMap 的 get_index(i) 是 O(1)。
这是一个小到不能再小的数据结构选型决定,但它让整个 balancer 的 hot-path 复杂度从 O(n) 降到 O(1)。一本好书会让你在这种细节处停一停——工业级代码的优秀往往不在算法,而在数据结构。
7.3.3 promote_pending_to_ready 里的 loop + 三分支语义
打开 balance/p2c/service.rs:130-155 的 promote_pending_to_ready:
fn promote_pending_to_ready(&mut self, cx: &mut Context<'_>) {
loop {
match self.services.poll_pending(cx) {
Poll::Ready(Ok(())) => {
// There are no remaining pending services.
debug_assert_eq!(self.services.pending_len(), 0);
break;
}
Poll::Pending => {
// None of the pending services are ready.
debug_assert!(self.services.pending_len() > 0);
break;
}
Poll::Ready(Err(error)) => {
// An individual service was lost; continue processing
// pending services.
debug!(%error, "dropping failed endpoint");
}
}
}
}
看似简单的循环、三种分支各有严格语义:
① Poll::Ready(Ok(())) —— poll_pending 告诉我们”所有 pending 都被提升了、没剩下的”。break 跳出——不是有服务就绪、而是 “pending 集合为空”。这里 pending_len == 0 的断言正是这个意思。
② Poll::Pending —— 至少一个还没就绪、但没一个失败。没有服务就绪、但集合里还有待 poll 的——break 跳出、把 pending=> len > 0 的断言写进来保护。
③ Poll::Ready(Err(error)) —— 某个服务失败了。log 一下、继续 loop 处理剩下的 pending——一个坏端点不能让其他端点受牵连。
三个分支只有 ③ 继续 loop、其他两个都 break——循环终止条件是 “没有更多失败的端点要处理”。这样 promote_pending_to_ready 一次调用能处理多个失败、不会在第一个失败时提前退出。
这就是 fault-tolerant balancer 的核心——单个端点坏了可以无感剔除、其他端点继续提供服务。和 §11.3.3.6 讲的 hyper InvalidInput vs UnexpectedEof 的处理分层是**同一种 “区分错误严重性并对应不同处理”**的 Rust 风格。
7.4 P2C:Power of Two Choices 为什么能行
Balance<D, Req> 默认用的算法叫 P2C(Power of Two Choices):
- 从 ready 池随机选两个端点 A、B;
- 比较它们的当前 load;
- 把请求发给 load 较小的那个。
// tower/src/balance/p2c/service.rs:158-183
fn p2c_ready_index(&mut self) -> Option<usize> {
match self.services.ready_len() {
0 => None,
1 => Some(0),
len => {
let [aidx, bidx] = sample_floyd2(&mut self.rng, len as u64);
let aload = self.ready_index_load(aidx as usize);
let bload = self.ready_index_load(bidx as usize);
let chosen = if aload <= bload { aidx } else { bidx };
Some(chosen as usize)
}
}
}
简单到让人怀疑它真的有用。事实上它好得惊人。
7.4.0 sample_floyd2 的 Floyd 算法:两次 RNG 调用得两个不同索引
sample_floyd2 是 tower 内置的 RNG 采样函数(util/rng.rs:120-126):
pub(crate) fn sample_floyd2<R: Rng>(rng: &mut R, length: u64) -> [u64; 2] {
debug_assert!(2 <= length);
let aidx = rng.next_range(0..length - 1);
let bidx = rng.next_range(0..length);
let aidx = if aidx == bidx { length - 1 } else { aidx };
[aidx, bidx]
}
从 [0, length) 里随机选两个不同的索引——用 2 次 RNG 调用、常数时间、零分配。
朴素做法:loop { let a = rand(..n); let b = rand(..n); if a != b { break [a, b] } }——期望 1-2 次调用但最坏情况无界(每次都 a==b、概率低但存在)。
Floyd 算法:
- 第一次
aidx = next_range(0..length-1)——从[0, length-1)里选一个 = 从除了最后一个的 N-1 个里选 - 第二次
bidx = next_range(0..length)——从全范围[0, length)里选 if aidx == bidx { length - 1 }——冲突时把 aidx 替换成 length-1(最后一个)
为什么这样保证不重复?
- 如果 aidx != bidx:两者直接不同、完事
- 如果 aidx == bidx:把 aidx 替换成 length-1——而 bidx 是从全范围选的、但bidx != aidx(因为冲突时 aidx 变了)——所以 bidx 不可能等于 length-1 吗?其实可能等于。
重新看逻辑:冲突发生时、aidx = bidx = 某个 x < length-1。这时把 aidx 改成 length-1——新的 aidx = length-1 ≠ bidx = x。两者不同、√。
如果 aidx != bidx 但 aidx == length-1 和 bidx == 某个 x < length-1——这不可能、因为 aidx 的范围是 [0, length-1)、不包含 length-1。
如果 aidx != bidx 且两者都在 [0, length-1)——两者不同、√。
Floyd 算法用 “[0, n-1) × [0, n)” 的非对称范围 + 冲突映射、2 次 RNG 调用就能得到不重复的两个索引。代价是稍微的非均匀——length-1 被选中的概率略高于其他元素、但在 balance 的场景下这个偏差不影响 P2C 的统计性质。
对比 Fisher-Yates 洗牌需要 O(n) 时间和 O(n) 空间。对 balance 每次 poll_ready 都要采样一次、O(1) 时间 O(0) 空间是必须的——Floyd 算法是精确的匹配。
7.4.0.5 debug_assert_ne!(aidx, bidx) 的双层保证
p2c_ready_index 里有一行看起来多余的断言(service.rs:166):
let [aidx, bidx] = sample_floyd2(&mut self.rng, len as u64);
debug_assert_ne!(aidx, bidx, "random indices must be distinct");
sample_floyd2 的实现保证了返回两个不同索引——按理说 aidx != bidx 是固定的。为什么还要 debug_assert?
答案有三层:
① 防御 rng 的潜在 bug——如果 rng.next_range 实现有 bug(比如未来 hasher 升级、next_range 的范围算错)、sample_floyd2 的”冲突映射”可能失效。debug_assert 会在测试里立刻捕获——而不是到了生产才发现两个索引一样、p2c 退化成单选。
② 作为不变式的文档——看 service.rs 的人不一定去读 rng.rs 的 Floyd 算法证明——assert 明确写出 “这里两者必须不同”、把不变式当契约暴露。
③ debug_assert 在 release build 会被 compile 掉——成本是零。如果是 prod 的 assert!(不加 debug_)、就要承担运行时成本——tower 选 debug_assert 是**“只在测试和 dev build 验证” 的克制**。
这种 “外部函数已经保证 + 内部也 assert” 的双层防御是 Rust 库代码的 idiom——不信任任何单一组件、在组件边界上都验证。一旦出错、定位极快——assert 消息直接点明哪个不变式被破坏。
7.4.1 为什么两个就够
P2C 这个算法来自 Mitzenmacher 等人 1996 年的一篇论文《The power of two choices in randomized load balancing》。论文证明了一个反直觉的结论:从 N 个桶里随机选两个放最小的那个,得到的最大负载期望是 O(log log N);而纯随机选一个的最大负载期望是 O(log N / log log N)。
换成人话:有 100 个端点、100 个请求要分配——
- 纯随机:最繁忙的那个端点大概会接
log(100)/log(log(100)) ≈ 3个请求。 - P2C:最繁忙的那个端点大概只接
log(log(100)) ≈ 2个请求——几乎是最优的均匀分布。 - 理论最优:每个端点正好 1 个。
这个”用 2 换一次 log-reduction”的效果在负载很高、请求数远大于端点数时更显著。Linkerd、Finagle、Envoy 的默认负载均衡策略都是 P2C——不是因为它简单,而是因为它在现实世界里是最好的选择。
7.4.1.5 HasherRng 零依赖 RNG 的选择
Tower 内置的 HasherRng(util/rng.rs:100-110):
impl<H> Rng for HasherRng<H> where H: BuildHasher, {
fn next_u64(&mut self) -> u64 {
let mut hasher = self.hasher.build_hasher();
hasher.write_u64(self.counter);
self.counter = self.counter.wrapping_add(1);
hasher.finish()
}
}
不引入 rand crate——用标准库的 hasher(DefaultHasher 或用户提供)生成伪随机数。原理:counter 每次递增、用 hasher 把 counter hash 成”看似随机”的 u64。
为什么不用 rand?——因为 rand 是一个相当大的依赖(几十个相关 crate)——对 tower 这种轻量核心库不可接受。用标准库 hasher:
- 零额外依赖——hasher 是 std 必有
- “够随机”——用于 P2C 的 RNG 不需要密码学强度、hash 伪随机足够
- deterministic if needed——测试时可以固定 counter 和 hasher 得到可复现序列
代价——生成的随机数不如 rand 的 ThreadRng 好看(分布、周期、独立性有瑕疵)。但对 P2C 这种 “从 100 个里选 2 个” 的场景、瑕疵完全不可感——balance 的统计性质不受影响。
这种”用轻量近似替代重量级专业库”的选择是 Rust 生态轻量化的典型——tower 作为 Rust 异步服务的基础层、依赖树必须窄。每一个多余的 crate 都会传染到所有用 tower 的项目——tokio / axum / tonic / reqwest 全都间接依赖 tower。
对比 Python 的 numpy / pandas / sklearn 这种”一口气装 100MB”的生态、Rust 生态对依赖深度非常克制——因为编译时间和 binary size 的成本是实实在在的。tower 这种选择是 Rust “generalized minimal dependency” 文化的体现。
7.4.2 为什么不是”选最小的”
朴素直觉会说”直接扫描所有 ready 端点选 load 最小的那个不就行了吗?” 两个原因:
- 成本:n 个端点就是 n 次 load() 调用、n 次比较。1000 个 endpoint 的集群上,每请求一次 O(1000) 的开销在 hot path 上可观。
- 羊群效应(herd effect):如果总选最小的,所有并发调用者会同时把请求打到当前最小的那个端点——等到它 load() 更新后为时已晚。P2C 的随机性天然消解了这种 herd。
2 这个数字是”理论上最优”的妥协——把 2 增加到 3 或 4 收益微乎其微。P2C 就叫 “Power of Two”——不是凑数。
7.4.2.5 “N=3” 收益的精确量化
§7.4.2 提到”把 2 增加到 3 或 4 收益微乎其微”——这个结论值得精确看看。Mitzenmacher 后续论文 (2001) 分析了 d-choice:
| d = 选几个 | 最大 load | 超过 2 的改善 |
|---|---|---|
| d = 1(RR / 纯随机) | O(log N / log log N) | 基准 |
| d = 2 | O(log log N) | 指数级改善 |
| d = 3 | O((log log N) / log 2)(系数稍小) | 不到 50% 改善 |
| d = 4 | 继续有小改善 | <20% 改善 |
| d = n | 最小 load(完美均匀) | 无意义(退化成 “选最小”) |
关键的 “指数级” 改善发生在 d=1 → d=2——从 log N / log log N 到 log log N 是双 log 的区别、在 N=100 时从 ~3 变成 ~2,在 N=10000 时从 ~2.5 变成 ~2.2。
d=3 以后是 “系数上的改善”——不是阶的改善。具体到 N=100:
- d=2: 最大 load ~2.2
- d=3: 最大 load ~1.8
- d=4: 最大 load ~1.7
d=3 相对 d=2 的绝对值改善只有 0.4、相对改善 ~18%——但代价是多一次 RNG 调用 + 多一次 load() 评估——大部分场景不值。
Power of Two 是论文标题也是实践经典——2 这个数字是 d-choice 中收益 vs 成本最好的点。Tower / Finagle / Envoy 都选 2、不是历史偶然、是数学上的最优。
这种”精确量化的 trade-off”是系统设计领域少有的——大部分选择是”经验”、“感觉”、“主流这样”。P2C 有证明支持的 optimal d=2、让选 2 变成”理性推导的结果”而不是”跟风”。
7.4.3 Load trait:让端点自己报告负载
P2C 需要比较 load——但 load 是什么?Tower 把这件事抽象成 Load trait:
// tower/src/load/mod.rs
pub trait Load {
type Metric: PartialOrd;
fn load(&self) -> Self::Metric;
}
metric 只需要可比较,不需要是数字——可以是 Duration(用 EWMA 延迟)、usize(in-flight 计数)、自定义 struct(综合指标)。Tower 内置了两种最常见的:
PendingRequests:当前 in-flight 请求数。最便宜的 metric,几乎免费。PeakEwma:延迟的 EWMA 加权移动平均。对抖动敏感,但能检测”快慢端点”的差异。
在真实系统里,PeakEwma 用得多——因为 “in-flight count” 对延迟不敏感,可能所有端点 in-flight 都是 10,但其中一个实际 p99 是 500ms。Linkerd 公开的数据里 PeakEwma 是默认选项。
7.4.4 PeakEwma 的时间衰减计算——EWMA 背后的数学
PeakEwma(Peak-Exponentially-Weighted-Moving-Average)是 tower 最精巧的 Load metric。源码在 tower/src/load/peak_ewma.rs:
// 概念性核心
pub struct PeakEwma<S> {
inner: S,
decay: Duration,
rtt_estimate: Arc<Mutex<RttEstimate>>,
}
struct RttEstimate {
stamp: Instant,
estimate: f64, // 当前 EWMA 值
}
impl<S> Load for PeakEwma<S> {
type Metric = Cost;
fn load(&self) -> Cost {
let est = self.rtt_estimate.lock();
let penalty = est.estimate * (1.0 + self.pending_count() as f64);
Cost(penalty)
}
}
EWMA 的核心公式:
new_estimate = alpha * new_sample + (1 - alpha) * old_estimate
其中 alpha = 1 - exp(-delta_t / decay)
关键是 alpha 随时间动态变化——两次 RTT 采样间隔 delta_t 越长、alpha 越接近 1(新样本权重越大)。这比固定 alpha 的普通 EWMA 更准——因为采样间隔不均匀时、固定 alpha 会低估久违样本的重要性。
Peak 的意思——遇到比当前 estimate 大的样本、直接替换(不做 EWMA 混合)、只在样本更小时才做 EWMA decay。效果:延迟上升响应快(峰值被立即看到)、延迟下降响应慢(缓慢 decay 避免被偶然的快请求误导)。
这种 “快速上升、慢速下降” 的非对称响应在网络延迟测量里是经验最优——宁可错报 “延迟高”、不要漏报(漏报会让 load balancer 继续往慢端点打请求)。
加上 (1.0 + pending_count) 作为倍数——正在发送中的请求数也计入 load——这让 balancer 自然避开 “刚刚开始飙升但 EWMA 还没更新” 的端点。
Tower 的 PeakEwma 跟 Finagle 的同名实现是一样的公式——Twitter / Linkerd / Tower 三个社区共享这套经过生产检验的算法。代码不到 200 行、承载了 20 年的 load balancing 研究。
7.4.3.5 Load::Metric 为什么是 PartialOrd 而不是 Ord
Load trait 的 associated type 约束是 PartialOrd:
pub trait Load {
type Metric: PartialOrd;
fn load(&self) -> Self::Metric;
}
PartialOrd vs Ord 的区别:
Ord要求 total order——任意两个值都能比较(a < b 或 a > b 或 a == b)PartialOrd允许 partial order——某些对之间无法比较(返回None)
**为什么 Load metric 要选 PartialOrd?**因为某些 metric 不是 total order:
① 浮点数 NaN——f64::NAN 和任何数比较都返回 None。PeakEwma 内部可能产生 NaN(除以 0 等边界)——用 Ord 会 panic、用 PartialOrd 只是把 NaN 排在 “不可比较” 里。
② 多维 metric——某些 Load 实现可能返回 (latency, pending_count) 这样的 tuple——不同维度的主导可能冲突(a 的 latency 更小但 pending 更多)——PartialOrd 能表达 “这两个 load 没有严格顺序”。
③ future-proofing——留给用户自定义 metric 的自由——不强制他们提供 total order。
选 PartialOrd 的代价是 P2C 里的比较变复杂——实际代码里 if aload <= bload { aidx } else { bidx } 依赖 PartialOrd::le——遇到 None 时会走 else 分支(因为 <= 对 None 返回 false)。等价于 “不能比较时默认选 bidx”——统计上不影响 P2C 的分布性质、只是个小瑕疵。
这种 “约束选弱、代价吸收到使用处” 的设计给用户自定义 Metric 最大自由度——tower 的基础组件不强制自己不需要的性质、允许上层创造性的实现。
7.4.5 PendingRequests vs PeakEwma 的选型决策树
两种内置 Load metric 的选择指南:
选 PendingRequests 的场景:
- 端点延迟接近均匀(比如同一个 Pod 模板的多副本)——in-flight count 就是最好的 load 信号
- 极致性能敏感——PendingRequests 只要 AtomicUsize + 自增/自减、开销微不足道
- 短连接场景——请求很快完成、in-flight 数变化快、能实时反映 load
选 PeakEwma 的场景:
- 端点异构(多数据中心、不同代硬件混合)——延迟天然有差别、光 in-flight 误导
- 长尾敏感——care about P99 / P999、需要检测 “in-flight 少但实际慢” 的端点
- JIT 语言后端(Java/Node.js/Ruby)——刚启动时 warm up 阶段延迟高、PeakEwma 能自动避开
选择错了的后果:
- 用 PendingRequests 但端点异构——一个慢 endpoint 会接和快 endpoint 一样多的请求、慢的 p99 飞起
- 用 PeakEwma 但端点均匀——无用的 EWMA 计算开销 + mutex contention——白白降低单机 QPS 上限
Linkerd 的默认选 PeakEwma——因为它典型部署是多集群多数据中心、端点异构。grpc-rs 的默认是 PendingRequests——简单场景够用。
Tower 不替你选默认——两种都暴露、让用户根据自己场景决定。这又回到 §7.7.1 讲的 “零成本抽象让用户选择每一个 trade-off”——既不强制你用某个算法、也不隐藏实现细节让你无从决策。
7.5 Balance::poll_ready:把三者串起来
// tower/src/balance/p2c/service.rs:208-250
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
let _ = self.update_pending_from_discover(cx)?;
self.promote_pending_to_ready(cx);
loop {
if let Some(index) = self.ready_index.take() {
match self.services.check_ready_index(cx, index) {
Ok(true) => {
self.ready_index = Some(index);
return Poll::Ready(Ok(()));
}
Ok(false) => { /* no longer ready, try another */ }
Err(Failed(_, error)) => { /* endpoint failed, try another */ }
}
}
self.ready_index = self.p2c_ready_index();
if self.ready_index.is_none() {
return Poll::Pending;
}
}
}
四件事:
- 更新 discover:把最近的 Insert/Remove 吸收到 pending/ready 池子里。
- 把 pending 提升到 ready:对所有 pending service 做一次 poll(通过 FuturesUnordered),就绪的进入 ready 池。
- check/select:如果上一次已经选了一个 ready_index,确认它还就绪;否则用 P2C 选新的。
- 结论:有可用的——Ready;一个都没——Pending(waker 已经被 FuturesUnordered 和 discover 注册好了,服务就绪时会被唤醒)。
注意第 3 步是一个 loop——check 失败时不直接 Pending,而是继续 P2C 找下一个。这保证即使某些 ready endpoints 刚刚变成不可用,只要 ready 池里还有其他可用的,poll_ready 就能报告 Ready。这是 fault tolerance 的细节——一次 poll_ready 调用里可以自动跳过多个坏 endpoint。
7.5.0 check_ready_index 的再次验证——为什么不相信上次的 ready
§7.5 poll_ready 里有一段 subtle 的逻辑:
if let Some(index) = self.ready_index.take() {
match self.services.check_ready_index(cx, index) {
Ok(true) => {
self.ready_index = Some(index);
return Poll::Ready(Ok(()));
}
Ok(false) => { /* no longer ready */ }
// ...
}
}
为什么上次选好的 ready_index 还要 check_ready_index 一次?——直接返回 Ready 不更省事?
原因是 ready 的 service 可能在两次 poll_ready 之间变回 pending。具体场景:
- 某个端点用
ConcurrencyLimit(100)包过、上次 poll_ready 时有 permit、所以 Ready - 调用者没立即
call、而是去做了别的事(比如等更高优先级任务) - 期间其他 caller 抢走了 permit、这个端点变成 not-Ready
- 现在才来 call——如果不重新检查、就会 call 一个 not-Ready 的 service——违反 Tower 协议
check_ready_index 就是重新 poll 那个 service 的 poll_ready——确认 “从上次到现在它一直没变” 再让出 Ready。如果变了、Ok(false) 让 loop 重新 P2C 选一个。
这就是 Tower 协议的严格 recheck 规则——call 之前必须确认 poll_ready 立即就是 Ready。balance 把这个规则落到每个循环、给上层提供了一个铁板钉钉的承诺:从 Ready 返回到 call、中间不会 race。
这个 recheck 是有成本的——每次 poll_ready 多一次 service 内部的 poll_ready 调用。但比起 协议违反导致的难以调试 bug、这个成本完全值得。
7.5.1 call 的简洁
// tower/src/balance/p2c/service.rs:252-257
fn call(&mut self, request: Req) -> Self::Future {
let index = self.ready_index.take().expect("called before ready");
self.services
.call_ready_index(index, request)
.map_err(Into::into)
}
两行:拿出之前 poll_ready 选好的 index,对那个 service 发起 call。
最神奇的地方在self.services.call_ready_index(index, request)——它内部把那个 service 从 ready 池子 take 出来、调用 .call(request)、把 service 送回 pending 池子等下次 poll_ready。这是 Tower ReadyCache 的核心不变式:每个 service 每次 call 后都要重新 ready——因为它的 poll_ready 可能会因为这次 call 消费了某个 permit 而变回 pending。
这个”call → 重进 pending → FuturesUnordered 自动 poll_ready → 重进 ready”的循环,由 ReadyCache 透明地驱动。你写 Balance 代码时看到的是”选一个、用一个”,背后是整个一套高效的后端状态管理。
7.5.2 update_pending_from_discover 的内部 loop——一次 poll 吸收所有变更
看 update_pending_from_discover(service.rs:105-128)的核心结构:
fn update_pending_from_discover(&mut self, cx: &mut Context<'_>) -> ... {
loop {
match ready!(Pin::new(&mut self.discover).poll_discover(cx)) {
None => return Poll::Ready(None),
Some(Change::Remove(key)) => self.services.evict(&key),
Some(Change::Insert(key, svc)) => self.services.push(key, svc),
}
}
}
无限 loop——只要 discover 还有 Change 产生、就一直吸收。直到 poll_discover 返回 Pending、ready! 宏把 Pending 往外传(实际是 return Poll::Pending)。
为什么要 loop 吸收所有?——不要让”discover 堆积”发生。如果 discover 产生了 10 个 Change 但 balance 一次只吸收一个、那 9 个留在 discover 的内部队列里、下次 poll_ready 才被处理——导致 balance 对后端集合的认知滞后。
极端情况:k8s endpoints watch 瞬间产生几十个事件(滚动重启),balance 必须一次全部吸收、不能分散到多个 poll_ready 周期。否则新端点迟迟加不进 ready 池、老端点继续被选中、影响用户请求延迟。
loop { ready!(poll_discover) } 把拉取变更和处理变更合成一个紧密循环——discover 生产多快、balance 吸收多快。这是 Tower 对 “backpressure at every layer” 原则的具体实现——没有 middleware 会偷偷排队、数据流是即时的。
这种模式和 §11.2.2.5 讲的 httparse 的 parse_with_uninit_headers 类似——热路径里绝不分配、也和 §15.4.3.5 vite 的 concurrentModuleNodePromises 类似——生产者和消费者紧耦合、不留 buffer。
7.6 取消:CancelPair 的设计
ReadyCache 里有一对非常小但设计精巧的类型:CancelTx 和 CancelRx(tower/src/ready_cache/cache.rs:79-91)。
#[derive(Debug)]
struct Cancel {
waker: AtomicWaker,
canceled: AtomicBool,
}
#[derive(Debug)]
struct CancelRx(Arc<Cancel>);
struct CancelTx(Arc<Cancel>);
type CancelPair = (CancelTx, CancelRx);
一对 Tx/Rx 共享一个 Arc<Cancel>。AtomicWaker 存”谁在等 cancel”,AtomicBool 存”已经 cancel 了”。
这是比 oneshot 更轻量的替代品——oneshot::channel 内部有一个 Arc<State> 加一个 Mutex,开销大。ReadyCache 的 CancelPair 只用两个原子变量,在高频 insert/evict 场景下几乎零开销。
它的用法:每个 pending service 带一个 CancelRx,它的 Pending future 在每次 poll 里检查 canceled——一旦上层 evict(&key) 调用了对应的 CancelTx,canceled 被置 true,waker 被触发,pending future 下次被 poll 时发现已 cancel,产出一个 PendingError::Canceled(key),FuturesUnordered 把它剔除出集合。
.ready 里的 service 也绑着一个 CancelPair,evict 时同样是立即标记——但 ready 的 service 不是 future,直接从 IndexMap 里移除即可。CancelPair 的设计统一了 pending 和 ready 两套的 evict 路径。
这段代码的美感不在算法,而在用 Rust 的所有权和原子原语把”动态集合 + 取消”这件看似复杂的事情压缩到极简。如果你写过 Go 里类似的 service discovery + pool,会知道那一般需要几百行状态机代码。Tower 的这一套总共不到 50 行。
7.6.1 AtomicWaker 和 AtomicBool 为什么不合并
§7.6 Cancel 结构有两个 atomic 字段:
struct Cancel {
waker: AtomicWaker,
canceled: AtomicBool,
}
为什么不用单一 AtomicUsize 同时编码 “是否取消”和”waker 指针”?(某些 C++ 无锁结构会这么做)
原因:
① AtomicWaker 本身就是一个非平凡结构——不是一个 raw pointer、而是含有内部状态的 wrapper(处理 “waker 被更新时的并发可见性”)。简单的 AtomicUsize 不能承载 Waker 的全部语义(waker 的 clone、wake、drop 涉及 vtable 调用)。
② 分开两个原子的 contention 更低——大部分时间只有一端在改其中一个字段(rx 在注册 waker、tx 在 set canceled)、它们并发修改不同的 atomic 比修改同一个少竞争。
③ 简化代码——用 AtomicWaker 的现成 API + 一个 bool、比手写一个合并编码的 atomic dance 安全得多。并发原语的坑多如牛毛——能复用标准库的就复用。
这就是 Rust 并发编程的惯用风格——组合现成的 atomic primitives(AtomicWaker、AtomicUsize、AtomicBool、Arc)、而不是自己手写 SeqCst / Acquire / Release 的复杂 atomic 魔法。专业人士写的原语更可靠、代价是每个原子变量各占几字节内存——在 Cancel 这种 per-service 结构里完全可接受。
这种”优先组合 primitives 而不是手写 lock-free”的文化在 tokio、parking_lot、crossbeam 这些库里也一致——只有极端性能敏感场景才 hand-roll atomic、平常就堆叠 Arc<Mutex<_>> + 现成的并发工具。
7.7 组合出一个完整的客户端
把这一章讲的抽象和前面几章串起来,典型的一个”gRPC 客户端组合”长这样:
use std::time::Duration;
use tower::{ServiceBuilder, balance::p2c::Balance, discover::ServiceList, load::PendingRequests};
// 1. 有一组 endpoints
let endpoints = vec![grpc_client_a, grpc_client_b, grpc_client_c];
// 2. 把每个 endpoint 包成"带 load metric"的 service
let endpoints = endpoints.into_iter()
.map(PendingRequests::new)
.collect::<Vec<_>>();
// 3. 把它们打包成一个静态 Discover
let discover = ServiceList::new(endpoints);
// 4. 用 Balance 做 P2C 负载均衡
let balanced = Balance::new(discover);
// 5. 在外面套上常规中间件
let svc = ServiceBuilder::new()
.concurrency_limit(500)
.timeout(Duration::from_secs(10))
.layer(RetryLayer::new(ExponentialPolicy::new(3, Duration::from_millis(200))))
.service(balanced);
请求流:
caller → concurrency_limit → timeout → retry → balance → p2c_pick(A,B) → endpoint.call(req)
任何一个端点临时变慢、不可用,P2C 会自然绕开它;新的 endpoint 通过 discovery 进来后,几毫秒内就能开始接请求;失败的 endpoint 触发 Retry 自动换一个。整条链条是可编译期单态化的类型链,运行时零 vtable、零堆分配(除了 Box<dyn Rng>——可以替换成具体 RNG 类型消除这个分配)。
这就是 Rust 后端生态最优雅的一段组合拳。
7.7.1 为什么类型推导能让 ServiceBuilder 链零 annotation
§7.7 的示例代码里没有一个显式类型标注——都是 let x = ... 省略类型。这在 ServiceBuilder 链上尤其显眼:
let svc = ServiceBuilder::new()
.concurrency_limit(500)
.timeout(Duration::from_secs(10))
.layer(RetryLayer::new(ExponentialPolicy::new(3, Duration::from_millis(200))))
.service(balanced);
svc 的实际类型是:
ConcurrencyLimit<
Timeout<
Retry<
ExponentialPolicy,
Balance<ServiceList<PendingRequests<GrpcClient>>, Request>
>
>
>
~6 层嵌套泛型——如果要手写、几乎不可能。但 let svc = ... 让 Rust 的 Hindley-Milner 类型推导自动算出——用户不需要知道具体类型是什么。
这是 “zero-ceremony generics” 的精髓——Fn/impl Trait/Box<dyn Trait> 三种”把类型擦掉”的工具让用户把注意力放在行为上、而不是类型上。
但编译期类型依然是确定的——编译器 monomorphize 时每一层都被具体化。对比 Java/Scala 的 type erasure 生成统一 bytecode、Rust 的 monomorphization 让每一个具体类型组合都有自己的代码段——运行时完全没有 type dispatch 成本。
这是 Rust 泛型系统独特的甜蜜点——写起来像动态语言、跑起来像 C。tower 的 ServiceBuilder 是这种哲学的展示柜——用户看到的是流畅的链式 API、编译器生成的是精确单态化的机器码。
7.8 和其他系统对照
Envoy 的 P2C 实现在 C++ 里,大约 300 行代码,核心思想和 Tower 完全一致——因为论文就是那个论文。差别:Envoy 的 endpoint 负载 metric 是跨 upstream 集群归一化的,Tower 允许你自由定义 Load::Metric。
Finagle(Scala)的 P2C 是 Tower 的直接源头——@carllerche 就是从 Finagle 的 LoadBalancer 得到启发。Finagle 的 P2C 包含 aperture(只在”一部分”端点里做 P2C),Tower 暂时没实现。
Go grpc-go 默认用 round-robin(RR),而不是 P2C。为什么?Go 生态普遍把负载均衡视为”基础设施问题”——让 Envoy/Linkerd 做——所以 grpc-go 的内置实现选了更简单的 RR。Rust 生态相反:Tower 把 P2C 做成库级能力,让应用代码直接获得工业级负载均衡——不需要外挂 service mesh。这是两种生态哲学的差异。
7.8.5 本章与其他章节的呼应
与第 6 章(Tokio Semaphore)的呼应——tower 的 ReadyCache 维护 “pending vs ready 双池” 和 Tokio Semaphore 维护 “permit 持有者 vs 等待者双队列” 是同构的——都用 “两个池子 + 在中间状态转移” 表达 “可用资源” 的模型。
与第 8 章(Filter/Steer)的呼应——Steer 的 Picker 按请求内容选 service、Balance 的 P2C 按运行时 load 选 service——两者本质都是 “在多个 service 间动态 dispatch”、只是决策依据不同(业务 vs 运行时状态)。
与第 16 章(h2 flow control)的呼应——Balance 的 WINDOW 语义其实也在这里——每个后端的”容量”本质上是它的 poll_ready 可接受速率。flow control 在 HTTP/2 层是每连接 + 每流、在 tower balance 层是每后端 —— 两者是跨协议层的同一种 “容量调度” 思想。
与 Vite 第 15 章 SSR 的呼应——vite 的 ensureBuiltins 启动一次性协商 + tower 的 ServiceList 静态 Discover 都在表达 “配置性的集合”——运行时不会变、Vec 或 list 足够、不需要 watch 机制。
与 React 第 6 章 Commit 的呼应——tower 的 check_ready_index 重新验证 ready 状态、React commit 的 finishedWork !== root.current 重新确认双缓冲不变式——都是在核心热路径上做廉价运行时验证、防止上游传来的一致性问题影响当前层。
7.9 关于 Unpin 的一个迂回
细心的读者可能已经注意到:Balance 的 Service impl 里有一条 D: Unpin 约束(源码第 194 行)。注释明确解释:
[
Balance] requires that the [Discover] you use is [Unpin] in order to implement [Service]. This is because it needs to be accessed from [Service::poll_ready], which takes&mut self.
原因:Service::poll_ready(&mut self) 拿到的是普通的 &mut self,不是 Pin<&mut Self>。但 Discover::poll_discover 需要 Pin<&mut D>。要把 &mut self.discover 转成 Pin<&mut D>,要么 D 是 Unpin(可以用 Pin::new(&mut self.discover) 零成本转换),要么 Balance 自己持有 Pin<Box<D>>(堆分配)。
Tower 选择前者——要求用户自己把 Discover 包成 Pin<Box<D>> 或者保证它 Unpin。这是一个 “把复杂性推到用户侧” 的决定,换来 Balance 内部实现的简洁。issue #319 有完整讨论。
这个现象在卷三《Rust 编译器与运行时揭秘》第 10 章(Pin / Waker / Future)里已经埋下伏笔——Pin 的传染性在 trait 设计里经常成为需要权衡的焦点。Tower 1.0 如果未来切到 async trait,这类约束可能会彻底简化。
7.9.5 P2C 和 ReadyCache 的工程密度——十条原则
把本章所有源码观察提炼为 10 条工程原则:
① Sealed trait 保留扩展权(§7.2.2)——Sealed<Change<(), ()>> 让 Discover 只能被 Tower 实现。
② Blanket impl 嫁接生态(§7.2)——任何 TryStream 自动 impl Discover。
③ FuturesUnordered 管理动态 pending 集合(§7.3.1)——N 个 future 一起 poll、先就绪先晋升。
④ IndexMap 兼顾 key 和 index(§7.3.2)——按 key 查、按 index 采样、两个场景都 O(1)。
⑤ Floyd 算法 2 次 RNG 得不同索引(§7.4.0)——常数时间零分配、给 P2C 提供完美采样。
⑥ HasherRng 避免 rand 依赖(§7.4.1.5)——基础库依赖树克制。
⑦ P2C 本身的 2 比 N 更好(§7.4.1)——Mitzenmacher 论文证明的 log-log vs log 差异。
⑧ PeakEwma 的非对称响应(§7.4.4)——延迟上升立即更新、下降慢慢 decay。
⑨ check_ready_index 再验证 ready 状态(§7.5.0)——Tower 协议的严格性落地。
⑩ update_pending_from_discover 内 loop 排空(§7.5.2)——生产者和消费者紧耦合、不堆积滞后。
这十条加起来就是 Tower Balance + ReadyCache 的设计结晶——3 个小文件(service.rs / cache.rs / rng.rs)合计 ~1500 行、承载了工业级客户端负载均衡的全部能力。
和其他语言同类代码对比:Go 的 grpc-go 负载均衡接口 + Round-Robin 实现约 800 行、没有 P2C。Envoy 的 C++ P2C 约 300 行、但依赖 Envoy 庞大的基础设施。Tower 这 1500 行是独立小库、可以被任何 Rust 异步服务嵌入——依赖足印和二进制体积都是最小的。
这种”小体量承载大能力”的密度只有 Rust 生态做得到——类型系统 + monomorphization 让抽象的零成本成为可能、allocation control 让每一 byte 内存可见、trait sealed 让扩展可控。把这十条内化、你就能自己设计类似质量的其他基础组件。
7.10 落到你键盘上
7.10.1 Pending<K, S, Req> future 的 pin projection
pending 池里每个元素是 Pending<K, S, Req>——一个自定义 Future、每次 poll 会调用内部 service 的 poll_ready。pin_project 把字段投影得很细(ready_cache/cache.rs):
pin_project! {
struct Pending<K, S, Req> {
key: Option<K>,
cancel_rx: CancelRx,
#[pin] service: Option<S>,
#[pin] rx: oneshot::Receiver<()>,
_pd: PhantomData<Req>,
}
}
#[pin] 标记的字段有 service 和 rx——它们需要被 pin 投影以 poll。key 和 cancel_rx 不加 pin——因为 K 和 CancelRx 都是 Unpin(普通可移动类型)、不需要 pin 就能安全访问。
这种精细的 pin 控制让 Pending 的 poll 实现可以混合使用:
this.key.take()——从 Option 里 move 出来、normal 访问this.service.as_mut().as_pin_mut()——pin 投影后调 poll_readythis.rx.as_mut().poll(cx)——pin 投影后调 future poll
如果全部字段都标 #[pin]——每次访问都要走 pin_project 的 API、key.take() 这种简单操作都变复杂。选择性标记 pin 是性能和 ergonomics 的平衡。
这也是 Rust 手写 future 的核心技巧——懂得区分哪些字段是 pin-sensitive、只在必要处标记。pin_project_lite 和 pin_project 都支持这种精细控制。
7.11 落到你键盘上
读完这一章,你能做的事:
- 阅读
tower/src/load/下的 PendingRequests 和 PeakEwma 实现。两者都不长,但 PeakEwma 有一个时间衰减的精巧计算——读它你会学到如何用原子更新做”移动平均”。 - 实验一个动态 Discover。用 tokio
mpscchannel 做一个手写 Discover,用tokio::spawn模拟”每 5 秒随机增删一个端点”,用tracing::debug!看 Balance 如何响应。 - 对照阅读 Linkerd2-proxy 的
linkerd-stack-discover模块(它在 GitHub 上开源)。Linkerd 的生产级负载均衡器建立在 Tower 的Balance之上,加了很多策略(aperture、退避、health check)——是学习”工业级应用如何扩展 Tower 抽象”的最好参考。
7.12 应用层的三个典型问题
P2C、Floyd、pin projection 这几条知识不是只对库作者有用——应用层也会在日常排障里碰上。
① axum + tower + tonic 的 p99 毛刺。tonic 的 balancer 选中的某个端点响应持续慢、整体 QPS 没变、p99 却飙。懂 P2C 的话你会把 PendingRequests 换成 PeakEwma——pending 数能看到”排了多少请求”但看不到”每个请求多慢”,PeakEwma 让 balancer 自动降低慢端点权重。
② reqwest 批量抓取遇到单 IP 限流。某个 upstream 返回 429、reqwest 的 pool 继续往那个 endpoint 打——后续都失败。正确做法是写一个 circuit breaker middleware,失败累计到阈值后通过 Discover 的 Remove 把那个端点从 balancer 池里剔除。
③ service mesh 里某些 endpoint load 偏高但延迟不高。Linkerd 默认的 P2C + PeakEwma——这个现象八成是 Load metric 选错:PendingRequests 看不到 GPU-bound 后端的真实负载(请求先排队、之后才真正 busy GPU)。换成 PeakEwma 或者自定义 Load::Metric(从后端 x-load header 读取)能解决。
下一章我们读 Tower 的一组更 low-level 的中间件:Filter、MapRequest、Steer——它们不涉及容量管理,专注于”请求变换”。
7.11 一次真实故事:Balance 和 HPA 的互动
在真实 k8s 集群里、Balance + HorizontalPodAutoscaler(HPA)有一个耐人寻味的互动故事——直接关系到本章设计原则在生产的意义。
场景:某服务 10 个 Pod、QPS 突然从 1000 翻到 5000。HPA 扩到 20 个 Pod——新 Pod 启动需要 30-60 秒。这 60 秒里发生了什么?
有 P2C 的情况:
- Balance 通过 Discover(比如 linkerd 或 kuma)立即看到 10 个新 endpoint Insert
- 新 endpoint 经过 push → pending → poll_ready → ready 的流程——大约 100ms 就进 ready 池(第一次 health check 完成)
- P2C 开始把新请求 spread 到 20 个端点——旧 10 个的 load 压力立刻减半
- 60 秒内整个集群从 5000 QPS / 10 pod 过渡到 5000 QPS / 20 pod——平滑
没有 P2C(纯 RR)的情况:
- RR 按固定顺序轮询、不看 load——新 endpoint 和老 endpoint 被公平对待
- 新 endpoint 刚启动时 JVM/Golang runtime 在 warm up、p99 latency 可能 10x 于老 endpoint
- 用户的 P99 latency 出现 spike——PagerDuty 炸
- SRE 开始调 HPA 参数”再激进点预热 Pod”——治标不治本
P2C 自带 “避开慢 endpoint” 的能力——新 endpoint 因为 warm up 阶段 PeakEwma 较高、被 P2C 自动少选,直到它稳定后再承接正常份额。两个机制各自工作、叠加起来产生远超单独工作的效果——这也是 Load metric 不能选错的原因:如果 balance 用 PendingRequests 而不是 PeakEwma,新 Pod 在 warm up 时 pending 数反而低(因为还没接几个请求),balance 反倒会优先把新请求打到慢 endpoint 上。