Rust in Everything
基于 pingora-load-balancing 0.9.0

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:地址、权重和一个不参与比较的扩展

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
    }
  1. self.selector.load() 从 ArcSwap 取出当前的选择器。整个过程没有锁,取到的是一个快照:这次选择进行到一半时选择器被换掉,这次请求也继续用旧的。
  2. 选择器按 key 给出一串候选(iter)。第一个是首选,后面是首选不能用时的备选,顺序由算法决定,见 选择算法。轮询和随机不看 key,上游文档传 b""。
  3. UniqueIterator 去掉重复的候选,并且最多走 max_iterations 步。重复的候选也算步数。
  4. 每个候选问一次 Backends::ready:既是当前成员、又没被手动禁用、健康检查也说健康,才是 true。accept 拿到这个布尔值,自己决定要不要。select 的 accept 就是 |_, health| health。
  5. 所有候选都被拒、或步数用完,返回 None。

max_iterations 上方有一行 // TODO: consider remove max_iterations as users have no idea how to set it.。上游文档和例子一律写 256。它确实不好设:步数是在 UniqueIterator::get_next 里先取下一个候选、再检查步数,而轮询的「取下一个」会推进一个全局计数器。

❗IMPORTANT
动脑筋:两个后端 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(),
            }));
            // ...
  1. 先发布一张过渡就绪表:旧成员和新成员的并集。这时候不管请求拿到的是旧选择器还是马上要换上的新选择器,它给出的每个候选都在表里,都能按真实健康状态判断。
  2. 调回调,新选择器在回调里 store 上去。
  3. 再发布新成员和只含新成员的就绪表。之后还拿着旧选择器的请求,如果选到一个被删掉的后端,ready 返回 false,跳过它找下一个。

成员没变时(大多数轮次),do_update 只按启用表改开关,不重建选择器。

就绪状态本身是共享的:BackendReadiness 里存的是 Arc<AtomicBool> 的启用开关和一个 Health 句柄,健康检查和 set_enable 改的是同一份,不用等下一次 update。新加入的后端从 Health::default() 开始,默认是健康的(见 健康检查与后台服务)。

📝NOTE
冷知识:「put this all under 1 ArcSwap」这条 TODO 留了两年

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 负责。

💬 关联讨论

发起讨论
加载中...