Meta ZGateway:用无状态代理将 ZippyDB 持久连接压缩 19 倍

2026-09-28 23 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

当数据库规模扩大时,先触顶的未必是磁盘或 CPU,也可能是连接数。Meta 为 ZippyDB 引入了无状态代理 ZGateway,把连接管理、流量路由、缓存、负载均衡和准入控制集中到数据库前方。该网关目前承载超过每秒 10 亿次操作,约占 ZippyDB 总流量的 40%;Meta 的模型估算,它将持久连接数量降低了 19 倍。

这里的重点不是“多加一层代理”,而是改变连接拓扑:大量客户端不再分别维持到数据库节点的长连接,而是连接一组可水平扩展的网关,再由网关使用规模受控的连接池访问后端。

19 倍减少来自连接复用,而不是查询加速

假设有 10 万个客户端,每个客户端都可能访问多个数据库分片。如果客户端直接连接后端,连接数量会随着“客户端数 × 分片数”快速增长。即使每条连接上的请求并不频繁,服务端也必须维护套接字、TLS 状态、缓冲区和超时计时器。

加入网关后,拓扑变为:

Clients -> ZGateway fleet -> ZippyDB nodes

客户端连接由网关层吸收,网关到数据库的连接则可以复用并设置明确上限。因此,19 倍指的是持久连接规模的模型估算,并不等同于查询延迟降低 19 倍或吞吐提升 19 倍。

这一模式尤其适合以下场景:

  • 客户端数量远多于数据库节点;
  • 请求短小、频繁,单条连接存在大量空闲时间;
  • 数据被分片,客户端原本需要感知路由规则;
  • 后端需要统一实施限流、负载均衡和故障隔离;
  • 客户端版本众多,难以同步升级连接与路由逻辑。

无状态网关不等于“什么都不保存”

ZGateway 被描述为无状态代理,核心含义通常是:请求处理不依赖某台特定网关上的持久业务状态。实例可以被替换、扩容或摘除,客户端也不需要绑定到固定实例。

这并不排斥网关维护运行时状态。例如,连接池、短期缓存、限流计数器和后端健康信息都可以存在于内存中。关键边界在于:这些状态丢失后不会破坏数据正确性,最多造成缓存命中率下降、连接重建或短暂吞吐波动。

把多项能力放进网关还有一个工程收益:策略只需在一个基础设施层实现。客户端不再分别实现分片发现、连接池参数、过载重试和故障节点摘除,从而减少不同语言 SDK 之间的行为差异。

但集中化也会放大风险。错误的路由规则可能影响大量流量;没有上限的重试会制造重试风暴;网关缓存若缺少明确的一致性语义,则可能返回旧数据。因此,网关必须被当成关键数据路径,而不是普通的边车组件。

可以这样实践:搭一个最小无状态数据网关

下面的示例不是 ZGateway 的实现,而是一个可运行的简化模型,用来演示四个关键机制:稳定路由、后端连接池、短期缓存和准入控制。

将以下内容保存为 gateway.py:

import asyncio
import hashlib
import os
import time
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI, HTTPException, Request, Response

ROLE = os.getenv('APP_ROLE', 'gateway')
BACKENDS = os.getenv(
    'BACKENDS',
    'http://127.0.0.1:9001,http://127.0.0.1:9002'
).split(',')
MAX_INFLIGHT = int(os.getenv('MAX_INFLIGHT', '100'))
CACHE_TTL = float(os.getenv('CACHE_TTL', '2'))

client = None
slots = asyncio.Semaphore(MAX_INFLIGHT)
cache: dict[str, tuple[float, bytes]] = {}
store: dict[str, bytes] = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    global client
    if ROLE == 'gateway':
        client = httpx.AsyncClient(
            timeout=1.0,
            limits=httpx.Limits(
                max_connections=64,
                max_keepalive_connections=32,
            ),
        )
    yield
    if client is not None:
        await client.aclose()


app = FastAPI(lifespan=lifespan)


def choose_backend(key: str) -> str:
    digest = hashlib.sha256(key.encode()).digest()
    index = int.from_bytes(digest[:4], 'big') % len(BACKENDS)
    return BACKENDS[index]


if ROLE == 'backend':
    @app.get('/kv/{key}')
    async def backend_get(key: str):
        if key not in store:
            raise HTTPException(status_code=404, detail='not found')
        return Response(store[key], media_type='application/octet-stream')

    @app.put('/kv/{key}')
    async def backend_put(key: str, request: Request):
        store[key] = await request.body()
        return {'ok': True}
else:
    @app.get('/kv/{key}')
    async def gateway_get(key: str):
        cached = cache.get(key)
        if cached and cached[0] > time.monotonic():
            return Response(cached[1], headers={'x-cache': 'hit'})

        try:
            await asyncio.wait_for(slots.acquire(), timeout=0.01)
        except TimeoutError:
            raise HTTPException(status_code=429, detail='gateway overloaded')

        try:
            upstream = await client.get(f'{choose_backend(key)}/kv/{key}')
            if upstream.status_code == 200:
                cache[key] = (time.monotonic() + CACHE_TTL, upstream.content)
            return Response(
                upstream.content,
                status_code=upstream.status_code,
                headers={'x-cache': 'miss'},
            )
        finally:
            slots.release()

    @app.put('/kv/{key}')
    async def gateway_put(key: str, request: Request):
        body = await request.body()
        try:
            await asyncio.wait_for(slots.acquire(), timeout=0.01)
        except TimeoutError:
            raise HTTPException(status_code=429, detail='gateway overloaded')

        try:
            upstream = await client.put(
                f'{choose_backend(key)}/kv/{key}',
                content=body,
            )
            cache.pop(key, None)
            return Response(upstream.content, status_code=upstream.status_code)
        finally:
            slots.release()

安装依赖并启动两个后端和一个网关:

python -m pip install fastapi uvicorn httpx

APP_ROLE=backend uvicorn gateway:app --port 9001 &
APP_ROLE=backend uvicorn gateway:app --port 9002 &
APP_ROLE=gateway \
  BACKENDS=http://127.0.0.1:9001,http://127.0.0.1:9002 \
  MAX_INFLIGHT=100 CACHE_TTL=2 \
  uvicorn gateway:app --port 8080

通过网关写入并读取数据:

curl -X PUT --data-binary 'hello gateway' http://127.0.0.1:8080/kv/user-42
curl -i http://127.0.0.1:8080/kv/user-42
curl -i http://127.0.0.1:8080/kv/user-42

第二次读取应出现 x-cache: hit。相同键会通过哈希稳定地进入同一个后端,而网关访问后端时只使用受限的 HTTP 连接池。并发请求超过准入上限且无法及时获得槽位时,网关返回 429,避免把无限排队转移给数据库。

生产环境不能直接照搬这个示例。至少需要加入后端健康检查、一致性哈希或分片元数据、身份认证、TLS、按租户配额、指标、分布式追踪,以及严格受限的重试策略。示例中的本地缓存也是非权威缓存:实例重启可以丢失,多个实例之间也不会自动同步。

落地时应盯住哪些指标

引入网关不能只比较请求吞吐。更有价值的验收清单包括:

  • 数据库端连接总数、连接建立速率与连接空闲比例;
  • 网关连接池利用率和等待时间;
  • 准入拒绝率、排队时间以及各租户的公平性;
  • P50、P99 和 P99.9 端到端延迟;
  • 缓存命中率、陈旧读取比例和失效传播时间;
  • 分片热点、后端负载偏差与故障转移耗时;
  • 网关实例故障时的重连峰值和数据库压力。

ZGateway 的价值在于把原本散落在客户端中的复杂性收拢成可统一控制的数据平面。代价则是网关成为新的容量边界和故障域。适合的采用路径不是一次性切换全部流量,而是按业务或分片灰度接入,先验证连接数确实下降,再逐步开启缓存、准入控制和更复杂的路由策略。


相关推荐