客户端负载均衡:每秒一百万请求的工程实践

客户端负载均衡:每秒一百万请求的工程实践

networkingperformanceload-balancingdistributed-systems

数据源:web research · HN

2026 年 6 月 23 日,Zalando 高级首席工程师 Conor Gallagher 发表了一篇技术长文,详细记录了团队如何为 Product Read API(PRAPI)构建一个进程内客户端负载均衡器,将超过每秒一百万次请求的内部扇出流量从共享 Skipper 边缘路由中剥离出来。这篇文章随后登上 Hacker News 首页,引发了一场关于「什么时候该自己造轮子」的讨论。

PRAPI 是 Zalando 最繁忙的 API 之一,服务于欧洲 25 个市场的商品页面、搜索结果和结账流程。一次短暂的性能下降会直接反映在销售数据上。为了保证毫秒级延迟,PRAPI 依赖一致性哈希路由:边缘负载均衡器 Skipper 将相同商品 ID 的请求始终路由到同一组 Pod,从而利用 Pod 本地缓存降低 DynamoDB 读取压力。

但问题出在批量接口上。PRAPI 的 product-sets 组件会将一个批量请求拆解为最多 100 个并行下游调用,每个调用都需要经过 Skipper。Skipper 单跳延迟不过几百微秒,但一个批次需要等待最慢的那一次——而那一跳恰好经过了共享基础设施上的某个不确定环节。

扇出问题:100 倍的风险暴露

Skipper 是 Zalando 开源的 Kubernetes Ingress 控制器和 HTTP 路由器,在边缘负载均衡方面表现出色:一致性哈希路由、限流保护、新 Pod 淡入机制,这些功能都运行得很好。PRAPI 的单品 GET 请求至今仍然通过 Skipper 处理。

但批量的扇出路径不同。一个包含 100 个商品 ID 的请求会被展开为 100 个并行子请求,每一个都经过 Skipper。单次跳转延迟也许只有几百微秒,但批次的尾部延迟由最慢的那一跳决定。Skipper 同时是共享基础设施——PRAPI 与集群内其他服务共用同一套 Skipper 实例,运行在全局配置下,团队无法独立调优。

Gallagher 在文章中写道:「在事故期间,我们永远无法确定延迟峰值源自 Skipper 还是我们自己的代码。它处于每个请求的热路径中,我们并不运维它,也无法干净地分离它的行为与我们的行为。即便 Skipper 本身很快,这种『共同命运』才是真正的问题。」

团队决定:边缘流量继续保持原状,内部高扇出流量的路由决策应该下沉到调用进程内部——将内部扇出路径「毕业」到一个进程内客户端负载均衡器,Skipper 继续承担边缘路由职责。

PRAPI 直接路由架构 来源:Zalando Engineering Blog — PRAPI 从通过 Skipper 扇出改为直接路由到产品 Pod

哈希一致性:与已有基础设施对齐

构建客户端负载均衡器面临的首要约束是哈希一致性——性能在这里只能排第二位。在迁移过程中,新旧两条路径需要同时将请求路由到同一个 Pod 池。如果哈希环不一致,同一个商品 ID 可能被路由到不同 Pod,导致缓存分裂、DynamoDB 负载翻倍。

团队实现了与 Skipper 完全相同的算法:基于 xxHash64 的可配置虚拟节点环。每个端点 URL 在 64 位哈希环上占据 100 个位置(与 Skipper 默认一致),请求到达时对商品 ID 做哈希,通过二分查找定位到顺时针方向最近的端点。

添加或删除端点时,只有大约 1/N 的键会被重新映射,最大程度减少缓存扰动。单元测试对此做了严格保证:对任意 Pod 集合断言哈希环与 Skipper 一致,并在每次构建中运行,防止后续变更导致静默漂移。金丝雀发布期间的验证也证实:两条路径的缓存命中率完全一致。

实现上,这是一个独立的、无框架依赖的 JVM 模块。核心依赖只有一个零分配哈希库(用于 xxHash64),哈希环、占有率统计、限流计算全部基于 JDK 标准库。Kubernetes 客户端和 Micrometer 指标库位于模块外围,负责服务发现和可观测性。

Kubernetes 服务发现:从轮询到 Watch

负载均衡器需要知道当前的 Pod 集合。最初的方案是每几秒轮询一次 Kubernetes EndpointSlice API,但团队对此保持警惕——Zalando 曾经历过 Akka Cluster 部署因高频轮询 Kubernetes API 导致控制面全部宕机的事故。PRAPI 运行在数百个 product-sets Pod 上,如果每个 Pod 独立按自己的节奏轮询,累积效应足以引发同类问题。

最终方案切换为基于 Watch 的 Kubernetes Informer。启动时先 List 当前 EndpointSlice 构建初始环,然后保持一个持久 Watch 实时接收增量变更。2 秒的去抖机制将扩缩容期间的大量变更折叠为单次环更新,避免每新增一个 Pod 就重建一次环。

如果 Kubernetes API 不可用,最近一次有效的端点集合保持不变——负载均衡器永远不会因为 API 瞬时故障而呈现空环。数据陈旧问题由 HTTP 层的连接错误和调用方的重试逻辑兜底。

N 环淡入:消除扩缩容时的延迟尖峰

负载均衡器上线后的第一个优化目标,是长期被默认为「正常」的扩缩容延迟尖峰。当 Horizontal Pod Autoscaler 一次新增 50 个 Pod 时,简单实现会将它们应得的全部流量立刻分配过去。这些 Pod 的本地缓存是空的,50 个 Pod 同时对 DynamoDB 发起冷读,延迟尖峰波及整个集群。

Skipper 有一个淡入机制来缓解这个问题:在一致性哈希环之前加一层概率预过滤,在淡入期间逐步添加或移除新 Pod。但这带来的副作用是新 Pod 在预热期间接收的流量可能与稳态时不同,缓存了一些最终不属于自己的商品数据。

Gallagher 团队的优化方案是 N 环淡入。每次扩缩容事件创建一个新环,该环是当前稳态环的超集。新环按独立的 ^2.5 曲线(慢启动、快收尾)在可配置的时间窗口内(默认 30 秒)逐步接管流量:

已用时间进度流量占比
3 秒10%0.3%
9 秒30%4.9%
15 秒50%17.7%
21 秒70%41.0%
27 秒90%76.8%
30 秒100%100%

如果第一个淡入尚未完成时发生第二次扩缩容,两者拥有各自独立的时间窗口。即将完成的环不会被新事件打断。Pod 删除操作对所有环立即生效。整个结构通过一个原子引用进行切换:每次变更交换一个不可变快照,每个路由决策读取一致的版本而不会阻塞在更新上。

因为 Pod 在所有环中占据相同位置,它们在淡入期间收到的流量恰好等于稳态时将要服务的流量。预热的数据就是最终需要的数据,没有浪费的缓存条目。

N 环淡入机制示意 来源:Zalando Engineering Blog — 每次扩缩容事件添加一个新环,按独立曲线淡入

占有率替代并发数:用正确的信号做限流

消除扩缩容延迟尖峰后,下一个问题是稳态下的负载不均:有些 Pod 很热,有些几乎空闲。要让负载变均匀,首先需要正确衡量一个 Pod 到底有多忙——而最直观的信号恰好是错误的选择。

最直观的信号是 in-flight(并发请求数),即某个时刻正在处理的请求数量。Skipper 的限流机制就是基于这个信号,PRAPI 此前也一直用它。但 in-flight 无法区分一个缓存命中率高的 Pod(每秒处理上千个 1 毫秒请求)和一个卡在慢速缓存未命中上的 Pod——从数字上看两者是一样的。

in-flight 还有一个更隐蔽的盲点:它是局部的、瞬时的。每个负载均衡器实例只看到自己发出的调用,从未见过 Pod 的真实负载;在请求突发之间,计数归零,即便 Pod 一直很忙。

团队转向了一个不同的信号:占有率(occupancy),单位是「每秒的工作秒数」。在一个滑动窗口中累加 Pod 处理请求所花费的时间,除以窗口长度——一个忙了完整一秒的 Pod 读出 1.0。因为基于时间而非瞬时快照,它在突发之间保持平稳,并且随 Pod 的真实工作量(无论谁发出的请求)上升。

第一次绘制占有率图表的时刻颇具戏剧性。按 in-flight 看 Pod 负载均匀而紧凑;按占有率看,分布从 0.40 到 1.30,热缓存 Pod 几乎空转,缓存未命中 Pod 跑满上限。这种失衡一直存在,只不过被一个看不到它的信号掩盖了。

将占有率作为限流决策的依据并不顺利。Gallagher 的第一次尝试用了吞吐量(每秒请求数)作为负载信号——理论上合理,但实践中缓存命中为主的负载下(每次请求约 1 毫秒),吞吐量严重高估了负载。限流步移被频繁触发,请求在环上四处散落,缓存命中率急剧下降。

最终的解法来自排队论中的利特尔法则(Little’s Law):平均并发数等于到达率乘以平均服务时间(L = λW)。用滑动窗口累加请求耗时再除以窗口时长,得到的就是真正的「工作秒数」。窗口设为 150 毫秒,分为 5 个 30 毫秒的桶,比单次请求的超时略长,确保刚完成的请求仍在窗口内。

实际使用的信号是 max(inflight, occupancy) 的复合值,再乘以一个从 Finagle 借鉴的延迟权重因子:Pod 的有效负载等于基础并发 × min(Pod 延迟 / 集群平均延迟,5)。延迟因子上限 5 倍,防止单个慢 Pod 让所有其他 Pod 看起来都很廉价。对于有 in-flight 但无响应的 Pod(可能卡死),直接赋予 5 倍满载。

限流的步移本身也被加了限制:最多遍历 10 跳。如果找不到低于阈值的 Pod,就路由到已见过的最轻负载节点。请求不会在环上无休止地漫游,瞬时扰动不会演变为全环踩踏。实际生产中,半数请求一步不移,99 分位数也不过 4 跳,远未触及上限。

产出与成本:从调试问题到成本优化

将百万 RPS 从共享基础设施上移除带来的效果立竿见影。延迟下降,此前归因于「网络」或「栈内某个未知环节」的延迟尖峰消失了。

一个意料之外的副作用是:Skipper 用于 PRAPI 路由的集群从超过 50 个 Pod 缩减到最低 8 个。每天 Skipper 节点组的成本从约 $450 降至约 $110。

占有率限流进一步消除了负载不均。Pod 占有率从 0.40—1.30 的宽幅分布收紧到 0.60—0.90 的紧凑区间。限流因子从保守的 1.10 放松到 1.25,HPA 扩容阈值从 50% CPU 提升到 65%——不再需要为保护异常 Pod 而提前扩容。Pod 数量减少超过 25%,每天额外节省超过 $1,000。

部署流水线也在此期间完成了重建:构建时间从 21 分钟降至 12 分钟,40 多个手动流量步骤合并为单步 CI/CD,中位部署时间从 289 分钟压缩到 128 分钟。在不到七周内,超过 100 个 PR 通过这套流水线进入生产。

CSLB 切换前后延迟对比 来源:Zalando Engineering Blog — 延迟尖峰在 CSLB 切换后显著平滑

Skipper 节点组日成本变化 来源:Zalando Engineering Blog — Skipper 节点组日成本从约 $450 降至约 $110

可用区感知路由:一个暂停的赌注

团队还尝试了可用区感知路由(AZ-Aware Routing),目标是将跨可用区流量转化为区内流量以节省 AWS 跨区数据传输费用。在一个三可用区的 Kubernetes 集群中,约三分之二的跳转跨越可用区边界,按百万 RPS 计算每天会产生数百美元的数据传输成本。

但与扩缩容淡入不同,可用区亲和性意味着每个 Pod 需要缓存更大的商品子集——在一个可用区内只有全集群 Pod 的约三分之一来分担流量。团队为此设计了延迟健康因子和渐进淡入曲线,但首次试验就以缓存命中率崩塌和告警触发告终。根本原因是限流机制在跨区与区内混合路由阶段使用了错误的分母:Pod 被判定为过载,步移将请求散落,缓存局部性崩溃,DynamoDB 读取率从 5 万飙升到接近 40 万。

修复后的算法按当前流量分配权重分别计算区内和全局期望负载,再求和作为 Pod 的阈值。这个修复解决了数值问题,但区亲和性淡入与 N 环扩缩容淡入的交汇处暴露了一系列边界情况:健康检查抖动的瞬时抑制与恢复、失效淡入被新扩缩容事件重新触发等。Gallagher 选择将可用区感知路由暂时关闭,等待团队连续几周 100% 健康度后再重启试验。这是整篇文章中唯一未在生产运行的组件。

硬化扇出路径

移除 Skipper 意味着 PRAPI 需要自己处理每一次重试、每一次超时和每一次过载决策。

重试策略被收紧为单次快速重试,附加几毫秒的抖动退避。只在传输失败和 5xx 响应时触发,不在 4xx 或 404 时触发——后者是结果而非故障。每次重试排除已尝试过的 URL,确保不会落到同一个 Pod 上。

扇出前增加了一个 FIFO 缓冲区。当出站并发超过硬上限时,过载过滤器直接给新请求返回 503 和 Retry-After;FIFO 缓冲区则位于上限之下,短暂排队已准入的扇出请求而非让它们直接堆上网络。

最有价值的改动反而最不起眼:在错误日志中增加一行目标 Pod IP 和所在节点。这个改动让团队第一次看清了多年来只在噪音中感知到的故障模式:每隔一段时间,某个节点在几秒内入站和出站网络完全中断,然后自行恢复。JVM 日志静默,Pod 保持 CPU 余量,Socket 状态健康——故障位于节点或网络层,而不是应用层。

因为客户端负载均衡器拥有完整的路由可见性,当一个节点的产品 Pod 全部静默时,延迟权重因子和卡死 Pod 上限将它们标记为不可用,流量自动转向健康节点。当承载 product-sets 的节点冻结时,范围仅限于该节点的几个 Pod,持续两三秒,其余集群不受影响。Gallagher 写道:冻结仍然发生,偶尔甚至多个节点同时冻结,但已经几周没有因它触发的告警。曾经凌晨三点的告警电话,现在只是图表上一次轻微的抖动。

该不该自己造一个?

Gallagher 在文末给出了明确的建议:对绝大多数团队来说,不要造。成熟的代理如 Skipper 或 Envoy 开箱即提供一致性哈希路由,由他人维护并经数千用户验证。Kubernetes 客户端库让这些机制看起来很容易实现——Watch EndpointSlices 并搭建一个哈希环是一个周末的工作量,而非一个季度。但「构建是容易的部分,永久运维才是成本」。

Zalando 的案例之所以成立,是因为它位于一个真实的极端情况:单条内部路径超过百万 RPS,一个共享跳转将风险暴露放大一百倍。如果团队确实处于这种极端场景,Gallagher 给出的建议是:将实现放在运行时开关之后,带上回退到现有代理的路径;在生产环境而非基准测试中做性能剖析;只替换需要的那条路径,而不是全面替代代理——PRAPI 的所有边缘请求至今仍然经过 Skipper。

关于迁移过程中的回退开关是否应长期保留,HN 讨论中出现了一个值得注意的观点。用户 charleshn 引用了 AWS Builder 关于分布式系统中避免回退开关的论述,认为回退机制可能引入双模态故障和亚稳态失效。Gallagher 回应称回退开关主要是迁移期间的临时措施,目前 PRAPI 的 CI/CD 系统为每个部署版本创建独立的 ConfigMap,重新启用 Skipper 需要一个蓝绿部署流程而非一键回退。


这项工作带来的最大启示并非某个具体算法,而是一条更朴素的规律:拥有路由决策意味着拥有遥测数据。整个项目中价值最高的改动是在错误日志中增加一行 Pod IP 和节点信息——它带来的洞察超过了 N 环淡入和占有率限流之和。从此团队能够看清一个被共享基础设施遮蔽多年的故障模式。拥有路由决策也意味着拥有一个新的故障面:哈希环可能因 Kubernetes API 停摆而过期,数百个 Pod 持有 Watch 连接,读取 EndpointSlice 需要独立的 RBAC 权限,出问题时告警电话打给的是自己的团队而不是基础设施团队。每一项都有缓解措施,但复杂性是真实的,而且是永久的。

本文的素材来自公开信息和社区讨论。如果你对这个话题有更深入的一手经验,欢迎指出文中的不足。

参考链接: