LoadBalancer
主线位置:请求主线的
upstream_peer那一步。pingora-proxy 本身不选上游,用户在upstream_peer里调LoadBalancer::select,拿到一个Backend再包成HttpPeer。后端集合的刷新和健康检查在服务主线上:LoadBalancer作为一个后台服务跑在自己的运行时里。
flowchart TB disc["discover"] --> du["do_update"] du --> trans["过渡就绪表"] du --> cb["回调:建选择器"] cb --> sw["ArcSwap 选择器"] du --> fin["新成员与就绪表"] sel["select_with"] --> sw sel --> it["UniqueIterator"] it --> ready["Backends::ready"] ready --> fin
pingora-load-balancing 的 src/ 有 6596 行,其中 lib.rs 一个文件就是 4448 行,但从 L1920 起是单元测试,正文 1919 行。0.8.0 的 lib.rs 只有 646 行:多出来的部分几乎都是 0.9.0 新加的 LoadBalancerGroup 和共享健康状态,见本页最后两节。不算测试,整个 crate 的正文是 3318 行。
本页四个类型分工如下:Backend 是一个后端;ServiceDiscovery 回答「现在有哪些后端」;Backends 保存当前的成员和每个成员能不能用;LoadBalancer 把 Backends 和一个选择算法(下一页 选择算法)绑在一起,对外提供 select。健康检查怎么改写「能不能用」,在 健康检查与后台服务。
实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建。
Backend:地址、权重和一个不参与比较的扩展
#[derive(Derivative)]
#[derivative(Clone, Hash, PartialEq, PartialOrd, Eq, Ord, Debug)]
pub struct Backend {
/// The address to the backend server.
pub addr: SocketAddr,
/// The relative weight of the server. Load balancing algorithms will
/// proportionally distributed traffic according to this value.
pub weight: usize,
// ...
#[derivative(PartialEq = "ignore")]
#[derivative(PartialOrd = "ignore")]
#[derivative(Hash = "ignore")]
#[derivative(Ord = "ignore")]
pub ext: Extensions,
}
addr是pingora_core自己的SocketAddr,可以是 TCP 地址也可以是 UDS 路径;但Backend::new_with_weight只parse成std::net::SocketAddr,源码里留着// TODO: UDS。Backend::new是权重 1 的简写。- 比较、排序、哈希都带上
weight,只忽略ext。所以同一个地址改了权重就是另一个后端:hash_key变了,健康状态和手动禁用的开关都按hash_key存,换权重等于从零开始。 ext是http::Extensions,用户放任意附加数据(比如这台机器的 SNI),本 crate 不看它。Backend实现了ToSocketAddrs,所以可以直接传给HttpPeer::new(backend, ...),上游文档的例子都这样写。
ServiceDiscovery 与 Static
ServiceDiscovery 只有一个方法:
async fn discover(&self) -> Result<(BTreeSet<Backend>, HashMap<u64, bool>)>;
返回当前的后端集合,外加一张「启用表」:键是 Backend 的哈希,值为 false 的后端算作手动禁用,不在表里的算启用。trait 文档还要求实现方经得起中途取消:停机时后台服务会直接丢掉这个 future(见 健康检查与后台服务)。
crate 只带一个实现 Static,内部是一个 ArcSwap<BTreeSet<Backend>>。它有 set、add、remove 三个方法,但都是 pub(crate),crate 外面调不到;源码旁边的注释是「Concurrent set/add/remove might race with each other」。也就是说用 Static 建的负载均衡器,后端集合从启动起就固定了。要在运行时增删后端,得自己实现 ServiceDiscovery:本页后面的实验就是一个包着 ArcSwap 的十行实现。discovery.rs 里还有一行 // TODO: add DNS base discovery。
Static::try_from_iter 接受任何 ToSocketAddrs,对每一项调 to_socket_addrs(),每个解析出来的地址都变成一个 Backend。这是标准库的同步解析,LoadBalancer::try_from_iter 的文档也提醒了「will invoke blocking network IO for DNS lookup」。在 macOS 上:
try_from_iter(["localhost:18701"]) -> ["127.0.0.1:18701", "[::1]:18701"]
一个名字变成两个后端。上游只监听 127.0.0.1 时,[::1] 那个永远连不上;没有健康检查的话,轮到它的请求都会因为连接被拒而失败。名字只在构造时解析一次,之后 IP 变了也不会跟着变。HttpPeer::new 里那次解析是另一回事,见 L4 连接。
LoadBalancer::select
LoadBalancer 的字段:backends: Backends、selector: ArcSwap<S>、选择器的配置 config,以及三个给后台服务用的公开字段 health_check_frequency、update_frequency、parallel_health_check(下一节和健康检查页讲)。
select 就是 select_with 加一个「只看健康状态」的判断函数。select_with:
pub fn select_with<F>(&self, key: &[u8], max_iterations: usize, accept: F) -> Option<Backend>
where
F: Fn(&Backend, bool) -> bool,
{
let selection = self.selector.load();
let mut iter = UniqueIterator::new(selection.iter(key), max_iterations);
while let Some(b) = iter.get_next() {
if accept(&b, self.backends.ready(&b)) {
return Some(b);
}
}
None
}
self.selector.load()从ArcSwap取出当前的选择器。整个过程没有锁,取到的是一个快照:这次选择进行到一半时选择器被换掉,这次请求也继续用旧的。- 选择器按
key给出一串候选(iter)。第一个是首选,后面是首选不能用时的备选,顺序由算法决定,见 选择算法。轮询和随机不看key,上游文档传b""。 UniqueIterator去掉重复的候选,并且最多走max_iterations步。重复的候选也算步数。- 每个候选问一次
Backends::ready:既是当前成员、又没被手动禁用、健康检查也说健康,才是true。accept拿到这个布尔值,自己决定要不要。select的accept就是|_, health| health。 - 所有候选都被拒、或步数用完,返回
None。
max_iterations 上方有一行 // TODO: consider remove max_iterations as users have no idea how to set it.。上游文档和例子一律写 256。它确实不好设:步数是在 UniqueIterator::get_next 里先取下一个候选、再检查步数,而轮询的「取下一个」会推进一个全局计数器。
127.0.0.1:18701、127.0.0.1:18702,RoundRobin,没有健康检查,用 backends().set_enable 把 18702 禁用。然后连续调 6 次 select(b"", 1),有几次返回 Some?提示:被拒之后,get_next 还会再调一次 iter.next() 才发现步数用完。答案在文末。
换掉后端集合:一个回调,三次 store
后台服务每隔 update_frequency 调一次 LoadBalancer::update。它调 Backends::update,传一个回调;成员有变化时,Backends 在回调里让 LoadBalancer 重建选择器:
self.backends
.update(|backends| {
let build_start = Instant::now();
let selector = build_selector::<S>(&backends, self.config.as_ref());
self.selector.store(Arc::new(selector));
build_nanos.store(build_start.elapsed().as_nanos() as u64, Relaxed);
})
.await?;
问题在于,一次选择要读两个 ArcSwap:select_with 先 load 选择器,再对每个候选去 Backends 读就绪状态。两次读之间,更新可能已经换掉了其中一个。Backends::do_update 用三次 store 处理这个空档(中间建 new_readiness 的循环省略):
let old_state = self.state.load_full();
let membership_changed = *old_state.backends != new_backends;
if membership_changed {
let generation = self.generation.fetch_add(1, Relaxed) + 1;
// ...
// Cover both the old and new readiness during a synchronous selector
// rebuild so a request in the callback does not lose a backend the
// about-to-be-replaced selector may still yield. This allocation is
// linear in old and new membership and only runs on membership changes.
let mut transition = old_state.readiness.0.as_ref().clone();
for (key, readiness) in new_readiness.0.iter() {
transition.insert(*key, readiness.clone());
}
self.state.store(Arc::new(BackendViewState {
backends: Arc::clone(&old_state.backends),
readiness: ReadinessSnapshot(Arc::new(transition)),
}));
callback(Arc::clone(&new_backends));
self.state.store(Arc::new(BackendViewState {
backends: Arc::clone(&new_backends),
readiness: new_readiness.clone(),
}));
// ...
- 先发布一张过渡就绪表:旧成员和新成员的并集。这时候不管请求拿到的是旧选择器还是马上要换上的新选择器,它给出的每个候选都在表里,都能按真实健康状态判断。
- 调回调,新选择器在回调里
store上去。 - 再发布新成员和只含新成员的就绪表。之后还拿着旧选择器的请求,如果选到一个被删掉的后端,
ready返回false,跳过它找下一个。
成员没变时(大多数轮次),do_update 只按启用表改开关,不重建选择器。
就绪状态本身是共享的:BackendReadiness 里存的是 Arc<AtomicBool> 的启用开关和一个 Health 句柄,健康检查和 set_enable 改的是同一份,不用等下一次 update。新加入的后端从 Health::default() 开始,默认是健康的(见 健康检查与后台服务)。
0.3.0 的 Backends::update 返回一个 bool,告诉调用方成员变了没有;LoadBalancer::update 在它返回 true 之后再重建选择器。于是有一段时间,新成员已经发布、选择器还是旧的。a518740(「Add callback function to Backends update() to address atomicity issue when building selector」,首次发布于 0.4.0)把重建挪进回调,在发布成员之前执行,并留了一条注释:「TODO: put this all under 1 ArcSwap so the update is atomic」。
问题没有全解决:0.4.0 到 0.8.0 的 Backends 有两个 ArcSwap,backends 和 health,回调之后先存成员、再存健康表。新加的后端在这两次 store 之间查不到健康记录,0.8.0 的 ready 为此写了一句「Racing: return None when this function is called between the backend store and the health store」,查不到时返回 self.health_check.is_none():配了健康检查,这个后端就暂时被当成不可用。
d6257c1(「Share health across load-balancing selectors」,首次发布于 0.9.0)把成员和就绪表放进同一个 BackendViewState、由一个 ArcSwap 发布,这条 TODO 随之删掉,同时加上了上面的过渡就绪表。
update 是 async 的,但建选择器是同步的计算,发生在后台服务的那个线程上。一致性哈希的环在 1000 个后端时要建 3 ms 多(下面的实验);后端更多、更新更频繁时,这段时间这个线程上的健康检查都得等。0.9.0 的 LoadBalancerGroup 把这一步挪到了阻塞线程池里。
0.9.0:LoadBalancerGroup,多个选择器共享一份后端
CHANGELOG 0.9.0 的「Allow sharing backends across load-balancing selectors」,对应 8f9f44f、d6257c1、e136189 三个提交。新类型 LoadBalancerGroup<S> 持有一个 Backends 和 N 个选择器槽位,每个槽位可以有自己的配置(比如两种虚拟节点数的一致性哈希)。lib.rs 开头 100 多行的模块文档画了它的结构;要点三条:
update只做发现和发布成员,然后给每个槽位排一个重建请求就返回。重建在run_selector_rebuilds里,用spawn_blocking建选择器。排队期间又来了更新,旧请求被新请求替换(coalesced_rebuilds计数)。每个槽位里发布的是
PublishedSelector:选择器加上它那一代的就绪表。select_with用的是这张表,不是Backends的当前表:let published = self.selectors.get(selector_index)?.selector.load(); let mut iter = UniqueIterator::new(published.selector.iter(key), max_iterations); while let Some(backend) = iter.get_next() { if accept(&backend, published.readiness.ready(&backend)) { return Some(backend); } } None选择器和就绪表一次
load取出,上一节那个「两次读」的空档在这里不存在。代价是:重建完成之前,旧一代的选择器还会选中已经删掉的后端。所有槽位(可以跨多个 group)共用一个
SelectorRebuildGate:同一时刻只建一个选择器,而且要等被换下来的那个选择器的最后一个读者放手,才开始建下一个。文档注释的说法是内存里最多多出「one additional selector generation」。
实验:1000 个后端、两个一致性哈希选择器的 group,找一个首选是第 500 号后端的键,然后让发现结果删掉它(程序在 lb-lab/src/bin/group.rs):
gen 1 ready; select(0, k1136) = Some("10.1.244.1:80")
update() returned after 394.125µs: backend_generation=2, selector_generation(0)=Some(1)
right away: select(0) = Some("10.1.244.1:80"), backends().ready(victim) = false
converged after 7.960709ms: select(0) = Some("10.0.229.1:80")
selector 0: gen 2 queue 7.833µs build 3.391709ms
selector 1: gen 2 queue 3.410416ms build 3.324375ms
LoadBalancer::update() returned after 3.656792ms (discovery 358.083µs, build 3.298042ms); select = Some("10.0.229.1:80")
update()0.4 ms 就返回了,成员已经是第 2 代,选择器还在第 1 代。- 紧接着
select(0)仍然选中被删的10.1.244.1,而Backends::ready对它已经是false:group 的选择器只认自己那一代的就绪表。等两个选择器都到第 2 代(selectors_ready_for),同一个键换到了环上的下一个后端。 - 选择器 1 排队 3.41 ms,正好是选择器 0 的建造时间:两个槽位共用一个闸门,排着队建。
- 同样的变更交给普通的
LoadBalancer,update()要 3.66 ms 才返回,其中 3.30 ms 是建环;返回时选择已经是新的。
要等新一代生效再放流量,就读 backend_generation(),再轮询 selectors_ready_for。group 作为后台服务跑时,启动阶段的就绪通知就是这样等的:它等所有选择器都追上最新一代才 notify_ready。
共享的健康状态:HealthRegistry
同一批机器常常出现在好几个负载均衡器里。0.8.0 每个 LoadBalancer 各探各的,同一台机器被探 N 次。0.9.0 把健康状态拆到 HealthRegistry:
Backends::new照旧,自带一个私有的 registry,由所属的LoadBalancer安排检查。Backends::new_with_health_registry(别名BackendView)把自己的成员登记到一个共享的 registry。registry 把所有视图的成员取并集(reconcile),每个目标只留一份健康状态。这种视图的检查由单独的HealthCheckService做,LoadBalancer上的health_check_frequency对它不起作用。- 「同一个目标」默认按
Backend的完整身份算(地址加权重);HealthRegistry::with_equivalence可以换成别的键,比如只看地址,让不同权重的同一台机器共用一次探测。
两个视图 {a, b} 和 {b, c} 登记到同一个 registry,跑一轮检查:
shared HealthRegistry, views {a,b} and {b,c}:
target_count = 3
probe 127.0.0.1:1
probe 127.0.0.1:2
probe 127.0.0.1:3
b 只探了一次。
动脑筋答案
max_iterations=1: 127.0.0.1:18701 None None None None None
max_iterations=2: 127.0.0.1:18701 127.0.0.1:18701 127.0.0.1:18701 127.0.0.1:18701 127.0.0.1:18701 127.0.0.1:18701
只有第一次是 Some。两个后端按地址排序,18701 在下标 0。RoundRobin 的计数器从 0 开始:
- 第 1 次:计数器 0,选 18701,接受;计数器变成 1。
- 第 2 次:计数器 1,选 18702,被拒,计数器变成 2。
get_next再调一次iter.next()取备选,轮询的备选也是fetch_add,计数器变成 3;这时才发现步数到了 1,返回None。 - 之后每次都从奇数开始:先撞上 18702、再白白消耗一个偶数。计数器永远停在奇数上,18701 再也选不到。
max_iterations=2 时每次都能走到备选,全部是 18701。所以这个参数不只是「最多试几个后端」:在轮询下它还决定了一次失败的选择会吃掉计数器的几格。照文档写 256 就不会碰到这个问题。
小结
Backend的身份是地址加权重,ext不参与比较;改权重等于换了一个后端,健康状态从默认的「健康」重新开始。Static的set/add/remove是pub(crate),后端集合要变就自己实现ServiceDiscovery;try_from_iter同步解析名字,每个地址一个Backend,localhost在 macOS 上得到两个。select_with无锁读选择器的快照,UniqueIterator去重并限制步数,每个候选问一次Backends::ready,accept决定要不要。- 换后端集合时,
do_update先发布新旧并集的就绪表,再在回调里换选择器,最后发布新成员;成员和就绪表在 0.9.0 才合进一个ArcSwap(d6257c1)。 - 0.9.0 的
LoadBalancerGroup让多个选择器共享一份Backends,在阻塞线程池里排队重建,每个选择器带着自己那一代的就绪表;实测重建完成前旧选择器还会选中刚删掉的后端。 HealthRegistry让多个视图共享健康状态,每个目标只探一次,由HealthCheckService负责。