多级分区(子分区)
多级分区(Sub-partitioning,也叫复合分区 Composite Partitioning)把分区再分区:一级分区按某个维度切开后,每个一级分区内部继续用另一种分区策略切分,形成树形结构。典型结构是「先按时间 RANGE,再按 tenant_id HASH」或「先按 region LIST,再按 created_at RANGE」。
它的价值在于同时满足两个维度的切分需求:一级负责归档(按期 DROP 旧数据),二级负责进一步拆分(打散写入或裁剪到更小的子集)。代价是分区数量按乘法增长——4 个月 × 8 个哈希 = 32 个叶子分区,管理复杂度与规划开销同步上升。
前置知识
阅读本节前,建议先了解:声明式分区概述、范围分区(RANGE)、列表分区(LIST)、哈希分区(HASH)
基础概念
多级分区在解决什么问题
单一维度的分区往往不够用。考虑两种两难:
场景一:既要归档又要打散。 一张日志表需要按月 DROP 旧数据(必须 RANGE(时间)),但每个月的写入量太大,单分区写入竞争激烈,希望再打散。此时「RANGE 一级 + HASH 二级」就同时解决了归档与并发。
场景二:两个业务维度都要裁剪。 一张跨区域订单表,查询有时带 region,有时带 created_at。「LIST(region) 一级 + RANGE(created_at) 二级」能让两类查询分别在一、二级命中裁剪。
如果只有一个维度需要切分,多级分区就是过度设计——单级分区足够,且更简单。
树形结构:分区表也是分区表
多级分区的本质是:一个分区本身可以是分区表。可以在 CREATE TABLE ... PARTITION OF 时再跟一个 PARTITION BY,让这个分区继续往下切。
events (PARTITION BY RANGE (created_at))
/ | \
events_2026_01 events_2026_02 events_2026_03 ← 一级分区,各自也是分区表
(PARTITION BY HASH (tenant_id))
/ | | \
..._p0 ..._p1 ..._p2 ..._p3 ← 二级分区(叶子分区)用 pg_partition_tree() 可以直观看到这棵树的形状:
name | type | level
--------------------------------------------------+---------+-------
events | p | 0
events_2026_01 | p | 1
events_2026_01_p0 | r | 2
events_2026_01_p1 | r | 2
events_2026_01_p2 | r | 2
events_2026_01_p3 | r | 2
events_2026_02 | p | 1
...type 列 p 表示该节点是分区表(还有下级),r 表示叶子分区(不再有下级)。两者都是真实存在的表,只是层级不同。
三对策略组合的对比
一级与二级可以任选 RANGE / LIST / HASH,实践中常见以下几种组合:
| 组合 | 典型场景 | 一级裁剪 | 二级裁剪 | 分区数增长 |
|---|---|---|---|---|
RANGE → HASH | 时间归档 + 写入打散 | 按时间 | 按等值键 | 月份数 × 哈希数 |
RANGE → LIST | 时间归档 + 按地区进一步隔离 | 按时间 | 按地区 | 月份数 × 地区数 |
LIST → RANGE | 多租户隔离 + 租户内按期轮转 | 按租户 | 按时间 | 租户数 × 月份数 |
HASH → RANGE | 先打散再按期分区 | 按等值键 | 按时间 | 哈希数 × 月份数 |
LIST → HASH | 租户隔离 + 租户内打散 | 按租户 | 按等值键 | 租户数 × 哈希数 |
RANGE → RANGE | 年 → 月两级时间分区 | 按年 | 按月 | 年份数 × 月份数 |
选择组合的判断顺序是:先问「归档维度是什么」(决定一级),再问「其余维度里哪个查询最频繁」(决定二级)。
叶子分区才是数据真正落地的地方
一个关键认知:只有最底层的叶子分区才存数据。一级分区(如果它还是分区表)本身不存任何行。
-- 向父表插入数据,会一路路由到叶子分区
INSERT INTO events (event_id, tenant_id, created_at) VALUES (1, 42, '2026-01-15');
-- 查看数据落在哪个叶子分区
SELECT tableoid::regclass AS leaf FROM events WHERE event_id = 1; leaf
------------------
events_2026_01_p2这意味着:一级分区的行数统计永远为 0(在 pg_class.reltuples 里),只有叶子分区才有意义。
-- 一级分区(分区表)没有自己的数据
SELECT relname, relkind, reltuples FROM pg_class WHERE relname = 'events_2026_01';
-- relkind = 'p',插入的数据实际在叶子分区
-- 叶子分区才有数据
SELECT relname, relkind, reltuples FROM pg_class WHERE relname LIKE 'events_2026_01_p%';逐级裁剪:每一级都要命中
多级分区的裁剪是逐级进行的:规划器先按一级分区键裁剪出若干一级分区,再在每个保留下来的一个级分区里,按二级分区键继续裁剪。
要让查询享受完整的裁剪,条件必须同时命中每一级的分区键:
-- 全裁剪:时间命中一级、tenant_id 命中二级
SELECT * FROM events WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01' AND tenant_id = 42;
-- 只裁剪一级:只有时间条件,每个保留下来的月份会扫全部 4 个二级分区
SELECT * FROM events WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
-- 完全无法裁剪:只有 tenant_id,每个月都要扫(除非规划器能证明某个二级分区不含该值)
SELECT * FROM events WHERE tenant_id = 42;第三类查询值得特别说明:WHERE tenant_id = 42 配上 RANGE → HASH 结构时,每个一级分区下的 4 个二级分区都会被扫描,一共 4 × N个月 个子计划。如果这类查询是主力查询,说明一级选错了维度。
ATTACH/DETACH 在多级结构中的传播
多级分区里,ATTACH / DETACH 的作用对象既可以是叶子分区,也可以是整棵子树:
| 操作 | 效果 |
|---|---|
DETACH 一个叶子分区 | 只摘下一个二级分区 |
DETACH 一个一级分区 | 把整棵子树(一级 + 其全部二级)从父表摘下,子树内部结构保持 |
ATTACH 一个叶子分区 | 需要其边界与一级分区兼容 |
ATTACH 一个一级分区表 | 把整棵子树挂上去 |
-- 把整个 2026_03 子树(含其 4 个二级分区)一次摘下
ALTER TABLE events DETACH PARTITION events_2026_03;DETACH 之后的 events_2026_03 仍然是一个分区表(带 4 个二级分区),只是不再挂在 events 下。这带来一个便利:归档时可以直接 DETACH + DROP 一整棵子树,一次操作下线一个月的数据。
分区数量爆炸:必须正视的成本
多级分区最容易被忽视的代价是分区数量的乘法增长:
| 一级分区数 | 二级分区数 | 叶子分区总数 |
|---|---|---|
| 12(月) | 4 | 48 |
| 24(月) | 8 | 192 |
| 36(月) | 16 | 576 |
| 60(月) | 32 | 1920 |
分区数量直接影响:
- 规划时间。查询规划器要为每个可能被扫描的叶子分区生成子计划,576 个分区时规划时间可达数十毫秒,远超执行时间。
- 元数据膨胀。每个分区在
pg_class、pg_inherits、pg_attribute等系统表里都有多条记录,影响系统目录缓存与 DDL 速度。 - 维护成本。预建分区、备份、
VACUUM的脚本复杂度随叶子数线性上升。 - 锁申请。某些操作需要在父表及各级加锁,层级越深、分区越多,锁管理开销越大。
经验阈值:叶子分区总数控制在几百个以内,超过 1000 要非常谨慎。
-- 统计当前分区表的叶子分区总数
SELECT count(*) AS leaf_partitions
FROM pg_class c
WHERE c.relispartition AND c.relkind = 'r'
AND pg_partition_root(c.oid) = 'events'::regclass;声明式分区与多级分区的版本基础
多级分区本身不需要额外特性——它是 CREATE TABLE ... PARTITION OF ... PARTITION BY ... 的自然组合。但配套能力有版本差异:
| 版本 | 与多级分区相关的能力 |
|---|---|
| PostgreSQL 10 | 声明式分区(RANGE/LIST),多级分区从此时可用 |
| PostgreSQL 11 | HASH 分区加入,多级组合更灵活;支持 DEFAULT 分区 |
| PostgreSQL 12 | pg_partition_tree() / pg_partition_ancestors() / pg_partition_root(),让树形结构可查询 |
| PostgreSQL 14 | DETACH PARTITION CONCURRENTLY,减少摘分区时的锁影响 |
| PostgreSQL 18 | DETACH PARTITION ... FINALIZE |
其中 pg_partition_tree() 系列函数对多级分区尤其重要——单级分区时手写 pg_inherits 查询尚可,多级时几乎必须借助这些函数。
语法与用法
先建父表,声明一级策略
一级策略通常是归档维度:
CREATE TABLE table_name (
column_name data_type [ column_constraint [ ... ] ]
[, ... ]
)
PARTITION BY RANGE ( created_at );建一级分区时声明二级策略
一级分区的 DDL 在普通的 CREATE TABLE ... PARTITION OF 后面再加一个 PARTITION BY:
CREATE TABLE partition_name
PARTITION OF parent_table
FOR VALUES partition_bound_spec
PARTITION BY HASH ( tenant_id );建二级叶子分区
叶子分区只带边界,不再跟 PARTITION BY:
CREATE TABLE leaf_name
PARTITION OF level1_partition
FOR VALUES WITH ( MODULUS m, REMAINDER r );完整示例
下面构建「RANGE(created_at) 一级 + HASH(tenant_id) 二级」的两级分区表:
-- 1. 父表:按 created_at 范围分区
CREATE TABLE events (
event_id bigint NOT NULL,
tenant_id int NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb,
PRIMARY KEY (created_at, tenant_id, event_id)
) PARTITION BY RANGE (created_at);
-- 2. 一级分区:2026 年 1 月,声明继续按 tenant_id 哈希分区
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
PARTITION BY HASH (tenant_id);
-- 3. 二级分区:4 个叶子
CREATE TABLE events_2026_01_p0 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE events_2026_01_p1 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE events_2026_01_p2 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE events_2026_01_p3 PARTITION OF events_2026_01 FOR VALUES WITH (MODULUS 4, REMAINDER 3);查看树形结构
pg_partition_tree() 返回整棵子树,含层级:
SELECT relid::regclass AS name, relkind, level
FROM pg_partition_tree('events')
ORDER BY level, name; name | relkind | level
-------------------------------------------------+---------+-------
events | p | 0
events_2026_01 | p | 1
events_2026_01_p0 | r | 2
events_2026_01_p1 | r | 2
events_2026_01_p2 | r | 2
events_2026_01_p3 | r | 2:regclass 转换能直接把 OID 显示成表名。relkind = 'p' 是分区表(中间节点),'r' 是普通表(叶子)。
查某个叶子分区的祖先链
pg_partition_ancestors() 返回从父表到该分区的整条路径:
SELECT pg_partition_ancestors('events_2026_01_p2'); pg_partition_ancestors
------------------------
events
events_2026_01
events_2026_01_p2插入并观察逐级路由
INSERT INTO events (event_id, tenant_id, created_at, payload)
VALUES (1, 42, '2026-01-15 10:00:00+08', '{"a":1}'),
(2, 43, '2026-01-20 11:00:00+08', '{"b":2}'),
(3, 44, '2026-02-03 09:00:00+08', '{"c":3}'); -- 这行若无 2 月分区则报错
SELECT event_id, tableoid::regclass AS leaf FROM events ORDER BY event_id; event_id | leaf
----------+------------------
1 | events_2026_01_p2
2 | events_2026_01_p1注意第 3 行会失败,因为 2 月的一级分区还没建:
ERROR: no partition of relation "events" found for row
DETAIL: Partition key of the failing row contains (created_at) = (2026-02-03 09:00:00+08).逐级裁剪演示
同时命中两级条件:
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
AND tenant_id = 42; QUERY PLAN
--------------------------------------------------------------
Seq Scan on events_2026_01_p2
Filter: ((created_at >= ...) AND (created_at < ...) AND (tenant_id = 42))一二级都裁剪到位,只留一个叶子。
只命中一级条件:
EXPLAIN (COSTS OFF)
SELECT * FROM events
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; QUERY PLAN
--------------------------------------------------------------
Append
-> Seq Scan on events_2026_01_p0
-> Seq Scan on events_2026_01_p1
-> Seq Scan on events_2026_01_p2
-> Seq Scan on events_2026_01_p3一级裁到 1 个,但二级的 4 个叶子全扫。
详细说明
一级策略应当选「归档维度」
一级策略决定「怎么下线数据」,所以选型必须服务于归档。绝大多数情况下,一级用 RANGE(时间):
CREATE TABLE logs (...) PARTITION BY RANGE (created_at);
CREATE TABLE logs_2026_01 PARTITION OF logs FOR VALUES FROM ('2026-01-01') TO ('2026-02-01') PARTITION BY HASH (device_id);原因是时间归档是最普遍的需求——旧数据整块过期,DETACH + DROP 是秒级操作。若一级用 HASH,则无法按时间下线,每月清理只能逐行 DELETE。
一级用 LIST 的场景是「多租户下每个租户独立归档」:
CREATE TABLE tenant_data (...) PARTITION BY LIST (tenant_id);
-- 每个租户一个一级分区,租户内部再按月分区
CREATE TABLE tenant_data_t42 PARTITION OF tenant_data FOR VALUES IN (42) PARTITION BY RANGE (created_at);二级策略应当选「高频查询维度」
二级策略服务于「查询裁剪」或「写入打散」,因此要选实践中查询最常带的条件列,或者需要分摊写入的高基数列。
-- 查询主力是 (tenant_id, ...),二级就用 HASH(tenant_id) 或 LIST(tenant_id)
CREATE TABLE events_2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
PARTITION BY HASH (tenant_id);如果二级选了一个查询从不使用的维度,那这些二级分区只是白白增加了分区数量与规划开销。
逐级裁剪的机制与 EXPLAIN 表现
裁剪是逐级的:规划器在一级上做一次裁剪,得到一个保留下来的分区列表;再对每个保留的一级分区,在其二级上做裁剪。EXPLAIN 输出中可以看到 Append 的嵌套结构:
EXPLAIN (COSTS OFF)
SELECT * FROM events WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01' AND tenant_id = 42;若期间跨了两个月:
EXPLAIN (COSTS OFF)
SELECT * FROM events WHERE created_at >= '2026-01-01' AND created_at < '2026-03-01' AND tenant_id = 42; QUERY PLAN
--------------------------------------------------------------
Append
-> Seq Scan on events_2026_01_p2
Filter: ((created_at >= ...) AND (created_at < ...) AND (tenant_id = 42))
-> Seq Scan on events_2026_02_p2
Filter: ((created_at >= ...) AND (created_at < ...) AND (tenant_id = 42))可以看到:一级裁到 2 个月,每个月内部二级都裁到同一个余数 p2。这体现了逐级裁剪的「分治」本质——每一级独立裁剪,结果取交集。
维护操作的传播与锁
| 操作 | 一层叶子 | 整个一级子树 |
|---|---|---|
VACUUM | 单个叶子 | 需逐叶子做(父表 VACUUM 不含数据) |
DETACH | 摘一个叶子 | 摘整棵树(含全部下级) |
DROP | 删一个叶子 | 删整棵树 |
CREATE INDEX ON parent | 自动在所有叶子建 | 同 |
ATTACH | 挂一个叶子(边界须与一级兼容) | 挂一棵子树 |
DETACH 整个一级子树是归档的利器:
BEGIN;
ALTER TABLE events DETACH PARTITION events_2026_01; -- 摘掉整棵树
DROP TABLE events_2026_01; -- 叶子随子树一起删除
COMMIT;但要注意:DETACH 需要在父表 events 上取 ACCESS EXCLUSIVE 锁(PostgreSQL 14+ 可用 DETACH PARTITION CONCURRENTLY,但要求该分区表没有 DEFAULT 分区)。详见分区维护(DETACH / DROP)。
每级都可以有自己的索引
在分区表上 CREATE INDEX 会自动下发到全部叶子;也可以对某个一级分区单独建索引:
-- 全部叶子都建
CREATE INDEX events_tenant_idx ON events (tenant_id, event_id);
-- 只给某个一级子树建(其下叶子都建)
CREATE INDEX events_2026_01_tenant_idx ON events_2026_01 (tenant_id, event_id);postgres=# \d events_2026_01_p2
Table "public.events_2026_01_p2"
Indexes:
"events_2026_01_p2_pkey" PRIMARY KEY, btree (created_at, tenant_id, event_id)
"events_2026_01_p2_tenant_idx" btree (tenant_id, event_id)多级分区下,主键/唯一约束必须包含每一级的分区键列。因此 PRIMARY KEY (created_at, tenant_id, event_id) 是必需的——created_at 是一级键、tenant_id 是二级键,二者都得在内。
分区数量爆炸的规划时间影响
这是多级分区最实在的痛点。PostgreSQL 的规划器要为每个「可能被扫描的叶子分区」生成子计划,规划时间与候选叶子数大致成正比。
构造一个直观的对比:把 60 个月 × 8 个哈希的 RANGE → HASH 表拿来对比单级 RANGE 表:
-- 单级:60 个分区
EXPLAIN (ANALYZE, COSTS OFF) SELECT * FROM events_single WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; Planning Time: 0.8 ms
Execution Time: 0.05 ms-- 多级:60 × 8 = 480 个叶子,只裁剪了一级,二级全扫
EXPLAIN (ANALYZE, COSTS OFF) SELECT * FROM events_multi WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'; Planning Time: 9.4 ms ← 二级未裁剪时规划开销显著上升
Execution Time: 0.12 ms当规划时间远大于执行时间时,就说明分区数量已成为负担。缓解手段:
- 让二级条件也参与查询,把候选叶子数降下来;
- 合并二级(例如把 8 个哈希减到 4 个);
- 使用
prepared语句,让规划只做一次(PostgreSQL 12+ 对分区表的参数化查询能在执行期裁剪); - 减少保留的一级分区,及时归档旧月份。
prepared statement 与运行时裁剪
多级分区 + 参数化查询是规划开销的重灾区。PostgreSQL 的通用计划(generic plan)会跳过参数值相关的裁剪,导致每次执行都扫全部叶子。可以用 plan_cache_mode 观察与调整:
PREPARE q(timestamptz, timestamptz, int) AS
SELECT * FROM events WHERE created_at >= $1 AND created_at < $2 AND tenant_id = $3;
EXPLAIN (ANALYZE, COSTS OFF) EXECUTE q('2026-01-01', '2026-02-01', 42); QUERY PLAN
-----------------------------------------------
Seq Scan on events_2026_01_p2 (actual rows=...)
Planning Time: 1.2 ms
Execution Time: 0.09 ms-- 强制每次重新规划,以便利用参数值做裁剪
SET plan_cache_mode = force_custom_plan;代价是每次执行都要重新规划;在多级分区下这往往是划算的(裁剪收益 > 规划成本)。
用 pg_partition_tree 做维护与巡检
多级分区下,巡检脚本必须借助 pg_partition_tree() 才能正确遍历:
-- 列出所有叶子分区及其大小
SELECT relid::regclass AS leaf,
pg_size_pretty(pg_total_relation_size(relid)) AS size
FROM pg_partition_tree('events')
WHERE relkind = 'r'
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10; leaf | size
--------------------------------+---------
events_2026_03_p0 | 512 MB
events_2026_03_p1 | 508 MB
events_2026_01_p2 | 61 MB
...批量维护也可以用 pg_partition_tree() 驱动:
DO $$
DECLARE r record;
BEGIN
FOR r IN SELECT relid FROM pg_partition_tree('events') WHERE relkind = 'r' LOOP
EXECUTE format('VACUUM (ANALYZE) %s', r.relid::regclass);
END LOOP;
END $$;与 pg_partman 的配合
手工维护多级分区的预建、轮转、清理非常繁琐,pg_partman 扩展正是为此设计的。它支持多级分区配置(sub_partitioning),可以自动按模板创建一级与二级分区、自动 DETACH 过期分区。
-- pg_partman 中配置子分区(示意,具体 API 随版本变化)
SELECT partman.create_parent(
p_parent_table => 'public.events',
p_control => 'created_at',
p_type => 'range',
p_interval => '1 month'
);使用前需评估扩展的维护负担与升级兼容性,但「多级 + 自动化」几乎是必经之路——手工创建 480 个叶子分区不现实。
多级分区下的默认分区
每一级都可以有 DEFAULT,但实践中建议只在最需要兜底的那一级设 DEFAULT,否则 DEFAULT 渗透会带来麻烦:
-- 一级设 DEFAULT(兜底所有未建月份)
CREATE TABLE events_default PARTITION OF events DEFAULT
PARTITION BY HASH (tenant_id);
CREATE TABLE events_default_p0 PARTITION OF events_default FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- ...风险与单级相同:DEFAULT 里一旦有数据,新建分区就要搬数据;且 DEFAULT 子树无法用 DETACH PARTITION CONCURRENTLY(PostgreSQL 要求父表无 DEFAULT 分区)。
何时不该用多级分区
多级分区不是「更高级所以更好」。以下情况应当避免:
| 情况 | 建议 |
|---|---|
| 只需要一个维度裁剪 | 用单级分区 |
| 两个维度中有一个查询频率很低 | 只按高频维度分区,另一维度建索引 |
| 叶子分区总数会超过几千 | 减少一级或二级的分区数 |
| 表总量在千万行以内 | 通常不需要多级分区 |
| 团队缺乏分区运维经验 | 先从单级分区起步 |
-- 单级分区 + 复合索引,往往比多级分区更简单有效
CREATE TABLE orders (...) PARTITION BY RANGE (created_at);
CREATE INDEX orders_tenant_created_idx ON orders (tenant_id, created_at);实战示例
下面完整构建一张「区域订单表」,需求是:
- 按
created_at月度归档(可快速下线旧数据); - 每个月的查询常带
region条件,希望月内再按region裁剪; - 保留 3 个月数据。
这是一个典型的「RANGE → LIST」两级结构。
第 1 步:建父表,声明一级 RANGE
CREATE TABLE orders (
order_id bigint NOT NULL,
region text NOT NULL,
created_at timestamptz NOT NULL,
amount numeric(12,2) NOT NULL,
PRIMARY KEY (created_at, region, order_id)
) PARTITION BY RANGE (created_at);主键包含一级键 created_at 与二级键 region,满足「唯一约束须含全部分区键」的要求。
第 2 步:建 3 个月的一级分区,声明二级 LIST
CREATE TABLE orders_2026_01 PARTITION OF orders
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01')
PARTITION BY LIST (region);
CREATE TABLE orders_2026_02 PARTITION OF orders
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01')
PARTITION BY LIST (region);
CREATE TABLE orders_2026_03 PARTITION OF orders
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01')
PARTITION BY LIST (region);第 3 步:为每个一级分区建二级 LIST 叶子
DO $$
DECLARE
m text;
parent text;
BEGIN
FOREACH m IN ARRAY ARRAY['2026_01','2026_02','2026_03'] LOOP
parent := 'orders_' || m;
EXECUTE format('CREATE TABLE %I_east PARTITION OF %I FOR VALUES IN (''east'')', parent, parent);
EXECUTE format('CREATE TABLE %I_west PARTITION OF %I FOR VALUES IN (''west'')', parent, parent);
EXECUTE format('CREATE TABLE %I_north PARTITION OF %I FOR VALUES IN (''north'')', parent, parent);
EXECUTE format('CREATE TABLE %I_other PARTITION OF %I DEFAULT', parent, parent);
END LOOP;
END $$;共 3 × 4 = 12 个叶子分区。
第 4 步:查看树形结构
SELECT relid::regclass AS name, relkind, level
FROM pg_partition_tree('orders')
ORDER BY level, name; name | relkind | level
---------------------------+---------+-------
orders | p | 0
orders_2026_01 | p | 1
orders_2026_02 | p | 1
orders_2026_03 | p | 1
orders_2026_01_east | r | 2
orders_2026_01_north | r | 2
orders_2026_01_other | r | 2
orders_2026_01_west | r | 2
...(其余月份同理)第 5 步:写入数据并观察逐级路由
INSERT INTO orders (order_id, region, created_at, amount)
SELECT g,
(ARRAY['east','west','north','south'])[1 + (g % 4)],
timestamptz '2026-01-01' + (g % 90) * interval '1 day',
(random() * 1000)::numeric(12,2)
FROM generate_series(1, 10000) AS g;
ANALYZE orders;SELECT tableoid::regclass AS leaf, count(*)
FROM orders
GROUP BY tableoid
ORDER BY 1; leaf | count
----------------------+-------
orders_2026_01_east | 1258
orders_2026_01_north | 1243
orders_2026_01_other | 1260 ← south 落在这里
orders_2026_01_west | 1239
orders_2026_02_east | ...
...可以看到 south 因为没有专属的二级分区,落入了 _other(DEFAULT)。
第 6 步:验证逐级裁剪
同时带时间与区域条件:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT * FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'
AND region = 'east'; QUERY PLAN
---------------------------------------------------------------
Seq Scan on orders_2026_01_east (actual rows=1258 loops=1)
Filter: ((created_at >= ...) AND (created_at < ...) AND (region = 'east'))
Planning Time: 0.6 ms
Execution Time: 0.4 ms只命中单个叶子分区。
第 7 步:归档最旧月份(整棵子树下线)
BEGIN;
ALTER TABLE orders DETACH PARTITION orders_2026_01;
DROP TABLE orders_2026_01; -- 12 → 8 个叶子,一次删除
COMMIT;DETACH TABLE
DROP TABLE验证子树已被移除:
SELECT count(*) AS remaining_leaves
FROM pg_partition_tree('orders') WHERE relkind = 'r'; remaining_leaves
------------------
8第 8 步:清理后确认剩余分区可用
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at >= '2026-02-01' AND created_at < '2026-03-01' AND region = 'west'; QUERY PLAN
--------------------------------------------------------------
Seq Scan on orders_2026_02_west
Filter: ((created_at >= ...) AND (created_at < ...) AND (region = 'west'))归档后剩余分区结构完好,裁剪依旧有效。
注意事项
不要为了「看起来更专业」而用多级分区。 单一维度能满足需求时,多级分区只会让运维变复杂、规划变慢。
一级键与二级键都必须进入唯一约束。 漏掉任何一个都无法建主键/唯一约束。
textERROR: unique constraint on partitioned table must include all partitioning columns DETAIL: PRIMARY KEY constraint on table "orders" lacks column "created_at" which is part of the partition key.二级未命中条件时,二级分区会被全部扫描。 这是多级分区最常见的性能陷阱。
sql-- 不推荐:只有二级键条件,每个一级分区下的所有二级叶子都要扫描 SELECT * FROM orders WHERE region = 'east'; -- 扫 3 个月 × 各自全部叶子叶子分区数量必须受控。 一级 × 二级的乘积可能迅速失控,计划期规划开销会明显放大。
sql-- 巡检:叶子分区总数 SELECT count(*) FROM pg_partition_tree('orders') WHERE relkind = 'r';不要给每个一级分区都挂
DEFAULT二级分区。DEFAULT会阻塞新叶子的创建,且使DETACH PARTITION CONCURRENTLY不可用。sql-- 不推荐:每个一级分区下都挂 DEFAULT CREATE TABLE orders_2026_01_other PARTITION OF orders_2026_01 DEFAULT;多级分区的中间节点不存数据。 对一级分区表做
SELECT count(*)会扫描其全部叶子;对一级分区做VACUUM不等于对其叶子做VACUUM。sql-- 正确做法:借助 pg_partition_tree 逐叶子维护 SELECT relid::regclass FROM pg_partition_tree('orders') WHERE relkind = 'r';DETACH整棵子树仍需父表ACCESS EXCLUSIVE锁。 归档窗口要选在低峰期,或使用DETACH PARTITION CONCURRENTLY(PostgreSQL 14+,要求无DEFAULT分区)。跨级边界变更代价高。 想改变二级分区策略(如从
HASH改成LIST),必须重建整个一级子树并搬运数据,不存在原地转换。不要在多级结构里混用不一致的二级策略。 例如 1 月按
HASH(4)、2 月按HASH(8),会让维护脚本和查询假设都变得脆弱。迁移大表到多级分区需要停机窗口或双写方案。
ATTACH PARTITION需要扫描校验数据,多级结构下校验的是整棵子树。
最佳实践
一级选归档维度、二级选查询维度。 这是一条几乎可以照搬的规则:一级决定下线方式,二级决定裁剪能力。
sql-- 一级 RANGE(时间) 便于归档,二级 LIST(region) 便于裁剪 CREATE TABLE orders_2026_01 PARTITION OF orders FOR VALUES FROM ('2026-01-01') TO ('2026-02-01') PARTITION BY LIST (region);叶子总数保持在几百以内。 规划开销与叶子数量正相关,超过 1000 个叶子要重新评估二级分区数。
用
pg_partition_tree()驱动所有巡检与维护脚本。 不要手写多层pg_inherits递归查询。sqlSELECT relid::regclass AS leaf FROM pg_partition_tree('orders') WHERE relkind = 'r';让主键覆盖所有层级的分区键。 这是硬性要求,也在设计阶段就决定了业务主键形态。
sqlPRIMARY KEY (created_at, region, order_id) -- 一级键 + 二级键 + 业务键预建分区时把「当前月 + 未来若干月」都建好。 避免插入时因缺分区报错,也为归档留缓冲。
sqlDO $$ DECLARE m date; BEGIN FOR m IN SELECT generate_series(date_trunc('month', now()), date_trunc('month', now()) + interval '2 months', interval '1 month')::date LOOP EXECUTE format('CREATE TABLE IF NOT EXISTS orders_%s PARTITION OF orders FOR VALUES FROM (%L) TO (%L) PARTITION BY LIST (region)', to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date); END LOOP; END $$;归档时整棵子树一起
DETACH+DROP。 比逐叶子删除快得多,且一次清理一个完整时间窗。sqlBEGIN; ALTER TABLE orders DETACH PARTITION orders_2025_12; DROP TABLE orders_2025_12; COMMIT;对参数化查询考虑
force_custom_plan。 多级分区下,为利用参数值裁剪,重新规划往往比复用通用计划更划算。sqlSET plan_cache_mode = force_custom_plan;在二级分区键上建足索引。 当二级条件经常缺席时,二级键上的索引是唯一的兜底手段。
sqlCREATE INDEX orders_region_idx ON orders (region, created_at);定期核对分区均匀性与增长趋势。 各叶子分区体积悬殊时,说明分区键或边界划分不合理。
sqlSELECT relid::regclass AS leaf, pg_size_pretty(pg_total_relation_size(relid)) AS size FROM pg_partition_tree('orders') WHERE relkind = 'r' ORDER BY pg_total_relation_size(relid) DESC;多级分区务必引入自动化(如 pg_partman)。 手工维护几十上百个叶子的创建、轮转、清理几乎必然出错。
下一节
下一节:创建与附加分区——学习 CREATE TABLE ... PARTITION OF、ATTACH / DETACH PARTITION 的完整操作与锁行为。
参考链接
- PostgreSQL 18 官方文档:Table Partitioning(分区总览)
- PostgreSQL 18 官方文档:CREATE TABLE(
PARTITION OF与PARTITION BY组合) - PostgreSQL 18 官方文档:ALTER TABLE(
ATTACH/DETACH PARTITION) - PostgreSQL 18 官方文档:Partition Pruning(逐级裁剪)
- PostgreSQL 18 官方文档:Partition Information Functions(
pg_partition_tree/pg_partition_ancestors/pg_partition_root) - PostgreSQL 18 官方文档:Overview of Partitioning(分区维护与默认分区限制)