文章目录
- 一、实战:建立测试环境
-
- [1. 创建测试库](#1. 创建测试库)
- [2. 切换到 demo 数据库](#2. 切换到 demo 数据库)
- [3. 启用 pg_trgm 扩展](#3. 启用 pg_trgm 扩展)
- [4. 创建测试表及索引](#4. 创建测试表及索引)
-
- [test 基准表(作为数据源)](#test 基准表(作为数据源))
- [btree_test:用于 B-tree 索引测试](#btree_test:用于 B-tree 索引测试)
- [gin_test:用于 GIN + pg_trgm 测试(本次重点推荐)](#gin_test:用于 GIN + pg_trgm 测试(本次重点推荐))
- [gist_test:用于 GiST + pg_trgm 测试(常被提及的备选方案)](#gist_test:用于 GiST + pg_trgm 测试(常被提及的备选方案))
- [二、基准数据表准备(200 万条随机文本)](#二、基准数据表准备(200 万条随机文本))
- 三、数据插入测试
-
- [1. btree_test插入测试](#1. btree_test插入测试)
- [2. gin_test插入测试](#2. gin_test插入测试)
- [3. gist_test插入测试](#3. gist_test插入测试)
- [4. 数据插入与索引构建性能对比](#4. 数据插入与索引构建性能对比)
- [四、查询性能对比(LIKE / ILIKE)](#四、查询性能对比(LIKE / ILIKE))
-
- [BTree索引LIKE 搜索](#BTree索引LIKE 搜索)
- [结合PG_TGRM索引 LIKE / ILIKE 搜索](#结合PG_TGRM索引 LIKE / ILIKE 搜索)
- [Gin结合PG_TGRM索引 LIKE / ILIKE 搜索](#Gin结合PG_TGRM索引 LIKE / ILIKE 搜索)
- 五、最终结论与最佳实践
- 六、结语
在 PostgreSQL 中,LIKE 与 ILIKE 搜索是最常见的字符串匹配方式,但当匹配模式不是"左锚定"(即不是 column LIKE 'abc%' 这种以固定前缀开头的情况)时,传统的 B-tree 索引 就无法派上用场,只能做全表扫描。
前面文章" PostgreSQL pg_trgm 模糊搜索完全指南"中介绍了pg_tgrm,今天这篇文章我们将实测btree/gin+pg_trgm/gist+trgm在LIKE/ILIKE模糊搜寻场景下的不同表现
一、实战:建立测试环境
1. 创建测试库
创建独立数据库用于实验,避免影响现有业务。
bash
create database demo;
2. 切换到 demo 数据库
bash
postgres# \c demo;
3. 启用 pg_trgm 扩展
pg_trgm(trigram,三元组)是 PostgreSQL 官方提供的文本相似度与索引扩展,它把字符串切分为连续的三个字符片段(trigram),并支持为这些片段建立倒排索引,从而实现高效的任意位置匹配。
bash
demo=# create extension pg_trgm;
CREATE EXTENSION
4. 创建测试表及索引
test 基准表(作为数据源)
此表仅用于生成随机测试数据,后续将复制这个表的数据到三种索引测试表。
bash
CREATE TABLE test(
id serial PRIMARY KEY,
some_text text
);
btree_test:用于 B-tree 索引测试
创建 B-tree 索引(使用 text_pattern_ops 支持 LIKE 优化)
bash
CREATE TABLE btree_test(
id serial ,
some_text text
);
创建btree索引
bash
create index idx_btree on btree_test(some_text text_pattern_ops);
B-tree 是 PostgreSQL 默认索引结构,但只能用于 左前缀匹配(LIKE 'abc%')。
对于 '%xxx%' 这类查询完全无效,但本实验仍建立以便观察其行为。
gin_test:用于 GIN + pg_trgm 测试(本次重点推荐)
bash
CREATE TABLE gin_test(
id serial ,
some_text text
);
创建 GIN trigram 索引:
bash
create index idx_gin_trgm on gin_test using gin (some_text gin_trgm_ops);
gist_test:用于 GiST + pg_trgm 测试(常被提及的备选方案)
bash
CREATE TABLE gist_test(
id serial ,
some_text text
);
创建 GiST trigram 索引:
bash
CREATE INDEX idx_gist_trgm ON gist_test USING gist (some_text gist_trgm_ops);
- GiST 虽也支持 trigram,但结构更适合"空间、多维数据"。
针对巨量 trigram,其查询常常不如 GIN,写入还更慢。 - GIN(Generalized Inverted Index)是为倒排索引设计的,天然适合 trigram 这种"一个字段产生成千上万个 key"的场景;
二、基准数据表准备(200 万条随机文本)
1.启用执行计时:
bash
demo=# \timing
Timing is on.
2.插入 200 万随机文本数据
bash
INSERT INTO test (some_text)
SELECT substr(
repeat(md5(random()::text), 10), -- 字串加長很多
1,
30 + (random()*150)::int -- 長度約 30~180
)
FROM generate_series(1, 2000000);
输出:
bash
INSERT 0 2000000
Time: 56046.427 ms (00:56.046)
- 查看基表表大小:
bash
select pg_size_pretty(pg_relation_size('test'));
pg_size_pretty
----------------
274 MB
(1 row)
Time: 0.317 ms
- 数据规模统计
非常简单的表格,里面有一些文字:
bash
demo=# select count(*), min(length(some_text)), max(length(some_text)), sum(length(some_text)) from test;
count | min | max | sum
---------+-----+-----+-----------
2000000 | 30 | 180 | 209953738
生成完成后数据表大小约:
- test:274 MB
- 总字符串长度约 2.1 亿字符
这些数据足以模拟大规模模糊匹配测试场景。
三、数据插入测试
1. btree_test插入测试
- 将 200 万行数据写入 btree_test
bash
demo# insert into btree_test select * from test;
INSERT 0 2000000
Time: 181216.579 ms (03:01.217)```
- B-tree 索引大小:
bash
demo=# select pg_size_pretty(pg_relation_size('idx_btree'));
pg_size_pretty
----------------
354 MB
(1 row)
- 写入速度中等
- B-tree 对长文本的索引占用较大空间
- 但查询时对 "%xxx%" 根本无法使用
2. gin_test插入测试
- 将 200 万行数据写入 gin_test
bash
demo=# insert into gin_test select * from test;
INSERT 0 2000000
Time: 213317.511 ms (03:33.318)
- gin索引大小
bash
demo=# select pg_size_pretty(pg_relation_size('idx_gin_trgm'));
pg_size_pretty
----------------
231 MB
(1 row)
- GIN 写入速度略慢于 B-tree(倒排结构更新成本略高)
- 但索引文件更紧凑
- 是三者中最适合 trigram 查询的结构
3. gist_test插入测试
- 将 200 万行数据写入 gin_test
bash
insert into gist_test select * from test;
INSERT 0 2000000
Time: 366391.749 ms (06:06.392)
- GiST 索引大小:
bash
demo=# select pg_size_pretty(pg_relation_size('idx_gist_trgm'));
pg_size_pretty
----------------
348 MB
(1 row)
- GiST 写入最慢(近 6 分钟)
- 索引文件接近 B-tree 大小
- 大量 trigram 节点导致 GiST 分裂成本巨大
- 后续查询也会受影响
4. 数据插入与索引构建性能对比
最终,三种索引数据插入与索引构建性能对比如下:
| 表名 | 插入时间 | 索引大小 | 备注 |
|---|---|---|---|
| btree_test | 3分01秒 | 354MB | 写入较快,但索引最大 |
| gin_test3 | 3分33秒 | 231MB | 写入稍慢,索引最省空间 |
| gist_test6 | 6分06秒 | 348 MB | 写入最慢,索引体积接近 B-tree |
结论:GIN 索引在构建时间和空间占用上均优于 GiST,且差距会随数据量增大而更加明显。
四、查询性能对比(LIKE / ILIKE)
BTree索引LIKE 搜索
优化器判断 B-tree 无法支持这种非左锚定的匹配方式,于是主动选择全表扫描。
bash
psql -d demo -c "explain (analyze,buffers) select * from btree_test where some_text like '%gec%';"```
输出结果:
```bash
----------------------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..46527.35 rows=200 width=111) (actual time=18830.988..18837.693 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared read=35090
-> Parallel Seq Scan on btree_test (cost=0.00..45507.35 rows=83 width=111) (actual time=18741.119..18741.120 rows=0 loops=3)
Filter: (some_text ~~ '%gec%'::text)
Rows Removed by Filter: 666667
Buffers: shared read=35090
Planning:
Buffers: shared hit=66 read=20
Planning Time: 509.591 ms
Execution Time: 18837.891 ms
(12 rows)
- PostgreSQL 无法利用 B-tree 去匹配中间文本
- 只能做 Parallel Seq Scan
- 即使并行,也要读 35000+ buffer
- 随数据量线性放大
BTree ILIKE搜索:
清空 OS 缓存与 Shared Buffers
bash
postgres@k8sm01:/$ sudo sync
postgres@k8sm01:/$ echo 3 | sudo tee /proc/sys/vm/drop_caches
postgres@k8sm01:/$ /usr/lib/postgresql/16/bin/pg_ctl -D /etc/postgresql/16/main/ restart
执行ILIKE搜索
bash
psql -d demo -c "explain (analyze,buffers) select * from btree_test where some_text ilike '%gec%';"
输出:
bash
----------------------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..46527.35 rows=200 width=111) (actual time=24396.984..24403.287 rows=0 loops=1)
Workers Planned: 2
Workers Launched: 2
Buffers: shared hit=96 read=34994
-> Parallel Seq Scan on btree_test (cost=0.00..45507.35 rows=83 width=111) (actual time=24224.277..24224.278 rows=0 loops=3)
Filter: (some_text ~~* '%gec%'::text)
Rows Removed by Filter: 666667
Buffers: shared hit=96 read=34994
Planning:
Buffers: shared hit=91
Planning Time: 9.229 ms
Execution Time: 24403.437 ms
(12 rows)
ILIKE 需要大小写转换,在无索引可用情况下更慢
结合PG_TGRM索引 LIKE / ILIKE 搜索
bash
postgres@k8sm01:/$ psql -d demo -c "explain (analyze,buffers) select * from gist_test where some_text like '%gec%';"
bash
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on gist_test (cost=21.96..779.16 rows=200 width=110) (actual time=385151.943..385151.944 rows=0 loops=1)
Recheck Cond: (some_text ~~ '%gec%'::text)
Buffers: shared read=40363
-> Bitmap Index Scan on idx_gist_trgm (cost=0.00..21.91 rows=200 width=0) (actual time=385151.928..385151.928 rows=0 loops=1)
Index Cond: (some_text ~~ '%gec%'::text)
Buffers: shared read=40363
Planning:
Buffers: shared hit=67 read=21
Planning Time: 226.820 ms
Execution Time: 385170.559 ms
(10 rows)
ILIKE搜索
bash
postgres@k8sm01:/$ sudo sync
postgres@k8sm01:/$ echo 3 | sudo tee /proc/sys/vm/drop_caches
bash
postgres@k8sm01:/$ psql -d demo -c "explain (analyze,buffers) select * from gist_test where some_text ilike '%gec%';"
bash
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on gist_test (cost=21.96..779.16 rows=200 width=110) (actual time=391923.967..391923.968 rows=0 loops=1)
Recheck Cond: (some_text ~~* '%gec%'::text)
Buffers: shared read=40363
-> Bitmap Index Scan on idx_gist_trgm (cost=0.00..21.91 rows=200 width=0) (actual time=391923.941..391923.941 rows=0 loops=1)
Index Cond: (some_text ~~* '%gec%'::text)
Buffers: shared read=40363
Planning:
Buffers: shared hit=67 read=21 dirtied=1
Planning Time: 418.588 ms
Execution Time: 391942.294 ms
(10 rows)
为什么 GiST 反而更慢?
- trigram 空间维度巨大
- GiST 需要 大量 Recheck
- 索引扫描本身 I/O 超高(shared read=40363)
- 性能甚至比全表扫描还差
Gin结合PG_TGRM索引 LIKE / ILIKE 搜索
LIKE 搜索:
bash
psql -d demo -c "explain (analyze,buffers) select * from gin_test where some_text like '%gec%';"```
输出:
```bash
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on gin_test (cost=38.74..795.94 rows=200 width=111) (actual time=111.630..111.631 rows=0 loops=1)
Recheck Cond: (some_text ~~ '%gec%'::text)
Buffers: shared hit=1 read=3
-> Bitmap Index Scan on idx_gin_trgm (cost=0.00..38.69 rows=200 width=0) (actual time=111.619..111.620 rows=0 loops=1)
Index Cond: (some_text ~~ '%gec%'::text)
Buffers: shared hit=1 read=3
Planning:
Buffers: shared hit=87 read=11
Planning Time: 807.022 ms
Execution Time: 111.944 ms
(10 rows)
ILIKE 搜索(不区分大小写)
bash
postgres@k8sm01:/$ sudo sync
postgres@k8sm01:/$ echo 3 | sudo tee /proc/sys/vm/drop_caches
bash
postgres@k8sm01:/$ psql -d demo -c "explain (analyze,buffers) select * from gin_test where some_text ilike '%gec%';"
输出:
```bash
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on gin_test (cost=38.74..795.94 rows=200 width=111) (actual time=84.231..84.232 rows=0 loops=1)
Recheck Cond: (some_text ~~* '%gec%'::text)
Buffers: shared hit=1 read=3
-> Bitmap Index Scan on idx_gin_trgm (cost=0.00..38.69 rows=200 width=0) (actual time=84.219..84.219 rows=0 loops=1)
Index Cond: (some_text ~~* '%gec%'::text)
Buffers: shared hit=1 read=3
Planning:
Buffers: shared hit=70 read=23
Planning Time: 693.906 ms
Execution Time: 84.534 ms
(10 rows)
注意:本次测试是冷缓存(drop_caches)下的最差情况,实际生产环境中开启共享缓冲池后,GIN 查询通常稳定在 20~50ms 以内。
五、最终结论与最佳实践
| 场景 | 推荐索引 | 原因 |
|---|---|---|
| 左锚定匹配 LIKE 'abc% | 普通B-tree + text_pattern_ops | 最快、最省空间 |
| 任意位置匹配 LIKE '%abc%' | GIN + pg_trgm | 性能最高、索引最小、写入可接受 |
| 不区分大小写 ILIKE '%Abc%' | GIN + pg_trgm | 原生支持,无需函数包装 |
| 同时需要相似度排序 %, <% | 依然是 GIN + pg_trgm | 支持 <->, %, <% 操作符 |
额外调优建议
- PostgreSQL 14+ 默认启用了 pg_trgm.parallel 并行建索引,可大幅缩短建索引时间对超大表可考虑分区 + 局部索引
- 如果查询非常频繁且对延迟极致敏感,可结合 materialized view + 触发器维护关键词倒排表
- 监控索引膨胀:SELECT * FROM pg_stat_user_indexes WHERE relname='idx_xxx_trgm';
六、结语
在 %keyword% 这种经典的"无解"场景下,pg_trgm + GIN 索引是 PostgreSQL 官方给出且经过大规模生产验证的最佳答案。它让原本动辄几十秒的查询稳定到百毫秒以内,性价比极高,几乎没有理由不使用。
如果你的业务还有模糊搜索性能瓶颈,赶紧试试这套方案吧------200 万数据的实测已经说明一切。