Postgres 分片并不是一个突然出现的新需求。数据规模、写入吞吐、租户隔离和跨地域部署早已迫使团队把单机数据库拆开。真正耗时的部分在于:把数据拆到多台机器很容易,把它重新包装成仍然可靠、可运维、符合 PostgreSQL 使用习惯的数据库能力,却极其困难。过去二十年的演进,正是这段工程债被逐项偿还的过程。
分片难的从来不是“按键取模”
最朴素的分片方案通常只有一条规则:根据某个分片键决定数据落在哪个节点。例如,tenant_id % 4 决定租户归属的数据库。这能解决单表容量问题,却会立刻把原本由数据库承担的复杂性推给应用:
- 查询必须带上分片键,否则需要向所有节点广播。
- 跨分片
JOIN、聚合、排序和分页会变成分布式执行问题。 - 全局唯一 ID、约束、事务语义和二级索引都需要重新定义。
- 扩容不是增加一台机器,而是迁移既有数据,并在迁移期间维持读写正确性。
- 备份、恢复、故障切换、监控和版本升级的对象从一套数据库变成一个集群。
因此,早期“Postgres 分片”往往意味着应用层路由、多个独立 PostgreSQL 实例和大量约定。它可用,但成本高,而且每个团队都要重复实现路由、重平衡和故障处理。
二十年里逐渐补齐的能力
从历史角度看,Postgres 分片之所以长期显得不够成熟,并不表示 PostgreSQL 缺少扩展性,而是分片需要多个层面同时成熟。
数据库内核需要足够可扩展。 分布式查询规划、执行器扩展、分区机制、复制能力、逻辑解码,以及更可靠的并发控制,都会影响分片系统能否像一个数据库而非一组脚本那样工作。
生态需要形成稳定接口。 当扩展能复用 PostgreSQL 的 SQL、协议、工具链和运维经验时,用户才不必为了水平扩展彻底重写应用。否则,迁移成本会抵消分片带来的收益。
运维语义需要可落地。 真正困难的是节点加入与移除、数据重分布、失败恢复、升级兼容和可观测性。这些事情无法只靠一次 CREATE TABLE 完成,必须在长期生产环境中打磨。
也正因如此,“好用的 Postgres 分片”不是某个单独功能上线,而是查询、数据移动、一致性和日常操作逐步闭环后的结果。
设计分片前,先把访问路径画出来
可以这样实践:先在应用数据库中提取最常见的查询,再判断它们是否天然围绕同一个键聚集。多租户 SaaS 往往适合按 tenant_id 分片,因为大多数读写、权限判断和业务关联都限定在一个租户内。
下面这个最小 SQL 示例展示了一个适合按租户路由的订单模型。它不是完整的分布式数据库实现,而是用来检验分片键是否进入了关键访问路径的起点。
CREATE TABLE orders (
tenant_id bigint NOT NULL,
order_id bigint NOT NULL,
customer_id bigint NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
total_cents integer NOT NULL CHECK (total_cents >= 0),
PRIMARY KEY (tenant_id, order_id)
);
CREATE INDEX orders_tenant_created_idx
ON orders (tenant_id, created_at DESC);
-- 适合定向路由:条件中包含 tenant_id。
SELECT order_id, status, total_cents
FROM orders
WHERE tenant_id = 42
AND created_at >= now() - interval '30 days'
ORDER BY created_at DESC
LIMIT 50;
这里把 tenant_id 放进主键和索引前缀,不只是索引优化,也是明确告诉系统:订单的归属和最常见的访问边界都是租户。相反,若核心接口经常执行“查询全站最近订单”或“按任意 customer_id 查历史订单”,就要提前设计汇总表、搜索索引、分析副本,或接受跨分片查询的代价。
用一个可运行的路由器暴露应用层成本
在没有数据库级分片能力时,团队常常会在应用层做类似下面的路由。示例可直接运行,它用 tenant_id 选择连接串;实际项目应把连接池、健康检查、重试和凭据管理交给成熟组件。
from dataclasses import dataclass
@dataclass(frozen=True)
class Shard:
name: str
dsn: str
SHARDS = [
Shard("shard_a", "postgresql://app:secret@db-a:5432/app"),
Shard("shard_b", "postgresql://app:secret@db-b:5432/app"),
Shard("shard_c", "postgresql://app:secret@db-c:5432/app"),
]
def shard_for_tenant(tenant_id: int) -> Shard:
if tenant_id <= 0:
raise ValueError("tenant_id must be positive")
return SHARDS[tenant_id % len(SHARDS)]
if __name__ == "__main__":
for tenant_id in (1, 2, 3, 42):
shard = shard_for_tenant(tenant_id)
print(f"tenant={tenant_id} -> {shard.name} ({shard.dsn})")
这段代码刻意简单,也恰好暴露了问题:当分片数量从 3 变成 4,取模结果会大面积变化。实际扩容不能直接替换 len(SHARDS),否则大量租户会被路由到尚未迁移数据的新节点。成熟的分片方案必须把“路由规则变更”和“数据迁移完成”协调为一个可恢复的流程。
采用分片时检查四个边界
分片适合那些单机资源已经成为明确瓶颈,并且业务数据能够按稳定键分组的系统。采用前至少回答下面四个问题:
- 定位率:多少关键请求能带上分片键并只访问一个节点?
- 跨分片语义:跨租户查询、全局唯一约束和事务需要强一致,还是可通过异步汇总解决?
- 重平衡窗口:迁移一个热点租户或增加节点时,读写如何继续进行,失败后如何回滚或续传?
- 运维所有权:谁负责节点生命周期、备份恢复演练、容量预测和全局监控?
Postgres 分片花了很长时间才变得更可用,原因正是这些问题没有任何一个能被“把表拆开”单独解决。对团队而言,最稳妥的路径通常不是尽早分片,而是先把数据访问围绕明确边界组织起来;当容量和隔离需求确实到来时,才让数据库能力和运维流程一起承担分布式复杂性。