今天,我们深入学习下Potgresql的GIN索引。GIN索引时Generalized Inverted Index的缩写,意思是广义的倒排索引。GIN索引和Gist索引类似,都是一个通用的索引框架,我们可以基于此框架开发自定义的GIN索引。
了解Java内部HashMap结构的应该了解,Java8之后的HashMap内部结构是Hash Table+链表或者Hash Table+红黑树。其实GIN也采用了类似的思想,只不过是Entry Tree+Posting List或者Entry Tree+Posting Tree。这里我们先解释下,构成GIN索引的四个部分:
GIN索引就是一个构建在Entry(键值)上的BTree索引(Entry Tree)。Entry Tree内部节点与普通的BTree一样,叶子节点分为两种情况:
1、当叶子节点小于宏TOAST_INDEX_TARGET指定的值时,叶子节点组成Posting List。
2、当叶子节点大于宏TOAST_INDEX_TARGET指定的值时,叶子节点组成Posting Tree。
当我们向一个建立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索引之后,插入更新数据的效率变低了。
在上面的过程中我们记录了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索引,在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查询结果集大小,防止查询的完整结果集过大。