本文翻译自 https://habr.com/en/company/postgrespro/blog/443284/,已经得作者同意。

我们已经讨论了 PostgreSQL 索引引擎访问方法接口,以及其中一种访问方法:哈希索引。本文将介绍最传统且使用最广泛的索引:B-树。长文警告,请各位保持耐心。

B树

结构

B-树索引类型是 btree 访问方法的一种实现,适用于可排序的数据。也就是说,数据类型必须定义 greater , greator or equal , less , less or equalequal 这些运算符。注意,相同的数据有时可能排序不同,这跟我们之前提到的运算符族的概念有关。

类似地,B-树的索引行也保存在对应的页中。叶子页(leaf page)中的索引行包含被索引的数据(key)和指向数据行的 TID;内部页(internal page)中,每行数据指向其一个孩子页,并且保存了该孩子页的最小值。

B-树有几个重要的特性:

  • B-树是平衡树,这意味着每个叶子与根之间有相同数量的内部页。即,搜索任何值的时间是相同的。
  • B-树是多路平衡树,即每个页(通常为 8KB)包含很多(数以百计)TID。因此,B-树的深度很小,对于非常大的表深度大概 4-5 层。
  • 索引中的数据以非递减序(页面之间和页面内部都是)排列,同一层的页面通过双向链表彼此连接。因此,我们可以遍历链表或者反向遍历链表得到排序的数据,而无需每次都返回根页查找。

以下是一个简单的以整数为键的索引示例:
image.png

索引的第一个页是 mate 页,其指向索引的根节点。内部节点在根节点之下,叶子节点在最下层。最下边向下的箭头表示从叶子节点指向数据行(TID)。

等值搜索

我们来看看如何在树中通过一个形如 indexed-filed = expression 的等值条件搜索一个值。不妨假设我们想查询的键是 49。
image.png

搜索从根节点开始,随后需要确定向下到哪个子节点继续搜索。由于知晓根节点的值是 (4,32,64),我们可以计算出孩子节点中值的范围。由于 32 <= 49 < 64,我们需要到第二个孩子节点中搜索。接下来,重复递归相同的过程,直到到达叶子节点,从中获取到需要的 TID。

事实上,许多细节使这个看似简单的过程变得复杂。比如,索引键的值可能不是唯一的,且相同的值可能有很多,多到一个页都不足以容纳。还是回到上边的例子,也许在第二层查找时,我们应该定位到 49,但可以清楚地看到,这样将会跳过前一个叶子节点中的一个 49。因此,一旦在内部节点中找到一个与键值完全相等的值,我们必须向左挪动一个位置,然后从左到右查看其下层的索引行,以搜索所需的键。

(另一个复杂的问题是,在搜索期间,其他进程有可能会修复数据:树可能被重建,页面可能分裂等等。工程上所有算法都是为这些并发操作而设计的,确保这些操作不会相互干扰,也不会导致额外的锁开销。本文将尽量避免在这方面进行展开。)

非等值搜索

当使用形如 indexed-filed <= expression 的条件搜索时,首先通过等值条件 indexed-filed = expression 在索引中找到一个匹配的值(如果有的话),然后沿适当的方向遍历叶子节点直到结束。

下图描述了按条件 n <= 35 搜索的过程:
image.png

greaterless 运算符的搜索过程是类似的,但必须删除最初找到的相等的值。

范围搜索

当使用形如 expression1 <= indexed-field <= expression2 的范围条件搜索时,我们通过条件 indexed-field == expression1 找到一个值,然后遍历叶子节点,直到不再满足 indexed-field <= expression2 的条件;或者相反的顺序,从第二个表达式开始,然后沿相反的方向遍历叶子节点,直到不再满足第一个表达式。

下图描述了按条件 23 <= n <= 64 搜索的过程:
image.png

举例

我们来看一个使用 B-树 的查询计划示例。照例使用我们的演示数据库,这次使用 aircrafts 表,仅包含 9 行数据。由于整张表仅有一页数据,计划器不会选择使用索引。但这个表在满足我们说明目的的同时还比较有趣,因此选择它。

  1. demo=# select * from aircrafts;
  2. aircraft_code | model | range
  3. ---------------+---------------------+-------
  4. 773 | Boeing 777-300 | 11100
  5. 763 | Boeing 767-300 | 7900
  6. SU9 | Sukhoi SuperJet-100 | 3000
  7. 320 | Airbus A320-200 | 5700
  8. 321 | Airbus A321-200 | 5600
  9. 319 | Airbus A319-100 | 6700
  10. 733 | Boeing 737-300 | 4200
  11. CN1 | Cessna 208 Caravan | 1200
  12. CR2 | Bombardier CRJ-200 | 2700
  13. (9 rows)
  14. demo=# create index on aircrafts(range);
  15. demo=# set enable_seqscan = off;

(或者显式地用 using btree(range)aircrafts 表上创建索引,若不指定,默认创建的就是 B-树索引。)

等值搜索:

  1. demo=# explain(costs off) select * from aircrafts where range = 3000;
  2. QUERY PLAN
  3. ---------------------------------------------------
  4. Index Scan using aircrafts_range_idx on aircrafts
  5. Index Cond: (range = 3000)
  6. (2 rows)

不等式搜索:

  1. demo=# explain(costs off) select * from aircrafts where range < 3000;
  2. QUERY PLAN
  3. ---------------------------------------------------
  4. Index Scan using aircrafts_range_idx on aircrafts
  5. Index Cond: (range < 3000)
  6. (2 rows)

范围搜索:

  1. demo=# explain(costs off) select * from aircrafts
  2. where range between 3000 and 5000;
  3. QUERY PLAN
  4. -----------------------------------------------------
  5. Index Scan using aircrafts_range_idx on aircrafts
  6. Index Cond: ((range >= 3000) AND (range <= 5000))
  7. (2 rows)

排序

再次强调,无论使用何种扫描方式(index,index-only 或 bitmap), btree 访问方法都返回有序的数据,从上文的图示中也可以清楚地看到这一点。

因此,如果一个表在排序条件上有索引,优化器将有两种选择:索引扫描,或顺序扫描并对结果排序。

排列顺序

创建索引时可以显式指定排列顺序,比如,我们可以按航班飞行距离创建索引:

  1. demo=# create index on aircrafts(range desc);

此时,较大的值将在 B 树的左侧,而较小的值在右侧。既然我们可以从两个方向上遍历索引值,为什么还需要指定排列顺序呢?

其目的是多列索引。我们创建一个视图,将飞机划分为短程、中程和远程:

  1. demo=# create view aircrafts_v as
  2. select model,
  3. case
  4. when range < 4000 then 1
  5. when range < 10000 then 2
  6. else 3
  7. end as class
  8. from aircrafts;
  9. demo=# select * from aircrafts_v;
  10. model | class
  11. ---------------------+-------
  12. Boeing 777-300 | 3
  13. Boeing 767-300 | 2
  14. Sukhoi SuperJet-100 | 1
  15. Airbus A320-200 | 2
  16. Airbus A321-200 | 2
  17. Airbus A319-100 | 2
  18. Boeing 737-300 | 2
  19. Cessna 208 Caravan | 1
  20. Bombardier CRJ-200 | 1
  21. (9 rows)

然后,创建一个表达式索引:

  1. demo=# create index on aircrafts(
  2. (case when range < 4000 then 1 when range < 10000 then 2 else 3 end),
  3. model);

现在,我们可以使用索引来获取按两列升序排列的数据。

  1. demo=# select class, model from aircrafts_v order by class, model;
  2. class | model
  3. -------+---------------------
  4. 1 | Bombardier CRJ-200
  5. 1 | Cessna 208 Caravan
  6. 1 | Sukhoi SuperJet-100
  7. 2 | Airbus A319-100
  8. 2 | Airbus A320-200
  9. 2 | Airbus A321-200
  10. 2 | Boeing 737-300
  11. 2 | Boeing 767-300
  12. 3 | Boeing 777-300
  13. (9 rows)
  14. demo=# explain(costs off)
  15. select class, model from aircrafts_v order by class, model;
  16. QUERY PLAN
  17. --------------------------------------------------------
  18. Index Scan using aircrafts_case_model_idx on aircrafts
  19. (1 row)

同样,也可以获取降序排列的数据:

  1. demo=# select class, model from aircrafts_v order by class desc, model desc;
  2. class | model
  3. -------+---------------------
  4. 3 | Boeing 777-300
  5. 2 | Boeing 767-300
  6. 2 | Boeing 737-300
  7. 2 | Airbus A321-200
  8. 2 | Airbus A320-200
  9. 2 | Airbus A319-100
  10. 1 | Sukhoi SuperJet-100
  11. 1 | Cessna 208 Caravan
  12. 1 | Bombardier CRJ-200
  13. (9 rows)
  14. demo=# explain(costs off)
  15. select class, model from aircrafts_v order by class desc, model desc;
  16. QUERY PLAN
  17. -----------------------------------------------------------------
  18. Index Scan BACKWARD using aircrafts_case_model_idx on aircrafts
  19. (1 row)

然而,我们无法使用该索引获取按一列降序、另一列升序的数据。这需要额外的排序操作:

  1. demo=# explain(costs off)
  2. select class, model from aircrafts_v order by class ASC, model DESC;
  3. QUERY PLAN
  4. -------------------------------------------------
  5. Sort
  6. Sort Key: (CASE ... END), aircrafts.model DESC
  7. -> Seq Scan on aircrafts
  8. (3 rows)

(注意,计划器无视我们上文中设置的 enable_seqscan=off 仍然选择了顺序扫描。事实上,这种设置并不能禁止表扫描,而只是将扫描代价设置为较大的值。可以在执行 explian 时设置 cost on 来查看代价。)

为了使该查询可以使用索引,需要创建一个满足所需排序的索引:

  1. demo=# create index aircrafts_case_asc_model_desc_idx on aircrafts(
  2. (case
  3. when range < 4000 then 1
  4. when range < 10000 then 2
  5. else 3
  6. end) ASC,
  7. model DESC);
  8. demo=# explain(costs off)
  9. select class, model from aircrafts_v order by class ASC, model DESC;
  10. QUERY PLAN
  11. -----------------------------------------------------------------
  12. Index Scan using aircrafts_case_asc_model_desc_idx on aircrafts
  13. (1 row)

列的顺序

使用多列索引时的另一个问题是索引中列的顺序。对于 B-树 索引而言,顺序是非常重要的:页面内的数据首先按第一个字段排序,然而按第二个字段排序,以此类推。

我们可以用下图表示在 rangemodel 上创建的多列索引:

image.png

事实上,一个根节点足以容纳这样一个很小的索引。但为方便描述,上图特意将其分布在多个页中。

从上图可以清楚地看到,通过诸如 class = 3 (仅通过第一个字段 进行搜索)或 class = 3 and model = 'Boeing 777-300' (同时搜索两个字段) 这样的谓词搜索将会非常高效。

而根据谓词 model = 'Boeing 777-300' 进行搜索的效率则很低:从根节点开始,我们无法判断应该向下搜索哪个子节点,因此必须搜索每一个子节点。但这并不意味着这样的索引不能使用,只是效率堪忧。例如,如果我们有三类飞机,每一类又有很多型号,我们将不得不扫描整个索引的约 1/3,这可能比全表扫描更加高效,当然,也可能相反。

如果我们创建如下索引:

  1. demo=# create index on aircrafts(
  2. model,
  3. (case when range < 4000 then 1 when range < 10000 then 2 else 3 end));

字段的排序将会发生改变:
image.png

创建该索引之后,通过谓词 model = 'Boeing 777-300' 搜索将非常高效,而通过谓词 class = 3 搜索的效率则不尽如人意。

NULL

btree 访问方法可以索引 NULL 值,并支持通过 IS NULL 和 IS NOT NULL 进行搜索。

让我们看看有 NULL 值得 flights 表:

  1. demo=# create index on flights(actual_arrival);
  2. demo=# explain(costs off) select * from flights where actual_arrival is null;
  3. QUERY PLAN
  4. -------------------------------------------------------
  5. Bitmap Heap Scan on flights
  6. Recheck Cond: (actual_arrival IS NULL)
  7. -> Bitmap Index Scan on flights_actual_arrival_idx
  8. Index Cond: (actual_arrival IS NULL)
  9. (4 rows)

NULL 值位于叶子节点的一端或者另一端的末尾,具体取决于创建索引的方法(NULLS FIRST 或 NULLS LAST)。这对包含排序的查询是至关重要的:如果 SELECT 命令在其 ORDER BY 子句中指定的 NULL 值顺序与创建索引时指定的 NULL 值顺序相同(NULLS FIRST 或 NULLS LAST),则可以使用该索引。

以下例子中,查询要求的 NULL 值排序就与索引的 NULL 值排序相同,因此可以使用索引:

  1. demo=# explain(costs off)
  2. select * from flights order by actual_arrival NULLS LAST;
  3. QUERY PLAN
  4. --------------------------------------------------------
  5. Index Scan using flights_actual_arrival_idx on flights
  6. (1 row)

以下查询要求的 NULL 值排序则与索引不同,因此,优化器选择顺序扫描并进行排序:

  1. demo=# explain(costs off)
  2. select * from flights order by actual_arrival NULLS FIRST;
  3. QUERY PLAN
  4. ----------------------------------------
  5. Sort
  6. Sort Key: actual_arrival NULLS FIRST
  7. -> Seq Scan on flights
  8. (3 rows)

该查询若想使用索引,需要创建一个 NULL 值排在前面的索引:

  1. demo=# create index flights_nulls_first_idx on flights(actual_arrival NULLS FIRST);
  2. demo=# explain(costs off)
  3. select * from flights order by actual_arrival NULLS FIRST;
  4. QUERY PLAN
  5. -----------------------------------------------------
  6. Index Scan using flights_nulls_first_idx on flights
  7. (1 row)

NULL 值只能存储在所有叶子节点最开始或最末尾这个问题的根本原因是 NULL 值不能进行排序,即 NULL 与其他任何值比较的结果都是未定义的。

  1. demo=# \pset null NULL
  2. demo=# select null < 42;
  3. ?column?
  4. ----------
  5. NULL
  6. (1 row)

这与 B-树 的概念背道而驰,也区别于一般的模式。而 NULL 在数据库系统中扮演着非常重要的角色,因此我们不得不为它做一些特殊处理。

由于 NULL 可以被索引,使得即便没有在表上施加任何条件也可以使用索引成为可能(因为索引肯定包含表中所有行的信息,包括是 NULL 的行)。如果查询需要排序,并且索引可以确保所需的顺序,那么这可能是有意义的。在这种情况下,计划器可以选择索引访问避免一次额外的排序操作。

属性

我们来看 btree 访问方法的属性(查询方法在之前的文章中已经给出).

  1. amname | name | pg_indexam_has_property
  2. --------+---------------+-------------------------
  3. btree | can_order | t
  4. btree | can_unique | t
  5. btree | can_multi_col | t
  6. btree | can_exclude | t

如上所示,B-树可以对数据进行排序,并支持唯一索引—这是唯一一个支持类似属性的访问方法。B-树也支持多列索引,当然也有其他访问方法(尽管不是全部)支持多列索引。支持 EXCLUDE 约束也有其原因,我们后续会讨论。

  1. name | pg_index_has_property
  2. ---------------+-----------------------
  3. clusterable | t
  4. index_scan | t
  5. bitmap_scan | t
  6. backward_scan | t

bree 访问方法支持两种扫描方式:index scan 和 bitmap scan。如上所示,也支持正向( forword)和反向( backward)对树进行遍历。

  1. name | pg_index_column_has_property
  2. --------------------+------------------------------
  3. asc | t
  4. desc | f
  5. nulls_first | f
  6. nulls_last | t
  7. orderable | t
  8. distance_orderable | f
  9. returnable | t
  10. search_array | t
  11. search_nulls | t

前四个属性说明特定列的值是如何被排序的。本例中,值按照递增( asc)和 NULL 在最后( null_las )的方式排序。当然,如上文所说,其他组合也是有可能的。

search_array 属性说明支持如下表达式使用索引:

  1. demo=# explain(costs off)
  2. select * from aircrafts where aircraft_code in ('733','763','773');
  3. QUERY PLAN
  4. -----------------------------------------------------------------
  5. Index Scan using aircrafts_pkey on aircrafts
  6. Index Cond: (aircraft_code = ANY ('{733,763,773}'::bpchar[]))
  7. (2 rows)

returnable 属性说明支持 index-only scan,这是合理的,因为索引行本身存储了被索引的值(与哈希索引不同)。这里有必要谈一谈基于 B-树 的覆盖索引。

具有附加行的唯一索引

如之前文章所述,覆盖索引存储了查询所需的所有数据,(几乎)不需要再访问数据表本身。

假设为满足查询所需,我们想为唯一索引增加一个额外的列。但这种组合值的唯一性不能保证原先唯一索引键的唯一性,因此,需要在同一个列上建两个索引:一个唯一索引支持完整性约束,另一个用于覆盖。显然这是比较低效的。

我的同事 Anastasiya Lubennikova 改进了 btree 方法,使 btree 索引可以包含附件的、非唯一的列。该特性在 PostgreSQL 11 中已经支持了。

我们来看 bookings 表:

  1. demo=# \d bookings
  2. Table "bookings.bookings"
  3. Column | Type | Modifiers
  4. --------------+--------------------------+-----------
  5. book_ref | character(6) | not null
  6. book_date | timestamp with time zone | not null
  7. total_amount | numeric(10,2) | not null
  8. Indexes:
  9. "bookings_pkey" PRIMARY KEY, btree (book_ref)
  10. Referenced by:
  11. TABLE "tickets" CONSTRAINT "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)

该表中,主键 (book_ref) 是一个普通的 btree 索引。我们创建一个带有附加列的唯一索引:

  1. demo=# create unique index bookings_pkey2 on bookings(book_ref) INCLUDE (book_date);

现在,我们用这个新索引替换之前的旧索引(在事务中,同时应用所有的变化):

  1. demo=# begin;
  2. demo=# alter table bookings drop constraint bookings_pkey cascade;
  3. demo=# alter table bookings add primary key using index bookings_pkey2;
  4. demo=# alter table tickets add foreign key (book_ref) references bookings (book_ref);
  5. demo=# commit;

结果如下:

  1. demo=# \d bookings
  2. Table "bookings.bookings"
  3. Column | Type | Modifiers
  4. --------------+--------------------------+-----------
  5. book_ref | character(6) | not null
  6. book_date | timestamp with time zone | not null
  7. total_amount | numeric(10,2) | not null
  8. Indexes:
  9. "bookings_pkey2" PRIMARY KEY, btree (book_ref) INCLUDE (book_date)
  10. Referenced by:
  11. TABLE "tickets" CONSTRAINT "tickets_book_ref_fkey" FOREIGN KEY (book_ref) REFERENCES bookings(book_ref)

现在,同一个索引既满足唯一约束,又可以用于覆盖索引,如下:

  1. demo=# explain(costs off)
  2. select book_ref, book_date from bookings where book_ref = '059FC4';
  3. QUERY PLAN
  4. --------------------------------------------------
  5. Index Only Scan using bookings_pkey2 on bookings
  6. Index Cond: (book_ref = '059FC4'::bpchar)
  7. (2 rows)

索引的创建

众所周知,对于非常大的表,加载数据时最好不带索引,数据导入之后再创建所需的索引。这样不仅速度更快,而且索引可能更小。

其原因在于,创建 btree 索引会使用一种比按行插入更加高效的方式。大体而言,表中所有数据先排好序,并创建这些数据对应的叶子节点,然后基于这些叶子节点创建内部节点,直到整个金字塔收敛到根节点。

这个构建过程的速度取决于可使用内存的大小,由参数 maintenance_work_mem 决定,增加该参数的值,可以加快处理速度。对于唯一索引, 除 maintenance_work_mem 之外,还需要分配 work_mem 大小的内存。

比较语义

介绍哈希索引时我们提到,PostgreSQL 需要知道不同数据类型的值应该使用哪个哈希函数,这种对应关系存储在 hash 访问方法中。同样,系统也必须知道如何对值进行排序。这在排序、分组(有时)、归并连接等操作中是必需的。PostgreSQL … TODO:

例如,这些比较运算符用于 bool_ops 运算符族:

  1. postgres=# select amop.amopopr::regoperator as opfamily_operator,
  2. amop.amopstrategy
  3. from pg_am am,
  4. pg_opfamily opf,
  5. pg_amop amop
  6. where opf.opfmethod = am.oid
  7. and amop.amopfamily = opf.oid
  8. and am.amname = 'btree'
  9. and opf.opfname = 'bool_ops'
  10. order by amopstrategy;
  11. opfamily_operator | amopstrategy
  12. ---------------------+--------------
  13. <(boolean,boolean) | 1
  14. <=(boolean,boolean) | 2
  15. =(boolean,boolean) | 3
  16. >=(boolean,boolean) | 4
  17. >(boolean,boolean) | 5
  18. (5 rows)

这里,我们看到五个比较运算符,但正如前文提到的,我们不应该依赖他们的名字。为了搞清楚每个运算符都做哪些比较,引入了策略(strategy)的概念。定义了五种策略来描述运算符的语义:

  • 1 — less
  • 2 — less or equal
  • 3 — equal
  • 4 — greater or equal
  • 5 — greater

一些运算符族可以包含实现一个策略的多个运算符。例如, integer_ops 运算符族包含以下策略为 1 的运算符:

  1. postgres=# select amop.amopopr::regoperator as opfamily_operator
  2. from pg_am am,
  3. pg_opfamily opf,
  4. pg_amop amop
  5. where opf.opfmethod = am.oid
  6. and amop.amopfamily = opf.oid
  7. and am.amname = 'btree'
  8. and opf.opfname = 'integer_ops'
  9. and amop.amopstrategy = 1
  10. order by opfamily_operator;
  11. opfamily_operator
  12. ----------------------
  13. <(integer,bigint)
  14. <(smallint,smallint)
  15. <(integer,integer)
  16. <(bigint,bigint)
  17. <(bigint,integer)
  18. <(smallint,integer)
  19. <(integer,smallint)
  20. <(smallint,bigint)
  21. <(bigint,smallint)
  22. (9 rows)

由于这些特性,优化器在比较一个运算符族中包含的不同类型的值时,可以不用做类型转换。

索引支持新数据类型

官方文档中有一个创建新数据类型的例子:复数以及对其值排序的运算符类。这个例子是用 C 语言写的,如果考虑执行效率,用 C 语言写当然无可厚非。但为了更好地理解“比较”的语义,我们用 SQL 来做一个同样的实验。

创建一个包含两个字段的新复合类型:实部和虚部。

  1. postgres=# create type complex as (re float, im float);

建一张使用新类型的表,并添加一些值:

  1. postgres=# create table numbers(x complex);
  2. postgres=# insert into numbers values ((0.0, 10.0)), ((1.0, 3.0)), ((1.0, 1.0));

问题来了,如果没有在数学意义上为复数定义顺序关系,如何对它们进行排序?

事实证明,比较运算符已经定义好了。

  1. postgres=# select * from numbers order by x;
  2. x
  3. --------
  4. (0,10)
  5. (1,1)
  6. (1,3)
  7. (3 rows)

默认情况下,对于复合类型,排序是按照组合字段进行的:首先比较第一个字段,然后比较第二个字段,以此类推。这与逐个字符比较字符串类似。但我们可以定义不同的排序。例如,可以将复数看做向量,按模(长度)排序,模(长度)等于直角坐标中平方和的平方根(毕达哥拉斯定理)。为了定义这样的顺序,我们创建一个计算模的辅助函数:

  1. postgres=# create function modulus(a complex) returns float as $$
  2. select sqrt(a.re*a.re + a.im*a.im);
  3. $$ immutable language sql;

现在,使用此辅助函数为所有 5 个比较运算符定义对应的比较函数:

  1. postgres=# create function complex_lt(a complex, b complex) returns boolean as $$
  2. select modulus(a) < modulus(b);
  3. $$ immutable language sql;
  4. postgres=# create function complex_le(a complex, b complex) returns boolean as $$
  5. select modulus(a) <= modulus(b);
  6. $$ immutable language sql;
  7. postgres=# create function complex_eq(a complex, b complex) returns boolean as $$
  8. select modulus(a) = modulus(b);
  9. $$ immutable language sql;
  10. postgres=# create function complex_ge(a complex, b complex) returns boolean as $$
  11. select modulus(a) >= modulus(b);
  12. $$ immutable language sql;
  13. postgres=# create function complex_gt(a complex, b complex) returns boolean as $$
  14. select modulus(a) > modulus(b);
  15. $$ immutable language sql;

创建对应的运算符。为了说明运算符无需命名为 “>”,”<” 等,我们给它们起一些怪异的名字。

  1. postgres=# create operator #<#(leftarg=complex, rightarg=complex, procedure=complex_lt);
  2. postgres=# create operator #<=#(leftarg=complex, rightarg=complex, procedure=complex_le);
  3. postgres=# create operator #=#(leftarg=complex, rightarg=complex, procedure=complex_eq);
  4. postgres=# create operator #>=#(leftarg=complex, rightarg=complex, procedure=complex_ge);
  5. postgres=# create operator #>#(leftarg=complex, rightarg=complex, procedure=complex_gt);

现在,我们可以比较复数了:

  1. postgres=# select (1.0,1.0)::complex #<# (1.0,3.0)::complex;
  2. ?column?
  3. ----------
  4. t
  5. (1 row)

除以上 5 个运算符, btree 访问方法还需要定义一个额外的函数:第一个值小于、等于或大于第二个值时,分别返回-1,0 或 1。这个辅助函数称为 support 函数。其他访问方法可能需要定义其他的 support 函数。

  1. postgres=# create function complex_cmp(a complex, b complex) returns integer as $$
  2. select case when modulus(a) < modulus(b) then -1
  3. when modulus(a) > modulus(b) then 1
  4. else 0
  5. end;
  6. $$ language sql;

现在,我们创建一个运算符类(与此同时会自动创建一个同名的运算符族):

  1. postgres=# create operator class complex_ops
  2. default for type complex
  3. using btree as
  4. operator 1 #<#,
  5. operator 2 #<=#,
  6. operator 3 #=#,
  7. operator 4 #>=#,
  8. operator 5 #>#,
  9. function 1 complex_cmp(complex,complex);

现在就可以根据我们的需要排序了:

  1. postgres=# select * from numbers order by x;
  2. x
  3. --------
  4. (1,1)
  5. (1,3)
  6. (0,10)
  7. (3 rows)

以上,btree 就支持我们新定义的数据类型了。可以使用以下查询获取 support 函数:

  1. postgres=# select amp.amprocnum,
  2. amp.amproc,
  3. amp.amproclefttype::regtype,
  4. amp.amprocrighttype::regtype
  5. from pg_opfamily opf,
  6. pg_am am,
  7. pg_amproc amp
  8. where opf.opfname = 'complex_ops'
  9. and opf.opfmethod = am.oid
  10. and am.amname = 'btree'
  11. and amp.amprocfamily = opf.oid;
  12. amprocnum | amproc | amproclefttype | amprocrighttype
  13. -----------+-------------+----------------+-----------------
  14. 1 | complex_cmp | complex | complex
  15. (1 row)

内部结构

我们可以使用 pageinspect 插件一探 B-树内部的结构。

  1. demo=# create extension pageinspect;

索引 meta 页:

  1. demo=# select * from bt_metap('ticket_flights_pkey');
  2. magic | version | root | level | fastroot | fastlevel
  3. --------+---------+------+-------+----------+-----------
  4. 340322 | 2 | 164 | 2 | 164 | 2
  5. (1 row)

这里最有趣的是索引层数(level):一个包含一百万行数据的表,在两个列上创建索引,仅仅有两层(不包括根节点)。

块 164(root) 的统计信息如下:

  1. demo=# select type, live_items, dead_items, avg_item_size, page_size, free_size
  2. from bt_page_stats('ticket_flights_pkey',164);
  3. type | live_items | dead_items | avg_item_size | page_size | free_size
  4. ------+------------+------------+---------------+-----------+-----------
  5. r | 33 | 0 | 31 | 8192 | 6984
  6. (1 row)

块中的数据内容如下( data 以二进制形式表示索引的键值):

  1. demo=# select itemoffset, ctid, itemlen, left(data,56) as data
  2. from bt_page_items('ticket_flights_pkey',164) limit 5;
  3. itemoffset | ctid | itemlen | data
  4. ------------+---------+---------+----------------------------------------------------------
  5. 1 | (3,1) | 8 |
  6. 2 | (163,1) | 32 | 1d 30 30 30 35 34 33 32 33 30 35 37 37 31 00 00 ff 5f 00
  7. 3 | (323,1) | 32 | 1d 30 30 30 35 34 33 32 34 32 33 36 36 32 00 00 4f 78 00
  8. 4 | (482,1) | 32 | 1d 30 30 30 35 34 33 32 35 33 30 38 39 33 00 00 4d 1e 00
  9. 5 | (641,1) | 32 | 1d 30 30 30 35 34 33 32 36 35 35 37 38 35 00 00 2b 09 00
  10. (5 rows)

真正的索引元组数据从第二个字段开始, ctid 记录其子节点的信息。显然,最左侧的子节点是块 163,随后是 323,以此类推。同样可以使用相同的函数来探究其他块的内容。其他字段的含义大家可以阅读官方文档,README 和源码,在此不多加阐述。

PostgreSQL 从 10 版本开始支持了 amcheck 模块,可以检测 B-树中数据的逻辑一致性,使我们能够提前检测故障。