本文主要内容:HaishanDB 分区表(Partitioned Table)的完整内核实现机制,包括三种分区策略(RANGE / LIST / HASH)的语法与系统目录存储、relcache 层的分区键(PartitionKey)与分区描述符(PartitionDesc)构建、PartitionBoundInfo 的边界表示与二分 / 取模定位、Planner 层的继承展开与静态分区剪枝、Executor 层的元组路由(Tuple Routing)与运行时剪枝、分区智能连接(Partitionwise Join)与分区智能聚合,以及 ATTACH / DETACH PARTITION 的 DDL 处理与并发一致性保障。文中所有代码均取自 PostgreSQL 15.5 内核源码。
目录
- 背景:什么是分区表
- 技术概览:三种分区策略与整体架构
- 2.1 三种分区策略对比
- 2.2 整体架构分层
- 内核深度解析
- 3.1 系统目录:pg_class / pg_partitioned_table / pg_inherits
- 3.2 分区键构建:RelationBuildPartitionKey
- 3.3 分区描述符:RelationBuildPartitionDesc
- 3.4 边界表示:PartitionBoundInfoData 与定位算法
- 3.5 DDL 处理:transformPartitionSpec 与 StorePartitionBound
- 3.6 规划期继承展开:expand_inherited_rtentry
- 3.7 静态分区剪枝:gen_partprune_steps 与 get_matching_partitions
- 3.8 分区智能连接与分区智能聚合
- 3.9 执行期元组路由:get_partition_for_tuple
- 3.10 运行时剪枝与子计划映射
- 3.11 ATTACH / DETACH PARTITION 与并发一致性
- 实践洞察与踩坑指南
- 总结
1. 背景:什么是分区表
分区表是把一张逻辑大表按某种规则拆分为多张物理子表的机制。逻辑上的“父表”只保存定义、不保存数据,真正的行数据全部存放在叶子分区中。它的核心价值在于:
- 可管理性 —— 可按时间 / 类别快速 ATTACH、DETACH、TRUNCATE 或批量清理整段数据;
- 查询性能 —— 分区剪枝(Partition Pruning)让优化器跳过无关分区;
- 并行与本地化 —— 分区智能连接 / 聚合、按分区并行扫描;
- 分层归档 —— 冷热数据可落在不同表空间甚至不同存储。
HaishanDB 的分区表不是一个独立的存储引擎,而是复用继承(Inheritance)机制、在系统目录上叠加的一层逻辑组织。这一点决定了它的全部架构特征:目录里描述“怎么分”,缓存里算好“分到哪”,规划器和执行器只读缓存。
-- RANGE 分区:按日志时间 |
2. 技术概览:三种分区策略与整体架构
2.1 三种分区策略对比
特性 | RANGE | LIST | HASH |
键列 | 通常单列,可多列 | 通常单列 | 任意列组合 |
边界来源 | FOR VALUES FROM ... TO ... | FOR VALUES IN (...) | FOR VALUES WITH (MODULUS, REMAINDER) |
存储结构 | 升序去重的上界 datums[] | 升序去重的取值 datums[] | (模数 M, 余数 R) 二元组 |
nindexes | ndatums + 1 | ndatums(与 datums 一一对应) | 所有分区模数的最小公倍数(最大模数 Mmax) |
定位方式 | 二分上界后取 indexes[off+1] | 二分命中后取 indexes[off] | rowHash % Mmax 直接索引 |
NULL 处理 | 不含 NULL,落 DEFAULT | 用 null_index 记录接收 NULL 的分区 | 函数 compute_partition_hash_value 参与哈希 |
缺口(gap) | 允许,缺口处 indexes = -1 | 不存在 | 允许,无分区处 indexes = -1 |
三种策略最终都收敛到同一个数据结构 PartitionBoundInfoData,只是 datums[] / indexes[] 的语义不同(见 3.4)。
2.2 整体架构分层
┌────────────────────────────────┐
│ 语法层 │
│ gram.y: PARTITION BY { RANGE | LIST | HASH } (key) │
│ FOR VALUES FROM/IN/WITH ... / DEFAULT │
├────────────────────────────────┤
│ DDL 处理层 │
│ parse_utilcmd.c transformPartitionBoundValue() │
│ tablecmds.c transformPartitionSpec() / DefineRelation() │
│ ComputePartitionAttrs() / StorePartitionBound │
│ partbounds.c check_new_partition_bound() │
│ check_default_partition_contents() │
├────────────────────────────────┤
│ 存储层(系统目录) │
│ pg_class relkind='p' / relispartition / relpartbound│
│ pg_partitioned_table partstrat / partnatts / partdefid / ... │
│ pg_inherits 父子关系 + inhdetachpending │
├────────────────────────────────┤
│ 缓存层(relcache) │
│ partcache.c RelationBuildPartitionKey() -> PartitionKey │
│ partdesc.c RelationBuildPartitionDesc() -> PartitionDesc │
│ partbounds.c partition_bounds_create() -> PartitionBoundInfo │
│ PartitionDirectory(查询期间视图固定) │
├────────────────────────────────┤
│ 规划层(Planner) │
│ inherit.c expand_inherited_rtentry() 继承展开 │
│ partprune.c gen_partprune_steps() 静态剪枝 │
│ make_partition_pruneinfo() │
│ partbounds.c partition_bounds_merge() 分区智能连接 │
│ planner.c create_partitionwise_grouping_paths() 聚合 │
├────────────────────────────────┤
│ 执行层(Executor) │
│ execPartition.c ExecSetupPartitionTupleRouting() 路由建树 │
│ get_partition_for_tuple() 按策略定位 │
│ CreatePartitionPruneState() 运行时剪枝 │
└────────────────────────────────┘
核心思想:一条数据从产生到落盘,要穿过“目录 → 缓存 → 规划 → 执行”四层,但每一层都不重新发明轮子——分区表复用继承拓扑、复用约束表达、复用索引与权限体系,只把“边界”这一新增概念在 PartitionBoundInfo 里表达清楚。
3. 内核深度解析
3.1 系统目录:pg_class / pg_partitioned_table / pg_inherits
分区表的元数据分散在三张系统目录里,分工明确。
- pg_class 承担分区表类型。
// src/include/catalog/pg_class.h:120, 140, 161-170 |
- pg_partitioned_table 承担记录分区键和分区策略。 它是对分区父表的一对一扩展表,仅父表有对应行。
// src/include/catalog/pg_partitioned_table.h:30-58 |
注意 partattrs[i] == 0 表示第 i 个键是表达式键(如 PARTITION BY RANGE ((a + b))),此时键的类型 / 排序规则从 partexprs 里推导。
- pg_inherits 承担“父子拓扑”,分区表没有额外的树结构,直接复用继承。
// src/include/catalog/pg_inherits.h:37 |
结论:目录只记录“声明式边界”,不记录“数据落在哪”。数据定位完全由缓存层派生,这正是分区表能做到零额外存储开销的原因。
3.2 分区键构建:RelationBuildPartitionKey
当某个分区父表第一次被访问时,relcache 会把 pg_partitioned_table 编译成一个只读的 PartitionKey:
// src/backend/utils/cache/partcache.c:80-268(节选) |
几个设计要点:
- 支撑函数的选择决定一切:RANGE / LIST 用 B-tree 比较函数(BTORDER_PROC)做升序二分;HASH 用扩展哈希(HASHEXTENDED_PROC)把任意类型压成 64 位哈希值。这就是为什么“分区键列必须有所需 opclass”。
- 表达式键预折叠:eval_const_expressions + fix_opfuncids 让分区键表达式与用户 WHERE 谓词经过相同处理后能精确匹配,这是静态剪枝能生效的前提。
- 键不可变:源码注释明确“分区键在创建后不允许修改”,所以 relcache 可以把指针直接交给调用方,只要持有 relation 就安全。
3.3 分区描述符:RelationBuildPartitionDesc
PartitionDesc 负责分区、边界和 OID。构建入口在源码中。
// src/backend/partitioning/partdesc.c:134-370(节选) |
两个容易被忽略但极重要的细节:
- mapping[] 的规范序:目录扫描返回的子分区顺序是任意(按 OID / 物理序)的,但 boundinfo 里的 datums[] 必须按边界升序。partition_bounds_create 返回的 mapping 就是把“目录序”重排成“边界序”的桥梁,partdesc->oids[] 因此始终是边界有序的,执行层可以直接用 indexes[] 里存的下标去取 OID。
- 双描述符 + xmin 可见性(并发关键):relcache 同时缓存 rd_partdesc(含全部,含正在被 DETACH 的)与 rd_partdesc_nodetached(排除它们)。后者附带你构建它时 pg_inherits.xmin,只有当这个 xmin 在当前快照中不可见时才能复用;否则重建。这是为了在“DETACH 正在进行”的空窗期里,让不同事务看到一致、不撕裂的目录视图。
执行期间还有一层保护:PartitionDirectory 保证同一个 OID 在一次查询中始终返回同一个 PartitionDesc:
// src/backend/partitioning/partdesc.c:409-429(节选) |
3.4 边界表示:PartitionBoundInfoData 与定位算法
整个分区机制最核心的数据结构,是 里的 PartitionBoundInfoData:
// src/include/partitioning/partbounds.h:79-99 |
datums[] 在三种策略下的语义(源码注释 写得很清楚):
- RANGE:datums[] 存的是分区上界,且相邻分区的上界 == 下一个的下界,所以只需存一份,ndatums 通常远小于 2 * nparts。indexes[] 长度为 ndatums + 1,多出的那一格表示“最后一个上界之上的区间”;缺口处为 -1。
- LIST:datums[] 是去重后的取值集合,按 opclass / collation 升序;indexes[off] 就是接收该值的分区序号,nindexes == ndatums。NULL 不进 datums,由 null_index 单独记录。
- HASH:datums[] 是 (模数 M, 余数 R) 二元组;nindexes 取所有分区模数的公倍数(最大模数 Mmax),查询时按 hash % Mmax 直接查 indexes[],未定义余数处为 -1。
归约入口 partition_bounds_create 会按策略分派到三个构造函数,并对输入做去重、排序、缺口检测:
// src/backend/partitioning/partbounds.c:303-356(节选) |
查找函数则是三个高度对称的二分 / 索引例程,执行层直接调用它们:
// src/include/partitioning/partbounds.h:135-144 |
把分区集合优化成有序边界数组 + 下标映射之后,定位简化成为一次二分或一次取模,与分区数量无关地保持 O(log n) / O(1),同时 datums 的紧凑存储让元数据内存占用远低于“逐分区存一份区间表达式”。
interleaved_parts 则是 LIST 策略特有的保守标记:若一个分区允许存放多个值、且存在别的分区可能落在这些值之间(如 IN (3,5) 与 IN (4)),就把它标记为“交错分区”。它只用于证明“某分区一定不交错”,用于剪枝时的安全判断。
3.5 DDL 处理:transformPartitionSpec 与 StorePartitionBound
CREATE TABLE ... PARTITION BY / PARTITION OF ... FOR VALUES 在 DDL 阶段完成三件事:解析策略、解析键列、把边界固化进目录。
- 解析分区键:transformPartitionSpec 校验策略与列/表达式,ComputePartitionAttrs 把键列编号与表达式填进 partattrs / partexprs:
// src/backend/commands/tablecmds.c:17238 / 17309 |
(2)解析边界值:transformPartitionBoundValue 对 FROM / TO / IN / WITH 中的实际值做类型转换并求值为 Const;MINVALUE / MAXVALUE 则由 transformPartitionBound 分派给 transformPartitionRangeBounds 转成专用 kind 标记(PARTITION_RANGE_DATUM_MINVALUE / PARTITION_RANGE_DATUM_MAXVALUE)。
(3)固化边界:DefineRelation 在建分区时调用 StorePartitionBound 把 PartitionBoundSpec 以 pg_node_tree 形式写入 pg_class.relpartbound,并设置 relispartition(StorePartitionBound 同时失效父表 relcache 以触发描述符重建):
// src/backend/commands/tablecmds.c:1106, 1146-1149(建分区路径) |
对于 PARTITION OF(直接建分区并 ATTACH),边界合法性由 check_new_partition_bound 统一校验:与既有分区是否重叠、是否落进 DEFAULT 分区已覆盖的范围、是否与“已被 DEFAULT 腾出的空间”冲突等。
3.6 规划期继承展开:expand_inherited_rtentry
规划器并不理解“分区”这个特殊词,它只看到“继承”。改写后的查询 RTE 是一个分区父表,规划器在 的 expand_inherited_rtentry 里把它展开为 Append + 各成员的 RELOPT_OTHER_MEMBER_REL:
// src/backend/optimizer/util/inherit.c:60-84(节选) |
展开后会附加 AppendRelInfo(含父/子 column 的 attno 映射与 child_relid),并额外记录分区方案(part_scheme)到 RelOptInfo,供后续剪枝与智能连接使用。分区表与普通继承表在这一层的处理是统一的,差别只在父表可能带 part_scheme 且会做剪枝。
3.7 静态分区剪枝:gen_partprune_steps 与 get_matching_partitions
剪枝的核心是把 WHERE 子句转换成对边界数组的操作。入口 gen_partprune_steps 递归分析谓词,生成一串 PartitionPruneStep:
- PartitionPruneStepOp:一条可用于剪枝的比较子句(键、比较表达式、比较函数、算子);
- PartitionPruneStepCombine:把多个步骤按 INTERSECT(对应 AND)或 UNION(对应 OR)合并。
// src/backend/partitioning/partprune.c:924-963(注释节选) |
生成后由 get_matching_partitions 逐步求值:先按 step_id 逐个执行 perform_pruning_base_step,再做 combine,最终把命中的 bound_offsets 经 boundinfo->indexes[] 映射成分区下标集合,并补上 NULL 分区 / DEFAULT 分区:
// src/backend/partitioning/partprune.c:879-919(节选) |
三个策略各自的“命中边界求解”实现在 、、:
- HASH 只支持等值类剪枝:col = c → 算出余数 → 一个分区;col IN (...) 亦可,逐值求余后合并命中分区;
- LIST 支持 = / <> / IN / NOT IN(<> 与 NOT IN 仅 LIST 策略支持,、),但交错分区会保守放行;
- RANGE 支持 = < <= > >=,用 bin_search 定位区间端点。
结果打包成 PartitionPruneInfo 挂到 Append / MergeAppend 计划节点(part_prune_info,、;PG 15 的 ModifyTable 不携带),构造入口为 、,这份信息同时被运行期复用(见 3.10)。
3.8 分区智能连接与分区智能聚合
当连接两侧都是“分区方式兼容”的表、且连接条件包含分区键的等值条件时,一个跨分区的大连接可以拆成“配对分区之间的小连接”。这由 partition_bounds_merge 完成——它不仅判断能否配对,还推导出连接关系自身的分区边界,让 N 路连接逐层下推:
// src/include/partitioning/partbounds.h:114-121 |
其算法思想见 :即使两侧边界不完全一致,只要“一方的每个分区最多只与另一方的一个分区相交”,就能配对;两侧的 PartitionSchemeData 还会被规划器做规范化去重,相同方案的 RelOptInfo 共享同一对象,既省内存又便于比较。
分区智能聚合(create_partitionwise_grouping_paths)则更直接:当 GROUP BY 包含全部分区键时,每个分组的数据必然来自单一分区,于是聚合可以完全下推到各分区完成,由上层 Append 汇总。
触发条件由 GUC 控制:enable_partitionwise_join、enable_partitionwise_aggregate,默认关闭以避免边界匹配与路径膨胀的开销。
3.9 执行期元组路由:get_partition_for_tuple
写入(INSERT / UPDATE / COPY)时,执行器需要把每一行送到正确的叶子分区。入口 ExecSetupPartitionTupleRouting 刻意“少做准备”——只有根父表的 PartitionDispatch 被立即初始化,子分区父表的 dispatch 与各叶子分区的 ResultRelInfo 都在首次命中时按需创建。每个 dispatch 对应一张分区父表,其 indexes[] 对每个分区记录路由目标——叶子分区指向 partitions[] 数组里 ResultRelInfo 的下标,子分区父表指向下一层 dispatch 的下标,尚未创建时为 -1( 文件头注释):
// src/backend/executor/execPartition.c:146-155(PartitionDispatch 结构) |
随后每一行调用 get_partition_for_tuple 按策略定位:
// src/backend/executor/execPartition.c:1356-1442(节选) |
命中后,对应的 ResultRelInfo(含表打开状态、触发器等)首次用到时才建立并缓存(ExecInitPartitionInfo),避免为所有分区预先开表;ExecFindPartition 找不到分区则报错并给出分区键值描述。
关键设计:路由框架一次建立、整条语句复用;子层 dispatch 与叶子关系按需惰性创建;定位全部复用 boundinfo,与规划期完全同一套边界。
3.10 运行时剪枝与子计划映射
静态剪枝只能利用规划期常量;当谓词里含执行期才知道的参数(嵌套循环内层参数、prepared statement 参数)时,需要在执行期再做一次剪枝。CreatePartitionPruneState 把规划期的 PartitionPruneInfo 与执行期最新的 PartitionDesc 对齐,构造 subplan_map / subpart_map:
// src/backend/executor/execPartition.c:1795-1833(节选) |
两件事值得强调:
- 分区“只增不减”的假设成立:因为 DETACH 会等待旧事务结束,所以执行期可以安全地请求“含 detached”的描述符,不会出现“计划里有、执行时没了”的悬空;
- 计划后新增的分区不参与本次查询:若本次执行时发现比计划期多出分区,新增者一律映射为 -1(当作已被剪掉),保证计划与结果的对应关系不破。
规划期若发现步骤含执行参数(do_exec_prune 为真),运行期由 Append / MergeAppend 执行节点初始化 PartitionPruneState(ExecInitPartitionPruning)并经 ExecFindMatchingSubPlans 只初始化真正需要的子计划(PG 15 的 nodeModifyTable.c 不参与运行时剪枝)。
3.11 ATTACH / DETACH PARTITION 与并发一致性
ATTACH PARTITION(ATExecAttachPartition)把一张已有表挂成某父表的分区:
- 取父子锁,写 relispartition + relpartbound( 调用 StorePartitionBound);
- 用 check_new_partition_bound 校验边界不与既有分区重叠、不侵入 DEFAULT 分区;
- 约束校验:若该表上已有与分区约束等价的 CHECK 约束,可跳过;否则必须全表扫描逐行验证,不允许任何“漏网”行。
DETACH PARTITION 分两种:
- 普通 DETACH(ATExecDetachPartition)在单事务内完成,取 AccessExclusiveLock;
- DETACH CONCURRENTLY 是两阶段:ATExecDetachPartition 第一阶段先由 MarkInheritDetached 在 pg_inherits 上置 inhdetachpending = true 做“半分离”——此时对分区只加 ShareUpdateExclusiveLock,不阻塞并发 DML——并由 DetachAddConstraintIfNeeded 补上等价 CHECK 约束、提交第一阶段事务;随后等待父表与分区上所有持锁旧事务结束,最后 DetachPartitionFinalize 收尾——删除 pg_inherits 行、清空 relpartbound 并重置 relispartition、解除克隆的触发器与继承外键。若第一阶段事务意外中止,分区停留在 inhdetachpending 中间态,需由 ALTER TABLE ... DETACH PARTITION ... FINALIZE(ATExecDetachPartitionFinalize)完成清理。
这里与 3.3 的双描述符形成闭环:正在 inhdetachpending 的分区会被 rd_partdesc_nodetached 依据 xmin 的可见性从新视图中排除,但仍在 rd_partdesc(旧视图)中可见——每个事务要么看到“还挂着”,要么看到“已脱离”,不会看到撕裂的中间态。
4. 实践洞察与踩坑指南
- 分区数量不是越多越好。计划期会为每个分区展开 AppendRelInfo 与子计划,元数据(oids[]、datums[]、indexes[])常驻 relcache。分区上千后,规划时间与 describe 开销显著上升,建议控制在“数百”量级,必要时用多级分区收敛基数。
- 剪枝能力取决于谓词形态和策略。HASH 只支持等值剪枝;LIST 遇到“交错分区”(IN (3,5) 与 IN (4) 并存)会保守放行;RANGE 对 = / 范围都能剪。写了非剪枝友好的谓词(如函数包裹分区键)会导致全分区扫描。
- ATTACH 的隐藏代价是全表校验。若待挂表上已有等价 CHECK 约束,ATTACH 可“秒挂”;否则内核必须扫描全表验证每一行都落在目标边界内——对大表而言这可能是一次沉重的 I/O。生产上建议先加 CHECK 再 ATTACH。
- DETACH CONCURRENTLY 的真实限制:不能在事务块 / 函数中执行(,否则中途提交会撕裂事务块);父表存在 DEFAULT 分区时直接报错拒绝(“cannot detach partitions concurrently when a default partition exists”);执行中途会提交一轮事务并等待所有持锁旧事务,长事务会拖住收尾,期间分区处于 inhdetachpending 的中间态,必要时用 ALTER TABLE ... DETACH PARTITION ... FINALIZE 完成清理。
- DEFAULT 分区既是兜底也是剪枝的“黑洞”。任何可能落入缺口 / 未命中范围的谓词都会把 DEFAULT 分区重新纳入扫描,剪枝收益被削弱。能用显式边界覆盖的数据,尽量不要依赖 DEFAULT。
- 分区智能连接 / 聚合有前提且默认关闭。需要分区键与连接键 / GROUP BY 匹配、分区方案兼容,且要开 enable_partitionwise_join / enable_partitionwise_aggregate;否则退化为普通 Append + 全局连接 / 聚合。
- 索引要落到每个分区。分区父表上的 CREATE INDEX 生成的是 RELKIND_PARTITIONED_INDEX(无存储),真正的索引在各自分区上;UNIQUE / 主键必须包含分区键,否则无法在各分区内独立保证全局唯一。
- CREATE INDEX / REINDEX 对分区索引是逐分区进行的,大分区表上要有“分区粒度”的维护策略。
- relcache 命中是性能前提。PartitionKey / PartitionDesc 都在首次访问时构建并常驻缓存,且分区键不可变;频繁触发 relcache 失效重建(如 DDL 风暴)会反复付出构建成本。
- 运行期剪枝让 prepared statement 也能受益,但要求参数是“剪枝可识别”的位置(能对应到 PartitionPruneStepOp 的比较表达式);把参数藏在不可拆分的表达式里会退化。
5. 总结
PostgreSQL 分区表的技术架构可以用一条主链概括:
系统目录(声明边界) → relcache(编译为 PartitionKey + PartitionDesc) → 规划器(继承展开 + 静态剪枝 + 分区智能连接) → 执行器(元组路由 + 运行时剪枝)。
其中的设计取舍:
维度 | 做法 | 收益 |
存储 | 复用继承,父表不存数据 | 零额外存储,复用约束 / 索引 / 权限 |
元数据 | pg_class.relpartbound 存声明式边界 | 与用户约束隔离,可原样 dump |
边界表示 | datums[] + indexes[] 有序数组 | 定位 O(log n) / O(1),内存紧凑 |
缓存 | 双描述符 + xmin 可见性 | 并发 DETACH 下视图一致不撕裂 |
规划 | 静态剪枝 + 分区智能连接 / 聚合 | 大幅减少扫描与连接规模 |
执行 | 路由树惰性建 ResultRelInfo + 运行时剪枝 | 均摊开销低,参数化查询也受益 |
一句话总结:分区表把“分区”实现为“继承 + 一张有序边界表”,剪枝、路由、智能连接都建立在这张边界表之上。理解了 PartitionBoundInfoData,就理解了分区表的全部行为。




