受支持版本: 16 / 15 / 14

62.4. 实现 #

本节介绍一些可能对高级用户有用的 B-树索引实现细节。若要查看更详细、更加侧重内部机制的 B-树实现说明,请参见源码发布包中的 src/backend/access/nbtree/README

62.4.1. B-树结构 #

PostgreSQL 的 B-树 索引是多层树结构,其中树的每一层都可以用作页的双向链表。索引的第一个段文件起始处的固定位置保存着一个元页。其余页面要么是叶页,要么是内部页。叶页位于树的最低层,其余各层都由内部页组成。每个叶页都包含指向表中行的元组。每个内部页都包含指向树中下一层的元组。通常,超过 99% 的页面都是叶页。内部页和叶页都使用Section 68.6中描述的标准页格式。

当现有叶页无法容纳一个新传入的元组时,B-树索引就会增加新的叶页。一次页拆分操作会把原本属于溢出页的一部分项移动到新页中,从而为这些项腾出空间。页拆分还必须在父页中插入一个指向新页的下行链接,这又可能导致父页继续拆分。页拆分会以递归的方式向上级联。当根页最终也容纳不下新的下行链接时,就会发生一次根页拆分操作。它通过创建一个位于原始根页之上的新根页,为树结构增加一个新的层级。

62.4.2. 自底向上索引删除 #

B-树索引并不直接知道,在 MVCC 下同一个逻辑表行可能存在多个现存版本;对索引来说,每个元组都是一个独立对象,都需要自己的索引项。版本频繁更替元组有时会积累起来,并对查询延迟和吞吐量造成不利影响。这通常发生在以 UPDATE 为主的工作负载中,因为其中大多数单次更新都无法应用 HOT 优化。 在一次 UPDATE 中,如果哪怕只修改了某一个被某个索引覆盖的列的值,总是都需要生成一组新的索引元组,也就是表上每一个索引都要有一个。要特别注意,这也包括那些没有被 逻辑修改、但仍会受到UPDATE影响的索引。所有索引都需要一个新的后继物理索引元组,指向表中的最新版本。每个索引中的新元组通常都需要在一小段时间内与原始的被更新元组共存(通常直到 UPDATE 事务提交后不久)。

B-树索引会通过执行自底向上索引删除轮次,增量地删除这类版本频繁更替产生的索引元组。每一轮删除都是因预期中的版本频繁更替页拆分而触发的。这只会发生在那些没有被 UPDATE 语句逻辑修改的索引上,否则过时版本就会集中积累在某些特定页面中。通常可以避免页拆分,不过也可能出现某些实现层面的启发式规则甚至一个垃圾索引元组都识别不出来、删不掉的情况(这时就要靠页拆分或一次去重轮次来解决新元组放不进叶页的问题)。任何一次索引扫描在单个逻辑行上必须穿越的最坏版本数,是影响整个系统响应能力和吞吐量的重要因素。一次自底向上索引删除轮次会基于涉及逻辑行与版本的定性区别,针对单个叶页中疑似垃圾的元组。这与自动清理工作进程执行的自顶向下索引清理不同,后者是在超出某些定量的表级阈值时触发的(见Section 24.1.6)。

Note

在 B-树索引内部执行的删除操作,并不全都是自底向上删除。索引元组删除还有另一类独立类别:简单索引元组删除。这是一种延迟维护操作,用来删除那些已知可以安全删除的索引元组(即其项标识符的 LP_DEAD 位已经被设置的元组)。和自底向上索引删除一样,简单索引删除也发生在预期将要进行页拆分的时候,以此避免拆分。

简单删除具有机会性,因为它只能发生在最近的索引扫描顺便设置了受影响项的 LP_DEAD 位时。在 PostgreSQL 14 之前,B-树 删除的唯一类别就是简单删除。它与自底向上删除的主要区别在于:只有前者会由经过的索引扫描活动机会性地驱动,而只有后者专门针对那些不会逻辑修改已索引列的 UPDATE 所造成的版本频繁更替。

对于某些工作负载下的特定索引,自底向上索引删除承担了绝大多数垃圾索引元组清理工作。任何一个 B-树索引,只要它遭受了来自 UPDATE 的显著版本频繁更替,而这些更新又很少或从不逻辑修改该索引覆盖的列,通常都会出现这种情况。仅通过有针对性的增量删除轮次,就能把每个逻辑行的平均版本数和最坏版本数都控制在较低水平。尽管 持续不断UPDATE 会持续造成版本频繁更替,某些索引的磁盘大小也完全有可能连一个页面/块都不会增加。即便如此,最终仍然需要一次彻底的全面清扫,由 VACUUM 操作(通常运行在自动清理工作进程中)执行,作为表及其各个索引整体清理的一部分。

VACUUM 不同,自底向上索引删除并不会对最老垃圾索引元组的年龄给出任何强保证。不允许任何索引保留那些在表及其所有索引共同遵守的保守截断点之前就已经死亡的浮动垃圾索引元组。这个基本的表级不变量使得回收表 TID 成为安全操作。也正因如此,不同的逻辑行才有可能随着时间推移重用同一个表 TID (当然,这绝不可能发生在生命周期跨越同一个 VACUUM 周期的两个逻辑行之间)。

62.4.3. 去重 #

重复项是这样一种叶页元组(即指向表中行的元组):其中所有被索引的键列值,都与同一索引中至少另一个叶页元组对应列的值相匹配。重复元组在实践中相当常见。当启用一种可选技术时,B-树索引可以为重复项采用一种特殊且节省空间的表示形式:去重

去重通过周期性地把一组组重复元组合并起来,为每一组形成一个 posting list 元组。在这种表示中,列键值只出现一次,后面跟着一个排好序的 TID 数组,指向表中的各行。这能显著减小那些每个值(或每一种不同列值组合)平均会出现多次的索引的存储大小。查询延迟可能显著降低,整体查询吞吐量也可能显著提升,例行索引清理的开销同样可能显著减少。

Note

即使重复项中包含 NULL 值,B-树 去重同样有效,尽管根据任何 B-树操作符类的 = 成员,NULL 值彼此永远不相等。对于实现中任何理解磁盘上 B-树 结构的部分来说,NULL 只是索引值域中的另一个值而已。

去重过程是惰性发生的:当插入一个放不进现有叶页的新项时,只有在索引元组删除也无法为该新项释放足够空间的情况下,才会进行去重(通常只会短暂考虑删除,然后就跳过)。与 GIN 的 posting list 元组不同,B-树的 posting list 元组不需要在每次插入新的重复项时都扩展;它们只是叶页原始逻辑内容的一种替代物理表示。这种设计优先考虑混合读写工作负载下的一致性能。大多数客户端应用至少都能从去重中获得适度的性能收益。去重默认启用。

CREATE INDEXREINDEX 都会应用去重来创建 posting list 元组,只是两者采用的策略略有不同。对于从表中取出的已排序输入中遇到的每一组普通重复元组,都会在被加入当前待写入叶页之前先合并成一个 posting list 元组。每个 posting list 元组都会尽量容纳更多的 TID。叶页按通常方式写出,不需要额外独立的去重过程。由于 CREATE INDEXREINDEX 都是一次性的批处理操作,这种策略非常适合它们。

如果某个写密集型工作负载由于索引中的重复值很少甚至没有,而无法从去重中获益,那么它会承担很小且固定的性能损耗(除非显式禁用去重)。 deduplicate_items 存储参数可用于在单个索引内禁用去重。而只读工作负载绝不会因此遭受性能损失,因为读取 posting list 元组至少与读取标准元组表示一样高效。禁用去重通常并没有帮助。

有时唯一索引(以及唯一约束)也可以使用去重。这允许叶页临时吸收因版本频繁更替产生的额外重复项。唯一索引中的去重能够增强自底向上索引删除,特别是在长事务持有阻塞垃圾回收的快照时。其目标是为自底向上索引删除策略再次发挥作用争取时间。把页拆分推迟到某个单独的长事务自然结束之后,可能使一次自底向上删除轮次在较早一次失败的地方获得成功。

Tip

系统会应用一种特殊的启发式规则,来判定唯一索引中是否应当执行一次去重轮次。它往往可以直接跳到拆分叶页,从而避免把周期浪费在无益的去重过程中而造成性能损耗。如果你担心去重的开销,可以考虑有选择地设置 deduplicate_items = off。在唯一索引中保持去重启用,坏处很小。

由于实现层面的限制,并非所有情况下都能使用去重。去重是否安全,是在运行 CREATE INDEXREINDEX 时确定的。

请注意,在下列情况下,相等的 datum 之间存在语义上重要的差异,因此去重被视为不安全且不可使用:

  • textvarcharchar 在使用非确定性排序规则时,不能使用去重。必须保留相等的 datum 之间的大小写和重音差异。

  • numeric 不能使用去重。必须保留相等的 datum 之间的小数位数。

  • jsonb 不能使用去重,因为 jsonb 的 B-树操作符类在内部使用了 numeric

  • float4float8 不能使用去重。这些类型对 -00 有不同的表示形式,但它们仍然被视为相等,这种差异必须保留。

还有一个实现层面的限制,未来版本的 PostgreSQL 也许会取消它:

  • 容器类型(例如复合类型、数组或范围类型)不能使用去重。

还有一个实现层面的限制,无论使用何种操作符类或排序规则都适用:

  • INCLUDE 索引永远不能使用去重。

提交更正

如果您发现文档中有不正确的内容、与您使用特定功能的经验不符或需要进一步说明,请使用此表单来报告文档问题。