PostgreSQL 18 预览: 支持并行创建 GIN 索引

John Doe 四月 23, 2025

不知道你有没有注意到,在 PostgreSQL 中创建 GIN 索引时,需要很长时间才能创建完成。现在,PostgreSQL 支持并行创建 GIN 索引了。

在山坡上奔跑的大象

特性提交日志

支持并行创建 GIN 索引的操作。

允许使用并行工作进程构建 GIN 索引,类似于 B 树(BTREE)和 BRIN 索引的构建方式。对于大型表而言,如果构建过程受 CPU 性能限制,这一改进可能会显著提升构建速度。

具体工作分配方式为,每个工作进程基于表的一个子集构建索引项,这个子集由用于读取数据的常规并行扫描来确定。每个工作进程使用本地元组排序(tuplesort)对相同键的索引项进行排序和合并。TID 列表(对于给定键)不会重叠,这意味着合并排序只需简单地连接两个列表。合并后的索引项会被写入到供主进程使用的共享元组排序中。

主进程在将排序后的索引项写入索引之前,需要再次进行合并。但通过这种方式,大部分工作由工作进程完成,主进程只需合并数量较少的大型索引项,从而提高了效率。

大部分并行构建的基础架构是对 B 树索引所用代码的简化复制,省略了与 GIN 索引无关的部分(例如唯一性检查)。

讨论:https://postgr.es/m/6ab4003f-a8b8-4d75-a67f-f25ad98582dc@enterprisedb.com

示例

GIN 索引功能强大,但创建速度相当缓慢。因此,在某些情况下,这一改进可能会带来实质性的变化。

让我们来尝试进行测试。我们需要一些可以使用 GIN 索引进行搜索的数据,所以编写一些数组数据:

#!/usr/bin/env ruby
# frozen_string_literal: true
arr = (1..100_000).to_a
1_000_000.times do
  puts "{#{arr.sample(rand() * 100 + 50).join(',')}}"
end

这段代码会输出 100 万个随机数组,每个数组的元素数量随机(介于 50 到 149 之间),且每个元素都是 1 到 100000 之间的数字。将输出保存到/tmp/test.arrays.txt文件中,文件大小约为 560MB。

现在,我们可以创建一个表并加载数据:

-- 创建表
postgres=# create table test_gin (
    the_array int4[]
);

-- 从文件导入数据
postgres=# \copy test_gin from /tmp/test.arrays.txt

之后,通过\dt+ test_gin命令查看,表大小为 444MB。

接下来,请确保 PostgreSQL 不会使用并行操作,再创建索引:

-- 设置最大并行维护工作线程数为0
set max_parallel_maintenance_workers = 0;
-- 创建GIN索引
create index qq on test_gin using gin (the_array);

耗时:77111.482 毫秒(01:17.111)。

当然,我们应该验证索引是否在查询中被使用:

-- 执行查询并分析查询计划
explain (analyze, buffers) select * from test_gin where the_array @> '{1,2}'::int4[];
                                                   QUERY PLAN
-----------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on test_gin  (cost=51.31..150.05 rows=25 width=421) (actual time=0.093..0.094 rows=1.00 loops=1)
    Recheck Cond: (the_array @> '{1,2}'::integer[])
    Heap Blocks: exact=1
    Buffers: shared hit=4 read=6
    I/O Timings: shared read=0.017
    ->  Bitmap Index Scan on qq  (cost=0.00..51.30 rows=25 width=0) (actual time=0.082..0.082 rows=1.00 loops=1)
          Index Cond: (the_array @> '{1,2}'::integer[])
          Buffers: shared hit=4 read=5
          I/O Timings: shared read=0.013
 Planning:
    Buffers: shared hit=12 read=6 dirtied=4
    I/O Timings: shared read=0.550
 Planning Time: 0.764 ms
 Execution Time: 0.112 ms
(14 rows)

再次检查下索引大小,通过\di+ qq命令显示索引大小为 1099MB。

现在,让我们看看是否可以并行创建索引:

-- 删除索引
drop index qq;

-- 设置最大并行维护工作线程数为10
set max_parallel_maintenance_workers = 10;

-- 并行创建GIN索引
create index qq on test_gin using gin (the_array);

耗时:53602.822 毫秒(00:53.603)。

在索引创建过程中,ps命令的输出显示:

USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
...
postgres  361901 10.2  0.3 282400 209020 ?       Rs   12:41   2:41      \_ postgres: postgres postgres [local] CREATE INDEX
postgres  391026 99.4  0.0 270036 60428 ?        Rs   13:07   0:29      \_ postgres: parallel worker for PID 361901
...

这表明仅有 2 个并行工作进程(主进程和一个额外的工作进程)。创建时间减少了约 30%,效果不错。但奇怪的是:

postgres=# \di+ qq
                                        List of indexes
 Schema | Name | Type  |  Owner   |  Table   | Persistence | Access method |  Size  | Description
--------+------+-------+----------+----------+-------------+---------------+--------+-------------
 public | qq   | index | postgres | test_gin | permanent   | gin           | 769 MB | [null]
(1 row)

索引大小怎么变小了?让我们来验证下索引是否工作正常:

-- 执行查询并分析查询计划
explain (analyze, buffers) select * from test_gin where the_array @> '{1,2}'::int4[];
                                                   QUERY PLAN
-----------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on test_gin  (cost=34.31..133.05 rows=25 width=421) (actual time=0.152..0.153 rows=1.00 loops=1)
    Recheck Cond: (the_array @> '{1,2}'::integer[])
    Heap Blocks: exact=1
    Buffers: shared hit=4 read=6
    I/O Timings: shared read=0.026
    ->  Bitmap Index Scan on qq  (cost=0.00..34.30 rows=25 width=0) (actual time=0.143..0.144 rows=1.00 loops=1)
          Index Cond: (the_array @> '{1,2}'::integer[])
          Buffers: shared hit=4 read=5
          I/O Timings: shared read=0.023
 Planning:
    Buffers: shared hit=5 read=2 dirtied=1
    I/O Timings: shared read=0.015
 Planning Time: 0.200 ms
 Execution Time: 0.176 ms
(14 rows)

并行创建 GIN 索引会使用排序的方式对数据进行预先整理,使其能以高效的方式插入到索引中(这与 B 树索引类似,但不完全相同),而以非并行方式创建索引时,则是将数据直接插入到索引中。从索引的角度来看,这与先创建表、再创建索引、然后向表中插入数据的操作没有太大区别。

因为 GIN 会按顺序存储数据,所以按照相同的顺序插入数据将提高性能。索引插入操作是顺序进行的,而非随机的,从而减少了随机的缓冲区 I/O 操作,并且能让 GIN 的创建过程以更紧凑的方式存储元组。插入操作通常只发生在最右侧的页面上,这样能实现更高效的页面拆分。

非常不错的改进。感谢社区的所有相关人员。

参考

提交日志:https://git.postgresql.org/pg/commitdiff/e839c8ecc9352b7754e74f19ace013c0c0d18613