PostgreSQL中的索引(一)
2019-08-08 15:04 abce 阅读(2921) 评论(0) 编辑 收藏 举报引言
这一系列文章主要关注PostgreSQL中的索引。
可以从不同的角度考虑任何主题。我们将讨论那些使用DMBS的应用开发人员感兴趣的事项:有哪些可用的索引;为什么会有这么多不同的索引;以及如何使用索引来加速查询。这个主题可以之用寥寥数语就被涵盖,但是,在内心深处,我们希望那些充满好奇心、同时也对内部细节感兴趣的开发人员,特别是因为对这些细节的理解,不会只是听从别人的理解,而是形成自己的结论。
开发新类型的索引超出了范畴。这需要了解C编程语言,需要系统程序员而不是应用程序开发人员的专业知识。出于同样的原因,我们几乎不会讨论编程接口,只会关注使用索引的重要事项。
在本文中,我们将讨论与DBMS核心相关的通用索引引擎和各个索引访问方法之间的职责分配,PostgreSQL允许我们将其添加为扩展。在下一篇文章中,我们将讨论访问方法的接口以及类和运算符系列等关键概念。在这些冗长但必要的介绍之后,我们将考虑不同类型索引的结构和应用的细节:Hash,B-tree,GiST,SP-GiST,GIN和RUM,BRIN和Bloom。
在开始之前,我要感谢Elena Indrupskaya将文章翻译成英文。自最初发表以来,情况发生了一些变化。我对现状的评论是这样的。
索引
在PostgreSQL中,索引是特殊的数据库对象,主要用于加速数据访问。它们是辅助结构:可以删除每个索引并从表中的信息重新创建。你有时可能会听到DBMS可以在没有索引的情况下工作,尽管速度很慢。 但是,情况并非如此,因为索引还可用于强制执行某些完整性约束。
目前,PostgreSQL 9.6内置了六种不同的索引,由于版本9.6的重大变化,另外还有一种索引作为扩展。期望在不久的将来有新类型的索引。
尽管索引类型(也称为访问方法)之间存在所有差异,但它们中的每一个最终都会将一个键(例如,索引列的值)与包含此键的表行相关联。每一行由TID(元组id)标识,它由文件中的块号和块内行的位置组成。也就是说,使用已知的键或有关它的一些信息,我们可以快速读取那些可能包含我们感兴趣的信息的行,而无需扫描整个表。
重要的是要理解,索引可以加速数据访问是需要一定的维护成本的。对于被索引数据的每个操作,无论是表行的插入,删除还是更新,该表的索引也需要更新,并且在同一事务中。 请注意,更新尚未构建索引的表字段不会导致索引更新; 这种技术叫做HOT(Heap-Only Tuples)。
可扩展性带来一些影响。为了能够容易地向系统添加新的访问方法,已经实现了通用索引引擎的接口。它的主要任务是从访问方法中获取TID并使用它们:
·从相应版本的表行中读取数据。
·通过TID获取行版本TID,或使用预构建的位图批量获取行版本。
·检查当前事务的行版本的可见性,同时考虑其隔离级别。
索引引擎参与执行查询。根据在优化阶段创建的计划调用它。优化器,整理和评估执行查询的不同方法,应该了解可能适用的所有访问方法的性能。该方法是否能够以所需的顺序返回数据,还是我们应该参与排序?我们可以使用此方法搜索NULL吗?这些是优化器常规要解决的问题。
不仅优化器需要有关访问方法的信息。构建索引时,系统必须决定是否可以在多个列上构建索引以及此索引是否确保唯一性。
因此,每种访问方法都应提供有关自身的所有必要信息。低于9.6的版本使用了«pg_am»表;而从版本9.6开始,数据在特殊功能中移动到更深层次。我们将进一步了解这个接口。
剩下的就是访问方法的任务了:
·实现用于构建索引的算法并将数据映射到页面中(用于缓冲区缓存管理器以统一处理每个索引)。
·通过谓词“indexed-field operator expression”在索引中搜索信息。
·评估索引使用成本。
·操纵正确并行处理所需的锁。
·生成预写日志(WAL)记录。
我们将首先考虑通用索引引擎的功能,然后继续考虑不同的访问方法。
索引引擎
索引引擎使PostgreSQL能够统一使用各种访问方法,但要考虑它们的特性。
主要的扫描技术
1.索引扫描
我们可以使用索引提供的TID进行不同的工作。 我们来看一个例子:
postgres=# create table t(a integer, b text, c boolean); postgres=# insert into t(a,b,c) select s.id, chr((32+random()*94)::integer), random() < 0.01 from generate_series(1,100000) as s(id) order by random(); postgres=# create index on t(a); postgres=# analyze t;
我们创建了一个三字段的表。第一个字段包含1到100000之间的数字,并且在此字段上创建索引(无论何种类型)。第二个字段包含除非可打印字符之外的各种ASCII字符。最后,第三个字段包含一个逻辑值,对于大约1%的行是true,对于其余的是false。行以随机顺序插入表中。
让我们尝试通过条件«a=1»来选择一个值。请注意,条件看起来像“索引字段运算符表达式”,其中运算符为“=”,表达式(搜索键)为“1”。 在大多数情况下,对于要使用的索引,条件必须如此。
postgres=# explain (costs off) select * from t where a = 1; QUERY PLAN ------------------------------- Index Scan using t_a_idx on t Index Cond: (a = 1) (2 rows)
在这种情况下,优化器决定使用索引扫描。 通过索引扫描,访问方法逐个返回TID值,直到达到最后一个匹配的行。 索引引擎依次访问由TID指示的表行,获取行版本,检查其对多版本并发规则的可见性,并返回获得的数据。
2.位图扫描
当我们只处理几个值时,索引扫描工作正常。但是,随着检索到的行数增加,更有可能多次返回同一个表页。因此,优化器切换到位图扫描。
postgres=# explain (costs off) select * from t where a <= 100; QUERY PLAN ------------------------------------ Bitmap Heap Scan on t Recheck Cond: (a <= 100) -> Bitmap Index Scan on t_a_idx Index Cond: (a <= 100) (4 rows)
访问方法首先返回与条件匹配的所有TIDs(位图索引扫描节点),并且从这些TIDs构建行版本的位图。然后从表中读取行版本(位图堆扫描),每个页面只读取一次。
请注意,在第二步中,可能会重新检查条件(重新检查条件)。检索到的行数可能太大,行版本的位图无法完全适合RAM(受«work_mem»参数限制)。在这种情况下,位图仅针对包含至少一个匹配行版本的页面构建。这个“有损”(«lossy»)位图需要的空间更少,但在阅读页面时,我们需要重新检查其中包含的每一行的条件。请注意,即使对于少量检索到的行并因此“精确”(«exact»)位图(例如在我们的示例中),«Recheck Cond»步骤仍然在计划中表示,尽管实际上并未执行。
如果对多个表字段施加条件并对这些字段建立索引,则位图扫描允许同时使用多个索引(如果优化程序认为这样高效)。对于每个索引,构建行版本的位图,然后执行按位布尔乘法(如果表达式由AND连接)或布尔加法(如果表达式由OR连接)。例如:
postgres=# create index on t(b); postgres=# analyze t; postgres=# explain (costs off) select * from t where a <= 100 and b = 'a'; QUERY PLAN -------------------------------------------------- Bitmap Heap Scan on t Recheck Cond: ((a <= 100) AND (b = 'a'::text)) -> BitmapAnd -> Bitmap Index Scan on t_a_idx Index Cond: (a <= 100) -> Bitmap Index Scan on t_b_idx Index Cond: (b = 'a'::text) (7 rows)
这里BitmapAnd节点通过按位«and»操作连接两个位图。
位图扫描使我们能够避免重复访问同一数据页。但是如果表页面中的数据的物理排序方式与索引记录完全相同怎么办?毫无疑问,我们不能完全依赖页面中数据的物理顺序。如果需要排序数据,我们必须在查询中显式指定ORDER BY子句。但实际情况可能是“几乎所有”数据都被排序的情况:例如,如果按所需顺序添加行,并且在此之后或执行CLUSTER命令之后不进行更改。在这种情况下,构建位图是一个过度的步骤,常规索引扫描也同样好(除非我们考虑连接多个索引的可能性)。因此,在选择访问方法时,计划程序会查看一个特殊统计信息,该统计信息显示物理行排序与列值逻辑排序之间的相关性:
postgres=# select attname, correlation from pg_stats where tablename = 't'; attname | correlation ---------+------------- b | 0.533512 c | 0.942365 a | -0.00768816 (3 rows)
接近1的绝对值表示高相关性(对于列«c»),而接近于零的值则相反,表示无序分布(列«a»)。
3.顺序扫描
查看下面的执行计划,我们应该注意到,在非选择性条件下,优化器将优先选择整个表的顺序扫描而不是使用索引:
postgres=# explain (costs off) select * from t where a <= 40000; QUERY PLAN ------------------------ Seq Scan on t Filter: (a <= 40000) (2 rows)
条件的选择性越高,索引的效率就越好,也就是说,匹配的行数就越少。随着检索的行数的增加,读取索引页的开销成本也就越大。
随机扫描使情况更加复杂,而顺序扫描更快。这特别适用于硬盘,其中将磁头寻址的机械操作比读取数据本身花费更多的时间。这一点,SSD的效果不太明显。有两个参数可用于考虑访问成本的差异,«seq_page_cost»和«random_page_cost»,它们不仅可以在全局设置,而且可以在表空间级别设置,这样可以调整到不同磁盘子系统的特性。
4.覆盖索引
通常,访问方法的主要任务是返回索引引擎的匹配表行的标识符,以从这些行中读取必要的数据。但是,如果索引已包含查询所需的所有数据,该怎么办?这样的索引称为覆盖索引,在这种情况下,优化器可以应用仅索引扫描:
postgres=# vacuum t; postgres=# explain (costs off) select a from t where a < 100; QUERY PLAN ------------------------------------ Index Only Scan using t_a_idx on t Index Cond: (a < 100) (2 rows)
此名称可能会提示索引引擎根本不访问该表,并仅从索引覆盖访问方法获取所有必要信息。但事实并非如此,因为PostgreSQL中的索引不存储使我们能够判断行可见性的信息。因此,索引覆盖访问方法返回与搜索条件匹配的行的版本,而不管它们在当前事务中的可见性。
但是,如果索引引擎每次都需要查看表中的可见性,则此扫描方法与常规索引扫描不会有任何不同。
为了解决这个问题,对于表,PostgreSQL维护着一个所谓的可见性映射(VM),其中vacuuming操作标记在足够长的时间内数据未被更改的页面,以使所有事务都能看到这些数据,而不管启动时间和隔离级别如何。如果索引返回的行的标识符与这样的页面相关,则可以避免可见性检查。
因此,定期vacuuming操作提高了覆盖索引的效率。此外,优化器会考虑死元组的数量,并且如果它预测可见性检查的开销成本高,则可以决定不使用索引覆盖扫描。
我们可以使用EXPLAIN ANALYZE命令了解对表的强制访问次数:
postgres=# explain (analyze, costs off) select a from t where a < 100; QUERY PLAN ------------------------------------------------------------------------------- Index Only Scan using t_a_idx on t (actual time=0.025..0.036 rows=99 loops=1) Index Cond: (a < 100) Heap Fetches: 0 Planning time: 0.092 ms Execution time: 0.059 ms (5 rows)
在这种情况下,不需要访问表(Heap Fetches:0),因为刚刚进行了vacuuming。 一般来说,这个数字越接近零越好。
并非所有索引都存储索引值以及行标识符。如果访问方法无法返回数据,则不能将其用于仅索引扫描。
PostgreSQL 11引入了一项新功能:INCLUDE-indexes。如果有一个唯一索引缺少某些列可用作某些查询的覆盖索引,该怎么办?您不能简单地将列添加到索引中,因为它会破坏其唯一性。该特性允许包含不影响唯一性且不能在搜索谓词中使用的非键列,但仍可以提供index-only scans。该补丁由我的同事Anastasia Lubennikova开发。
NULL
作为表示不存在或未知值的便捷方式,NULL在关系数据库中扮演重要角色。
但是特殊的值需要特殊的处理。常规布尔代数变为三元数;目前还不清楚NULL是小于还是大于常规值(这需要特殊的排序结构,NULLS FIRST和NULLS LAST);聚合函数是否应该考虑NULL还不明显;但是计划器(planner)需要一个特殊的统计信息......
从索引支持的角度来看,我们还不清楚是否需要对这些值进行索引。如果未对NULL进行索引,则索引可能更紧凑。但是如果对NULL进行索引,我们将能够将索引用于诸如“indexed-field IS [NOT] NULL”之类的条件,并且当没有为表指定条件时也作为覆盖索引(因为在这种情况下,index必须返回所有表行的数据,包括那些具有NULL的表行)。
对于每种访问方法,开发人员单独决定是否索引NULL。但作为一项规则,他们确实被编入索引。
5.多列索引
要支持多个字段的条件,可以使用多列索引。例如,我们可以在表的两个字段上构建索引:
postgres=# create index on t(a,b); postgres=# analyze t;
优化器很可能更喜欢将这个索引加入位图,因为在这里我们很容易获得所需的TID而无需任何辅助操作:
postgres=# explain (costs off) select * from t where a <= 100 and b = 'a'; QUERY PLAN ------------------------------------------------ Index Scan using t_a_b_idx on t Index Cond: ((a <= 100) AND (b = 'a'::text)) (2 rows)
多列索引也可用于加速某些字段条件的数据检索,从第一个字段开始:
postgres=# explain (costs off) select * from t where a <= 100; QUERY PLAN -------------------------------------- Bitmap Heap Scan on t Recheck Cond: (a <= 100) -> Bitmap Index Scan on t_a_b_idx Index Cond: (a <= 100) (4 rows)
通常,如果不对第一个字段强加条件,则不使用索引。但有时优化器可能会认为索引的使用比顺序扫描更有效。在考虑«btree»索引时,我们将扩展这个主题。
并非所有访问方法都支持在多个列上构建索引。
6.表达式索引
我们已经提到搜索条件必须看起来像“索引字段运算符表达式”。在下面的示例中,将不使用index,因为使用包含字段名称的表达式而不是字段名称本身:
postgres=# explain (costs off) select * from t where lower(b) = 'a'; QUERY PLAN ------------------------------------------ Seq Scan on t Filter: (lower((b)::text) = 'a'::text) (2 rows)
并不需要重写此特定查询,既然只有字段名称写入运算符的左侧。但这是不可能的,表达式(函数索引)上的索引将有助于:
postgres=# create index on t(lower(b)); postgres=# analyze t; postgres=# explain (costs off) select * from t where lower(b) = 'a'; QUERY PLAN ---------------------------------------------------- Bitmap Heap Scan on t Recheck Cond: (lower((b)::text) = 'a'::text) -> Bitmap Index Scan on t_lower_idx Index Cond: (lower((b)::text) = 'a'::text) (4 rows)
函数索引不是建立在表字段上,而是建立在任意表达式上。优化器会将此索引视为“indexed-expression operator expression”之类的条件。如果要索引的表达式的计算是昂贵的操作,则索引的更新还将需要相当多的计算资源。
还请记住,为索引表达式收集了单独的统计信息。我们可以通过索引名称在«pg_stats»视图中了解这个统计信息:
postgres=# \d t Table "public.t" Column | Type | Modifiers --------+---------+----------- a | integer | b | text | c | boolean | Indexes: "t_a_b_idx" btree (a, b) "t_a_idx" btree (a) "t_b_idx" btree (b) "t_lower_idx" btree (lower(b)) postgres=# select * from pg_stats where tablename = 't_lower_idx';
如有必要,可以使用与常规数据字段相同的方式控制直方图篮子的数量(注意列名称可能因索引表达式而异):
postgres=# \d t_lower_idx Index "public.t_lower_idx" Column | Type | Definition --------+------+------------ lower | text | lower(b) btree, for table "public.t" postgres=# alter index t_lower_idx alter column "lower" set statistics 69;
PostgreSQL 11通过在ALTER INDEX ... SET STATISTICS命令中指定列号,引入了一种更简洁的方法来控制索引的统计目标。补丁由我的同事Alexander Korotkov和Adrien Nayrat开发。
7.部分索引
有时需要仅索引表行的一部分。这通常与高度不均匀的分布有关:通过索引搜索不频繁的值是有意义的,但通过对表的完全扫描更容易找到频繁值。
我们当然可以在«c»列上建立一个常规索引,它将以我们期望的方式工作:
postgres=# create index on t(c); postgres=# analyze t; postgres=# explain (costs off) select * from t where c; QUERY PLAN ------------------------------- Index Scan using t_c_idx on t Index Cond: (c = true) Filter: c (3 rows) postgres=# explain (costs off) select * from t where not c; QUERY PLAN ------------------- Seq Scan on t Filter: (NOT c) (2 rows)
索引是276个页:
postgres=# select relpages from pg_class where relname='t_c_idx'; relpages ---------- 276 (1 row)
但由于«c»列仅对1%的行具有true值,因此99%的索引实际上从未使用过。 在这种情况下,我们可以构建一个部分索引:
postgres=# create index on t(c) where c; postgres=# analyze t;
索引的页减少到5个页:
postgres=# select relpages from pg_class where relname='t_c_idx1'; relpages ---------- 5 (1 row)
有时,索引大小和性能的差异可能非常显着。
8.排序
如果访问方法以某种特定顺序返回行标识符,则会为优化程序提供执行查询的其他选项。
我们可以扫描表,然后对数据进行排序:
postgres=# set enable_indexscan=off; postgres=# explain (costs off) select * from t order by a; QUERY PLAN --------------------- Sort Sort Key: a -> Seq Scan on t (3 rows)
但是我们可以按照所需的顺序轻松地使用索引读取数据:
postgres=# set enable_indexscan=on; postgres=# explain (costs off) select * from t order by a; QUERY PLAN ------------------------------- Index Scan using t_a_idx on t (1 row)
所有访问方法中只有«btree»可以返回排序数据,所以让我们推迟更详细的讨论,直到考虑这种类型的索引。
并发构建
通常构建索引会获取表的SHARE锁。此锁允许从表中读取数据,但禁止在构建索引时进行任何更改。
我们可以确保这一点,例如,在表«t»中构建索引时,我们在另一个会话中执行以下查询:
postgres=# select mode, granted from pg_locks where relation = 't'::regclass; mode | granted -----------+--------- ShareLock | t (1 row)
如果表足够大并且广泛用于插入,更新或删除,则这似乎是不可接受的,因为修改进程将等待锁定释放很长时间。
在这种情况下,我们可以使用并发构建索引:
postgres=# create index concurrently on t(a);
此命令以SHARE UPDATE EXCLUSIVE模式锁定表,该模式允许读取和更新(仅禁止更改表结构,以及同时vacuuming,分析或在此表上构建另一个索引)。
然而,还有另一面。首先,索引将比平常构建得更慢,因为在表中完成两次传递而不是一次,并且还需要等待完成修改数据的并行事务。其次,通过并发构建索引,可能发生死锁或者可能违反唯一约束。但是,该仍将将被建立,尽管该索引nonoperating。这样的索引必须删除并重建此类索引。nonoperating的索引在psql \d命令的输出中用INVALID字标记,下面的查询返回完整的列表:
postgres=# select indexrelid::regclass index_name, indrelid::regclass table_name from pg_index where not indisvalid; index_name | table_name ------------+------------ t_a_idx | t (1 row)
原文地址:https://habr.com/en/company/postgrespro/blog/441962/