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»就不支持。