ZGateway:在 ZippyDB 前架设代理层的经验与思考
- 我们推出 ZGateway,一个用于统一 ZippyDB 流量的代理。ZippyDB 是 Meta 使用最广泛的 key value store。
- 顺带一提,它还支持 admission control、load balancing、cross-region resilience,以及更丰富的 operations。
ZippyDB 是 Meta 使用最广泛的 key value store,支撑产品元数据、计数器和配置,并能在全球分布式集群中每秒处理数十亿次操作。
前一篇帖子介绍了 ZippyDB 的工作原理。本文讨论它前面的一层:ZGateway,一个用于统一 ZippyDB 客户端流量的代理。
ZGateway 源于管理 ZippyDB 客户端集群无序扩张的需要,其价值是结构性的。ZippyDB 客户端可能分布在超过一百万台主机上,由数百个团队负责,我们无法快速修改。代理处于完全不同的位置——同时位于众多客户端的路径上——这个共享视角让它能完成单个客户端做不到的事。
下面每一项能力,放在一个受管层中实现,都比放在一百万个客户端二进制中更容易、更安全,甚至只有这样才能实现。我们通过两个最清晰的例子来讲这个故事:connection management 和 request batching。
为什么 ZippyDB 需要一个代理层
只要大量、多样的客户端访问共享后端,就会出现代理:connection pooler、service mesh sidecar、CDN edge、API gateway。在多个调用方和共享资源之间插入一层,能带来三点好处。
- 它收敛问题边界:后端不再直接面对客户端群体,而是面对一个由自身运维人员控制的集群。
- 它为共享逻辑提供统一落点——pooling、retries、routing、caching、admission control——由最了解后端的团队一次性解决,而不是散落在每个客户端里。
- 它形成控制点:这是唯一能观察整体负载、将负载归因到产生者、并在几分钟内调整行为的地方,而不是等待全量客户端发布。
代价是多一跳,也多一层需要运维;当客户端群体庞大、多样且无法由你修改时,这种权衡就值得。ZippyDB 是极端案例。
在直连模式下,每个 ZippyDB 客户端都要连接它需要访问的每一台数据库主机。一个客户端在稳定窗口内可能触及数万个不同的分片,而这些分片分布在数十万台数据库主机上。结果就是一张密集的多对多 TLS 连接网:典型的客户端会持有数万条出站连接,典型的数据库主机也要接受数万条入站连接。

这张连接网既浪费又脆弱。每条打开的连接都要在两端消耗内存、CPU 和文件描述符,而且大部分时间处于空闲状态;入站连接数还会随客户端规模增长,每来一批新客户端,所有数据库主机的负担都会加重。此外,由于每个客户端各自管理连接池和故障转移,一旦连接复用率骤降——比如一批客户端重启或滚动发布——整个集群就会遭到新连接风暴的冲击。我们追查过多起主机崩溃事故,根源正是文件描述符耗尽和 OOM。
从客户端侧解决这个问题之所以困难,是因为两套系统都在同时变动。客户端集群在不断调整连接池策略、不断扩大规模,而数据库集群也按自己的节奏在整合。把两者直接耦合,就像在两辆行驶的车之间跳跃。引入代理可以把它们解耦,将连接网收敛为两个有界跳数——在效率、性能、可扩展性上都是收益,而最重要的是可靠性。
最后这一点最为关键。在直连模式下,重连风暴可能演变成灾难。曾有一次事故,一个路由 bug 导致每个客户端对每个分片都建一条连接,主机突破了文件描述符上限,整个集群陷入重启死循环。而有了 ZGateway,这类风暴会在代理层被拦截——代理集群由我们掌控、可观测、可以集中加固。连接管理并不会因代理而消失,只是转移到了唯一能把它彻底解决的地方。
在 ZippyDB 早期,直连是合理的设计。Fan-in 随客户端数量增长,资源开销也随之扩大:几千个客户端时,mesh 只是低效;规模更大后,它成为可靠性瓶颈。这些能力也按同一节奏到位——ServiceRouter 功能改进、Thrift 过载保护、轻量客户端等特性,才让共享层成为可能;如果更早构建 ZGateway,就得先完成这些能力。
ZGateway 是什么
ZGateway 是位于 ZippyDB 客户端与数据库(ZServer)集群之间的无状态代理层。它每秒可处理超过 10 亿次操作,承载约 40% 的 ZippyDB 流量,预计将超过 60%,且平均用例仅增加约 6% 计算开销。它通过 ServiceRouter 发现,并以区域层形式运行;ServiceRouter 是 Meta 的超大规模服务网格方案,可让每个客户端就近访问其网关。ZGateway 有两种形态,共享同一套流水线:纯代理和 read-through cache。它以内置的厚 C++ 客户端为引擎,每个用例对应一个内部客户端。ZGateway 是作为托管服务运行的 ZippyDB 客户端,因此把能力迁移到这里很自然。

客户端通过粘性连接把请求发往区域 ZGateway 主机;该主机终结 TLS,依据用例 ACL 鉴权,并执行按租户的准入控制、校验和整形。ZGateway 解析 shard,在缓存层检查本地缓存,并将该 shard 的未命中和写请求与其他在途请求做批处理和合并,再发送到正确副本。响应随后被解复用回各调用方,同时记录每个用例的指标、trace 和配额使用。
关键特性是连接数的不对称性。每个客户端只需维护到其所在区域 ZGateway 主机的粘性连接池;每个 ZServer 只看到来自 ZGateway 集群的连接,而集群规模由我们控制。有些职责刻意保持不变——Thrift/ServiceRouter 栈中的 TLS、shard locator 中的 key 到 shard 映射、嵌入式客户端中的副本选择与 hedging。ZGateway 负责的是流量管理,而不是重新实现数据库客户端。
Fan-In/Fan-Out 降低
计算如下:
将集群建模为球入桶问题:将 B 个球(一台主机触及的 shard 数)投入 H 个桶(主机),统计被命中的不同桶数。
期望值为:
某个特定桶被命中的概率为:
Fan-out 是被命中的不同桶数;fan-in 为该概率乘以调用方数量。取一组粗略模拟数字——20 个区域、50 万数据库主机、3 万代理主机、100 万客户端、每客户端 5 万 shard——模型结果为:

连接并没有消失,而是转移到了专门承载它们的层级。端到端来看,持久连接总数仍下降约 19 倍,因为每个后端连接都复用了大量客户端。
但无论这种一次性减少多么显著,真正重点都不在此。真正重点在于扩展行为的变化。在直连模型中,数据库主机的 fan-in 为 H_client · p,随客户端数量线性增长;因此每新增一批客户端,都会让所有数据库主机变得更糟。引入 ZGateway 后,客户端数量完全被消去:fan-in 近似降为 R · S_host,即区域数乘以每主机 shard 密度,与两个集群规模均无关。剩下的唯一调节变量是 shard 密度,而它由我们掌控。一个由所有人共同驱动的无界数字,变成了我们可控的有界数字。
合并请求流:批处理与请求合并
ZGateway 位于众多客户端的必经之路上,因此能做客户端库做不到的事——把互不相关的调用方的工作合并起来。每台主机上的共享 batcher 会按用例和物理分片,将发往同一目的地的请求归组,合并成一次后端 RPC。
它还能做请求合并(coalescing)。如果多个调用方在同一时刻请求同一个 key,网关只取一次,再把结果分发给所有人。客户端 batcher 只能合并自己进程内的请求,而网关能跨客户端合并。
每次 RPC 都有固定开销,与请求大小无关——Thrift 序列化、分片查找、鉴权、系统调用——把多个操作合并成一次,就能摊薄这些开销。效果是后端请求更少更大,QPS 和 CPU 更低,而且 linger 窗口能平滑微突发,让负载更稳定。另外,由于用例是按其发送的 QPS 计费的,批处理等于变相扩大了它的限流预算,不用客户端做任何改动就能减少被限流的情况。
批处理还有两个与效率无关的好处。第一个是请求合并对热点 key 的作用:成千上万个并发调用会合并成一次后端读取,热点 key 永远不会演变成对单个副本的请求风暴。
第二个是它让我们得以做减法。多年来,想要批处理的客户都得用客户端库,这些库脆弱、吃 CPU、需要逐个调优,还常常引发故障——因为这份复杂性分散在上百万个我们无法控制的二进制文件里。共享 batcher 能做到这些库做不到的跨客户端合并,于是我们顺势把它们淘汰了。
每个请求会先存进内存中的批次,满足以下任一条件就触发刷写:linger 窗口到期、负载超过大小上限,或请求数达到上限——因此额外延迟是有界的。超大或刚迁移的批次则退回单独发送。把请求暂存在内存里有 OOM 风险,所以批处理自带两套安全机制。
空闲清除应对缓慢增长:批次 map 中空闲超过 TTL 的条目会在下次刷写时被清除。在途上限应对突发过载:当后端变慢时,执行刷写的协程堆积速度超过消化速度,一旦数量越过上限就拒绝新的执行。日常的清理加上紧急的安全阀,才让批处理做到了默认安全。

ZGateway 的演进
流量一旦集中到某一层,这一层自然成为承载通用能力的入口,避免每个客户端重复实现。批处理是最典型的例子,下面介绍其余能力。
流量路由与安全迁移路径
把流量切到代理上是一次高风险迁移,必须渐进、可回滚、范围可控。ZGateway 的路由由客户端配置开关控制,按服务和分片前缀限定范围:百分比旋钮逐步放量,区域过滤器限制影响范围,全局紧急开关可立即回滚。整个过程只改配置、不改客户端代码,因此可以实时控制灰度。
租户隔离与准入控制
共享层服务数百种用例,一个异常租户不能拖垮其他租户。ZGateway 的防线是判别式负载丢弃(Discriminant Load Shedding,DLS)。每个请求映射到一个租户桶,桶以用例为键,并按优先级拆分;各桶按轮询方式处理请求。当某个租户流量激增时,只有它自己的桶被填满并丢弃超额请求,其他桶继续正常处理——隔离来自结构本身,而不是运气。在 DLS 之前,CPU 并发控制器通过 AIMD 循环调整共享令牌桶的准入速率;内存处理器也以同样方式防止 OOM。
丢弃过程保持精准。在一次受控过载测试中,约 1,350 个活跃租户桶的 CPU 处于 >90% 状态,只有 6 个真正的“吵闹邻居”发生丢弃;其余约 1,344 个桶以零拒绝处理了 99.9% 的请求,goodput 保持在 97–98% 附近,机制本身只消耗约 8% CPU。

支持实时失效的读缓存
在缓存层,热读请求由进程内缓存直接返回;未命中时,网关会对单个 key 加填充锁,使同一 key 的惊群请求收敛为一次后端读取。数据新鲜度来自变更数据捕获流,其中写事件和 checkpoint 事件会失效或回填受影响条目,并遵循显式的有界陈旧契约;每台主机通过一致性哈希负责 keyspace 的一个分片。收益是显著降低存储读压力,同时降低延迟且不损失正确性。
层内负载均衡
由于 ZGateway 无状态,区域层内任意主机都能处理任意请求,因此可以调度流量来均衡负载。但层内主机并不均匀,混合了约 26 核到约 126 核的主机,大规模任务替换也可能在几分钟内改变容量分布。对不均衡的主机一视同仁会产生热点离群值,而 ZGateway 主机过热会进一步引发错误率飙升和 ServiceRouter 限流。由于 ServiceRouter 按加权一致性哈希路由,关键就是给每台主机设置合适的权重。
控制面负载均衡器负责计算这些权重。它按固定周期读取每台主机近期 CPU 利用率,将层内平均值归一化为 1.0,并朝与负载相反的方向微调各权重。护栏机制保证稳定:调整会被阻尼和限幅;权重分布会围绕目标中位数重新居中,避免权重漂移到零;变更节流每次只调整最不平衡的主机,限制分片重排(在缓存层代价很高,因为移动权重意味着移动 key)。新主机按硬件容量初始化权重。
经验是,单一固定策略无法同时应对平稳层和受冲击层。因此负载均衡器正在变得自适应:先识别各层状态——平稳漂移、任务频繁变更、初始权重过平、双峰负载、热点离群、区域偏斜——再应用匹配的策略。
跨区域韧性
ZGateway 大部分时间严格限制在区域内,故障切换也只在区域内进行。这对延迟很友好,但当整个区域的层承压时,请求会在本地排队并超时,而邻近区域的健康容量却闲置。由于 ZGateway 位于 ServiceRouter 之上,我们可以以受控方式让路由跨越区域边界,通过三种机制实现:
- 全局路由构建跨区域的路由表,使饱和的本地层可以切换到健康层,而不是在本地失效。
- Mega-region 把地理位置相近的 region 归入同一个 locality,溢出流量会先流到邻近 region,从而保住大部分低延迟优势。
- Rings 则明确声明哪些 region 互为备份、备份比例是多少。
这两项能力都按 tier 和 region 粒度开启,通过百分比开关控制。除了路由,故障切换的触发信号同样关键。简单的 region 级 CPU 均值恰好会抹平我们最需要捕捉的热点状态,因此故障切换改用更敏锐的指标来触发,目标是在 region 即将过载时就切走,而不是等它已经过载。
事务与更丰富的操作
一个事务需要 somewhere 记录客户端侧的簿记信息:读集合、扫描范围、待写入数据。过去这些信息存放在厚客户端里。当 ZGateway 把用户迁移到瘦客户端后,这些信息就必须搬到网关上。最初的做法留下了两套并行实现——一套是为 ZGateway 专门打造的存储,另一套是引擎本来就在使用的内存路径——等于把整个流程中最关键的正确性部分做成了两个版本。
后来我们通过开关控制,分九个阶段逐步统一到一套实现上,一路推进到流量最大的 region,最终事务流量 100% 切换完成,可靠性没有任何回退。让 ZGateway 与引擎共享同一条路径,意味着它能与服务端事务的演进保持同步:在 tier 内演进一次能力,所有客户端就都能继承。
ZGateway 的生产运维
ZGateway 以庞大的服务器规模运行在几十个 region 上,按业务负载划分为几个 tier:一个面向大量长尾场景的大型通用 tier、为最大客户专设的 tier,以及一个独立的高吞吐代理 tier。各 tier 的规模和体量相差一个数量级以上,即便单个 tier 内部也不均匀,主要原因在于机器堆叠:多个任务以不同密度打包到同一台机器上,与独占整机的专用宿主混布在一起。在这套体系之上,ZGateway 提供了丰富的按用例观测能力——正是这种可观测性,让前文提到的准入控制和负载均衡能在共享基础设施上安全运行。
ZGateway 的下一步
短期目标不变:让所有 ZippyDB 流量统一经由 ZGateway。更有意思的问题是,一个被全面采用的网关还能带来什么。目前有三个方向值得关注,它们的共同点在于——ZGateway 看得最多,也决策得最多。
Agent 驱动的启发式策略。这里几乎所有能力都由控制回路和人工调优的参数控制:负载丢弃的桶大小和 CPU 阈值、负载均衡参数、故障切换触发条件、批量刷新窗口、缓存陈旧度上限。目前这些参数由人工调优,并由定时任务微调;上文的自适应负载均衡器其实已经是一个只差名字的 Agent。下一步是把它显式化,将这些启发式策略和内部状态暴露为结构化控制面,让 AI Agent 观察与我们相同的遥测数据——诊断层级状态、将故障归因到吵闹租户,并在护栏内比任何 oncall 更快地执行修复。
就近部署。ZGateway 是独立层级,会带来额外一跳网络和几个百分点的开销。对于延迟或效率敏感型工作负载,我们可以把部分网关下沉到 ZServer 主机旁,使网关到服务器这一跳变成本地调用,同时控制面保持集中。难点在于这样做时,不要把我们刻意解耦的集群重新耦合起来。连接管理和准入控制前端仍保持为共享的区域层级,只有能从数据局部性中受益的部分才下沉。
多进程网关。ZGateway 在单个进程中承担多种职责,因此某个租户的内存膨胀可能威胁到主机上的一切。把它拆分成协作的进程——连接/TLS 前端、请求工作进程、独立的缓存和事务组件——可以获得强故障隔离和独立生命周期。这也与 Agent 驱动的启发式策略互补:就近部署 Agent 可以管理主机上的进程集群;当数据面本身已是可放置的独立进程时,就近部署会更清晰。
这些改进共同把 ZGateway 从一个智能层级变成可编程层级:控制决策由 Agent 做出,资源足迹随工作负载移动,故障域在设计上相互隔离。