PostgreSQL中的索引(八)--RUM
RUM
儘管作者聲稱GIN是一個強大的精靈,但比較的最終結果證明:GIN的下一代被稱作RUM。
RUM訪問方法擴充套件了GIN的基礎概念,使我們能夠更快地執行全文搜尋。 在本系列文章中,這是唯一一個沒有包含在標準PostgreSQL交付中並且是一個外部擴充套件的方法。有幾個安裝選項可供選擇:
·從PGDG 資料庫中獲取«yum»或«apt»包。例如,如果從«PostgreSQL-10»包中安裝了PostgreSQL,那麼也要安裝«PostgreSQL-10-rum»。 ·在github上從原始碼構建並自己安裝(說明也在那裡)。 ·作為Postgres Pro企業版的一部分使用(或者至少從那裡閱讀文件)。
GIN的存在的限制
RUM讓我們超越了GIN的哪些限制?
首先,«tsvector»資料型別不僅包含lexemes,而且還包含它們在文件中的位置資訊。正如我們上次所觀察到的,GIN索引並不儲存這些資訊。因此,GIN索引對搜尋出現在9.6版本中的短語的操作的支援效率很低,並且必須訪問原始資料進行重新檢查。
其次,搜尋系統通常根據相關性(不管那意味著什麼)返回結果。 我們可以使用排序(ranking)函式«ts_rank»和«ts_rank_cd»來達到這個目的,但是它們必須對結果的每一行進行計算,這當然是很慢的。
近似地說,可以將RUM訪問方法看作GIN,它額外儲存位置資訊,並可以按需要的順序返回結果(就像GiST可以返回最近的鄰居)。讓我們一步一步來。
檢索短語
postgres=# select to_tsvector('Clap your hands, slap your thigh') @@ to_tsquery('hand <3> thigh'); ?column? ---------- t (1 row)
或者我們可以要求,這些詞必須一個接一個地放置:
postgres=# select to_tsvector('Clap your hands, slap your thigh') @@ to_tsquery('hand <-> slap'); ?column? ---------- t (1 row)
常規的GIN索引可以返回包含這兩個lexemes的文件,但是我們只能通過檢視tsvector來檢查它們之間的距離:
postgres=# select to_tsvector('Clap your hands, slap your thigh'); to_tsvector -------------------------------------- 'clap':1 'hand':3 'slap':4 'thigh':6 (1 row)
在RUM索引中,每個lexemes不僅僅引用錶行:每個TID都提供了該lexeme在文件中出現的位置列表。這就是我們可以設想在«slit-sheet»表上建立索引的方式,這對我們來說已經很熟悉了(«rum_tsvector_ops»操作符類預設用於tsvector):
postgres=# create extension rum; postgres=# create index on ts using rum(doc_tsv);
圖中的灰色方塊包含新增的位置資訊:
postgres=# select ctid, left(doc,20), doc_tsv from ts; ctid | left | doc_tsv -------+----------------------+--------------------------------------------------------- (0,1) | Can a sheet slitter | 'sheet':3,6 'slit':5 'slitter':4 (0,2) | How many sheets coul | 'could':4 'mani':2 'sheet':3,6 'slit':8 'slitter':7 (0,3) | I slit a sheet, a sh | 'sheet':4,6 'slit':2,8 (1,1) | Upon a slitted sheet | 'sheet':4 'sit':6 'slit':3 'upon':1 (1,2) | Whoever slit the she | 'good':7 'sheet':4,8 'slit':2 'slitter':9 'whoever':1 (1,3) | I am a sheet slitter | 'sheet':4 'slitter':5 (2,1) | I slit sheets. | 'sheet':3 'slit':2 (2,2) | I am the sleekest sh | 'ever':8 'sheet':5,10 'sleekest':4 'slit':9 'slitter':6 (2,3) | She slits the sheet | 'sheet':4 'sit':6 'slit':2 (9 rows)
當指定«fastupdate»引數時,GIN還提供了一個延遲插入;該功能在RUM中被刪除了。
為了瞭解索引是如何對實時資料工作的,讓我們使用熟悉的pgsql-hacker郵件列表歸檔。
fts=# alter table mail_messages add column tsv tsvector; fts=# set default_text_search_config = default; fts=# update mail_messages set tsv = to_tsvector(body_plain); ... UPDATE 356125
以下是如何使用GIN索引執行短語搜尋查詢:
fts=# create index tsv_gin on mail_messages using gin(tsv); fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello <-> hackers'); QUERY PLAN --------------------------------------------------------------------------------- Bitmap Heap Scan on mail_messages (actual time=2.490..18.088 rows=259 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Rows Removed by Index Recheck: 1517 Heap Blocks: exact=1503 -> Bitmap Index Scan on tsv_gin (actual time=2.204..2.204 rows=1776 loops=1) Index Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Planning time: 0.266 ms Execution time: 18.151 ms (8 rows)
正如我們從計劃中看到的,使用了GIN索引,但它返回1776個潛在匹配項,其中259個被保留,1517個在重新檢查階段被刪除。
讓我們刪除GIN索引並構建RUM。
fts=# drop index tsv_gin; fts=# create index tsv_rum on mail_messages using rum(tsv);
索引現在包含了所有必要的資訊,並且可以準確地執行搜尋:
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello <-> hackers'); QUERY PLAN -------------------------------------------------------------------------------- Bitmap Heap Scan on mail_messages (actual time=2.798..3.015 rows=259 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Heap Blocks: exact=250 -> Bitmap Index Scan on tsv_rum (actual time=2.768..2.768 rows=259 loops=1) Index Cond: (tsv @@ to_tsquery('hello <-> hackers'::text)) Planning time: 0.245 ms Execution time: 3.053 ms (7 rows)
fts=# select to_tsvector('Can a sheet slitter slit sheets?') <=>l to_tsquery('slit'); ?column? ---------- 16.4493 (1 row) fts=# select to_tsvector('Can a sheet slitter slit sheets?') <=> to_tsquery('sheet'); ?column? ---------- 13.1595 (1 row)
文件似乎與第一個查詢比與第二個查詢更相關:單詞出現的頻率越高,它的«valuable»就越低。
讓我們再次嘗試在一個相對大的資料量上比較GIN和RUM:我們將選擇十個最相關的包含«hello»和«hackers»的文件。
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello & hackers') order by ts_rank(tsv,to_tsquery('hello & hackers')) limit 10; QUERY PLAN --------------------------------------------------------------------------------------------- Limit (actual time=27.076..27.078 rows=10 loops=1) -> Sort (actual time=27.075..27.076 rows=10 loops=1) Sort Key: (ts_rank(tsv, to_tsquery('hello & hackers'::text))) Sort Method: top-N heapsort Memory: 29kB -> Bitmap Heap Scan on mail_messages (actual ... rows=1776 loops=1) Recheck Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Heap Blocks: exact=1503 -> Bitmap Index Scan on tsv_gin (actual ... rows=1776 loops=1) Index Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Planning time: 0.276 ms Execution time: 27.121 ms (11 rows)
fts=# explain (costs off, analyze) select * from mail_messages where tsv @@ to_tsquery('hello & hackers') order by tsv <=> to_tsquery('hello & hackers') limit 10; QUERY PLAN -------------------------------------------------------------------------------------------- Limit (actual time=5.083..5.171 rows=10 loops=1) -> Index Scan using tsv_rum on mail_messages (actual ... rows=10 loops=1) Index Cond: (tsv @@ to_tsquery('hello & hackers'::text)) Order By: (tsv <=> to_tsquery('hello & hackers'::text)) Planning time: 0.244 ms Execution time: 5.207 ms (6 rows)
fts=# create index on mail_messages using rum(tsv RUM_TSVECTOR_ADDON_OPS, sent) WITH (ATTACH='sent', TO='tsv');
我們可以使用這個索引返回對附加欄位排序的結果:
fts=# select id, sent, sent <=> '2017-01-01 15:00:00' from mail_messages where tsv @@ to_tsquery('hello') order by sent <=> '2017-01-01 15:00:00' limit 10; id | sent | ?column? ---------+---------------------+---------- 2298548 | 2017-01-01 15:03:22 | 202 2298547 | 2017-01-01 14:53:13 | 407 2298545 | 2017-01-01 13:28:12 | 5508 2298554 | 2017-01-01 18:30:45 | 12645 2298530 | 2016-12-31 20:28:48 | 66672 2298587 | 2017-01-02 12:39:26 | 77966 2298588 | 2017-01-02 12:43:22 | 78202 2298597 | 2017-01-02 13:48:02 | 82082 2298606 | 2017-01-02 15:50:50 | 89450 2298628 | 2017-01-02 18:55:49 | 100549 (10 rows)
在這裡,我們搜尋儘可能接近指定日期的匹配行,不管是早還是晚。為了得到嚴格在指定日期之前(或之後)的結果,我們需要使用<=|(或|=>)操作符。
如我們所期待,查詢只是通過一個簡單的索引掃描執行:
ts=# explain (costs off) select id, sent, sent <=> '2017-01-01 15:00:00' from mail_messages where tsv @@ to_tsquery('hello') order by sent <=> '2017-01-01 15:00:00' limit 10; QUERY PLAN --------------------------------------------------------------------------------- Limit -> Index Scan using mail_messages_tsv_sent_idx on mail_messages Index Cond: (tsv @@ to_tsquery('hello'::text)) Order By: (sent <=> '2017-01-01 15:00:00'::timestamp without time zone) (4 rows)
如果我們建立的索引沒有關於欄位關聯的附加資訊,那麼對於類似的查詢,我們將不得不對索引掃描的所有結果進行排序。
除了date之外,我們當然可以向RUM索引新增其他資料型別的欄位。實際上支援所有基本型別。例如,線上商店可以根據日期、價格(數字)和流行度或折扣值(整數或浮點)快速顯示商品。
其他操作符類
讓我們來看看其他的操作符類。從«rum_tsvector_hash_ops»和«rum_tsvector_hash_addon_ops»開始。它們類似於已經討論過的«rum_tsvector_ops»和«rum_tsvector_addon_ops»,但是索引儲存的是lexeme的雜湊程式碼,而不是lexeme本身。這可能會減少索引的大小,但是當然,搜尋會變得不那麼精確,需要重新檢查。此外,索引不再支援部分匹配的搜尋。
«rum_tsquery_ops»操作符類使我們能夠解決«inverse»問題:查詢與文件匹配的查詢。 為什麼需要這樣做?例如,根據使用者的篩選器向用戶訂閱新商品,或自動對新文件進行分類。 看看這個簡單的例子:
fts=# create table categories(query tsquery, category text); fts=# insert into categories values (to_tsquery('vacuum | autovacuum | freeze'), 'vacuum'), (to_tsquery('xmin | xmax | snapshot | isolation'), 'mvcc'), (to_tsquery('wal | (write & ahead & log) | durability'), 'wal'); fts=# create index on categories using rum(query); fts=# select array_agg(category) from categories where to_tsvector( 'Hello hackers, the attached patch greatly improves performance of tuple freezing and also reduces size of generated write-ahead logs.' ) @@ query; array_agg -------------- {vacuum,wal} (1 row)
其餘的操作符類«rum_anyarray_ops»和«rum_anyarray_addon_ops»被設計用來運算元組,而不是«tsvector»。這在上次的GIN中已經討論過了,不再重複。
索引的大小和WAL檔案的大小
很明顯,因為RUM比GIN儲存更多的資訊,它佔用的空間就會更大。上次我們比較了不同索引的大小;讓我們把RUM也加入比較吧:
rum | gin | gist | btree --------+--------+--------+-------- 457 MB | 179 MB | 125 MB | 546 MB
正如我們所看到的,規模增長相當明顯,這是快速搜尋的代價。
值得注意的一點是:RUM是一個擴充套件,也就是說,它可以在不修改系統核心的情況下進行安裝。這個功能在9.6版本中啟用,這多虧了Alexander Korotkov的一個補丁。為此必須解決的一個問題是日誌記錄的生成。操作日誌記錄技術必須絕對可靠,因此,不能讓擴充套件建立主機型別的日誌記錄。而是擴充套件會通知其想修改的頁,修改頁,並通知已經修改完成,pg的系統核心會比較頁的老版本和新版本,並生成統一的日誌記錄。
當前的日誌生成演算法對頁進行逐位元組比較,檢測更新的片段,並記錄每個片段及其從頁面開始時的偏移量。當只更新幾個位元組或整個頁面時,這種方法工作得很好。 但是如果我們在頁面中新增一個片段,向下移動其它的內容(反之亦然,刪除一個片段,向上移動內容),那麼所更改的位元組將遠遠多於實際新增或刪除的位元組。
因此,頻繁更改RUM索引可能會生成比GIN大得多的日誌記錄(GIN不是擴充套件,而是核心的一部分,它自己管理日誌)。這種惱人的效果的程度很大程度上取決於實際的工作負載,但是為了深入瞭解這個問題,讓我們嘗試多次刪除和新增一些行,並將這些操作與“vacuum”交織在一起。我們可以按如下方式計算日誌記錄的大小:在開始和結束時,使用«pg_current_wal_location»函式(早於版本10之前是«pg_current_xlog_location»)來記住日誌中的位置,然後檢視它們之間的差異。
當然,我們應該考慮很多方面。我們需要確保只有一個使用者在使用系統(否則,其它記錄將加入)。 即使是這樣,我們也不僅要考慮RUM,還要考慮對錶本身和支援主鍵的索引的更新。 配置引數的值也會影響大小(這裡使用的是沒有壓縮的«replica»日誌級別)。 但無論如何,讓我們測試一下。
fts=# select pg_current_wal_location() as start_lsn \gset fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 0; INSERT 0 3576 fts=# delete from mail_messages where id % 100 = 99; DELETE 3590 fts=# vacuum mail_messages; fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 1; INSERT 0 3605 fts=# delete from mail_messages where id % 100 = 98; DELETE 3637 fts=# vacuum mail_messages; fts=# insert into mail_messages(parent_id, sent, subject, author, body_plain, tsv) select parent_id, sent, subject, author, body_plain, tsv from mail_messages where id % 100 = 2; INSERT 0 3625 fts=# delete from mail_messages where id % 100 = 97; DELETE 3668 fts=# vacuum mail_messages; fts=# select pg_current_wal_location() as end_lsn \gset fts=# select pg_size_pretty(:'end_lsn'::pg_lsn - :'start_lsn'::pg_lsn); pg_size_pretty ---------------- 3114 MB (1 row)
大約3gb。但是如果我們對GIN index重複同樣的實驗,這將只產生大約700 MB。
因此,我們希望有一種不同的演算法,它將找到能夠將頁面的一種狀態轉換為另一種狀態的最小數量的插入和刪除操作。«diff»實用工具以類似的方式工作。Oleg Ivanov已經實現了這樣一個演算法,他的補丁正在討論中。在上面的示例中,這個補丁使我們能夠將日誌記錄的大小減少1.5倍,達到1900 MB,但代價是稍微降低速度。
不幸的是,補丁目前停住了。
屬性
和往常一樣,讓我們看看RUM訪問方法的屬性,注意它與GIN的區別。
訪問方法的屬性如下:
amname | name | pg_indexam_has_property --------+---------------+------------------------- rum | can_order | f rum | can_unique | f rum | can_multi_col | t rum | can_exclude | t -- f for gin
以下是索引層可用屬性:
name | pg_index_has_property ---------------+----------------------- clusterable | f index_scan | t -- f for gin bitmap_scan | t backward_scan | f
注意,與GIN不同的是,RUM支援索引掃描——否則,它就不可能在帶有«limit»子句的查詢中返回所要求的結果數。不需要對應的«gin_fuzzy_search_limit»引數。因此,RUM索引可以用於支援排除約束。
以下是列層可用屬性:
name | pg_index_column_has_property --------------------+------------------------------ asc | f desc | f nulls_first | f nulls_last | f orderable | f distance_orderable | t -- f for gin returnable | f search_array | f search_nulls | f
這裡的區別是,RUM支援排序操作符。但是,這並不是對所有操作符類都是支援的:例如,對於«tsquery_ops»就不支援。