做后端和数据库的,早晚会遇到一个场景:单张表的数据量到了几千万甚至上亿,索引越建越大,写并发上不去,原本毫秒级返回的SQL开始慢慢吞吞。我最初的处理方式是应用层分表,在SQL里拼接表名,路由逻辑散落在各个服务里,后来换了一台更强的机器,也只是把问题往后推了几个月。
直到我系统性地接触了 PostgreSQL 12 的声明式分区加 postgres_fdw 组合,才算把“分库分表”这件事从应用层真正下沉到了数据库层。简单说,就是用 PG 12 内置的分区表做逻辑表,用 postgres_fdw 把多个物理库映射成它的子分区,实现数据分片。这篇文章就围绕这套方案,把原理、步骤、调优和踩坑全部拆开讲清楚。
1. 为什么要用 FDW 做分库分表
1.1 单实例到瓶颈之后,我踩过的分表坑
我之前维护过一套电商订单库,单表跑到三千多万行的时候,体感非常明显。查询响应时间从几十毫秒涨到几百毫秒,一些带排序的报表 SQL 直接上秒级,日常的 VACUUM 也开始跟不上节奏。当时最痛苦的不是单表查询慢,而是索引根本压不住写并发,热点页竞争越来越严重。
做过很多尝试。最初是应用层分表,比如按月拆成多张物理表,业务层自己判断当前时间该读写哪张表。麻烦在于每加一张表都要改代码,SQL 拼接表名稍微手滑就是线上事故,而且跨月的统计查询要在应用层做多次查询再合并,维护成本非常高。
后来我试用过代理中间件,功能是齐全,但等于又引入了一套独立系统,部署、升级、高可用、监控都要额外伺候。对中小团队来说,为分片专门养一套基础设施,人力成本太不划算。
之后我把目光转回 PostgreSQL 自身的能力:用 12 版本内置的分区表配合 postgres_fdw,把分库分表下沉到数据库层。应用照常访问一张逻辑表,PG 自动按分片键把数据分发到多个物理库。尤其当你已经在 PG 生态里,这套方案的迁移路径其实是很短的。
1.2 几种分库分表方案怎么选
| 方案 | 路由位置 | 跨分片查询 | 事务能力 | 运维成本 |
|---|---|---|---|---|
| 应用层分表 | 应用代码 | 基本不可用 | 本地事务 | 高,改代码频繁 |
| 代理中间件 | 独立代理层 | 部分支持 | 受限于代理实现 | 高,多一套系统 |
| PG 分区+FDW | 数据库内核 | 支持但不完美 | 本地+2PC | 中,靠 SQL 管理 |
为什么我更建议 PG 分区+FDW 组合?因为它的路由逻辑在数据库内,父表就是一张普通逻辑表,应用不需要知道数据落在哪个分片。分区剪枝会把只涉及单个分片的查询精准送到对应节点,涉及全部分片的查询也可以并行下推,功能完整度对大多数业务来说已经够用。
不过也要提前把话说清楚,它不是银弹。跨分片事务需要两阶段提交来保障,全局唯一约束数据库管不了,分片数扩起来也比较费劲。如果预估分片数要动态增长、业务强一致要求极高,那还是去看专业分布式数据库。但控制在可控业务量级内、又不想引入额外重组件的话,FDW 这条路非常值得试。
2. 原理基础:分区表、外部表、FDW 是怎么协同的
2.1 PostgreSQL 12 的声明式分区
PostgreSQL 从 10 开始支持声明式分区,11 补上了 hash 分区和默认分区,12 版本把分区剪枝、执行期裁剪等性能短板又补了一截。声明式分区的意思是,你只需要声明“这张表按什么键、分成几个区”,剩下的行插入、匹配、剪枝都由数据库内核处理。
以 hash 分区为例:
CREATE TABLE orders (...) PARTITION BY HASH (user_id); CREATE TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 0);MODULUS 4 表示把整个 hash 空间切成 4 份,REMAINDER 0 到 3 对应用户 key 取模后的落点。数据库在插入时对分区键做 hash,自动决定走哪个分区。这个机制恰好就是“分片”需要的核心能力。
RANGE 分区适合按时间或 ID 范围拆,比如订单按月份拆:
CREATE TABLE orders_202401 PARTITION OF orders FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');LIST 分区适合按租户 ID 这种离散值拆。实际分库分表场景里,hash 分区用得最多,因为数据分布均匀,这也是本文的主线。需要注意的是,PG 12 里分区父表上的主键和唯一约束必须包含全部分区键,这在分片场景下会直接限制你建约束的思路,后面我会专门提到。
2.2 postgres_fdw 在干什么
postgres_fdw 是 PostgreSQL 官方自带的 FDW 实现,作用是把远端 PostgreSQL 实例中的表映射成本地一张“外部表”。你访问外部表,FDW 会把 SQL 发给远端执行,再把结果拉回来。
拿来分库分表,思路就很清晰:每个分片库建一套物理表,在总库上用外部表映射起来,然后让这些外部表分别充当父表的分区。父表在这里只是个“路由层”,本身不存数据,真正的数据全部落在各个分片库上。
CREATE EXTENSION postgres_fdw; CREATE SERVER shard1 FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '10.10.10.11', port '5432', dbname 'orderdb'); CREATE USER MAPPING FOR app_role SERVER shard1 OPTIONS (user 'shard_user', password '换成真实密码'); CREATE FOREIGN TABLE orders_p0 PARTITION OF orders FOR VALUES WITH (MODULUS 4, REMAINDER 0) SERVER shard1 OPTIONS (schema_name 'public', table_name 'orders');普通外部表需要逐个声明列,但如果用了 PARTITION OF 语法,列结构直接继承父表,不用重复写,省事很多。而且外部表的列类型、顺序必须与父表完全一致,用 PARTITION OF 一劳永逸地避免了手工维护列定义的繁琐;如果是先导入外部表再 ATTACH,列一多就容易在大小写、nullable 这类细节上跳脚,建议优先走 PARTITION OF 方式。
2.3 外部表作为分区:关键机制
外部表挂成子分区后,父表上的 DML 会被内核自动路由。比如插入一条 user_id=100 的订单,PG 算 hash 发现落在 REMAINDER 0,就把 INSERT 语句转发给 shard1 执行。查询时如果带了 user_id=100 条件,PG 会先做分区剪枝,把其他三个分区全部排掉,只向对应分片发查询。
这套机制的聪明之处在于把“数据路由”和“数据存储”解耦。路由决策由内核完成,每个分片库只负责自己的那部分数据,互不感知。对应用来说,数据库链接的还是那张逻辑的 orders 表,连接串、SQL、ORM 映射都不用动,这是它能落地的最大价值。
3. 动手前的准备:节点规划与基础配置
3.1 先想清楚分片键
分片键是整个方案里最不能拍脑袋的决定。我见过有人选 status 字段做分区键,结果大量数据挤到一两个分区,其余分区全是空壳;也见过按自增主键做 hash,因为主键顺序生成,某个时间段只有某个分区在写,热点问题非常严重。
分片键要满足三个条件:离散度高、业务查询高频带条件、写入能摊开。对订单系统来说 user_id 就是一个典型好键,用户访问自己的订单必然带 user_id,写并发也分散在不同用户身上。tenant_id 在多租户系统里也常见,前提是租户数量和访问足够均匀。
分片数量也要提前想好。PG 的 hash 分区声明了 MODULUS 4,后续想改成 8 是需要搬数据的,不是改个数字就完事。建议初期按业务未来两到三年的规模定,比如预计单库撑到 1 亿行、总数据量 5 亿,直接定 8 个分片,而不是先 4 个再说。
3.2 在远端节点完成建表
每个分片库不需要安装 postgres_fdw 扩展,它就是一台普通的 PostgreSQL 服务器,只需要把物理表和索引建好。
-- 在分片库执行 CREATE TABLE public.orders ( order_id bigint NOT NULL, user_id bigint NOT NULL, tenant_id bigint NOT NULL, product_code varchar(32) NOT NULL, amount numeric(12,2) NOT NULL DEFAULT 0, status varchar(16) NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX idx_orders_user_id ON public.orders (user_id); CREATE INDEX idx_orders_created_at ON public.orders (created_at); CREATE INDEX idx_orders_status ON public.orders (status);这里有个大多数人会忽略的点:外部表上不能直接加索引,所以所有索引都要在远端物理表上建。而且统计信息在远端,本地优化器默认不知道这些索引的存在,后面调 use_remote_estimate 参数时我会具体讲。
3.3 在本地库创建 FDW 服务器与用户映射
总库上先加载 postgres_fdw 扩展,然后为每个分片创建 server 和用户映射。
CREATE EXTENSION postgres_fdw; CREATE SERVER shard1 FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '10.10.10.11', port '5432', dbname 'orderdb', connect_timeout '10'); CREATE SERVER shard2 FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '10.10.10.12', port '5432', dbname 'orderdb', connect_timeout '10'); -- 建议给专门的数据库角色做映射,而不是一把梭用 PUBLIC CREATE ROLE app_role LOGIN; CREATE USER MAPPING FOR app_role SERVER shard1 OPTIONS (user 'shard_user', password '换成真实密码'); CREATE USER MAPPING FOR app_role SERVER shard2 OPTIONS (user 'shard_user', password '换成真实密码');连接超时一定要设,不设的话远端实例故障时,本地会一直等 TCP 超时,业务侧表现为“卡死”,排查半天才发现是远端连不上。权限方面,建议远端建一个只做数据读写的专用账号,只授予 SELECT、INSERT、UPDATE、DELETE,不要给 DDL 权限,避免本地库被攻破后连结构都能被改掉。
4. 核心实施:从建父表到挂载外部分区
4.1 建分区父表
本地库建逻辑父表,注意分区键和远端物理表列一一对应:
CREATE TABLE public.orders ( order_id bigint NOT NULL, user_id bigint NOT NULL, tenant_id bigint NOT NULL, product_code varchar(32) NOT NULL, amount numeric(12,2) NOT NULL DEFAULT 0, status varchar(16) NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now() ) PARTITION BY HASH (user_id);父表本身不存数据,也不建议在上面建索引。PG 12 对分区父表的索引传播对本地子分区有意义,但我们的子分区是外部表,索引必须建在远端物理表上,所以父表索引意义不大,反而可能给优化器带偏。
4.2 逐个分片挂载外部表
假设四个分片分别映射到四台服务器,分区定义如下:
CREATE FOREIGN TABLE orders_p0 PARTITION OF public.orders FOR VALUES WITH (MODULUS 4, REMAINDER 0) SERVER shard1 OPTIONS (schema_name 'public', table_name 'orders'); CREATE FOREIGN TABLE orders_p1 PARTITION OF public.orders FOR VALUES WITH (MODULUS 4, REMAINDER 1) SERVER shard2 OPTIONS (schema_name 'public', table_name 'orders'); -- orders_p2 对应 shard3,orders_p3 对应 shard4,写法同上如果你已经在某个库里通过 IMPORT FOREIGN SCHEMA 导入过外部表,还可以用另一种挂法:
ALTER TABLE public.orders ATTACH PARTITION orders_p0 FOR VALUES WITH (MODULUS 4, REMAINDER 0);两种方式等价,直接 PARTITION OF 一步到位更简洁。挂完之后可以查系统表确认:
SELECT relname, pg_get_partkeydef(oid) FROM pg_class WHERE relname = 'orders';4.3 存量数据怎么搬
数据迁移建议在业务停写窗口做,顺序是先迁历史表,再对增量做补偿。最简单的方式是写 SQL 按分片键 mod 值分批次插入:
DO $$ DECLARE i int; BEGIN FOR i IN 0..3 LOOP EXECUTE format( 'INSERT INTO public.orders SELECT order_id, user_id, tenant_id, product_code, amount, status, created_at FROM legacy_orders WHERE user_id %% %s = %s', 4, i ); END LOOP; END $$;由于插入目标是父表,PG 会自动路由到对应的外部分区,不需要指定分区名。每批提交一次,避免一个超大事务锁太久。搬完后在分片库分别执行 ANALYZE,再做两边行数校验:
SELECT count(*) FROM public.orders; SELECT count(*) FROM public.legacy_orders;刚迁移完 count(*) 可能受优化器估算影响显得不太可信,尤其外部表统计信息还没有同步的时候,所以先在远端做 ANALYZE 是很有必要的。
4.4 三步验证分片是否生效
第一步验证数据分布:在各分片库分别执行 count,加起来等于总行数。
第二步验证路由:用 EXPLAIN 带分区键的查询,看是否只扫描一个分区。
第三步验证 DML:
INSERT INTO public.orders (order_id, user_id, tenant_id, product_code, amount, status, created_at) VALUES (10001, 201, 7, 'SKU-001', 99.00, 'pending', now());执行完在对应分片库查这条记录,确认落点符合预期。注意这个顺序不要颠倒,先看分布再验路由最后验写入,每一步都对应一个容易出错的环节,哪一步挂掉都能快速缩小排查范围。
5. 路由与执行计划:让查询只打需要的分片
5.1 分区剪枝的工作方式
分区剪枝是 PG 优化的第一步。查询带 user_id=201 时,PG 会根据 hash 分区的 MODULUS 4 计算落点,只保留一个子分区,其他全部去除。
EXPLAIN (COSTS OFF) SELECT * FROM public.orders WHERE user_id = 201;输出大致是:
Append Subplans Removed: 3 -> Foreign Scan on public.orders_p1 Filter: (user_id = 201) Remote SQL: SELECT ... FROM public.orders WHERE user_id = 201Subplans Removed: 3 说明三个分区被剪掉,只访问了 orders_p1。这一步是 FDW 分片性能的生命线,如果剪枝失效,一次查询会打穿所有分片,性能和单库全表扫描没有本质区别。
需要注意 PG 12 的剪枝优化器默认是打开的(enable_partition_pruning=on),但如果你把分区键包在函数里,比如 WHERE abs(user_id)=201,剪枝就失效了。应用层写 SQL 时尽量让分区键裸列参与比较,不要做任何函数包裹。
5.2 跨分片查询到底行不行
不带分区键的查询,PG 会遍历所有分片。比如按订单号查:
SELECT * FROM public.orders WHERE order_id = 10001;因为没有 order_id 的 hash 信息,PG 无法剪枝,只能挨个分片问一遍再汇总。功能上没问题,性能就不一定了,这取决于分片数和网络延迟。建议这类查询要么走专门的汇总索引,要么限制在低频报表场景。
FDW 在 PG 12 里支持把排序、聚合、部分连接下推到远端执行。比如按状态统计金额:
EXPLAIN (VERBOSE, COSTS OFF) SELECT status, sum(amount) FROM public.orders GROUP BY status;如果下推成功,Remote SQL 里会带 GROUP BY,每个分片先算好统计结果,本地只做合并,传输的数据量大幅减少。这是 FDW 分片方案比应用层分表体验好的原因之一。
5.3 关闭剪枝的排查妙用
如果因为某些原因需要看到全部分片计划,可以临时关闭剪枝:
SET enable_partition_pruning = off; EXPLAIN (COSTS OFF) SELECT * FROM public.orders WHERE user_id = 201;输出会保留所有四个分片的 Foreign Scan。这是一条常用的排查手段:当怀疑路由没生效时,开启和关闭剪枝对比一下执行计划,能快速定位是优化器问题还是 SQL 写法问题。关闭剪枝后如果发现某几个分片远端 SQL 有明显异常,比如多带了莫名的过滤条件,那问题大概率出在外部表 OPTIONS 配置上。
6. 让分片跑得更稳,这些参数要调
6.1 关键参数一览
postgres_fdw 有一组参数直接影响执行计划和传输效率,我把常用的整理在一起:
| 参数 | 作用 | 建议值 |
|---|---|---|
| use_remote_estimate | 是否用远端 EXPLAIN 估算代价 | true |
| fdw_startup_cost | 建立连接的固定代价 | 10~50 |
| fdw_tuple_cost | 处理每行数据的 CPU 代价 | 0.1 左右 |
| fetch_size | 每次从远端 fetch 的行数 | 500~1000 |
| batch_size | 批量 INSERT 时一批的行数 | 100~200 |
use_remote_estimate 是影响最大的一个。默认 false 时,优化器对外部表行数只能用本地保存的统计信息,而外部表默认认为有 1000 行,这对千万级分片是灾难,会导致优化器误判,甚至把高效的远程聚合计划压下去。
ALTER SERVER shard1 OPTIONS (ADD use_remote_estimate 'true');打开后,每次生成计划前 FDW 会向远端发 EXPLAIN,拿到真实估算代价再决策。代价是计划生成时间变长,但对 OLTP 场景来说,准确性远比多出的那几十毫秒重要。如果同一个 SERVER 下的多个外部表使用场景差别大,也可以在单表级别覆盖这个参数,按表粒度控制准与快的取舍。
6.2 批量写入的 batch_size
FDW 插入默认是一条一条发,每条 INSERT 都经历一次网络往返。批量写入场景(数据迁移、日志灌入)下,这个默认值非常吃亏。把 batch_size 调大,postgres_fdw 会把多行攒成一个批量参数化 INSERT 发过去:
ALTER FOREIGN TABLE orders_p0 OPTIONS (SET batch_size '200');实测迁移场景下,batch_size 从 1 调到 200,灌数据速度能提高好几倍。但要注意 batch_size 不是越大越好,过大的话单条远程 SQL 太长,可能触发内存和锁的开销,200 左右是个稳妥起点,如果远端服务器内存充裕可以继续调大,400 到 500 也可以测试。批量写入时应用侧最好也禁用事务自动提交,一个事务内多 INSERT 语句批量执行,减少编译和远端提交次数。
6.3 跨分片事务与两阶段提交
分库分表最让人担心的是“写两个分片,一个成功一个失败”。PG 12 开始,postgres_fdw 在一个本地事务里同时修改多个远端分片时,会使用两阶段提交来保证原子性。
前提是本地库和分片库的 max_prepared_transactions 都要大于 0:
ALTER SYSTEM SET max_prepared_transactions = 32; SHOW max_prepared_transactions;这个参数需要重启 PG 生效。配置完成后务必在本地和所有分片库上确认,别只看主节点。如果保持默认 0,跨分片事务出问题时会出现“本地已提交、远端未提交”的中间状态,业务只能靠对账修数据,这在生产环境是非常要命的。所以只要走了 FDW 分片,所有节点这个参数都必须开着,并且预留一定余量,避免并发长事务把 prepared transaction 槽位打满。
7. 常见问题与排查实录
7.1 外键约束、唯一约束都不好使
FDW 外部分区表不支持创建唯一约束,父表上即便写了 PRIMARY KEY,PG 也只能在本地做判断,无法跨分片校验,实际效果等于没有。分库分表后,唯一性本来就是分布式问题,必须放到数据库之外解决。
我的做法是在应用层做幂等控制,比如唯一订单号用“业务前缀 + 发号器 ID”组合生成,重复提交时靠业务逻辑拒绝,而不是依赖 DB 唯一索引。如果确实是用户维度冲突,可以加一张“全局冲突检测表”放在总库单独维护,但这也是有代价的,要根据冲突频度决定是否值得。数据库层的约束在分片架构里只能退位让贤,这是架构演进必须接受的取舍。
7.2 全局 ID 怎么发
如果沿用本地 serial 或 identity,每个分片的计数互相独立,必然撞号。最简单的方案是每分片设置不同起始值和步长:
-- 分片1 CREATE SEQUENCE order_id_seq START WITH 101 INCREMENT BY 4; -- 分片2 CREATE SEQUENCE order_id_seq START WITH 102 INCREMENT BY 4;这个方案在小规模下够用,但扩容时步长重排非常痛苦,而且一旦业务合并分片,ID 冲突风险会上升。稍微正规一点是用独立发号服务生成雪花 ID 或类似结构的全局 ID,应用在插入前先取号。还有一条更省事的路:如果业务上允许,直接用 UUID 或带时间戳的随机串做主键,但要注意主键长度和索引性能的权衡。记住一点,全局 ID 方案要在分片方案定型前决定,不要到了数据量上来了再回头改主键,那不是小工程。
7.3 数据分布不均与热点
hash 分区只保证 hash 空间均匀,不保证业务数据均匀。比如大客户产生海量订单,某个用户的 hash 落点就把一个分片打满了。排查分布很简单:
-- 在总库跑,但会触发全分片扫描,适合低峰期 SELECT tableoid::regclass AS partition, count(*) FROM public.orders GROUP BY 1 ORDER BY 2 DESC;如果发现某个分片数据明显偏多,一是检查分片键是否真的离散,二是考虑对大客户的查询做独立路由,三是业务上对大客户单独拆表。FDW 方案本身就是牺牲部分一致性换取扩展性,不要指望数据库自动帮你做数据倾斜治理,分片键选型那一步做错,后面所有调优都是事倍功半。
7.4 prepared 事务残留与连接超时
开启 2PC 后,一旦某个分片在 prepare 阶段后宕机,总库会留下一批准备好的事务,状态是“未知”。排查命令:
SELECT * FROM pg_prepared_xacts;如果确认对应事务的协调节点已经结束,可以手动清理:
ROLLBACK PREPARED 'transaction_id';不建议长期积累这类残留,它占锁、拖慢 VACUUM、最后可能导致系统异常。另外远端实例发生网络分区时,FDW 连接会一直等待,除了设置 connect_timeout,还要留意应用侧的连接池超时配置,不然故障恢复后连接池里的旧连接还在等远端返回,表现成诡异的“超时错乱”。应对方法是把 FDW server 指向一个可靠的负载均衡或者数据库网关,配合心跳检测,从源头降低单点故障影响。
8. 这套方案适合什么场景,以及几点经验
8.1 适合与不适合的场景
我个人的判断标准很简单:如果单个 PG 实例的主要瓶颈是“单表数据量太大、写并发堆在少数热点”,同时业务上跨分片事务极少,FDW 分片就值得用。典型场景是订单、日志流水、消息记录这类海量写入、按用户或租户查询的数据。
如果业务里大量查询需要跨多个分片做 join,或者对分布式事务的强一致有硬性要求,趁早放弃这套方案。FDW 的分片优先保证扩展性,分布式一致只能做到两阶段提交这种尽力而为,做不到真正意义上的全局事务隔离。实践中跨分片 join 偶尔还能用,一旦 join 频繁出现,你会发现网络往返和中间结果集过大很容易拖垮整条链路。
8.2 运维自动化的实用习惯
分片多了以后,手工在每个节点执行 DDL 容易漏。我会把所有分片连接信息和表结构生成脚本放在版本库里管理,新增分片时执行同一个模板,物理表、用户映射、外部表分区一起生成。
# 伪代码:按模板循环生成 for shard in shard1 shard2 shard3 shard4; do psql -h $shard -c "CREATE TABLE ..." psql -h local -c "CREATE SERVER ...; CREATE USER MAPPING ...;" done这样可以保证不会出现“某个分片少建一个索引”这类低级事故。巡检时也建议定期用自动化脚本统计每个分片的连接数、活跃查询、数据增长率,做到心里有数,别等磁盘满了或者连接数爆了才去登录服务器。
8.3 一个少走弯路的技巧:IMPORT FOREIGN SCHEMA
如果分片库的物理表很多(几十张甚至上百张),手动写外部表 DDL 会非常累。可以用 IMPORT FOREIGN SCHEMA 批量导入:
IMPORT FOREIGN SCHEMA public LIMIT TO (orders, order_items, order_refunds) FROM SERVER shard1 INTO public;导入后得到的是普通外部表,再通过 ALTER TABLE ATTACH PARTITION 挂进对应的分区父表即可。这个技巧在处理“多表分片、表结构一致”的场景时能省下一大截工时,但前提是远端表结构必须与父表完全对齐,否则挂载时会报列不匹配的错误,错一处就要停下来重新对字段,反而更慢。
回到最开头说的那个三千万行的订单库,我最后就是用这套 PG 12 加 FDW 的方案,拆到 4 个分片之后,单分片数据量回到百万级,查询响应时间和写入压力都回到了可控范围。分库分表这件事,没有一劳永逸的银弹,但如果你也在 PG 生态里,先把这招学会,至少面对“表太大”的时候,手里多了一把能直接开干的刀。