PostgreSQL表分区
单表过了千万行,查询变慢、索引胀、删旧数据删到手酸,这几件事经常一块来。第一反应往往是「要不要分表、分库分表」——其实 PostgreSQL 原生分区(L1)往往就够:应用里还是查一张 orders,库在底下拆成很多小表;查某个月,就只碰那个月的小表。
下文分两段:§1 先在同一维度上区分 L1 分区 / L2 同库分表 / L3 分库分表,环境按 PostgreSQL 14+。
1. 先选路:分区、同库分表、分库分表
单表涨到千万行,常见解法都在 按行拆。差别不在拆不拆,而在 谁负责路由、应用 SQL 要不要改。
本文「分表」均指 水平分表(按行拆到多张表)。按字段拆的垂直分表解决宽表问题,和本文不在同一维度,不展开。
1.1 三个层级
| 层级 | 名称 | 谁来路由 | 数据在哪 | 应用 SQL | 跨片查询 | 典型触发 |
|---|---|---|---|---|---|---|
| L1 | PG 表分区 | 数据库 | 同库,多个 partition | 只写 orders | 不带分区键则扫全部分区 | 单表太大,查询/归档常带时间 |
| L2 | 同库分表 | 应用 / ORM / 中间件 | 同库,多张独立表 | 要知道 orders_202602 或走 sharding | 应用或中间件聚合 | 生命周期差大,或 L1 约束卡模型 |
| L3 | 分库分表 | 应用 / 中间件 | 多库多表 | 还要知道去哪个库 | 最重,常靠中间件 | 单库 CPU/IO/连接/存储长期顶满 |
一句话判据:代码里有没有写 orders_202602,或 sharding 路由逻辑? 有 → L2/L3;没有,只写 orders → L1。
1.2 决策树
1 | 单表几百万行,索引 / SQL 还能优化? |
1.3 三个场景锚点
- 订单按月查、按月删旧数据 → L1 RANGE,
SELECT * FROM orders WHERE created_at >= ...不用改路由。 - 128 张
order_0000…order_0127,GORM sharding 插件路由 → L2;PG 分区帮不上,因为路由在应用。 - 单库 CPU/IO 长期 90%+,分区只缓解单表、库实例仍扛不住 → L3;L1 往往是 L3 之前的库内手段,不是替代关系。
2. 心智模型:一张逻辑表,多个物理抽屉
决策树把你带到了 L1。最容易懵的是:到底有几张表?逻辑上一张,物理上多张。
1 | 应用看到的: |
想象一个文件柜。父表是柜门上的标签,分区是一个个抽屉:
1 | +---------------------------------------------------+ |
插入时库看 created_at 是几月,扔进对应抽屉。查询带时间条件,只开相关抽屉,不用把整个柜子倒空。
后面都围绕这张 orders。换业务,改表名和分区键就行,流程不变。
3. 动手之前:分区键与粒度怎么选
写 SQL 前先定两件事:按哪列切(分区键),每个抽屉多大(粒度)。选错列,分区基本白做。
3.1 分区键
分区键管两件事:数据往哪切、查询能不能只扫部分分区。对着 orders 问:
1 | 查询最常带什么 WHERE? |
分区键最好同时满足「查询常过滤」和「归档好删」。两者冲突时,先保查询;每次查都扫全部分区,切了也白切。
对 orders:查询几乎都带时间,旧数据按月删,所以选 created_at,用 RANGE。第 4 节按这个建。
3.2 粒度
粒度就是每个抽屉装多少行。按年切,抽屉里还是太大;按天切,抽屉太多,管不过来。
RANGE 按时间的经验值:
| 每月写入量 | 建议粒度 | 一年分区数 |
|---|---|---|
| < 10 万行 | 按季度或半年 | 2-4 个 |
| 10 万 ~ 500 万行 | 按月 | 12 个 |
| > 500 万行 | 按周或按天 | 52-365 个 |
单个分区落在 100 万~5000 万行比较合适。不到 100 万,维护成本往往大于收益;超过 5000 万,分区内索引照样胀。
orders 假设每月 10 万~500 万行,按月切,一年 12 个抽屉。可以开干了。
4. 动手做:orders 按月 RANGE 分区
RANGE 按连续值切。orders 已定 created_at、按月切。先讲主键那条规则,再建表。
4.1 建表前先搞清:主键为什么要带 created_at
order_id 明明唯一,为啥主键还要加 created_at?
因为每个分区底层是独立的物理表。库没法跨抽屉查「别的抽屉里有没有同一个 order_id」。PostgreSQL 因此要求:
分区表的主键(以及所有唯一约束)必须包含分区键。
order_id 要全局唯一,靠全局 sequence 或 UUID 在应用层兜。库只管分区内不重复,以及主键里带上分区键。
4.2 建父表和分区
建父表,定字段和分区方式,本身不存数据:
1 | CREATE TABLE orders ( |
PARTITION BY RANGE (created_at) 就是第 3 节定的方案。
再建分区,真正存数据:
1 | CREATE TABLE orders_2026_01 PARTITION OF orders |
FROM 闭区间,TO 开区间。'2026-02-01 00:00:00' 进 orders_2026_02,不会落到 1 月。
忘了建未来分区,可以挂一个 DEFAULT 分区兜底(PostgreSQL 11+),免得 INSERT 直接报错:
1 | CREATE TABLE orders_default PARTITION OF orders DEFAULT; |
DEFAULT 救急可以,别当常态。数据老往 DEFAULT 堆,说明规划有问题,后面迁移很烦。
4.3 插入和查询
代码不用改路由,还是写父表:
1 | -- 插入:数据库看 created_at,自动路由到 2 月分区 |
表能用了。下面看带时间条件为啥快。
5. 为什么变快:Partition Pruning
Partition pruning(分区裁剪):WHERE 里带了分区键,优化器直接跳过无关分区,只扫可能有的那些。
没分区,像翻整本账找 2 月的条目。有分区,直接开 2 月抽屉,别的碰都不碰。
5.1 EXPLAIN 对比
设有一张没分区的 orders_flat,1000 万行;分区版 orders 按月 12 个抽屉。查 2026 年 2 月。
分区前,全表扫:从第一行扫到最后一行,1000 万里筛 83 万,读 15 万数据页,将近 3 秒。
1 | EXPLAIN (ANALYZE, BUFFERS) |
1 | Seq Scan on orders_flat (cost=0.00..284371.00 rows=831024 width=72) |
重点看 Seq Scan(全表顺序扫)、Rows Removed by Filter: 9166781(扫完又扔掉 916 万行)、Execution Time: 2847 ms。
分区后,12 个月砍掉 11 个(Subplans Removed: 11),只扫 orders_2026_02,1.1 万页,0.3 秒内。
1 | EXPLAIN (ANALYZE, BUFFERS) |
1 | Append (cost=0.42..18293.71 rows=833219 width=72) |
Subplans Removed: 11 表示 11 个分区被裁掉;扫描对象只有 orders_2026_02;287 ms,大概快一个数量级。
5.2 什么情况 Pruning 会失效
不带分区键,每个抽屉都得打开:
1 | -- 分区键是 created_at,但查询只有 user_id → 扫描所有分区 |
能改 SQL 的,尽量加上时间范围,让 pruning 生效。改不了的(例如后台按 user_id 查全历史),接受扫全分区,并靠 §8.4 的索引减损。
分区键外面套函数,优化器推不出范围:
1 | -- 错误:date() 包裹后优化器无法推断 |
Pruning 依赖 enable_partition_pruning = on(默认即是)。EXPLAIN 里看到 Subplans Removed: N,说明裁剪生效;若每个分区都出现在计划里,先查 WHERE 是否含裸分区键。
6. 两种变体:LIST 和 HASH
orders 按 created_at 做 RANGE 是最顺的路。数据不是按时间堆的,换下面两种;表字段不变,改分区方式和主键。
| 你的数据长什么样 | 用什么 | orders 例子怎么改 |
|---|---|---|
| 按时间追加,按月/季度归档 | RANGE(前面已讲) | PARTITION BY RANGE (created_at) |
| 按地区、租户等固定几个值隔离 | LIST | PARTITION BY LIST (region) |
| 没有天然边界,只想均匀打散 | HASH | PARTITION BY HASH (user_id) |
6.1 LIST:按枚举值切
地区、租户、业务线这类有限离散值。主键要含 region:
1 | -- 父表:分区键换成 region,主键跟着换 |
WHERE region = 'cn-east' 只扫 orders_cn。
6.2 HASH:按哈希均匀打散
没有时间和枚举边界,又想摊到固定几个分区时用。主键要含 user_id:
1 | CREATE TABLE orders ( ... PRIMARY KEY (order_id, user_id) ) |
MODULUS(分区总数)定下来不好改。从 4 扩到 8,基本等于重建。
7. 其他约束与限制
7.1 外键
分区表可以引用别的表(outbound FK),别的表不能引用分区表的列(inbound FK)。需要这种关系,应用层自己维护,或者把被引用的表做成非分区表。
7.2 分区数量
没有硬上限,太多会让规划变慢、DDL 变重、文件描述符吃紧。
- 几十到几百个:正常。
- 上千个:规划时间会上来。
- 上万个:切太细了,该合并。
8. 生产运维
8.1 自动创建未来分区
没有匹配分区、也没有 DEFAULT 时,INSERT 直接报错。未来分区得提前建好。
pg_partman + pg_cron 比较省事:
1 | CREATE EXTENSION pg_partman; |
p_premake := 3 表示始终提前建未来 3 个月的分区。RDS 和多数托管 PG 都能用 pg_partman。用不了的话,自己拼 CREATE TABLE ... PARTITION OF,查 pg_class 防重复,挂 pg_cron 或应用启动时跑(pg_advisory_lock 防并发)。
8.2 归档旧分区
删整个分区是 O(1),不是百万行 DELETE:
1 | -- 第一步:分离旧分区(PG14+ 不阻塞查询) |
PostgreSQL 14 加了 DETACH PARTITION CONCURRENTLY。14 之前 DETACH 拿 ACCESS EXCLUSIVE 锁,查询全堵;14+ 只有短暂的 SHARE UPDATE EXCLUSIVE。删 83 万行 DELETE 要跑几分钟,还打 WAL、逼 vacuum;DROP TABLE 一下就好。
8.3 已有普通表转分区表
PostgreSQL 不能 ALTER TABLE orders PARTITION BY ...,大表往往是后来才想分区,只能 建新父表 + ATTACH 旧表。生产上高频踩坑如下。
8.3.1 主键必须先含分区键
旧表若是 PRIMARY KEY (order_id),ATTACH 前必须改掉:
1 | ALTER TABLE orders DROP CONSTRAINT orders_pkey; |
全局唯一仍靠 sequence / UUID;库只保证分区内 + 主键列组合唯一。
8.3.2 ATTACH 前加 CHECK,避免全表扫描
旧表没有与分区边界一致的 CHECK 时,ATTACH PARTITION 会 扫全表 验证每行落在范围内,亿级表可能跑几小时。
1 | -- 1. 重命名旧表 |
VALIDATE CONSTRAINT 单独跑一遍,把验证成本从 ATTACH 窗口里拆出来;CHECK 已 validated 且与分区边界匹配时,ATTACH 可跳过全表扫描。
8.3.3 切换与回滚
- 应用 读写都指向父表名
orders,RENAME 旧表后,同名父表接管,一般不用改连接串。 - 新写入会进 ATTACH 的分区或新建的未来分区;确认未来分区已建好,或临时挂 DEFAULT,否则 INSERT 报错。
- 回滚:父表
DROP、旧表RENAME回来——规划阶段先在从库或低峰演练一遍。
8.4 索引
父表建索引,已有和未来分区都会跟上:
1 | CREATE INDEX idx_orders_user_id ON orders (user_id); |
每个分区的索引只含本分区的数据,热分区索引小,更容易留在内存。删分区,索引一起没。
常查「某用户最近订单」 的,WHERE 尽量带 created_at,并建复合索引:
1 | CREATE INDEX idx_orders_created_user ON orders (created_at, user_id); |
只有 user_id、不带时间的查询仍会扫全部分区,索引只能让每个分区内快一点。时间范围特别大、单分区仍超千万行时,可试 BRIN (created_at) 减索引体积,属于可选优化。
8.5 运维检查清单
1 | -- 1. 各分区数据量 |
9. L1 分区:用还是不用
-1 决定要不要走 L1;这里只列 PG 分区本身 的适用条件。L2/L3 的时机回到 §1 决策树。
9.1 什么时候值得上 L1
下面命中 两条以上,且 §1 决策树指向 L1:
- 单表过 1000 万行,还在涨。
- 多数查询自带分区键(orders 就是时间范围)。
- 删历史数据很痛:DELETE 锁等待、WAL 胀、vacuum 扛不住。
- 热数据只在最近一段,索引却被全量历史拖大。
- 运维需要按时间快速归档(DETACH + DROP)。
9.2 什么时候别上 L1
- 几百万行,索引优化还没试透。
- 慢查询大多不带分区键,且短期改不了。
- 业务还在变,分区键随时可能改。
- 每个分区就几千行,维护比收益大。
9.3 L1 不够时
- L2:全局唯一约束模型和「主键必须含分区键」冲突,或多张表生命周期差太大——见 §1.1。
- L3:单库实例整体顶满,L1 只解决单表,解决不了连接数和 IO 上限——见 §1.2 最后一支。
参考资料
- PostgreSQL 官方文档:Table Partitioning
- PostgreSQL 官方文档:CREATE TABLE
- pg_partman:分区自动管理扩展
- pg_cron:数据库内定时任务
- PostgreSQL 14 Release Notes:DETACH PARTITION CONCURRENTLY