PostgreSQL 中唯一索引的工作原理
引言
在研究分区表相关解决方案时,曾聚焦于如何在包含多个子表的分区表内,实现跨分区的数据唯一性保障。这类功能有时被称为全局索引,相关领域的讨论可参考邮件线程此处。尽管其中提出的思路具备一定合理性,但对应的实现方式却引发了较多争议 —— 该方式改变了分区索引的存储逻辑,本质是将所有分区索引合并存储,并以TableOid作为内部引用的键值。
基于此,探索一种替代方案:在不改变 PostgreSQL 原有核心机制的前提下,实现跨分区唯一性保障。而要推进这一探索,首要任务是先厘清 PostgreSQL 分区表中,唯一性机制的底层工作原理。
CREATE INDEX 语句的唯一性保障
PostgreSQL 会通过以下基本流程检查唯一性冲突:
- 对目标子分区表执行堆扫描(heap scan)。
- 将可见元组(visible tuples)存储到一个
BTSpool
结构(记为 spool1)中,将无效元组(dead tuples)存储到另一个BTSpool
结构(记为 spool2)中。因此,这里会用到两个BTSpool
结构;若表中无无效元组,或无需保障唯一性,则 spool2 可能为 NULL。BTSpool
结构可理解为索引元组的集合。 - 若 spool1 和 spool2 存在,则对其进行排序。
- 排序算法内置重复检测功能:若排序后有两个相同的元组连续出现,即判定为重复;若该索引要求唯一性,则会在此处抛出错误。
- 若排序过程中未检测到重复,PostgreSQL 会基于 spool1 和 spool2 构建索引树。
- 索引创建完成后,销毁所有
BTSpool
结构。 - 上述逻辑位于
src/backend/access/nbtree/nbtsort.c
文件的btbuild()
函数中,并由indexcmds.c
文件的DefineIndex
函数调用。根据活跃子分区表的数量,DefineIndex
会多次调用btbuild
。
INSERT 与 UPDATE 语句的唯一性保障
在规划器(planner)和优化器(optimizer)阶段,PostgreSQL 已确定新数据应插入或更新至哪个子分区表:
- PostgreSQL 首先将堆元组(heap tuple)插入目标堆关系(heap relation)。
- 随后调用
src/backend/access/nbtree/nbtinsert.c
文件中的_bt_doinsert()
函数,尝试插入与该堆元组关联的新索引元组。 - 若索引要求进行唯一性检查,PostgreSQL 会根据新堆元组构建扫描键(scan key),并调用
_bt_check_unique()
函数从堆分区表中查询是否存在匹配的现有元组。 - 若当前子分区中未查询到匹配的堆元组,则无冲突。
- 若当前子分区中查询到匹配的堆元组,则需执行以下额外检查:
- 若查询到的元组尚未提交(例如,另一个后端进程仍在处理该元组且未提交),当前进程会在此处等待,直至该后端进程提交或回滚。
- 当该后端进程提交或回滚后,当前进程会再次查询同一元组。
- 若该后端进程回滚,则无法查询到重复元组,因此无冲突。
- 若该后端进程提交,则仍可查询到重复元组,因此存在潜在冲突。
- 在抛出错误前,PostgreSQL 会再执行一次检查:获取当前待插入堆元组的可见性状态。这是为了覆盖一种特殊场景——当当前后端进程尝试插入或更新数据时,另一个后端进程正在执行
CREATE UNIQUE INDEX CONCURRENTLY
(并发创建唯一索引)操作。 - 检查待插入的当前元组是否能从堆关系中查询到。
- 若能查询到,则当前元组仍处于可见状态,必然存在冲突。
- 若无法查询到,则当前元组已变为不可见状态,不视为冲突。
- 若未检测到冲突,则继续构建索引树。
上述逻辑主要位于src/backend/access/nbtree/nbtinsert.c
文件的_bt_doinsert()
和_bt_check_unique()
函数中。
ATTACH 操作的唯一性保障
待附加(ATTACH)的表可能已定义唯一索引(或非唯一索引),也可能完全没有索引。ATTACH 操作过程中存在两种潜在情况:
- 向分区表附加无索引的表时,PostgreSQL 会自动为该附加表创建新索引,且索引参数与原分区表保持一致。目前,该索引的创建流程遵循
2.0
节中定义的步骤。 - 向分区表附加已定义唯一索引的表(且该索引的键与分区表的全局唯一索引键相同)时,PostgreSQL 不会为该附加表创建新索引,而是直接完成附加操作。