声明式分区概述
分区(Partitioning)是把一张逻辑上完整的大表拆成多个物理上独立的小表的技术。应用仍然只看到父表一个名字,而 PostgreSQL 在内部按分区键(partition key)把每一行路由到对应的分区。它的最大价值不在于「查询更快」,而在于批量装载与批量删除可以退化成元数据操作——删掉一个月的数据从「删几千万行」变成「DROP 一张表」,这个差别往往是数量级的。
本节先讲清分区的收益与代价,再梳理 PostgreSQL 分区能力的演进史,重点讲透声明式分区(Declarative Partitioning)里「父表 + 分区 + 分区键」这三个概念,最后说明它相对表继承(Table Inheritance)的本质差异与硬性限制。
前置知识
阅读本节前,建议先了解:表继承概述
基础概念
分区解决什么问题
一张表随时间无限增长后,会同时遇到三类麻烦:
| 麻烦 | 具体表现 |
|---|---|
| 索引过大 | B-tree 索引有几十 GB,无法全部缓存,深层查找变成随机 I/O |
| 批量删除昂贵 | DELETE FROM t WHERE created_at < '2025-01-01' 要逐行标记删除,还要 VACUUM 回收 |
| 冷热数据混存 | 上周热数据和三年前冷数据挤在同一批数据页里,缓存命中率被拖低 |
分区的思路是:既然绝大多数查询只关心最近一小段时间的数据,那就按时间切开,让热数据集中在少数分区、让过期数据整体消失。
官方文档给出的四条收益可以归纳为:
| 收益 | 原理 |
|---|---|
| 查询性能提升 | 分区替代了索引的上层结构,热分区的索引更容易常驻内存 |
| 全分区扫描更快 | 访问某分区大部分数据时,顺序扫描优于跨全表的随机索引访问 |
| 批量装载与删除近似瞬时 | DROP TABLE / DETACH PARTITION 是元数据操作,且不产生 VACUUM 负担 |
| 冷数据可迁移到廉价存储 | 按分区指定不同表空间(tablespace) |
一条经验法则:表的大小超过服务器物理内存时,分区才真正划算。数据量不大时引入分区,只会增加规划期开销与维护复杂度。
父表、分区与分区键
声明式分区由三个角色组成:
┌───────────────────────────┐
│ orders(partitioned table)│ ← 父表:无自己的存储
│ PARTITION BY RANGE (...) │
└─────────────┬─────────────┘
│ 插入时按分区键路由
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
orders_2026_01 orders_2026_02 orders_2026_03
[2026-01-01, 02-01) [2026-02-01, 03-01) [2026-03-01, 04-01)
分区(partition) 分区(partition) 分区(partition)| 角色 | 英文 | 说明 |
|---|---|---|
| 父表 | partitioned table | 自身不含任何数据,只是「虚拟表」,负责路由与统一入口 |
| 分区 | partition | 普通表,真正持有数据;有各自的索引、约束、默认值、表空间 |
| 分区键 | partition key | 决定「这一行进哪个分区」的一列或多列(也可以是表达式) |
| 分区边界 | partition bounds | 每个分区声明的取值范围,互不重叠 |
几个必须记住的事实:
| 事实 | 说明 |
|---|---|
| 父表不存数据 | 对父表 SELECT 会展开为对子分区的 Append/MergeAppend |
| 分区是普通表 | 可以直接 INSERT INTO orders_2026_01(需满足该分区的边界约束) |
| 更新分区键会移动行 | 新值不属于原分区时,PostgreSQL 自动把行搬到正确分区 |
| 普通表与分区表不能互转 | 不能把普通表「变成」分区表,但可以 ATTACH / DETACH |
| 修改父表结构会级联 | ALTER TABLE orders ADD COLUMN 会自动加到所有分区 |
RANGE / LIST / HASH 三种分区方式
| 方式 | 关键字 | 划分依据 | 典型用途 | 支持裁剪 |
|---|---|---|---|---|
| 范围分区 | RANGE | 分区键落在某个左闭右开区间 | 按时间、按 ID 段 | 是(收益最大) |
| 列表分区 | LIST | 分区键等于列举出的某个值 | 按地区、状态、租户 | 是 |
| 哈希分区 | HASH | 分区键哈希后的模数余数 | 均匀打散、无自然分区键 | 是(但仅等值) |
范围分区是最常用的形式,因为时间几乎总是最重要的过滤维度:
CREATE TABLE orders (
id bigint NOT NULL,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at date NOT NULL
) PARTITION BY RANGE (created_at);声明式分区 vs 表继承
在 PostgreSQL 10 之前,人们用表继承(INHERITS)手工模拟分区:父表加 CHECK 约束,靠 constraint_exclusion 来跳过无关子表。声明式分区把它变成了一等公民:
| 维度 | 表继承模拟 | 声明式分区 |
|---|---|---|
| 边界定义 | 手写 CHECK 约束 | FOR VALUES ... 声明式 |
| 边界冲突检测 | 无,靠人工保证 | 内核保证互不重叠 |
| 插入路由 | 无,必须手动插对应子表(或写触发器) | 自动路由 |
| 查询裁剪 | 需要 constraint_exclusion = on | 内置,且支持运行期裁剪 |
| 分区数量可扩展性 | 差(规划期逐个遍历) | 好(PG 12+ 有显著优化) |
| 索引与约束传播 | 需手工维护 | 自动传播到所有分区 |
| 多级分区 | 可用,但管理复杂 | 原生支持 |
结论很明确:新项目一律用声明式分区,表继承只保留给「子表结构不同、需要共享列」的真正继承场景。
分区的硬性限制
-- 错误!唯一约束必须包含全部分区键列
CREATE TABLE orders_bad (
id bigint NOT NULL,
created_at date NOT NULL,
PRIMARY KEY (id)
) PARTITION BY RANGE (created_at);ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: PRIMARY KEY constraint on table "orders_bad" lacks column "created_at" which is part of the partition key.这是分区表最需要提前规划的一条。它带来的连锁影响是:
| 影响 | 说明 |
|---|---|
| 主键必须含分区键 | 按 created_at 分区,则主键只能写成 (id, created_at) |
| 外键引用分区表受限 | 引用方也需要持有被引用方的唯一键组合 |
| 「全局唯一」不再有保证 | 不同分区可以有相同 id |
| 需要全局 ID 时 | 用全局序列、uuidv7()(PostgreSQL 18+)或雪花 ID |
语法与用法
-- 创建分区表(父表)
CREATE TABLE table_name ( ... )
PARTITION BY { RANGE | LIST | HASH } ( column_name [, ... ] )
-- 其中分区键也可以写成表达式
CREATE TABLE table_name ( ... )
PARTITION BY RANGE ( (expression) )
-- 创建分区(三种 form)
CREATE TABLE partition_name PARTITION OF parent
FOR VALUES { IN ( ... ) | FROM ( ... ) TO ( ... ) | WITH ( MODULUS n, REMAINDER m ) | DEFAULT }
-- 把已有表挂成分区
ALTER TABLE parent ATTACH PARTITION partition_name FOR VALUES partition_bound_spec
-- 摘下一个分区(变成独立普通表)
ALTER TABLE parent DETACH PARTITION partition_name [ CONCURRENTLY | FINALIZE ]最简可运行示例:
-- 父表:按 created_at 做月度范围分区
CREATE TABLE orders (
id bigint NOT NULL,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at date NOT NULL
) PARTITION BY RANGE (created_at);CREATE TABLE-- 两个分区,边界左闭右开
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
CREATE TABLE插入时无需指定分区,内核自动路由:
INSERT INTO orders VALUES (1, 1001, 99.00, '2026-01-15');
INSERT INTO orders VALUES (2, 1002, 12.50, '2026-02-20');INSERT 0 1
INSERT 0 1-- 直接查父表,实际只扫了对应分区
SELECT * FROM orders WHERE created_at = '2026-01-15'; id | user_id | amount | created_at
----+---------+--------+------------
1 | 1001 | 99.00 | 2026-01-15
(1 row)系统目录里怎么看
-- 父表:relkind = 'p',partitioned table
SELECT relname, relkind, relispartition
FROM pg_class
WHERE relname IN ('orders', 'orders_2026_01', 'orders_2026_02')
ORDER BY relname; relname | relkind | relispartition
----------------+---------+----------------
orders | p | f
orders_2026_01 | r | t
orders_2026_02 | r | t
(3 rows)| 字段 | 含义 |
|---|---|
relkind = 'p' | 这是一张分区表(父表) |
relkind = 'r' | 普通表;分区在目录里就是普通表 |
relispartition = t | 该表是一个分区(有父表) |
分区键本身存在 pg_partitioned_table 里:
SELECT
c.relname AS parent,
p.partstrat AS strategy,
pg_get_partkeydef(p.partrelid) AS key_def
FROM pg_partitioned_table p
JOIN pg_class c ON c.oid = p.partrelid; parent | strategy | key_def
--------+----------+------------------
orders | r | RANGE (created_at)
(1 row)partstrat 的取值是 r(range)、l(list)、h(hash)三种之一,对应 PARTITION BY 的三种方式。
父子关系表 pg_inherits
分区的父子关系不靠什么特殊字段,而是复用继承机制的 pg_inherits:
SELECT
child.relname AS partition,
parent.relname AS parent_table
FROM pg_inherits
JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
JOIN pg_class child ON pg_inherits.inhrelid = child.oid
ORDER BY child.relname; partition | parent_table
----------------+--------------
orders_2026_01 | orders
orders_2026_02 | orders
(2 rows)查看分区边界与层级
PostgreSQL 12 起提供了三个专门的函数,是排查分区结构最顺手的工具:
-- 递归列出整棵分区树:relid / parentrelid / isleaf / level
SELECT * FROM pg_partition_tree('orders'); relid | parentrelid | isleaf | level
--------------------+--------------+--------+-------
orders | | f | 0
orders_2026_01 | orders | t | 1
orders_2026_02 | orders | t | 1
(3 rows)-- 从某个分区向上找所有祖先(最近的在前)
SELECT relid, relid::regclass AS ancestor FROM pg_partition_ancestors('orders_2026_01'); relid | ancestor
-----------+---------------
16387 | orders_2026_01
16380 | orders
(2 rows)pg_partition_root() 则用于从任意一个分区反查最顶层父表,在多级分区里非常有用。
详细说明
分区表是「虚拟」的,不占存储
CREATE TABLE ... PARTITION BY 之后,父表在磁盘上只有一个空的关系文件(0 字节),所有数据都在分区里:
SELECT pg_size_pretty(pg_relation_size('orders')) AS parent_size,
pg_size_pretty(pg_relation_size('orders_2026_01')) AS part_size; parent_size | part_size
-------------+-----------
0 bytes | 8192 bytes
(1 row)这解释了两个常见困惑:
- 给父表建索引不会立即产生一个集中式索引——它只是在每个分区上生成对应的「子索引」。
- 父表的
reltuples不会精确反映数据量——统计信息需要ANALYZE逐分区收集。
三种分区方式的路由规则
理解路由规则,才能明白为什么插入会报错、为什么某行「跑错」了分区。
| 方式 | 路由判断 | 未命中时 |
|---|---|---|
RANGE | lower <= key < upper | 报错(除非有 DEFAULT 分区) |
LIST | key IN (...) | 报错(除非有 DEFAULT 分区) |
HASH | hash(key) % modulus = remainder | 永不未命中(模数完备时) |
-- 范围:上界是排他的,2026-02-01 属于第二个分区
INSERT INTO orders VALUES (10, 1, 1.00, '2026-02-01');
SELECT tableoid::regclass AS which_partition FROM orders WHERE id = 10; which_partition
-----------------
orders_2026_02
(1 row)更新分区键会「搬行」
如果 UPDATE 改动了分区键,导致新值不再属于原分区,PostgreSQL 会在同一个语句内删除旧行、插入新行:
-- 把 1 月的数据改到 3 月:没有 3 月分区时会报错
UPDATE orders SET created_at = '2026-03-05' WHERE id = 1;ERROR: no partition of relation "orders" found for row
DETAIL: Partition key of the failing row contains (created_at) = (2026-03-05).这条错误的信息量很大:它没有说「越界」,而是说「这一行的分区键没能路由到任何分区」。补充一个 3 月分区即可成功:
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
UPDATE orders SET created_at = '2026-03-05' WHERE id = 1;CREATE TABLE
UPDATE 1分区键的选择
分区键的选择基本决定了分区方案能否长期维护。常见策略:
| 分区键类型 | 适用场景 | 注意 |
|---|---|---|
时间列(date/timestamptz) | 日志、订单、事件表 | 最常用,配合轮转删除极佳 |
业务枚举(region、status) | 地域、租户、分类 | 值域必须稳定,枚举增删代价低 |
| 主键哈希 | 无自然分区维度,只需打散 | 无裁剪收益,靠并行提升吞吐 |
| 复合键 | 时间 + 租户 | 逐级裁剪,两级都参与裁剪 |
一个关键判断标准:分区键必须高频率出现在查询的 WHERE 里,否则裁剪失效,分区只剩维护成本。
-- 好:查询几乎总带时间范围,可裁剪
SELECT count(*) FROM orders WHERE created_at >= '2026-01-01';
-- 差:查询只按 user_id,无法裁剪,要扫所有分区
SELECT count(*) FROM orders WHERE user_id = 1001;声明式分区不继承「主键全局唯一」
这是最容易踩的坑。分区表上的唯一约束是每个分区各自唯一:
-- 主键包含分区键,语法合法
CREATE TABLE events (
id bigint NOT NULL,
created_at date NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);CREATE TABLECREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- 同一个 id 出现在两个分区里,完全合法
INSERT INTO events VALUES (1, '2026-01-10');
INSERT INTO events VALUES (1, '2026-02-10');CREATE TABLE
CREATE TABLE
INSERT 0 1
INSERT 0 1需要全局唯一时,替代方案有:
-- 方案一:全局序列 / identity 列
id bigint GENERATED BY DEFAULT AS IDENTITY
-- 方案二:时间有序 UUID(PostgreSQL 18+),避免随机 UUID 的写放大
id uuid NOT NULL DEFAULT uuidv7()何时不该分区
分区不是越多越好,下列情况通常不该引入分区:
| 反例 | 原因 |
|---|---|
| 表只有几百万行、能装进内存 | 分区只增加规划开销 |
| 查询模式里根本没有分区键 | 无裁剪收益,纯粹增加复杂度 |
| 分区键会频繁变更 | 每次 UPDATE 都可能搬行,写放大严重 |
| 分区数量会失控增长(如按天 × 多年) | 规划时间与元数据开销上升 |
| 需要跨分区的全局唯一约束 | 分区表无法直接表达 |
分区数量是隐性成本
每个分区都是一次规划期的候选对象。数百个分区通常没问题,但上千个分区时规划时间会变得可观。PostgreSQL 12 起对此做了大幅优化,但仍应把分区数量控制在合理范围,必要时用多级分区「先粗后细」。
声明式分区与逻辑复制、触发器
分区表支持逻辑复制与行级 BEFORE 触发器,但有版本门槛:
| 能力 | 最低版本 |
|---|---|
在分区表上定义行级 BEFORE 触发器 | PostgreSQL 13 |
| 将分区表作为整体进行逻辑复制(发布父表) | PostgreSQL 13 |
分区表的 MERGE 支持(含跨分区移动行) | PostgreSQL 15 |
-- PostgreSQL 13+:发布父表,其所有分区自动被发布
CREATE PUBLICATION pub_orders FOR TABLE orders;注意:BEFORE 触发器不能改变行要去的分区(不能把行重定向到别的分区)。
实战示例
场景:为一张订单表设计按月的范围分区,验证路由、裁剪与目录信息。
第 1 步:建父表与三个分区
DROP TABLE IF EXISTS orders CASCADE;
CREATE TABLE orders (
id bigint NOT NULL,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
created_at date NOT NULL
) PARTITION BY RANGE (created_at);
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');第 2 步:灌入跨越三个分区的数据
INSERT INTO orders (id, user_id, amount, created_at)
SELECT
g,
(g % 500) + 1,
(random() * 1000)::numeric(12,2),
DATE '2026-01-01' + (g % 90)
FROM generate_series(1, 300000) AS g;INSERT 0 300000第 3 步:确认各行确实落到了不同分区
SELECT tableoid::regclass AS partition, count(*)
FROM orders
GROUP BY 1
ORDER BY 1; partition | count
----------------+-------
orders_2026_01 | 99999
orders_2026_02 | 90333
orders_2026_03 |109668
(3 rows)tableoid 是每个表都有的系统列,表示该行所在的物理表——在分区表上,它直接告诉你行落在哪个分区。
第 4 步:观察裁剪效果
EXPLAIN (COSTS OFF)
SELECT count(*) FROM orders WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01'; Aggregate
-> Append
Subplans Removed: 2
-> Seq Scan on orders_2026_02
Filter: ((created_at >= '2026-02-01'::date) AND (created_at < '2026-03-01'::date))
(4 rows)Subplans Removed: 2 就是裁剪的结果:三个分区里只保留了 1 个。
第 5 步:查看分区树的层级信息
SELECT
relid::regclass AS node,
parentrelid::regclass AS parent,
isleaf,
level
FROM pg_partition_tree('orders'); node | parent | isleaf | level
-----------------+--------------+--------+-------
orders | | f | 0
orders_2026_01 | orders | t | 1
orders_2026_02 | orders | t | 1
orders_2026_03 | orders | t | 1
(4 rows)第 6 步:对比「删一月数据」的两种方式
-- 方式 A:DETACH + DROP,近乎瞬时
ALTER TABLE orders DETACH PARTITION orders_2026_01;
DROP TABLE orders_2026_01;
-- 方式 B(不推荐,仅作对比):逐行删除
-- DELETE FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';DETACH PARTITION 在 PostgreSQL 12 起只需 SHARE UPDATE EXCLUSIVE 锁级别的能力,比方式 B 的巨大 WAL 与 VACUUM 开销低得多。
注意事项
- 分区表的唯一约束必须包含全部分区键列。 这是内核限制,不是配置问题。
-- 错误!unique constraint on partitioned table must include all partitioning columns
CREATE TABLE t (id bigint PRIMARY KEY, d date) PARTITION BY RANGE (d);- 没有匹配分区时插入会直接失败。 生产环境一定要有预建分区或
DEFAULT分区兜底。
-- 错误!no partition of relation "orders" found for row
INSERT INTO orders VALUES (999, 1, 1.00, '2027-01-01');- 父表与普通表之间不能互相转换。 已经是大表时,只能「建分区表 → 迁数据」或「逐个
ATTACH」。
-- 错误!"legacy" is not a partitioned table
ALTER TABLE legacy PARTITION BY RANGE (created_at);- 不同分区的边界不得重叠。 重叠会在创建分区时报错。
-- 错误!partition "orders_2026_02" would overlap partition "orders_2026_03"
CREATE TABLE orders_dup PARTITION OF orders
FOR VALUES FROM ('2026-01-15') TO ('2026-03-01');UPDATE分区键可能触发跨分区搬行。 频繁改动分区键列是明确的性能反模式。
-- 不推荐:把时间列当作可随意修改的普通字段
UPDATE orders SET created_at = now() WHERE id = 1;- 父表上的
ALTER TABLE会级联到所有分区。 分区数量多时,ADD COLUMN这类操作的持锁时间会随分区数增长。
ALTER TABLE orders ADD COLUMN remark text; -- 会对每个分区加锁不要在分区表上依赖「父表索引」的物理存在。 父表的索引是「模板」,实际索引在各分区上。
分区键用表达式时要留意可裁剪性。 表达式必须能在规划期求值到常量,且不会被函数「包住」分区键列。
-- 不推荐:函数包住分区键,破坏裁剪
SELECT * FROM orders WHERE date_trunc('month', created_at) = '2026-02-01';最佳实践
- 时间序数据一律按月或按天做
RANGE分区,并预留足够的分区。 提前预建未来 3~12 个月的分区,避免插入报错。
CREATE TABLE orders_2026_04 PARTITION OF orders
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');- 务必设置
DEFAULT分区作为兜底,但要监控它。 它能让越界数据不丢,但也会阻止后续新分区的创建(需先移出越界行)。
CREATE TABLE orders_default PARTITION OF orders DEFAULT;分区键必须出现在高频查询的
WHERE中。 否则分区只带来成本。先规划主键,再规划分区键。 分区键一旦确定,主键就被迫包含它,事后改动代价极高。
PRIMARY KEY (id, created_at) -- 分区键 created_at 必须在内- 用
enable_partition_pruning保持默认on,并通过EXPLAIN的Subplans Removed验证裁剪真的发生。
SHOW enable_partition_pruning;
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE created_at = '2026-02-10';- 大批量删除优先用
DETACH+DROP,而不是DELETE。
ALTER TABLE orders DETACH PARTITION orders_2026_01;
DROP TABLE orders_2026_01;- 定期用
pg_partition_tree()盘点分区结构,确认没有遗漏的区间。
SELECT relid::regclass, isleaf, level FROM pg_partition_tree('orders');- 分区数量失控时考虑多级分区。 例如先按年
RANGE、再按月RANGE,把单层扇出压下来。
下一节
继续学习:范围分区(RANGE)