每秒百万次请求的客户端负载均衡
摘要
Zalando 的工程团队描述了如何实现客户端负载均衡,以取代其高流量 Product Read API 的共享边缘负载均衡器。通过 Skipper 消除扇出瓶颈,从而降低延迟并提高可观测性。
暂无内容
查看缓存全文
缓存时间: 2026/07/01 23:02
# 每秒百万请求的客户端负载均衡
来源:https://engineering.zalando.com/posts/2026/06/client-side-load-balancing.html](https://engineering.zalando.com/posts/2026/06/client-side-load-balancing.html?v=2](https://engineering.zalando.com/posts/2026/06/client-side-load-balancing.html
客户端负载均衡
我们最繁忙的 API 曾将其高流量的内部流量通过集群的共享边缘入口负载均衡器。多年来,我们始终无法确定延迟峰值是来自我们自己的代码,还是来自内部重复使用那个共享边缘路由器。
在[上一篇文章](https://engineering.zalando.com/posts/2025/03/event-driven-to-api.html)中,我们描述了如何构建 Zalando 的产品读取 API (PRAPI),该 API 在 25 个欧洲市场中以个位数毫秒的延迟处理每秒数百万个请求。每个产品页面、搜索结果和结账流程都依赖它。短暂的性能下降会对销售产生可衡量的影响,因此对高性能和可用性有很高的要求。低延迟是通过一致性哈希路由实现的:[Skipper](https://github.com/zalando/skipper) 是集群的边缘负载均衡器,它将相同的产品 ID 路由到相同的 Pod,从而帮助利用底层应用中的 Pod 本地缓存。该 API 的路由基础设施至关重要。
上线时,Skipper 同时处理边缘路由和批处理组件与单次获取组件之间的内部流量。我一直打算用客户端负载均衡 (CSLB) 取代后者,并希望它能快速跟进。但 Skipper 很快,每次请求只增加几百微秒,团队自然不愿意对运行良好的系统进行重大改动。多年来,随着故障不断累积且根本原因从未完全明确(是 Skipper?还是 PRAPI?),结构性问题越来越难以忽视。对于单个包含 100 个请求的批量请求来说,PRAPI 对 Skipper 的暴露程度是 100 倍。当 Skipper 打喷嚏时,PRAPI 就会得流感。
其中一些故障后来发现既不是 Skipper 也不是 PRAPI 的问题。但在我们拥有路由决策权以及随之而来的详细日志之前,我们无法看到这一点。
## Skipper 与扇出问题
Skipper 是 Zalando 的开源 Kubernetes 入口控制器和 HTTP 路由器。它在边缘负载均衡方面表现出色:一致性哈希路由、有界负载保护、新 Pod 渐变引入。我们自己贡献了 Skipper 的关键特性,包括[在扩缩容期间最小化缓存丢失](https://github.com/zalando/skipper/issues/1712)和[防止热门产品过载](https://github.com/zalando/skipper/issues/1769)。至今我们仍将所有单一产品 GET 请求交给 Skipper 处理。
问题出在我们的批量端点。PRAPI 的 product-sets 组件将一个批量请求解包成最多 100 个并行的下游调用,发送到各个产品 Pod。这 100 个调用每个都经过 Skipper。Skipper 每次跳跃只增加几百微秒,但一个批量请求同时等待多达一百次这样的跳跃,因此其延迟取决于最慢的那一次,而不是典型值。而且 Skipper 是共享基础设施:我们与集群其他部分运行在同一集群上,使用我们继承而非设置的全局配置。
产品集扇出图
产品集通过入口负载均衡器进行扇出在故障期间,我们始终无法确定延迟峰值是来自 Skipper 还是我们自己的代码。它位于每个请求的热路径上,我们不运行它,也无法将其行为与我们的代码清晰分离。即使 Skipper 很快,这种共享命运也是问题所在。
我们决定,对于高扇出的内部流量,路由决策应该位于调用进程内部。Skipper 擅长的边缘流量应保持原样。我们并不是要取代 Skipper,而是将内部扇出路径升级为运行在进程内的客户端负载均衡器。
直连路由图
产品集直接路由到产品 Pod## 构建相同的哈希环
我们需要的不是抽象的客户端负载均衡,而是在我们自己的进程中拥有与 Skipper 完全相同的环。因此最关键的限制是哈希一致性。在迁移期间,Skipper 和我们的客户端负载均衡器都会将请求路由到同一批产品 Pod。如果哈希环不一致,Skipper 路由到 Pod A 的产品可能被我们的库路由到 Pod B。这将导致缓存分裂,并使 DynamoDB 负载加倍,与我们期望的完全相反。
我们实现了 Skipper 使用的相同算法:在可配置的虚拟节点环上使用 [xxHash64](https://github.com/zalando/skipper/blob/master/loadbalancer/algorithm.go)。每个端点 URL 在 64 位哈希环上放置 100 个位置,与 Skipper 的默认设置一致。当请求到来时,产品 ID 被哈希计算,然后在环上进行二分查找,找到顺时针方向上最近的端点。
这意味着添加或删除一个端点只会重新映射大约 1/N 的键,从而最大限度地减少缓存抖动。由于 Skipper 和我们的库使用相同的哈希函数和相同数量的虚拟节点,因此对于同一组 Pod,它们会产生相同的环。一组单元测试对此进行了固定:它们断言我们的环对于任何 Pod 集合都能将相同的键放在与 Skipper 算法相同的端点上,并且在每次构建时运行,这样后续更改就不会悄然偏离 Skipper。我们在生产中通过金丝雀部署也证实了这一点:两条路径上的缓存命中率保持一致。
我们将其编写为一个独立的、无框架的 JVM 模块,长期目标是将其从这个服务中抽离出来。它唯一真正的依赖是一个小型零分配哈希库,用于与 Skipper 匹配的 xxHash64;其他所有内容(环、占用率计算、有界负载计算)都来自 JDK 标准库。Kubernetes 客户端和 Micrometer 位于边缘,负责服务发现和指标收集。
## Kubernetes 服务发现
我们的负载均衡器需要知道哪些 Pod 存在。最初的方法是每隔几秒轮询 Kubernetes EndpointSlice API,但轮询是我们已经学会要谨慎对待的模式。Zalando 之前曾遇到过 Akka Cluster 部署高频率轮询 Kubernetes API 导致控制平面完全宕机的事故。PRAPI 运行在数百个 product-sets Pod 上;数百个 Pod 独立轮询,每个都有自己的时间表,正是那种聚合会导致此类事故。
我们切换到了基于 watch 的 Kubernetes informer。启动时,它会列出当前的 EndpointSlices 来初始化环,然后持有一个持久化的 watch,实时流式传输变化。一个 2 秒的去抖将扩缩容期间的快速变化合并为一次环更新,防止每个 Pod 都重建一次环。
```
informer(列表 + watch)
|-- 启动时:列出当前 EndpointSlices,初始化环
+-- 然后流式传输添加 / 更新 / 删除事件
每次事件:
|-- 更新本地每个切片的缓存
+-- 使用 2 秒去抖调度 applyLocalState()
+-- 去抖将滚动部署的抖动折叠为一次环更新
```
如果 Kubernetes API 变得不可用,则保留最后一个良好的端点集。负载均衡器永远不会因为一次短暂的 API 波动而呈现空环。过时数据由 HTTP 层的连接错误和调用方的重试逻辑处理。
## 先修复管道
有一件事必须首先完成,因为后续的所有操作在它修复之前都是不可能的。每次渐变引入曲线、负载信号实验和区域试验都需要许多小型、快速的部署。在我们最初的管道上,我们最多一天只能管理一次部署,而且即便这样,也经常在部署过程中触发警报。
这条管道在多年的谨慎积累中退化。每个手动审批门和 180 秒的休眠都是针对过去某次事故而添加的,直到累积的结果比它要保护的系统更慢、更脆弱。构建耗时 21 分钟。一次功能部署意味着要花一整个工作日来照看流量步骤;中位运行时间为 4 小时 49 分钟,记录中最差的一次是 4 天 21 小时。
修复方法是三个 PR。构建缓存将耗时从 21 分钟缩短到 12 分钟。我们将 40 多个手动流量步骤合并为一个 CI/CD 步骤。审批门被移除,取而代之的是分序列的市场组 rollout(测试 -> eu-0 -> eu-1 -> eu-2),利用较小的区域作为关键 eu-2 区域之前的警报缓冲区。中位部署时间从 289 分钟下降到 128 分钟。
CI/CD 管道运行时长前后对比
CI/CD 管道运行时长,从最坏情况下接近五天下降到一两个小时一旦团队信任了这条管道,节奏就变了:小的、可逆的步骤取代了大的、有风险的批次。而且这不是我一个人的特权:几周内,其他工程师就在并行运行他们自己的实验,涉及缓存调优等,因为尝试新事物的成本已经崩溃。这篇文章中后续的所有内容都建立在这种速度之上。
## 安全 rollout
我们为 rollout 构建了三个开关:
- **`CSLB\_ENABLED`**:一个布尔总开关
- **`CSLB\_PERCENTAGE`**:一个 0-100 的流量比例调节,控制多少比例的请求使用客户端路由与 Skipper 回退
- **隐式 Skipper 回退**:任何未通过客户端路由的请求,或任何客户端路由失败,都会透明地降级到 Skipper
我们逐步 rollout:在我们金丝雀市场组中先到 1%,然后是 10%、50%,最后在所有市场组中到 100%。每一步我们都在两条路径之间比较延迟、错误率和缓存命中率。
效果立竿见影。延迟下降了。我们曾归因于“网络”或“堆栈中的某处”的峰值消失了。在峰值时,每秒超过一百万请求被重定向离开 Skipper。
通过 Skipper 的每秒请求数
通过 Skipper 的每秒请求数:峰值接近一百万,在 CSLB 切换后骤降至接近零product-offers 延迟在 CSLB 前后
product-offers 延迟在 CSLB 前后:每日峰值变为稳定的基准线在 100% 比例时,Skipper 回退路径仍存在于代码中,但从未被命中。它作为紧急总开关保留:一次 ConfigMap 更改(通过蓝绿部署 rollout)即可使所有流量重新流经 Skipper。
立竿见影的收获是稳定性。但从共享基础设施中移除每秒一百万个请求产生了一个我们未曾预料到的副作用:我们路由的 Skipper 集群从超过 50 个 Pod 缩减到最低 8 个。始于一个延迟和可运维性项目,变成了一个成本项目。而拥有负载均衡器意味着我们可以继续前进。
每日 Skipper 节点组成本
每日 Skipper 节点组成本在 CSLB 切换后从每天约 450 美元下降到约 110 美元## 消除扩容峰值:N 环渐变引入
现在我们可以快速发布和测量了。第一个目标是扩容峰值,长期以来一直被视为不可避免的常数。团队出于充分的理由对自动伸缩器灵敏度持谨慎态度。尽管我们已经将 Skipper 从扇出路径中移除,但扩容时的冷缓存问题仍然存在。
当水平 Pod 自动伸缩器 (HPA) 添加 50 个新 Pod 时,一个天真的实现会立即将其全部流量份额路由给它们。这些 Pod 的缓存是冷的,因此所有 50 个 Pod 同时未命中到 DynamoDB,读取突发使整个集群的延迟飙升。
Skipper 有一个渐变引入机制来解决这个问题:一个概率性的预过滤器,在一致性哈希环之前运行,并在渐变期间添加或移除新 Pod。虽然这很有效,但通常意味着新 Pod 会被预热一些它们在渐变完成后可能不服务的产品。
我们的优化是 N 环渐变引入。每次伸缩事件创建一个新环,它是已建立环的超集。新环在一个可配置的时间窗口(默认 30 秒)内独立渐变引入,使用 ^2.5 曲线(慢启动,快速完成):
| 经过时间 | 进度 | 流量份额 |
|----------|------|----------|
| 3 秒 | 10% | 0.3% |
| 9 秒 | 30% | 4.9% |
| 15 秒 | 50% | 17.7% |
| 21 秒 | 70% | 41.0% |
| 27 秒 | 90% | 76.8% |
| 30 秒 | 100% | 100% |
如果在第一次渐变引入期间发生第二次伸缩事件,每个事件都会有自己的独立窗口。一个接近完成的环永远不会被在其之上到达的新伸缩事件中断。流量在已建立环和任何正在渐变的环之间共享,并且每个渐变环在其渐变完成后成为新的已建立环,取代之前的环。Pod 删除立即应用于所有环。所有这些都位于单个原子引用之后:每次更改都会换入环集的一个新的不可变快照,因此每个路由决策都会读取一个一致版本,而不会阻塞更新。
N 环渐变引入图
N 环渐变引入:每次伸缩事件添加一个环,最新环在最右边,随着它建立起来,获取逐渐增多的流量份额由于 Pod 在所有环中分配了相同的位置,它们在渐变引入期间接收的流量正好是它们在稳态下将服务的流量。它们用正确的产品预热。没有浪费的缓存条目,没有驱逐抖动。
扩容期间每个 Pod 的产品缓存大小
扩容期间每个 Pod 的产品缓存大小:新 Pod 从接近空开始,爬升到已建立带,只预热它们将保留的产品## 用有界负载驯服 Pod 占用率
随着渐变引入消除了扩容峰值,下一个目标是稳态负载。一些 Pod 运行得很热,而另一些则近乎空闲,为了平衡这种情况,我们首先需要测量一个 Pod 到底有多忙。显而易见的信号结果却是错误的信号。
显而易见的信号是在途请求数:任何时刻对一个 Pod 开放的调用数。Skipper 的[有界负载](https://opensource.zalando.com/skipper/reference/filters/#consistenthashbalancefactor)正是基于此运行的,我们给它设置了一条很短的绳子:平衡因子为 1.10,因此一个 Pod 在环重新分配其键之前只能比集群平均值高出百分之十。我们必须保持这么紧,因为从在途请求数无法区分一个拥有热缓存的 Pod 正在快速处理一千个 1 毫秒的请求,与一个因缓存未命中慢而积压的 Pod。从原始计数上看,两者看起来一样。为了防止挣扎的 Pod 翻倒,我们让整个集群都受限并提前扩容,因此拥有热缓存的 Pod 从未被允许做它们可以轻松吸收的额外工作。
在途请求数还有一个更隐蔽的盲点:它是一个本地、瞬时的数字。每个负载均衡器实例只看到自己的调用,而不是 Pod 的真实负载,并且在突发之间计数会降到零,即使 Pod 一直很忙。因此我们转向了不同的信号:占用率,即每秒的工作秒数。在一个窗口内累积 Pod 用于服务请求的时间,除以窗口长度,如果一个 Pod 每秒钟忙满一秒钟,读数就是 1.0。因为它基于时间而不是瞬时计数,所以在突发之间保持稳定,并且随着 Pod 真正的工作量而上升,无论谁发送的。
我们之前从未绘制过占用率图。第一次绘制时,差距非常明显。从在途请求数看,Pod 看起来紧凑而均衡。但从占用率看,它们分布在 0.40 到 1.30 之间:拥有热缓存的 Pod 在低处轻松运行,而缓存未命中的 Pod 接近其上限。这种不平衡一直存在,只是隐藏在了一个看不到它的信号背后。这就是为什么我们不得不以 50% CPU 使用率如此激进地扩缩容,保持许多 Pod 未充分利用仅仅是为了保护异常值。
优化前每个 Pod 的占用率
优化前每个 Pod 的占用率:从大约 0.4 到 1.3 的宽分布,空闲 Pod 和繁忙 Pod 并存对该信号采取行动的是有界负载机制:当一个 Pod 超过平衡因子乘以平均负载时,环重新分配其键。这确保没有 Pod 承受不成比例的工作量,同时允许拥有热缓存的 Pod 充分利用其能力。我们将平衡因子从 1.10 放宽到 1.35,并切换到占用率作为负载信号。结果:整体资源利用率提高,Pod 数量减少,同时延迟保持稳定甚至略有改善。
优化后每个 Pod 的占用率
优化后每个 Pod 的占用率:从大约 0.4 到 1.3 的宽分布缩小到大约 0.65 到 1.05 的更紧密带,空闲 Pod 较少(占用率不超过 0.75 的 Pod 数量大幅下降)并且繁忙 Pod 更均匀。集群整体使用更多的可用容量,资源得到更高效的分配。未浪费的容量意味着更少的 Pod,直接转化为成本节约。
## 处理有状态基准测试
在这些优化过程中,我们遇到了一个意外的障碍:A/B 测试。由于产品缓存是基于状态的,持有热键的 Pod 在第一次测量中自然表现更好,无论代码是否优化。标准的 A/B 测试假设两次试验之间的条件是独立的,但在这里并非如此。以前,在部署双倍数量的 Pod 并让它们静置一段时间后,我们可以找到一个清晰的基准,但开销很大。我们无法经济地做到这一点,并且经常以模糊的结果告终。
我们通过让流量在两条路径之间循环而不是静态分割来解决这个问题。测试将测试组(包含缓存键)分配给路径 A 一段时间,然后切换到路径 B,再切换回来,等等。由于两个路径遇到相同的键集,冷/热状态是可比较的。我们通过在晚上部署来进一步控制这种切换,此时稳态集群可以在不中断流量高峰的情况下进行测试。我们仍在使用这种技术来验证未来优化。
## 下一步:内存限制
通过将自动伸缩器降低到资源利用率的直接度量(占用率),我们已经移除了一个隐式约束。我们现在考虑使用 JVM 内存使用率作为自动伸缩的信号,这在理论上比 CPU 在容量预测方面更准确,因为 100% 的 CPU 并不一定等于 100% 的容量,而内存压力通常是一个硬限制。我们正在试验一种混合方法,其中占用率处理瞬态突发,而内存信号处理容量上限。
## 结论
结果很显著:延迟峰值被平坦化,Skipper 节点缩减了 80%,集群整体效率提升。但真正重要的不是数字,而是速度。拥有自己的路由决策意味着我们可以迭代,而以前每次变更都涉及协调、回退和跨团队的惊喜。也许同样关键的是,我们修复了管道,随之而来的是一种不同的节奏:小步快跑,而不是延迟的大爆炸。我们不再需要等待任何东西。
一个特别有用的副作用是降低了运营认知负荷。在产品页面上度过的每个令人沮丧的额外毫秒都会导致销售损失。通过消除路由基础设施作为潜在的故障源,我们可以更清晰地看到应用层正在发生的事情。当每个延迟峰值都有明确的归属时,运行一个高性能系统的压力显著降低。
最终,我们并没有取代 Skipper。我们只是将内部流量从共享基础设施迁移到进程内路由。Skipper 仍然负责我们所有的边缘流量,而且做得很好。但我们不再将内部流量视为边缘流量的变体。它们有不同的问题,需要不同的解决方案——一个是共享公共服务,另一个是内部连接。将这两个职责分离到不同的层,使两个层都更简单、更可靠。
## 致谢
感谢 PRAPI 团队和平台工程团队多年来的合作,使这一演变成为可能。
相似文章
负载均衡系统的惊人经济学
一篇博客文章分析了M/M/c队列模型,并表明在负载均衡系统中增加服务器数量,在恒定每服务器负载下可以改善延迟,这是云经济学中一个有益且有些违反直觉的结果。
@Akintola_steve: https://x.com/Akintola_steve/status/2055620856802357587
一份实用的蓝图,用于设计能够处理100万并发用户的后端系统,涵盖架构决策如语言选择、负载均衡、数据库分片、多层缓存及弹性模式。
@chessMan786: 负载均衡器:从零构建分布式系统 轮询、最少连接、一致性哈希及其他……
介绍一个从零构建负载均衡器的学习路线,涵盖轮询、最少连接、一致性哈希等算法,以及健康检查间隔等实际陷阱。
@larsencc: https://x.com/larsencc/status/2053862900289470765
本文详解了开源 browser-use 库的生产架构,阐述了如何利用 AWS Lambda、SQS 和 S3 扩展浏览器代理,实现状态管理与重试机制。
@jhleath: https://x.com/jhleath/status/2065408690992148698
作者解释了如何构建一个能够在恒定时间内每秒启动数百万个沙箱的计算平台,重点介绍了使用Cassandra和S3进行解耦调度和能力聚合。