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»就不支持。
原文地址:https://habr.com/en/company/postgrespro/blog/452116/