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 万数据的实测已经说明一切。

相关推荐
阿里小阿希6 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神6 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员6 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java6 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿7 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴7 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU7 小时前
三大范式和E-R图
数据库
一江寒逸7 小时前
零基础从入门到精通MySQL(上篇):筑基篇——吃透核心概念与基础操作,打通SQL入门第一关
数据库·sql·mysql
@土豆7 小时前
Ubuntu 22.04 运行 Filebeat 7.11.2 崩溃问题分析及解决文档
linux·数据库·ubuntu
专注API从业者7 小时前
淘宝商品详情 API 与爬虫技术的边界:合法接入与反爬策略的技术博弈
大数据·数据结构·数据库·爬虫