暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

移动云海山数据库 HaishanDB:分区表技术架构分析

原创 移动云HaishanDB 5天前
41

本文主要内容:HaishanDB 分区表(Partitioned Table)的完整内核实现机制,包括三种分区策略(RANGE / LIST / HASH)的语法与系统目录存储、relcache 层的分区键(PartitionKey)与分区描述符(PartitionDesc)构建、PartitionBoundInfo 的边界表示与二分 / 取模定位、Planner 层的继承展开与静态分区剪枝、Executor 层的元组路由(Tuple Routing)与运行时剪枝、分区智能连接(Partitionwise Join)与分区智能聚合,以及 ATTACH / DETACH PARTITION 的 DDL 处理与并发一致性保障。文中所有代码均取自 PostgreSQL 15.5 内核源码。

目录

  1. 背景:什么是分区表
  2. 技术概览:三种分区策略与整体架构
    • 2.1 三种分区策略对比
    • 2.2 整体架构分层
  3. 内核深度解析
    • 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 与并发一致性
  4. 实践洞察与踩坑指南
  5. 总结

1. 背景:什么是分区表

分区表是把一张逻辑大表按某种规则拆分为多张物理子表的机制。逻辑上的“父表”只保存定义、不保存数据,真正的行数据全部存放在叶子分区中。它的核心价值在于:

  1. 可管理性 —— 可按时间 / 类别快速 ATTACH、DETACH、TRUNCATE 或批量清理整段数据;
  2. 查询性能 —— 分区剪枝(Partition Pruning)让优化器跳过无关分区;
  3. 并行与本地化 —— 分区智能连接 / 聚合、按分区并行扫描;
  4. 分层归档 —— 冷热数据可落在不同表空间甚至不同存储。

HaishanDB 的分区表不是一个独立的存储引擎,而是复用继承(Inheritance)机制、在系统目录上叠加的一层逻辑组织。这一点决定了它的全部架构特征:目录里描述“怎么分”,缓存里算好“分到哪”,规划器和执行器只读缓存。

-- RANGE 分区:按日志时间
CREATE TABLE logs (
id bigint,
ts timestamp,
msg text
) PARTITION BY RANGE (ts);

CREATE TABLE logs_2026_01 PARTITION OF logs
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

-- LIST 分区:按区域
CREATE TABLE users (id int, region text) PARTITION BY LIST (region);
CREATE TABLE users_cn PARTITION OF users FOR VALUES IN ('cn', 'hk');

-- HASH 分区:按哈希均匀打散
CREATE TABLE kv (k int, v text) PARTITION BY HASH (k);
CREATE TABLE kv_p0 PARTITION OF kv FOR VALUES WITH (MODULUS 4, REMAINDER 0);

-- DEFAULT 分区:兜底
CREATE TABLE logs_default PARTITION OF logs DEFAULT;

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

分区表的元数据分散在三张系统目录里,分工明确。

  1. pg_class 承担分区表类型。

// src/include/catalog/pg_class.h:120, 140, 161-170
bool relispartition BKI_DEFAULT(f); /* 该表是否为某个分区 */
pg_node_tree relpartbound BKI_DEFAULT(_null_); /* 本分区的 PartitionBoundSpec */

#define RELKIND_RELATION 'r' /* 普通表(叶子分区就是 'r') */
#define RELKIND_PARTITIONED_TABLE 'p' /* 分区父表(只存定义,不存数据) */
#define RELKIND_PARTITIONED_INDEX 'I' /* 分区索引(无存储,仅挂接子索引) */

  1. pg_partitioned_table 承担记录分区键和分区策略。 它是对分区父表的一对一扩展表,仅父表有对应行。

// src/include/catalog/pg_partitioned_table.h:30-58
CATALOG(pg_partitioned_table,3350,PartitionedRelationId)
{
Oid partrelid; /* 分区父表 OID */
char partstrat; /* 分区策略:'r' / 'l' / 'h' */
int16 partnatts; /* 分区键列数 */
Oid partdefid; /* DEFAULT 分区 OID,0 表示没有 */

int2vector partattrs; /* 每个键列属性号;0 表示该键是表达式 */
oidvector partclass; /* 每个键列的 opclass */
oidvector partcollation; /* 每个键列的排序规则 */
pg_node_tree partexprs; /* 表达式键的 expression list(对应 partattrs 中的 0) */
} FormData_pg_partitioned_table;

注意 partattrs[i] == 0 表示第 i 个键是表达式键(如 PARTITION BY RANGE ((a + b))),此时键的类型 / 排序规则从 partexprs 里推导。

  1. pg_inherits 承担“父子拓扑”,分区表没有额外的树结构,直接复用继承。

// src/include/catalog/pg_inherits.h:37
bool inhdetachpending; /* 并发 DETACH 的两阶段标记(见 3.11) */

结论:目录只记录“声明式边界”,不记录“数据落在哪”。数据定位完全由缓存层派生,这正是分区表能做到零额外存储开销的原因。

3.2 分区键构建:RelationBuildPartitionKey

当某个分区父表第一次被访问时,relcache 会把 pg_partitioned_table 编译成一个只读的 PartitionKey:

// src/backend/utils/cache/partcache.c:80-268(节选)
static void
RelationBuildPartitionKey(Relation relation)
{
/* 分配独立的 partkeycxt,最终 re-parent 到 CacheMemoryContext */
partkeycxt = AllocSetContextCreate(CurTransactionContext,
"partition key", ALLOCSET_SMALL_SIZES);

key->strategy = form->partstrat; /* 'r' / 'l' / 'h' */
key->partnatts = form->partnatts;

/* 表达式键做常量折叠 + fix_opfuncids,保证与查询谓词同构可匹配 */
expr = eval_const_expressions(NULL, expr);
fix_opfuncids(expr);

/* 关键:按策略选择比较支撑函数 */
procnum = (key->strategy == PARTITION_STRATEGY_HASH) ?
HASHEXTENDED_PROC : BTORDER_PROC;

for (i = 0; i < key->partnatts; i++)
{
opclasstup = SearchSysCache1(CLAOID, ...);
opclassform = (Form_pg_opclass) GETSTRUCT(opclasstup);
key->partopfamily[i] = opclassform->opcfamily;
key->partopcintype[i] = opclassform->opcintype;

/* 取 opfamily 的支撑函数并 fmgr 缓存 */
funcid = get_opfamily_proc(opclassform->opcfamily,
opclassform->opcintype,
opclassform->opcintype, procnum);
fmgr_info_cxt(funcid, &key->partsupfunc[i], partkeycxt);

/* 键列的 typid / typmod / collation,供比较与哈希使用 */
get_typlenbyvalalign(key->parttypid[i], &key->parttyplen[i], ...);
}

MemoryContextSetParent(partkeycxt, CacheMemoryContext);
relation->rd_partkeycxt = partkeycxt;
relation->rd_partkey = key;
}

几个设计要点:

  • 支撑函数的选择决定一切: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(节选)
static PartitionDesc
RelationBuildPartitionDesc(Relation rel, bool omit_detached)
{
/* 1) 从 pg_inherits 一次快照取出全部子分区 OID */
inhoids = find_inheritance_children_extended(RelationGetRelid(rel),
omit_detached, NoLock,
&detached_exist, &detached_xmin);

/* 2) 逐个取 pg_class.relpartbound,得到 PartitionBoundSpec */
foreach(cell, inhoids)
{
tuple = SearchSysCache1(RELOID, inhrelid);
datum = SysCacheGetAttr(RELOID, tuple, Anum_pg_class_relpartbound, &isnull);
if (!isnull)
boundspec = stringToNode(TextDatumGetCString(datum));

/* 缓存可能滞后于并发 ATTACH,退化为直接扫 pg_class 兜底 */
if (boundspec == NULL) { /* systable scan on pg_class ... */ }

is_leaf[i] = (get_rel_relkind(inhrelid) != RELKIND_PARTITIONED_TABLE);
boundspecs[i] = boundspec;
}

/* 3) 把一堆 boundspec 归约成统一边界表示 + 到规范序的映射 */
boundinfo = partition_bounds_create(boundspecs, nparts, key, &mapping);

/* 4) 按 mapping 把 OID / leaf 标志重排到“边界序”,拷贝进私有 context */
for (i = 0; i < nparts; i++)
partdesc->oids[mapping[i]] = oids[i];

/* 5) 区分“含全部分区”与“排除正在并发 DETACH 的分区”两套描述符 */
if (is_omit) {
rel->rd_pddcxt = new_pdcxt;
rel->rd_partdesc_nodetached = partdesc;
rel->rd_partdesc_nodetached_xmin = detached_xmin;
} else {
rel->rd_pdcxt = new_pdcxt;
rel->rd_partdesc = partdesc;
}
}

两个容易被忽略但极重要的细节:

  • 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(节选)
PartitionDesc
PartitionDirectoryLookup(PartitionDirectory pdir, Relation rel)
{
pde = hash_search(pdir->pdir_hash, &relid, HASH_ENTER, &found);
if (!found) {
RelationIncrementReferenceCount(rel); /* 钉住 relation,防止描述符被释放 */
pde->rel = rel;
pde->pd = RelationGetPartitionDesc(rel, pdir->omit_detached);
}
return pde->pd;
}

3.4 边界表示:PartitionBoundInfoData 与定位算法

整个分区机制最核心的数据结构,是 里的 PartitionBoundInfoData:

// src/include/partitioning/partbounds.h:79-99
typedef struct PartitionBoundInfoData
{
char strategy; /* hash / list / range */
int ndatums; /* datums[] 长度 */
Datum **datums; /* 边界元组数组,每个含 partnatts 个 Datum */
PartitionRangeDatumKind **kind; /* 每个 range 边界的“开闭”语义;list/hash 为 NULL */
Bitmapset *interleaved_parts; /* LIST:可能有值交错的“交错分区” */
int nindexes; /* indexes[] 长度 */
int *indexes; /* 下标 -> 分区序号,-1 表示无分区 */
int null_index; /* 接收 NULL 的分区序号,-1 表示无 */
int default_index; /* DEFAULT 分区序号,-1 表示无 */
} PartitionBoundInfoData;

#define partition_bound_accepts_nulls(bi) ((bi)->null_index != -1)
#define partition_bound_has_default(bi) ((bi)->default_index != -1)

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(节选)
PartitionBoundInfo
partition_bounds_create(PartitionBoundSpec **boundspecs, int nparts,
PartitionKey key, int **mapping)
{
if (key->strategy == PARTITION_STRATEGY_HASH)
return create_hash_bounds(boundspecs, nparts, key, mapping); /* :356 */
if (key->strategy == PARTITION_STRATEGY_LIST)
return create_list_bounds(boundspecs, nparts, key, mapping); /* :471 */
if (key->strategy == PARTITION_STRATEGY_RANGE)
return create_range_bounds(boundspecs, nparts, key, mapping); /* :686 */
}

查找函数则是三个高度对称的二分 / 索引例程,执行层直接调用它们:

// src/include/partitioning/partbounds.h:135-144
extern int partition_list_bsearch(FmgrInfo *partsupfunc, Oid *partcollation,
PartitionBoundInfo boundinfo, Datum value, bool *is_equal);
extern int partition_range_datum_bsearch(FmgrInfo *partsupfunc, Oid *partcollation,
PartitionBoundInfo boundinfo, int nvalues,
Datum *values, bool *is_equal);
extern int partition_hash_bsearch(PartitionBoundInfo boundinfo, int modulus, int remainder);

把分区集合优化成有序边界数组 + 下标映射之后,定位简化成为一次二分或一次取模,与分区数量无关地保持 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 阶段完成三件事:解析策略、解析键列、把边界固化进目录。

  1. 解析分区键:transformPartitionSpec 校验策略与列/表达式,ComputePartitionAttrs 把键列编号与表达式填进 partattrs / partexprs:

// src/backend/commands/tablecmds.c:17238 / 17309
static PartitionSpec *transformPartitionSpec(Relation rel, PartitionSpec *partspec,
char *strategy);
static void ComputePartitionAttrs(ParseState *pstate, Relation rel, List *partParams,
AttrNumber *partattrs, List **partexprs,
Oid *partopclass, Oid *partcollation, char strategy);

(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(建分区路径)
StorePartitionBound(rel, parent, bound); /* 写入 relpartbound */
stmt->partspec = transformPartitionSpec(rel, stmt->partspec, &strategy);
ComputePartitionAttrs(pstate, rel, stmt->partspec->partParams, ...);

对于 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(节选)
void
expand_inherited_rtentry(PlannerInfo *root, RelOptInfo *rel, RangeTblEntry *rte,
Index rti)
{
...
/* 分区表同样走继承展开:取出所有活着的子关系,构造 AppendRelInfo */
...
}

展开后会附加 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(注释节选)
// OR BoolExpr 的步骤用 PARTPRUNE_COMBINE_UNION 合并;
// AND BoolExpr 的步骤用 PARTPRUNE_COMBINE_INTERSECT 合并;
// 收到互斥子句 / 与分区约束矛盾 / 伪常量 false 时:
// 置 context->contradictory = true,返回 NIL —— 调用方把所有分区视为已剪掉。

生成后由 get_matching_partitions 逐步求值:先按 step_id 逐个执行 perform_pruning_base_step,再做 combine,最终把命中的 bound_offsets 经 boundinfo->indexes[] 映射成分区下标集合,并补上 NULL 分区 / DEFAULT 分区:

// src/backend/partitioning/partprune.c:879-919(节选)
while ((i = bms_next_member(final_result->bound_offsets, i)) >= 0)
{
partindex = context->boundinfo->indexes[i];
if (partindex < 0) {
/* range 的 gap / hash 的空余数:查询范围仍覆盖它,需扫 DEFAULT */
scan_default |= partition_bound_has_default(context->boundinfo);
continue;
}
result = bms_add_member(result, partindex);
}
if (final_result->scan_null)
result = bms_add_member(result, context->boundinfo->null_index);
if (scan_default)
result = bms_add_member(result, context->boundinfo->default_index);

三个策略各自的“命中边界求解”实现在 、、:

  • 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
extern PartitionBoundInfo partition_bounds_merge(int partnatts, FmgrInfo *partsupfunc,
Oid *partcollation,
struct RelOptInfo *outer_rel,
struct RelOptInfo *inner_rel,
JoinType jointype,
List **outer_parts, List **inner_parts);

其算法思想见 :即使两侧边界不完全一致,只要“一方的每个分区最多只与另一方的一个分区相交”,就能配对;两侧的 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 结构)
typedef struct PartitionDispatchData
{
Relation reldesc; /* 分区父表 */
PartitionKey key; /* 其分区键 */
List *keystate; /* 分区键各表达式的求值状态 */
PartitionDesc partdesc; /* 分区描述符 */
TupleTableSlot *tupslot;
AttrMap *tupmap; /* 父子行描述的属性号映射 */
int indexes[FLEXIBLE_ARRAY_MEMBER]; /* 见上文 */
} PartitionDispatchData;

随后每一行调用 get_partition_for_tuple 按策略定位:

// src/backend/executor/execPartition.c:1356-1442(节选)
switch (key->strategy)
{
case PARTITION_STRATEGY_HASH:
rowHash = compute_partition_hash_value(key->partnatts, key->partsupfunc,
key->partcollation, values, isnull);
part_index = boundinfo->indexes[rowHash % boundinfo->nindexes];
break;

case PARTITION_STRATEGY_LIST:
if (isnull[0]) {
if (partition_bound_accepts_nulls(boundinfo))
part_index = boundinfo->null_index;
} else {
bound_offset = partition_list_bsearch(key->partsupfunc, key->partcollation,
boundinfo, values[0], &equal);
if (bound_offset >= 0 && equal)
part_index = boundinfo->indexes[bound_offset];
}
break;

case PARTITION_STRATEGY_RANGE:
/* 任一键为 NULL 则 range 无法匹配 */
if (!range_partkey_has_null) {
bound_offset = partition_range_datum_bsearch(key->partsupfunc,
key->partcollation, boundinfo,
key->partnatts, values, &equal);
/* bound_offset 是“小于等于”的边界,故取 off+1 才是所属上界 */
part_index = boundinfo->indexes[bound_offset + 1];
}
break;
}

/* 未命中则回退 DEFAULT 分区 */
if (part_index < 0)
part_index = boundinfo->default_index;

命中后,对应的 ResultRelInfo(含表打开状态、触发器等)首次用到时才建立并缓存(ExecInitPartitionInfo),避免为所有分区预先开表;ExecFindPartition 找不到分区则报错并给出分区键值描述。

关键设计:路由框架一次建立、整条语句复用;子层 dispatch 与叶子关系按需惰性创建;定位全部复用 boundinfo,与规划期完全同一套边界。

3.10 运行时剪枝与子计划映射

静态剪枝只能利用规划期常量;当谓词里含执行期才知道的参数(嵌套循环内层参数、prepared statement 参数)时,需要在执行期再做一次剪枝。CreatePartitionPruneState 把规划期的 PartitionPruneInfo 与执行期最新的 PartitionDesc 对齐,构造 subplan_map / subpart_map:

// src/backend/executor/execPartition.c:1795-1833(节选)
/* 执行期请求的是“含 detached”的描述符:detach 会等待旧事务,故分区只增不减 */
Assert(partdesc->nparts >= pinfo->nparts);
pprune->nparts = partdesc->nparts;
pprune->subplan_map = palloc(sizeof(int) * partdesc->nparts);

if (partdesc->nparts == pinfo->nparts) {
/* 无新分区:直接复用计划期映射并拷贝 subplan_map */
pprune->subpart_map = pinfo->subpart_map;
memcpy(pprune->subplan_map, pinfo->subplan_map, sizeof(int) * pinfo->nparts);
} else {
/* 计划后新增的分区:映射为 -1,视同已剪掉(本次查询看不到) */
...
}

两件事值得强调:

  • 分区“只增不减”的假设成立:因为 DETACH 会等待旧事务结束,所以执行期可以安全地请求“含 detached”的描述符,不会出现“计划里有、执行时没了”的悬空;
  • 计划后新增的分区不参与本次查询:若本次执行时发现比计划期多出分区,新增者一律映射为 -1(当作已被剪掉),保证计划与结果的对应关系不破。

规划期若发现步骤含执行参数(do_exec_prune 为真),运行期由 Append / MergeAppend 执行节点初始化 PartitionPruneState(ExecInitPartitionPruning)并经 ExecFindMatchingSubPlans 只初始化真正需要的子计划(PG 15 的 nodeModifyTable.c 不参与运行时剪枝)。

3.11 ATTACH / DETACH PARTITION 与并发一致性

ATTACH PARTITION(ATExecAttachPartition)把一张已有表挂成某父表的分区:

  1. 取父子锁,写 relispartition + relpartbound( 调用 StorePartitionBound);
  2. 用 check_new_partition_bound 校验边界不与既有分区重叠、不侵入 DEFAULT 分区;
  3. 约束校验:若该表上已有与分区约束等价的 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. 实践洞察与踩坑指南

  1. 分区数量不是越多越好。计划期会为每个分区展开 AppendRelInfo 与子计划,元数据(oids[]、datums[]、indexes[])常驻 relcache。分区上千后,规划时间与 describe 开销显著上升,建议控制在“数百”量级,必要时用多级分区收敛基数。
  2. 剪枝能力取决于谓词形态和策略。HASH 只支持等值剪枝;LIST 遇到“交错分区”(IN (3,5) 与 IN (4) 并存)会保守放行;RANGE 对 = / 范围都能剪。写了非剪枝友好的谓词(如函数包裹分区键)会导致全分区扫描。
  3. ATTACH 的隐藏代价是全表校验。若待挂表上已有等价 CHECK 约束,ATTACH 可“秒挂”;否则内核必须扫描全表验证每一行都落在目标边界内——对大表而言这可能是一次沉重的 I/O。生产上建议先加 CHECK 再 ATTACH。
  4. DETACH CONCURRENTLY 的真实限制:不能在事务块 / 函数中执行(,否则中途提交会撕裂事务块);父表存在 DEFAULT 分区时直接报错拒绝(“cannot detach partitions concurrently when a default partition exists”);执行中途会提交一轮事务并等待所有持锁旧事务,长事务会拖住收尾,期间分区处于 inhdetachpending 的中间态,必要时用 ALTER TABLE ... DETACH PARTITION ... FINALIZE 完成清理。
  5. DEFAULT 分区既是兜底也是剪枝的“黑洞”。任何可能落入缺口 / 未命中范围的谓词都会把 DEFAULT 分区重新纳入扫描,剪枝收益被削弱。能用显式边界覆盖的数据,尽量不要依赖 DEFAULT。
  6. 分区智能连接 / 聚合有前提且默认关闭。需要分区键与连接键 / GROUP BY 匹配、分区方案兼容,且要开 enable_partitionwise_join / enable_partitionwise_aggregate;否则退化为普通 Append + 全局连接 / 聚合。
  7. 索引要落到每个分区。分区父表上的 CREATE INDEX 生成的是 RELKIND_PARTITIONED_INDEX(无存储),真正的索引在各自分区上;UNIQUE / 主键必须包含分区键,否则无法在各分区内独立保证全局唯一。
  8. CREATE INDEX / REINDEX 对分区索引是逐分区进行的,大分区表上要有“分区粒度”的维护策略。
  9. relcache 命中是性能前提。PartitionKey / PartitionDesc 都在首次访问时构建并常驻缓存,且分区键不可变;频繁触发 relcache 失效重建(如 DDL 风暴)会反复付出构建成本。
  10. 运行期剪枝让 prepared statement 也能受益,但要求参数是“剪枝可识别”的位置(能对应到 PartitionPruneStepOp 的比较表达式);把参数藏在不可拆分的表达式里会退化。

5. 总结

PostgreSQL 分区表的技术架构可以用一条主链概括:

系统目录(声明边界) → relcache(编译为 PartitionKey + PartitionDesc) → 规划器(继承展开 + 静态剪枝 + 分区智能连接) → 执行器(元组路由 + 运行时剪枝)。

其中的设计取舍:

维度

做法

收益

存储

复用继承,父表不存数据

零额外存储,复用约束 / 索引 / 权限

元数据

pg_class.relpartbound 存声明式边界

与用户约束隔离,可原样 dump

边界表示

datums[] + indexes[] 有序数组

定位 O(log n) / O(1),内存紧凑

缓存

双描述符 + xmin 可见性

并发 DETACH 下视图一致不撕裂

规划

静态剪枝 + 分区智能连接 / 聚合

大幅减少扫描与连接规模

执行

路由树惰性建 ResultRelInfo + 运行时剪枝

均摊开销低,参数化查询也受益

一句话总结:分区表把“分区”实现为“继承 + 一张有序边界表”,剪枝、路由、智能连接都建立在这张边界表之上。理解了 PartitionBoundInfoData,就理解了分区表的全部行为。

最后修改时间:2026-10-29 22:24:47
「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论