引言
這一系列文章主要關注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/