应用通常希望面对一个稳定的数据库地址,但数据规模、租户隔离或吞吐压力可能迫使后端拆成数百台 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 台服务器时,大量租户都会改变目标分片,迁移成本很高。
更可控的做法是引入逻辑分片:
- 应用提交分片键,例如
tenant_id。 - 路由目录把分片键映射到逻辑分片,例如
shard_037。 - 服务发现再把逻辑分片映射到当前主库、只读副本和连接参数。
目录可以按租户记录,也可以用哈希槽减少记录数量。关键是让“数据属于哪个逻辑分片”和“逻辑分片当前运行在哪台服务器”成为两层映射。这样迁移数据时,只需切换目录中的物理位置,而不必修改应用代码。
路由目录本身必须具备版本号、缓存失效机制和高可用副本。若网关长期缓存旧映射,一个已经迁移的租户可能同时向新旧分片写入,形成比短暂不可用更难修复的数据分叉。
可以这样实践:构建最小租户路由服务
下面是一个可运行、可改造的最小示例。它假设每个请求都带有 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_DSN 和 SHARD_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_timeout 和 statement_timeout,避免一次迁移长期阻塞线上查询。
不要掩盖故障边界
统一入口很容易制造“一切正常”的错觉,但 768 台服务器意味着故障会持续发生。路由层应保留并暴露这些边界:
- 指标至少包含逻辑分片、物理节点、操作类型和错误类别。
- 超时、重试和熔断应按分片隔离,不能因为一台服务器异常拖慢全部请求。
- 写请求只有在具备幂等键时才能自动重试,否则网络超时可能造成重复写入。
- 主从切换后,目录更新与连接池回收必须协同,避免继续使用指向旧主库的连接。
- 跨分片查询应进入专门的分析链路,而不是在在线请求中扇出到数百台 PostgreSQL。
落地时,可以从强制所有表携带分片键开始,再引入统一路由 SDK 或网关。随后建立版本化路由目录、按需连接池、分批迁移和分片级可观测性。只有这些机制能够独立承受故障,768 台 PostgreSQL 才会在应用眼中表现得像一个入口,同时仍保留清晰、可操作的故障边界。