把 768 台 PostgreSQL 伪装成一个数据库:路由、连接与故障边界

2026-07-15 35 预计阅读时间: 1 分钟
来源: planetscale.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.

预计阅读时间:9 分钟

应用通常希望面对一个稳定的数据库地址,但数据规模、租户隔离或吞吐压力可能迫使后端拆成数百台 PostgreSQL。让 768 个独立实例“看起来像一个”,并不是把它们变成真正的单机数据库,而是在应用与数据库之间建立统一的寻址、路由和运维层。

“一个数据库”究竟意味着什么

对应用而言,统一数据库入口通常需要提供三种能力:

  • 统一连接方式:应用不保存 768 组主机名和凭据,只连接固定的 SDK、代理或数据访问服务。
  • 确定性路由:每个请求携带租户 ID、账户 ID 或其他分片键,路由层据此找到唯一的 PostgreSQL 实例。
  • 一致的操作模型:建表、迁移、监控、故障切换和容量调整能够批量执行,不需要人工逐台处理。

这里必须明确一个边界:统一入口不等于 PostgreSQL 获得了跨 768 台服务器的原生事务能力。单分片事务仍可使用 PostgreSQL 的 ACID 语义;跨分片事务、全局唯一约束、全局排序和大范围聚合,则需要额外设计。

典型请求路径可以抽象为:

Application
    |
    | tenant_id = 18422
    v
Router / Database Gateway
    |
    | lookup tenant_id -> shard_037
    v
Connection Pool for shard_037
    |
    v
PostgreSQL primary / replica

路由表比哈希公式更重要

最简单的分片算法是 hash(tenant_id) % 768,但它会把服务器数量写进数据位置。增加第 769 台服务器时,大量租户都会改变目标分片,迁移成本很高。

更可控的做法是引入逻辑分片:

  1. 应用提交分片键,例如 tenant_id
  2. 路由目录把分片键映射到逻辑分片,例如 shard_037
  3. 服务发现再把逻辑分片映射到当前主库、只读副本和连接参数。

目录可以按租户记录,也可以用哈希槽减少记录数量。关键是让“数据属于哪个逻辑分片”和“逻辑分片当前运行在哪台服务器”成为两层映射。这样迁移数据时,只需切换目录中的物理位置,而不必修改应用代码。

路由目录本身必须具备版本号、缓存失效机制和高可用副本。若网关长期缓存旧映射,一个已经迁移的租户可能同时向新旧分片写入,形成比短暂不可用更难修复的数据分叉。

可以这样实践:构建最小租户路由服务

下面是一个可运行、可改造的最小示例。它假设每个请求都带有 tenant_id,并用静态目录模拟实际系统中的配置中心。生产环境应把目录替换为高可用元数据服务,并将密码放入密钥管理系统。

安装依赖:

python -m venv .venv
. .venv/bin/activate
pip install fastapi 'uvicorn[standard]' 'psycopg[binary,pool]'

创建 app.py

import os
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from psycopg.rows import dict_row
from psycopg_pool import AsyncConnectionPool

SHARDS = {
    "shard_000": os.getenv(
        "SHARD_000_DSN",
        "postgresql://postgres:postgres@127.0.0.1:5432/app",
    ),
    "shard_001": os.getenv(
        "SHARD_001_DSN",
        "postgresql://postgres:postgres@127.0.0.1:5433/app",
    ),
}

# 示例使用固定槽位。生产环境应从带版本的路由目录读取映射。
def shard_for(tenant_id: int) -> str:
    return f"shard_{tenant_id % len(SHARDS):03d}"

pools = {
    name: AsyncConnectionPool(
        conninfo=dsn,
        min_size=0,
        max_size=10,
        open=False,
        kwargs={"row_factory": dict_row},
    )
    for name, dsn in SHARDS.items()
}


@asynccontextmanager
async def lifespan(_: FastAPI):
    for pool in pools.values():
        await pool.open()
    yield
    for pool in pools.values():
        await pool.close()


app = FastAPI(lifespan=lifespan)


@app.get("/tenants/{tenant_id}/orders/{order_id}")
async def get_order(tenant_id: int, order_id: int):
    shard = shard_for(tenant_id)
    async with pools[shard].connection() as conn:
        row = await conn.execute(
            """
            SELECT tenant_id, order_id, status, total_cents
            FROM orders
            WHERE tenant_id = %s AND order_id = %s
            """,
            (tenant_id, order_id),
        )
        order = await row.fetchone()

    if order is None:
        raise HTTPException(status_code=404, detail="order not found")
    return {"shard": shard, "order": order}

运行前,将 SHARD_000_DSNSHARD_001_DSN 改成两个已有 PostgreSQL 数据库,并在两边创建相同的表:

CREATE TABLE IF NOT EXISTS orders (
    tenant_id bigint NOT NULL,
    order_id bigint NOT NULL,
    status text NOT NULL,
    total_cents bigint NOT NULL,
    PRIMARY KEY (tenant_id, order_id)
);

启动并请求服务:

export SHARD_000_DSN='postgresql://app:secret@db-000:5432/app'
export SHARD_001_DSN='postgresql://app:secret@db-001:5432/app'
uvicorn app:app --host 0.0.0.0 --port 8080

curl http://127.0.0.1:8080/tenants/42/orders/1001

这个例子刻意把 tenant_id 同时放在 URL、路由函数和 SQL 条件中。即使请求误入某个分片,查询也不会仅凭 order_id 读取另一个租户的数据。生产系统还应通过数据库角色、行级安全策略或独立 schema 增加第二层隔离。

768 台服务器会放大连接与运维问题

如果 1,000 个应用进程各自为 768 个分片建立 10 条连接,理论连接数会达到 768 万。即使大部分连接空闲,PostgreSQL 后端进程、TLS 握手和内存占用也会迅速成为瓶颈。

因此,路由层通常需要按需创建连接池,并设置空闲回收策略。更大规模下,可以在每个分片前部署 PgBouncer,让网关维护较少的逻辑连接。连接池还应设置获取超时;某个分片耗尽连接时,请求应快速失败,而不是占满整个网关的工作线程。

批量 schema 迁移同样不能直接对 768 台服务器同时执行。可以把分片分批处理,并记录每台服务器的迁移版本:

#!/usr/bin/env bash
set -euo pipefail

while IFS= read -r dsn; do
  echo "Migrating ${dsn%%\?*}"
  psql "$dsn" -v ON_ERROR_STOP=1 -f migrations/2025_01_add_order_status.sql
done < shard-dsns.txt

该脚本适合小批量演示。实际运行时应限制并发、隐藏日志中的凭据、支持断点续跑,并先在少量分片上做金丝雀验证。DDL 还要设置 lock_timeoutstatement_timeout,避免一次迁移长期阻塞线上查询。

不要掩盖故障边界

统一入口很容易制造“一切正常”的错觉,但 768 台服务器意味着故障会持续发生。路由层应保留并暴露这些边界:

  • 指标至少包含逻辑分片、物理节点、操作类型和错误类别。
  • 超时、重试和熔断应按分片隔离,不能因为一台服务器异常拖慢全部请求。
  • 写请求只有在具备幂等键时才能自动重试,否则网络超时可能造成重复写入。
  • 主从切换后,目录更新与连接池回收必须协同,避免继续使用指向旧主库的连接。
  • 跨分片查询应进入专门的分析链路,而不是在在线请求中扇出到数百台 PostgreSQL。

落地时,可以从强制所有表携带分片键开始,再引入统一路由 SDK 或网关。随后建立版本化路由目录、按需连接池、分批迁移和分片级可观测性。只有这些机制能够独立承受故障,768 台 PostgreSQL 才会在应用眼中表现得像一个入口,同时仍保留清晰、可操作的故障边界。


相关推荐