Postgresql杂谈 12—深入学习GIN索引

       今天,我们深入学习下Potgresql的GIN索引。GIN索引时Generalized Inverted Index的缩写,意思是广义的倒排索引。GIN索引和Gist索引类似,都是一个通用的索引框架,我们可以基于此框架开发自定义的GIN索引。

一、GIN索引的内部结构

       了解Java内部HashMap结构的应该了解,Java8之后的HashMap内部结构是Hash Table+链表或者Hash Table+红黑树。其实GIN也采用了类似的思想,只不过是Entry Tree+Posting List或者Entry Tree+Posting Tree。这里我们先解释下,构成GIN索引的四个部分:

  1. Entry:索引中的一个词位,可理解为索引中一个键值
  2. Entry Tree:在一些Entry上构建的B-Tree
  3. Posting Tree:在一个Entry出现的物理位置上构建的B-tree
  4. Posting List:一个Entry出现的物理位置的列表

       GIN索引就是一个构建在Entry(键值)上的BTree索引(Entry Tree)。Entry Tree内部节点与普通的BTree一样,叶子节点分为两种情况:

1、当叶子节点小于宏TOAST_INDEX_TARGET指定的值时,叶子节点组成Posting List。

2、当叶子节点大于宏TOAST_INDEX_TARGET指定的值时,叶子节点组成Posting Tree。

Postgresql杂谈 12—深入学习GIN索引_第1张图片

 

二、GIN索引的插入更新性能

       当我们向一个建立GIN索引的表中插入元素时,由于GIN是全文索引,每插入一个元素都有可能引起大片的索引重建,最严重的可能是整个索引树的重建,所以GIN索引的更新效率实际上是非常低的,建议在向一个表中插入大量数据时,先删除GIN索引,等插入完成之后再重建索引。

       下面,我们使用实际例子演示,在大数据量的情况下,GIN索引的更新效率。

       我们先创建一个表users,用来记录每个用户信息,用户有一个varchar数组类型的字段tag,表示描述用户的标签,表结构如下:

stock_analysis_data=# \d+ users
                                                        Table "public.users"
 Column |          Type           | Collation | Nullable |              Default              | Storage  | Stats target | Description 
--------+-------------------------+-----------+----------+-----------------------------------+----------+--------------+-------------
 id     | integer                 |           | not null | nextval('users_id_seq'::regclass) | plain    |              | 
 name   | character varying(40)   |           |          |                                   | extended |              | 
 tag    | character varying(32)[] |           |          |                                   | extended |              | 

       接下来,向表中插入测试数据,不过在此之前,我们需要创建一个函数来生成组成tag字段的随机字符串数组。

create or replace FUNCTION random_str() 
returns varchar(32)[]
as
$BODY$
DECLARE
  str varchar(128);
  rs varchar(32)[];
  r1 varchar(32);
  r2 varchar(32);
  r3 varchar(32);
  r4 varchar(32);
  r5 varchar(32);
begin 
    str:='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
    r1:=substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1);
    r2:=substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1);
    r3:=substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1);
    r4:=substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1);
    r5:=substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1)||substr(str,ceil(random()*52)::int,1);		
    rs:=array[r1,r2,r3,r4,r5];
    return rs;
end;
$BODY$
LANGUAGE 'plpgsql' VOLATILE;

       向users表中插入100W条数据:

insert into users (name,tag) select 'user'||t.d,random_str() from generate_series(1,1000000) as t(d);

       为了验证创建的GIN索引对更新数据效率的影响,我们在创建GIN索引前,先执行insert,并查看执行计划:

stock_analysis_data=# explain (analyze,verbose,timing,costs)  insert into users (name,tag) select 'user10000000','{wer,asd,zxc,fgh,vbn}'; 
                                                                 QUERY PLAN                                                                 
--------------------------------------------------------------------------------------------------------------------------------------------
 Insert on public.users  (cost=0.00..0.01 rows=1 width=134) (actual time=0.035..0.036 rows=0 loops=1)
   ->  Result  (cost=0.00..0.01 rows=1 width=134) (actual time=0.010..0.011 rows=1 loops=1)
         Output: nextval('users_id_seq'::regclass), 'user10000000'::character varying(40), '{wer,asd,zxc,fgh,vbn}'::character varying(32)[]
 Planning Time: 0.066 ms
 Execution Time: 0.055 ms
(5 rows)

       可见,在建立GIN索引之前,在100W条数据中插入某一条数据,耗时将近2毫秒,多执行几次,基本每次都在2毫秒左右,插入耗时很稳定。然后,在Tag字段上创建GIN索引。

stock_analysis_data=# create index on users using gin(tag); 
CREATE INDEX Time: 23646.282 ms (00:23.646)

       100W条数据,创建GIN索引耗时23秒钟,GIN索引的庞大可见一斑。接下来,再执行之前的insert语句:

stock_analysis_data=# explain (analyze,verbose,timing,costs)  insert into users (name,tag) select 'user10000000','{wer,asd,zxc,wed,gh,jjc}'; 
                                                                  QUERY PLAN                                                                   
-----------------------------------------------------------------------------------------------------------------------------------------------
 Insert on public.users  (cost=0.00..0.01 rows=1 width=134) (actual time=0.069..0.070 rows=0 loops=1)
   ->  Result  (cost=0.00..0.01 rows=1 width=134) (actual time=0.013..0.014 rows=1 loops=1)
         Output: nextval('users_id_seq'::regclass), 'user10000000'::character varying(40), '{wer,asd,zxc,wed,gh,jjc}'::character varying(32)[]
 Planning Time: 0.062 ms
 Execution Time: 0.101 ms
(5 rows)

Time: 4.019 ms

       从上面的耗时可以看到,本次插入耗时4ms,是建立索引前耗时的2倍,而且多执行几次,会发现每次基本都在4ms上下,有时最高能到5毫秒。这足以说明:创建GIN索引之后,插入更新数据的效率变低了。

三、GIN索引重建性能

       在上面的过程中我们记录了100W条数据,建立GIN索引的耗时是将近24秒钟,我们可以通过调大maintenance_work_mem参数,加快重建GIN索引的速度。先来看下当前这个参数的大小:

stock_analysis_data=# show maintenance_work_mem;
 maintenance_work_mem 
----------------------
 64MB
(1 row)

Time: 5.615 ms

       现在是64m,将它调整到512m;

stock_analysis_data=# set maintenance_work_mem = 524288;
SET
Time: 0.247 ms
stock_analysis_data=# show maintenance_work_mem;
 maintenance_work_mem 
----------------------
 512MB
(1 row)

Time: 0.208 ms

       再执行创建索引的命令:

stock_analysis_data=# create index on users using gin(tag); 
CREATE INDEX Time: 17078.451 ms (00:24.078)

       发现,创建索引的时间果真减少了。

四、GIN索引查询性能

       我们先删除已经创建的gin索引,在explain中执行以下查询语句:

stock_analysis_data=# explain (analyze,verbose,timing,costs,buffers) select count(*) from users where '{xyz}' <@ tag;
                                                                QUERY PLAN                                                                 
-------------------------------------------------------------------------------------------------------------------------------------------
 Finalize Aggregate  (cost=19549.13..19549.14 rows=1 width=8) (actual time=341.516..341.517 rows=1 loops=1)
   Output: count(*)
   Buffers: shared hit=11941 read=1656
   ->  Gather  (cost=19548.92..19549.13 rows=2 width=8) (actual time=339.013..341.556 rows=3 loops=1)
         Output: (PARTIAL count(*))
         Workers Planned: 2
         Workers Launched: 2
         Buffers: shared hit=11941 read=1656
         ->  Partial Aggregate  (cost=18548.92..18548.93 rows=1 width=8) (actual time=316.970..316.971 rows=1 loops=3)
               Output: PARTIAL count(*)
               Buffers: shared hit=11941 read=1656
               Worker 0: actual time=298.985..298.986 rows=1 loops=1
                 Buffers: shared hit=3866 read=350
               Worker 1: actual time=313.241..313.242 rows=1 loops=1
                 Buffers: shared hit=3573 read=870
               ->  Parallel Seq Scan on public.users  (cost=0.00..18543.71 rows=2083 width=0) (actual time=18.436..316.932 rows=9 loops=3)
                     Output: id, name, tag
                     Filter: ('{xyz}'::character varying[] <@ users.tag)
                     Rows Removed by Filter: 333348
                     Buffers: shared hit=11941 read=1656
                     Worker 0: actual time=1.609..298.956 rows=7 loops=1
                       Buffers: shared hit=3866 read=350
                     Worker 1: actual time=47.886..313.205 rows=7 loops=1
                       Buffers: shared hit=3573 read=870
 Planning Time: 0.082 ms
 Execution Time: 341.622 ms
(26 rows)

Time: 342.208 ms

       查询执行了全表扫描,耗时342毫秒,下面我们创建GIN索引后,再去查询:

stock_analysis_data=# explain (analyze,verbose,timing,costs,buffers) select count(*) from users where '{xyz}' <@ tag;
                                                           QUERY PLAN                                                            
---------------------------------------------------------------------------------------------------------------------------------
 Aggregate  (cost=9882.66..9882.67 rows=1 width=8) (actual time=1.212..1.213 rows=1 loops=1)
   Output: count(*)
   Buffers: shared hit=26 read=6
   ->  Bitmap Heap Scan on public.users  (cost=62.75..9870.16 rows=5000 width=0) (actual time=0.056..1.163 rows=28 loops=1)
         Recheck Cond: ('{xyz}'::character varying[] <@ users.tag)
         Heap Blocks: exact=28
         Buffers: shared hit=26 read=6
         ->  Bitmap Index Scan on users_tag_idx  (cost=0.00..61.50 rows=5000 width=0) (actual time=0.032..0.032 rows=28 loops=1)
               Index Cond: ('{xyz}'::character varying[] <@ users.tag)
               Buffers: shared hit=4
 Planning Time: 5.168 ms
 Execution Time: 2.793 ms
(12 rows)

Time: 8.524 ms

       这个查询效率提高了40倍!!!

       上面是一个通过GIN索引加快数组类型查询的日志,在实际的应用中,我们经常遇到查询的索引返回的结果集过大的情况,导致从磁盘上读了大量的记录,消耗了大量的资源,但是大部分的数据是没有任何用处的。针对这种情况,GIN提供了一个可以配置结果集大小的参数gin_fuzzy_search_limit,参数默认值是0,但是可以设置非零值,表示从结果集中随机选择配置的个数条记录。比如,笔者设置这个参数为10:

stock_analysis_data=# set gin_fuzzy_search_limit = 10;
SET
Time: 0.250 ms
stock_analysis_data=# show gin_fuzzy_search_limit;
 gin_fuzzy_search_limit 
------------------------
 10
(1 row)

Time: 0.287 ms

       这个参数是个参考值,查询结果的条数不一定就是10条,但是一定在10条左右或者比起小,比如多执行几次如下查询语句,发现查询出来的数据一直在10左右变化。

select * from users where '{xyz}' <@ tag;

五、总结

       本文我们进一步深入了解了GIN索引,综合上述内容,总结如下:

(1) GIN索引的内部存储结构是采用Entry Tree+Posting List或者Entry Tree+Posting Tree,采用哪种结构通过宏TOAST_INDEX_TARGET指定,大于该值就是Entry Tree+Posting Tree,反之是Entry Tree+Posting List。

(2) GIN索引会影响数据库插入更新的效率,在存在大量的数据的情况下,建议先删除GIN索引再进行数据的插入和更新,完成之后再重建GIN索引。

(3) 可以通过加大maintenance_work_mem的参数配置加快GIN索引的创建速度。

(4) 可以通过设置gin_fuzzy_search_limit参数为一个非零值,建议GIN查询结果集大小,防止查询的完整结果集过大。

你可能感兴趣的:(Postgresql原理与实战,Postgresql,GIN,索引,倒排索引)