PostgreSQL表分区

单表过了千万行,查询变慢、索引胀、删旧数据删到手酸,这几件事经常一块来。第一反应往往是「要不要分表、分库分表」——其实 PostgreSQL 原生分区(L1)往往就够:应用里还是查一张 orders,库在底下拆成很多小表;查某个月,就只碰那个月的小表。

下文分两段:§1 先在同一维度上区分 L1 分区 / L2 同库分表 / L3 分库分表,环境按 PostgreSQL 14+。

1. 先选路:分区、同库分表、分库分表

单表涨到千万行,常见解法都在 按行拆。差别不在拆不拆,而在 谁负责路由应用 SQL 要不要改

本文「分表」均指 水平分表(按行拆到多张表)。按字段拆的垂直分表解决宽表问题,和本文不在同一维度,不展开。

1.1 三个层级

层级名称谁来路由数据在哪应用 SQL跨片查询典型触发
L1PG 表分区数据库同库,多个 partition只写 orders不带分区键则扫全部分区单表太大,查询/归档常带时间
L2同库分表应用 / ORM / 中间件同库,多张独立表要知道 orders_202602 或走 sharding应用或中间件聚合生命周期差大,或 L1 约束卡模型
L3分库分表应用 / 中间件多库多表还要知道去哪个库最重,常靠中间件单库 CPU/IO/连接/存储长期顶满

一句话判据:代码里有没有写 orders_202602,或 sharding 路由逻辑? 有 → L2/L3;没有,只写 orders → L1。

1.2 决策树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
单表几百万行,索引 / SQL 还能优化?
└─ 是 → 先别拆(见 §9.2)

单表 ≥ 1000 万且在涨,且命中 §9.1 两条以上?
└─ 否 → 继续观察,别过早分区
└─ 是 → 多数查询带时间(或固定枚举值)?
└─ 是 → 单库容量还没顶满?
└─ 是 → L1 PG 分区(本文主线)
└─ 否 → 库内先 L1 做归档,仍不够再 L3
└─ 否 → 慢查询主要按 user_id 等查,改查询不现实?
└─ 是 → L1 HASH 分区,或 L2 同库分表
└─ 否 → 先改查询 / 索引,别急着拆

L1 搞不定(全局唯一约束、跨业务生命周期冲突)?
└─ L2 同库分表

单库索引、改写、分区、垂直扩容都试过了还顶满?
└─ L3 分库分表

1.3 三个场景锚点

  • 订单按月查、按月删旧数据 → L1 RANGE,SELECT * FROM orders WHERE created_at >= ... 不用改路由。
  • 128 张 order_0000order_0127,GORM sharding 插件路由 → L2;PG 分区帮不上,因为路由在应用。
  • 单库 CPU/IO 长期 90%+,分区只缓解单表、库实例仍扛不住 → L3;L1 往往是 L3 之前的库内手段,不是替代关系。

2. 心智模型:一张逻辑表,多个物理抽屉

决策树把你带到了 L1。最容易懵的是:到底有几张表?逻辑上一张,物理上多张。

1
2
3
4
5
6
7
8
应用看到的:
orders ← 就这一张,INSERT / SELECT 都写它

数据库底层:
orders ← 父表(空壳,定义结构,不存数据)
├── orders_2026_01 ← 1 月的数据在这
├── orders_2026_02 ← 2 月的数据在这
└── orders_2026_03 ← 3 月的数据在这

想象一个文件柜。父表是柜门上的标签,分区是一个个抽屉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
+---------------------------------------------------+
| orders (父表,柜子正面的标签,本身不装东西) |
+---------------------------------------------------+
| +-------------+ +-------------+ +-------------+ |
| | 2026-01 | | 2026-02 | | 2026-03 | |
| | 1 月订单 | | 2 月订单 | | 3 月订单 | |
| | | | * <-- | | | |
| | | | 查 2 月 | | | |
| | | | 只开这个 | | | |
| +-------------+ +-------------+ +-------------+ |
+---------------------------------------------------+
^ ^
| |
INSERT 1/15 INSERT 2/15
自动进 1 月抽屉 自动进 2 月抽屉

插入时库看 created_at 是几月,扔进对应抽屉。查询带时间条件,只开相关抽屉,不用把整个柜子倒空。

后面都围绕这张 orders。换业务,改表名和分区键就行,流程不变。

3. 动手之前:分区键与粒度怎么选

写 SQL 前先定两件事:按哪列切(分区键),每个抽屉多大(粒度)。选错列,分区基本白做。

3.1 分区键

分区键管两件事:数据往哪切、查询能不能只扫部分分区。对着 orders 问:

1
2
3
4
5
6
7
8
9
10
11
查询最常带什么 WHERE?
→ 时间范围 → created_at,RANGE
→ 地区/租户 → region/tenant_id,LIST
→ 用户 ID → user_id,HASH

数据按什么维度增长?
→ 按时间追加 → RANGE(最常见)
→ 按固定类别 → LIST

归档按什么维度删?
→ 按月/季度删旧数据 → 分区键必须是时间

分区键最好同时满足「查询常过滤」和「归档好删」。两者冲突时,先保查询;每次查都扫全部分区,切了也白切。

对 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
2
3
4
5
6
7
8
9
CREATE TABLE orders (
order_id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id BIGINT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
region TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (order_id, created_at)
) PARTITION BY RANGE (created_at);

PARTITION BY RANGE (created_at) 就是第 3 节定的方案。

再建分区,真正存数据:

1
2
3
4
5
6
7
8
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');

CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

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
2
3
4
5
6
7
8
-- 插入:数据库看 created_at,自动路由到 2 月分区
INSERT INTO orders (user_id, amount, status, region, created_at)
VALUES (1001, 299.00, 'paid', 'cn-east', '2026-02-15 10:30:00+08');

-- 查询:带分区键条件,只扫 2 月那个抽屉
SELECT * FROM orders
WHERE created_at >= '2026-02-01'
AND created_at < '2026-03-01';

表能用了。下面看带时间条件为啥快。

5. 为什么变快:Partition Pruning

Partition pruning(分区裁剪):WHERE 里带了分区键,优化器直接跳过无关分区,只扫可能有的那些。

没分区,像翻整本账找 2 月的条目。有分区,直接开 2 月抽屉,别的碰都不碰。

5.1 EXPLAIN 对比

设有一张没分区的 orders_flat,1000 万行;分区版 orders 按月 12 个抽屉。查 2026 年 2 月。

分区前,全表扫:从第一行扫到最后一行,1000 万里筛 83 万,读 15 万数据页,将近 3 秒。

1
2
3
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders_flat
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
1
2
3
4
5
6
7
8
Seq Scan on orders_flat  (cost=0.00..284371.00 rows=831024 width=72)
(actual time=0.027..2834.109 rows=833219 loops=1)
Filter: ((created_at >= '2026-02-01'::timestamptz)
AND (created_at < '2026-03-01'::timestamptz))
Rows Removed by Filter: 9166781
Buffers: shared hit=12043 read=138912
Planning Time: 0.125 ms
Execution Time: 2847.332 ms

重点看 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
2
3
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01';
1
2
3
4
5
6
7
8
9
10
11
12
Append  (cost=0.42..18293.71 rows=833219 width=72)
(actual time=0.031..274.856 rows=833219 loops=1)
Subplans Removed: 11
-> Bitmap Heap Scan on orders_2026_02 orders_1
(cost=0.42..18293.71 rows=833219 width=72)
(actual time=0.029..261.443 rows=833219 loops=1)
Recheck Cond: ((created_at >= '2026-02-01'::timestamptz)
AND (created_at < '2026-03-01'::timestamptz))
Heap Blocks: exact=11284
Buffers: shared hit=11301
Planning Time: 0.342 ms
Execution Time: 287.651 ms

Subplans Removed: 11 表示 11 个分区被裁掉;扫描对象只有 orders_2026_02;287 ms,大概快一个数量级。

5.2 什么情况 Pruning 会失效

不带分区键,每个抽屉都得打开:

1
2
-- 分区键是 created_at,但查询只有 user_id → 扫描所有分区
SELECT * FROM orders WHERE user_id = 1001;

能改 SQL 的,尽量加上时间范围,让 pruning 生效。改不了的(例如后台按 user_id 查全历史),接受扫全分区,并靠 §8.4 的索引减损。

分区键外面套函数,优化器推不出范围:

1
2
3
4
5
6
-- 错误:date() 包裹后优化器无法推断
SELECT * FROM orders WHERE date(created_at) = '2026-02-15';

-- 正确:写成范围
SELECT * FROM orders
WHERE created_at >= '2026-02-15' AND created_at < '2026-02-16';

Pruning 依赖 enable_partition_pruning = on(默认即是)。EXPLAIN 里看到 Subplans Removed: N,说明裁剪生效;若每个分区都出现在计划里,先查 WHERE 是否含裸分区键。

6. 两种变体:LIST 和 HASH

orders 按 created_at 做 RANGE 是最顺的路。数据不是按时间堆的,换下面两种;表字段不变,改分区方式和主键。

你的数据长什么样用什么orders 例子怎么改
按时间追加,按月/季度归档RANGE(前面已讲)PARTITION BY RANGE (created_at)
按地区、租户等固定几个值隔离LISTPARTITION BY LIST (region)
没有天然边界,只想均匀打散HASHPARTITION BY HASH (user_id)

6.1 LIST:按枚举值切

地区、租户、业务线这类有限离散值。主键要含 region

1
2
3
4
5
6
7
8
9
-- 父表:分区键换成 region,主键跟着换
CREATE TABLE orders ( ... PRIMARY KEY (order_id, region) )
PARTITION BY LIST (region);

CREATE TABLE orders_cn PARTITION OF orders
FOR VALUES IN ('cn-east', 'cn-south', 'cn-north');

CREATE TABLE orders_us PARTITION OF orders
FOR VALUES IN ('us-east', 'us-west');

WHERE region = 'cn-east' 只扫 orders_cn

6.2 HASH:按哈希均匀打散

没有时间和枚举边界,又想摊到固定几个分区时用。主键要含 user_id

1
2
3
4
5
6
CREATE TABLE orders ( ... PRIMARY KEY (order_id, user_id) )
PARTITION BY HASH (user_id);

CREATE TABLE orders_h0 PARTITION OF orders
FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- h1, h2, h3 同理,REMAINDER 分别为 1, 2, 3

MODULUS(分区总数)定下来不好改。从 4 扩到 8,基本等于重建。

7. 其他约束与限制

7.1 外键

分区表可以引用别的表(outbound FK),别的表不能引用分区表的列(inbound FK)。需要这种关系,应用层自己维护,或者把被引用的表做成非分区表。

7.2 分区数量

没有硬上限,太多会让规划变慢、DDL 变重、文件描述符吃紧。

  • 几十到几百个:正常。
  • 上千个:规划时间会上来。
  • 上万个:切太细了,该合并。

8. 生产运维

8.1 自动创建未来分区

没有匹配分区、也没有 DEFAULT 时,INSERT 直接报错。未来分区得提前建好。

pg_partman + pg_cron 比较省事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE EXTENSION pg_partman;
CREATE EXTENSION pg_cron;

SELECT partman.create_parent(
p_parent_table := 'public.orders',
p_control := 'created_at',
p_type := 'range',
p_interval := '1 month',
p_premake := 3
);

SELECT cron.schedule(
'partman_maintenance',
'0 2 * * *',
$$SELECT partman.run_maintenance()$$
);

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
2
3
4
5
6
7
8
-- 第一步:分离旧分区(PG14+ 不阻塞查询)
ALTER TABLE orders
DETACH PARTITION orders_2025_01 CONCURRENTLY;

-- 第二步:处理分离后的独立表
DROP TABLE orders_2025_01;
-- 或
ALTER TABLE orders_2025_01 SET SCHEMA archive;

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
2
ALTER TABLE orders DROP CONSTRAINT orders_pkey;
ALTER TABLE orders ADD PRIMARY KEY (order_id, created_at);

全局唯一仍靠 sequence / UUID;库只保证分区内 + 主键列组合唯一。

8.3.2 ATTACH 前加 CHECK,避免全表扫描

旧表没有与分区边界一致的 CHECK 时,ATTACH PARTITION扫全表 验证每行落在范围内,亿级表可能跑几小时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 1. 重命名旧表
ALTER TABLE orders RENAME TO orders_old;

-- 2. 建分区父表(结构同 4.2)
CREATE TABLE orders ( ... ) PARTITION BY RANGE (created_at);

-- 3. 旧表加 CHECK(边界与 ATTACH 一致),并验完
ALTER TABLE orders_old ADD CONSTRAINT chk_range
CHECK (created_at >= '2020-01-01' AND created_at < '2026-06-01');

ALTER TABLE orders_old VALIDATE CONSTRAINT chk_range;

ALTER TABLE orders
ATTACH PARTITION orders_old
FOR VALUES FROM ('2020-01-01') TO ('2026-06-01');

-- 4. 建后续分区
CREATE TABLE orders_2026_06 PARTITION OF orders
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 1. 各分区数据量
SELECT
c.relname AS partition_name,
pg_get_expr(c.relpartbound, c.oid) AS bound,
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
pg_stat_get_live_tuples(c.oid) AS live_rows
FROM pg_inherits inh
JOIN pg_class c ON c.oid = inh.inhrelid
WHERE inh.inhparent = 'orders'::regclass
ORDER BY c.relname;

-- 2. DEFAULT 分区是否有积压(正常应为 0)
SELECT count(*) FROM orders_default;

-- 3. 确认 partition pruning 开启
SHOW enable_partition_pruning; -- 应返回 on

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 最后一支。

参考资料