PostgreSQL索引(8) – RUM

PostgreSQL索引(8) – RUM

本文翻译自Egor Rogov的英文博客,且已征得Egor Rogov的同意。译者做了少量修改。

审校:纪昀红,中国人民大学信息学院在读博士生。

在前面几篇文章中,我们讨论了通用索引引擎、AM的接口和五种AM(Hash索引、B-tree索引、GiST索引、SP-GiST索引、GIN索引)。这篇文章将介绍RUM索引。

下一代GIN索引被称为RUM。

它扩展了GIN索引中的概念,可以使我们更快地进行全文检索。在本系列介绍的所有AM中,它是唯一一个没有包含在PG标准安装包中的AM,它是一个插件。可以通过下面几种方式获得:

  • 使用yum或apt从PGDG源安装。例如,假如从postgresql-10包中安装了PG10,就安装postgresql-10-rum。
  • 使用源代码自行编译
  • 使用Postgres Pro Enterprise

1. GIN的缺点

RUM超越了GIN索引的什么限制呢?

首先,tsvector类型不但包含词素,它还包含词素在文档中的位置信息。就像上次描述的那样,GIN索引不存储这些信息。由于这个原因,在PG 9.6中使用GIN索引搜索短语时,效率非常低,它需要访问原始的数据进行recheck。

其次,搜索系统通常按照某种相关性(无论它是什么意思)返回结果。为了达到这个目的,我们可以使用ts_rank和ts_rank_cd两个函数。但是它需要为每行结果进行计算,效率很低。

大致上来说,RUM可以被看成是添加了位置信息的GIN的索引,并且可以按照所需的顺序返回结果(就像GiST中的KNN查询)。让我们逐步分析。

2. 搜索短语

一个全文检索的查询可以包含特殊的操作符,用来考虑词素之间的距离,例如,查找hand和thigh被两个单词分割的文档。

select to_tsvector('Clap your hands, slap your thigh') @@
                  to_tsquery('hand <3> thigh');
 ?column?
----------
 t
(1 row)

或者,我们指示一个单词必须在另一个的后面。

select to_tsvector('Clap your hands, slap your thigh') @@
                  to_tsquery('hand <-> slap');
 ?column?
----------
 t
(1 row)

常规的GIN索引,可以返回包含两个词素的文档,但是我们只能通过查询tsvector来计算它们之间的距离。

select to_tsvector('Clap your hands, slap your thigh');
             to_tsvector              
--------------------------------------
 'clap':1 'hand':3 'slap':4 'thigh':6
(1 row)

在RUM索引中,每个词素不仅指向基表的行:每个TID和一个链表绑定在一起,这个链表包含了每个词素在文档中出现的位置。上篇文章中的例子,使用RUM索引时,树的形状可能像下面这样。tsvector类型默认使用rum_tsvector_ops操作符类。

create extension rum;
create index on ts using rum(doc_tsv);

灰色方块表示位置信息。

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)

GIN提供了fastupdate参数,RUM删除了这个参数。

我们继续使用pgsql-hackers邮件列表作为示例。

alter table mail_messages add column tsv tsvector;
set default_text_search_config = default;
update mail_messages
set tsv = to_tsvector(body_plain);
...
UPDATE 356125

使用GIN索引时,查询是这样执行的:

create index tsv_gin on mail_messages using gin(tsv);
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条候选,最终被淘汰1517条,只剩下259条。

让我们删除GIN索引,建立RUM索引。

drop index tsv_gin;
create index tsv_rum on mail_messages using rum(tsv);

这个索引包含了所有所需的信息,查询非常精确:

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)

3. 按相关性排序

为了快速按照所需的顺序返回文档,RUM支持排序操作符,在GiST文档中描述过。RUM插件定义了这个操作符,<=>,它可以返回文档(tsvector)和查询(tsquery)之间的距离。例如:

select to_tsvector('Can a sheet slitter slit sheets?')
       <=> to_tsquery('slit');
 ?column?
----------
  16.4493
(1 row)
select to_tsvector('Can a sheet slitter slit sheets?')
       <=> to_tsquery('sheet');
 ?column?
----------
  13.1595
(1 row)

这个文档看起来与第二个文档比与第一个文档更加相关:一个单词出现的次数越多,这个文档价值越低。

我们再一个相对大一点的数据比较GIN和RUM:找出包含hello和hackers的十篇最相关的文档。

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)

GIN索引返回了1776条,接着使用一个单独的步骤排序,选出十个最好的匹配。

借助RUM索引,查询可以通过一个简单的索引扫描完成:无需额外查看文档,无需额外的排序:

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)

4. 附加字段

RUM索引,和GIN索引一样,可以建立在多个字段上。但是GIN索引把每个列的词素单独存储。RUM可以把一个附加属性附加到一个主属性上。这可以借助rum_tsvector_addon_ops操作符类完成。

create index on mail_messages
  using rum(tsv RUM_TSVECTOR_ADDON_OPS, sent)
  WITH (ATTACH='sent', TO='tsv');

我们可以使用这个索引对结果按照附加的属性进行排序:

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)

我们在这个查询中,查询了距离指定日期最近的行,无论早于还是晚于指定日期。如果想要查找严格早于或晚于指定日期时,应该使用<=|(o或|=>)操作符。正如所期待的那样,查询只是需要一个简单的索引扫描:

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)

如果没有这个附加的属性,我们需要把索引扫描的结果使用Sort算子排序。

除了日期类型,我们当然可以附加其它数据类型的字段。几乎所有的基本类型都支持。

5. 其它操作符类

首先看rum_tsvector_hash_ops和rum_tsvector_hash_addon_ops。它们与已经介绍过的rum_tsvector_ops和rum_tsvector_addon_ops很相似,只是它们存储词素的Hash值,而不是词素本身。这可以减小索引的体积,当然查询变得不精确,并且需要重新检查。而且,这导致无法支持部分匹配。

rum_tsquery_ops非常有趣,它们帮助我们解决一个相反的问题:找出符合文档的查询。为什么有这个需求?假如一个用户订阅了某种新的商品或者每种类型的文档,当新出现这种文档时,应该推送给用户。例如:

create table categories(query tsquery, category text);
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');
create index on categories using rum(query);
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为了数组而设计,已经在GIN索引中介绍过,此处不再赘述。

6. 索引体积和WAL日志量

因为RUM存储的信息比GIN更多,它的体积也更大。上一篇文章已经比较过体积,现在把把RUM加进去:

  rum   |  gin   |  gist  | btree
--------+--------+--------+--------
 457 MB | 179 MB | 125 MB | 546 MB

可以看到,体积增大了很多,这是快速查找所需的代价。

值得注意的是:RUM是一个插件,因此安装它并不需要对内核进行任何修改。需要解决的一个问题是日志记录里的生成问题。PG的Xlog子系统必须要绝对地可靠,因此不允许插件自己写Xlog。写Xlog的过程需要交给PG内核。插件首先通知内核想要修改页面,然后修改页面,最后通知内核已经修改完毕。内核根据页面修改前后的编号生成Xlog。

现在的日志生成算法会按字节比较页面,检测出需要更新的段,记录每个段。当更新页面中少量字节时,或整个页面时这种方法还可以。但是需要对页面多处进行修改时,实际记录的Xlog量比实际修改的字节数要多很多。

由于这个原因,频繁更新RUM索引可能会产生比GIN(不是插件,而是内核的一部分,以它自己的方式管理日志)索引多的多的日志。这个现象与具体的负载有关。我们可以进行一些操作,记录一下日志产生的数量。在开始和结束之前,使用pg_current_wal_location(PG 9.x版本中为pg_current_xlog_location)函数获取当前Xlog的位置,然后计算日志量。

当然,我们需要考虑很多方面。我们需要保证只有一个用户在使用这个系统,否则会产生额外的日志记录。即使这样,我们也需要考虑,除了RUM,更新表本身也会产生日志。一些参数也会影响日志量,下面使用replica日志级别、没有压缩。

select pg_current_wal_location() as start_lsn \gset
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
delete from mail_messages where id % 100 = 99;
DELETE 3590
vacuum mail_messages;
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
delete from mail_messages where id % 100 = 98;
DELETE 3637
vacuum mail_messages;
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
delete from mail_messages where id % 100 = 97;
DELETE 3668
vacuum mail_messages;
select pg_current_wal_location() as end_lsn \gset
select pg_size_pretty(:'end_lsn'::pg_lsn - :'start_lsn'::pg_lsn);
 pg_size_pretty
----------------
 3114 MB
(1 row)

日志大约为3GB,如果使用GIN索引重复试验,只生成大约700MB。

因此,很有必要研发一种不同的算法,可以用来找出插入和删除最小的差异,可以把页面的状态转换成另外一个状态。diff使用类似的工作方式。Oleg Ivanov实现了一个这种算法,社区正在讨论,在上面的例子中,日志里减小到1900M,代价时性能稍慢一些。

不幸的是,这个patch停止开发了。

7. 属性

和往常一样,看一下它的属性。
AM的属性:

 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支持Index scan —— 否则它就不可能返回LIMIT算子的结果。它不需要使用gin_fuzzy_search_limit参数。它可以支持排它约束。

列层面的约束:

        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就不成立。

Comments are closed.