Postgresql-使用 pg_trgm 实现高效的 LIKE / ILIKE 模糊搜索

文章目录

  • 一、实战:建立测试环境
    • [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)
  1. 查看基表表大小:
bash 复制代码
select pg_size_pretty(pg_relation_size('test'));
 pg_size_pretty 
----------------
 274 MB
(1 row)
Time: 0.317 ms
  1. 数据规模统计
    非常简单的表格,里面有一些文字:
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插入测试

  1. 将 200 万行数据写入 btree_test
bash 复制代码
demo# insert into btree_test select * from test;
INSERT 0 2000000
Time: 181216.579 ms (03:01.217)```
  1. 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插入测试

  1. 将 200 万行数据写入 gin_test
bash 复制代码
demo=# insert into gin_test select * from test;
INSERT 0 2000000
Time: 213317.511 ms (03:33.318)
  1. 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插入测试

  1. 将 200 万行数据写入 gin_test
bash 复制代码
insert into gist_test select * from test;
INSERT 0 2000000
Time: 366391.749 ms (06:06.392)
  1. 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 万数据的实测已经说明一切。

相关推荐
r***11331 小时前
如何实现Redis安装与使用的详细教程
数据库·redis·缓存
翔云1234561 小时前
MySQL中,binlog文件开头的Previous_gtids_log_event是如何计算的
数据库·mysql·adb
堇舟1 小时前
数据库系统原理及应用 第一章 绪论
数据库
Alex Gram1 小时前
Mysql增量同步到PostgreSQL实战
数据库·mysql·postgresql
闲人编程1 小时前
Django缓存策略:Redis、Memcached与数据库缓存对比
数据库·redis·缓存·django·memcached·codecapsule
小二李1 小时前
第8章 Node框架实战篇 - 文件上传与管理
前端·javascript·数据库
z***67771 小时前
macOS安装Redis
数据库·redis·macos
YJlio1 小时前
[编程达人挑战赛] 用 PowerShell 写了一个“电脑一键初始化脚本”:从混乱到可复制的开发环境
数据库·人工智能·电脑
x***13391 小时前
MySQL 篇 - Java 连接 MySQL 数据库并实现数据交互
java·数据库·mysql