- Published on
PostgreSQL 高级索引完全指南:GIN·GiST·BRIN·Partial Index 实战活用
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言
- PostgreSQL 索引类型概览
- B-tree 的局限与高级索引的必要性
- GIN 索引深度解析
- GiST 索引 - 空间数据与范围类型
- BRIN 索引 - 时序与大规模表
- Partial Index 与 Expression Index
- 用 EXPLAIN ANALYZE 做索引性能分析
- 索引膨胀的管理与 REINDEX
- 各索引类型对比表
- 运维检查清单
- 结语

引言
PostgreSQL 提供 B-tree、Hash、GIN、GiST、SP-GiST、BRIN 等 6 种索引类型。大多数教程止步于 B-tree,但实务中 JSONB 检索、全文检索、空间查询、时序大表等只靠 B-tree 难以解决的问题层出不穷。
本文将超越 B-tree,理解 GIN、GiST、BRIN、Partial Index、Expression Index 的内部结构,并结合基于 EXPLAIN ANALYZE 的真实性能数据,讲解各自应该在什么场景下如何应用。
PostgreSQL 索引类型概览
把 PostgreSQL 提供的索引类型一览式整理如下。
| 索引类型 | 内部结构 | 最佳使用场景 | 大小 | 写入成本 |
|---|---|---|---|---|
| B-tree | 平衡树 | 等值、范围、排序、UNIQUE | 一般 | 低 |
| GIN | 倒排索引(Posting List/Tree) | JSONB、数组、tsvector | 大 | 高 |
| GiST | 泛化搜索树 | 空间数据、范围、邻近检索 | 一般 | 一般 |
| BRIN | 块范围摘要 | 时序、append-only 大表 | 非常小 | 非常低 |
| Hash | 哈希表 | 纯等值检索 | 小 | 低 |
| SP-GiST | 空间分割树 | 电话号码、IP、非平衡树结构 | 一般 | 一般 |
B-tree 的局限与高级索引的必要性
B-tree 针对标量值的等值比较与范围检索做了优化。但在下面这些场景里,B-tree 要么低效,要么根本用不了。
- JSONB 包含检索:
WHERE metadata @> '...'这类运算符无法用 B-tree 建索引。 - 全文检索:基于
to_tsvector()的检索必须依靠 GIN 索引。 - 空间查询:
ST_DWithin()、ST_Contains()这类 PostGIS 函数需要 GiST 索引。 - 数十亿行的时序表:B-tree 索引本身就会膨胀到数十 GB,造成严重的内存压力。
为了解决这些问题,PostgreSQL 提供了专门化的索引类型。
GIN 索引深度解析
内部结构
GIN(Generalized Inverted Index)使用倒排索引结构。内部为键(key)值构建一棵 B-tree,每个叶节点保存包含该键的行的 TID(Tuple Identifier)列表,即 Posting List 或 Posting Tree。
举例来说,如果 JSONB 列里有 "tags": ["python", "database"] 这样的值,GIN 索引会把 "python" 和 "database" 各自登记为键,并把该行的 TID 追加到 Posting List 里。
JSONB 索引化
在 JSONB 列上创建 GIN 索引后,@>、?、?|、?& 等运算符就能通过索引扫描来处理。
-- 创建表
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
metadata JSONB NOT NULL
);
-- 插入 100 万条测试数据
INSERT INTO products (name, metadata)
SELECT
'product_' || i,
jsonb_build_object(
'category', (ARRAY['electronics', 'clothing', 'food', 'toys'])[1 + (i % 4)],
'price', (random() * 1000)::int,
'tags', jsonb_build_array(
(ARRAY['sale', 'new', 'popular', 'limited'])[1 + (i % 4)],
(ARRAY['premium', 'budget', 'mid-range'])[1 + (i % 3)]
),
'in_stock', (i % 2 = 0)
)
FROM generate_series(1, 1000000) AS i;
-- 创建 GIN 索引(默认 jsonb_ops)
CREATE INDEX idx_products_metadata_gin ON products USING gin (metadata);
-- jsonb_path_ops 运算符类(索引更小,仅支持 @>)
CREATE INDEX idx_products_metadata_path ON products USING gin (metadata jsonb_path_ops);
-- 包含检索查询与 EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE metadata @> '{"category": "electronics", "in_stock": true}';
执行结果示例:
Bitmap Heap Scan on products (cost=52.01..2084.18 rows=500 width=20)
(actual time=1.234..5.678 rows=125000 loops=1)
Recheck Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
Heap Blocks: exact=8334
-> Bitmap Index Scan on idx_products_metadata_gin (cost=0.00..51.88 rows=500 width=0)
(actual time=0.891..0.891 rows=125000 loops=1)
Index Cond: (metadata @> '{"category": "electronics", "in_stock": true}'::jsonb)
Planning Time: 0.152 ms
Execution Time: 12.345 ms
jsonb_ops 与 jsonb_path_ops 对比:
| 特性 | jsonb_ops(默认) | jsonb_path_ops |
|---|---|---|
| 支持的运算符 | @>, ?, ?|, ?&, @@, @? | @>, @@, @? |
| 索引大小 | 大(键+路径全部索引) | 小(只存路径哈希) |
| 键存在性检查 | 可以 | 不可以 |
| 包含检索速度 | 快 | 更快 |
全文检索 (Full-Text Search)
-- 全文检索用的表以及 GIN 索引
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
tsv tsvector GENERATED ALWAYS AS (
setweight(to_tsvector('english', title), 'A') ||
setweight(to_tsvector('english', body), 'B')
) STORED
);
-- 创建 GIN 索引
CREATE INDEX idx_articles_tsv ON articles USING gin (tsv);
-- 全文检索查询
EXPLAIN ANALYZE
SELECT id, title, ts_rank(tsv, q) AS rank
FROM articles, to_tsquery('english', 'postgresql & indexing') AS q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;
数组索引化
-- 在标签数组上创建 GIN 索引
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT,
tags TEXT[]
);
CREATE INDEX idx_posts_tags_gin ON posts USING gin (tags);
-- 数组包含检索
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags @> ARRAY['postgresql', 'performance'];
-- 数组重叠检索
EXPLAIN ANALYZE
SELECT * FROM posts WHERE tags && ARRAY['database', 'backend'];
GIN Pending List 与 fastupdate
GIN 索引默认设置为 fastupdate=on。新行插入时不会立刻更新索引,而是先临时存入 Pending List,等到 VACUUM 或 Pending List 超出大小时再批量合并。
- 优点:写入性能提升(尤其是大批量 INSERT 时)
- 缺点:Pending List 合并时可能出现 CPU/IO 尖峰,检索时还需要额外扫描 Pending List
在生产环境中,调整 gin_pending_list_limit 参数,或者在高峰期到来之前手动调用 SELECT gin_clean_pending_list('idx_name') ,都是有效的策略。
GiST 索引 - 空间数据与范围类型
内部结构
GiST(Generalized Search Tree)是一个可扩展的平衡树框架。与 B-tree 不同,GiST 并不依赖单一固定的比较策略,而是通过运算符类中定义的 consistent、union、penalty、picksplit 等方法来支持各种数据类型和检索策略。
内部使用 R-tree 结构,把空间数据用边界框(MBR: Minimum Bounding Rectangle)包起来并按层级组织。
空间数据索引化 (PostGIS)
-- 安装 PostGIS 扩展
CREATE EXTENSION IF NOT EXISTS postgis;
-- 空间数据表
CREATE TABLE stores (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
location GEOMETRY(Point, 4326) NOT NULL
);
-- 100 万条测试数据(首尔周边的随机坐标)
INSERT INTO stores (name, location)
SELECT
'store_' || i,
ST_SetSRID(ST_MakePoint(
126.9 + random() * 0.2, -- 经度
37.4 + random() * 0.2 -- 纬度
), 4326)
FROM generate_series(1, 1000000) AS i;
-- 创建 GiST 索引
CREATE INDEX idx_stores_location_gist ON stores USING gist (location);
-- 检索半径 1km 以内的门店
EXPLAIN ANALYZE
SELECT id, name,
ST_Distance(location::geography,
ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography) AS distance_m
FROM stores
WHERE ST_DWithin(location::geography,
ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)::geography,
1000)
ORDER BY distance_m
LIMIT 20;
执行结果示例:
Limit (cost=8.45..8.50 rows=20 width=44)
(actual time=2.345..2.678 rows=20 loops=1)
-> Sort (cost=8.45..8.52 rows=25 width=44)
(actual time=2.340..2.350 rows=20 loops=1)
Sort Key: (st_distance(...))
Sort Method: top-N heapsort Memory: 27kB
-> Index Scan using idx_stores_location_gist on stores
(cost=0.42..7.89 rows=25 width=44)
(actual time=0.234..2.123 rows=785 loops=1)
Index Cond: (location && ...)
Filter: st_dwithin(...)
Planning Time: 0.456 ms
Execution Time: 2.789 ms
如果没有 GiST 索引,就会发生 Sequential Scan,必须扫描全部 100 万条数据,耗时长达数秒。
范围类型索引化
-- 预约系统中的时间范围重叠检索
CREATE TABLE reservations (
id SERIAL PRIMARY KEY,
room_id INT NOT NULL,
period TSTZRANGE NOT NULL,
guest_name TEXT
);
-- 用 GiST 索引优化范围重叠检索
CREATE INDEX idx_reservations_period_gist ON reservations USING gist (period);
-- 查询与特定时段重叠的预约
EXPLAIN ANALYZE
SELECT * FROM reservations
WHERE period && tstzrange('2026-03-10 14:00', '2026-03-10 18:00', '[)');
-- 用 EXCLUDE 约束防止重叠(必须有 GiST)
ALTER TABLE reservations
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, period WITH &&);
GiST 支持的范围运算符:&&(重叠)、@>(包含)、<@(被包含)、<<(左侧)、>>(右侧)、-|-(相邻)
KNN (K-Nearest Neighbor) 检索
GiST 索引支持把 ORDER BY distance 模式放到索引层处理的 KNN 检索。
-- 用索引扫描查询最近的 10 家门店
SELECT id, name, location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326) AS dist
FROM stores
ORDER BY location <-> ST_SetSRID(ST_MakePoint(127.0, 37.5), 4326)
LIMIT 10;
<-> 运算符与 ORDER BY ... LIMIT 的组合,会执行使用 GiST 索引的高效 KNN 检索。它不对整张表排序,而是一边遍历索引树一边按由近到远的顺序返回。
BRIN 索引 - 时序与大规模表
内部结构
BRIN(Block Range Index)只按表的物理块范围(默认 128 页)保存最小值和最大值的摘要信息。索引大小小到极致,即使是数十亿行的表也只有数 MB 的量级。
核心前提条件是:列的值与物理存储顺序之间 必须存在很强的相关性。时序数据里的时间戳列就是典型代表。
时序数据的活用
-- 事件日志表(时序)
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
event_type TEXT NOT NULL,
payload JSONB
);
-- 插入 5000 万条测试数据
INSERT INTO events (created_at, event_type, payload)
SELECT
'2025-01-01'::timestamptz + (i || ' seconds')::interval,
(ARRAY['click', 'view', 'purchase', 'signup'])[1 + (i % 4)],
jsonb_build_object('user_id', (i % 100000), 'value', random() * 100)
FROM generate_series(1, 50000000) AS i;
-- 创建 B-tree 索引(作为比较基准)
CREATE INDEX idx_events_created_btree ON events (created_at);
-- 创建 BRIN 索引
CREATE INDEX idx_events_created_brin ON events USING brin (created_at)
WITH (pages_per_range = 128);
-- 索引大小对比
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'events' AND indexname LIKE 'idx_events_created%';
大小对比结果示例:
indexname | index_size
------------------------------+------------
idx_events_created_btree | 1071 MB
idx_events_created_brin | 128 kB
与 B-tree 相比,可以用 约小 8,500 倍的索引 处理同样的范围检索。
-- 使用 BRIN 索引的范围检索
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE created_at BETWEEN '2025-06-01' AND '2025-06-30';
执行结果示例:
Aggregate (cost=456789.12..456789.13 rows=1 width=8)
(actual time=234.567..234.568 rows=1 loops=1)
-> Bitmap Heap Scan on events (cost=48.12..445678.90 rows=2592000 width=0)
(actual time=12.345..198.765 rows=2592000 loops=1)
Recheck Cond: (created_at >= ... AND created_at <= ...)
Rows Removed by Recheck: 45678
Heap Blocks: lossy=19200
-> Bitmap Index Scan on idx_events_created_brin (cost=0.00..47.50 rows=2600000 width=0)
(actual time=0.234..0.234 rows=192000 loops=1)
Planning Time: 0.123 ms
Execution Time: 256.789 ms
pages_per_range 调优
pages_per_range 的取值是精度与索引大小之间的权衡。
| pages_per_range | 索引大小 | 精度 | 最佳使用场景 |
|---|---|---|---|
| 32 | 大幅增加 | 高 | 小范围查询频繁的场景 |
| 128(默认) | 一般 | 一般 | 通用时序数据 |
| 256 以上 | 非常小 | 低 | 以非常大的范围查询为主 |
BRIN 不适合的情况
- 数据是随机插入的,物理顺序与逻辑顺序不一致
- 因 UPDATE 导致行迁移到别的页面(HOT 失败),相关性被破坏
- 主要负载是点查询(单行查询)
查看 pg_stats 视图的 correlation 值就能判断 BRIN 是否合适。绝对值越接近 1 越合适。
SELECT tablename, attname, correlation
FROM pg_stats
WHERE tablename = 'events' AND attname = 'created_at';
-- correlation 值在 0.95 以上就适合使用 BRIN
Partial Index 与 Expression Index
Partial Index(部分索引)
只对表中的一部分行创建索引,从而缩小索引体积并改善写入性能。
-- 在订单表里只索引活跃订单
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
total_amount NUMERIC(10,2),
created_at TIMESTAMPTZ DEFAULT now()
);
-- 全量索引 vs 部分索引的大小对比
CREATE INDEX idx_orders_status_full ON orders (status, created_at);
CREATE INDEX idx_orders_status_partial ON orders (created_at)
WHERE status IN ('pending', 'processing');
-- 会用到部分索引的查询
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE status = 'pending'
AND created_at > now() - interval '7 days'
ORDER BY created_at DESC;
部分索引的关键在于 查询的 WHERE 子句必须蕴含(imply)索引的 WHERE 条件。PostgreSQL 优化器能够识别简单的等价关系和一部分蕴含关系,但复杂的表达式可能识别不出来。
用 Unique Partial Index 实现条件唯一约束
-- 每个用户只允许有一个活跃邮箱
CREATE UNIQUE INDEX idx_unique_active_email
ON users (email) WHERE is_active = true;
-- is_active = false 的行允许邮箱重复
-- is_active = true 的行保证邮箱唯一
Expression Index(表达式索引)
对列值变换后的结果创建索引。查询里必须使用完全相同的表达式,索引才会被用上。
-- 为忽略大小写的检索创建表达式索引
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- 这个查询会使用索引
EXPLAIN ANALYZE
SELECT * FROM users WHERE lower(email) = 'user@example.com';
-- 日期提取表达式索引
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));
-- 用于按日期聚合
EXPLAIN ANALYZE
SELECT created_at::date AS order_date, count(*)
FROM orders
WHERE created_at::date = '2026-03-10'
GROUP BY created_at::date;
-- 针对 JSONB 特定键的表达式索引(比 GIN 更小)
CREATE INDEX idx_products_category ON products ((metadata->>'category'));
-- 基于 B-tree,因此可以做等值/范围检索
EXPLAIN ANALYZE
SELECT * FROM products WHERE metadata->>'category' = 'electronics';
Partial + Expression 的组合
-- 优化最近 7 天活跃用户的邮箱检索
CREATE INDEX idx_recent_active_users_email
ON users (lower(email))
WHERE last_login_at > now() - interval '7 days'
AND is_active = true;
这种组合能把索引体积压缩到极小,同时对特定查询模式提供最优性能。
用 EXPLAIN ANALYZE 做索引性能分析
要确认索引是否真的被使用,最可靠的方法就是 EXPLAIN ANALYZE。
-- 基本的 EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM products
WHERE metadata @> '{"category": "electronics"}';
-- 执行计划的核心确认要点:
-- 1. Scan 类型: Index Scan vs Bitmap Index Scan vs Seq Scan
-- 2. actual time: 实际耗时
-- 3. rows: 预估行数 vs 实际行数的差距
-- 4. Buffers: shared hit (缓存) vs shared read (磁盘)
索引未被使用的原因诊断
索引明明存在却不被使用,主要原因有:
- 统计信息不准:没有执行
ANALYZE,优化器估算出错误的基数 - 选择性偏低:结果超过表的 10-15% 时,Sequential Scan 反而更高效
- 类型不匹配:查询条件的数据类型与索引列的类型不同
- 表达式不一致:Expression Index 与查询中的表达式没有完全一致
- enable_indexscan = off:在会话级别关闭了索引扫描
-- 查找未被使用的索引
SELECT
schemaname || '.' || relname AS table,
indexrelname AS index,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
idx_scan AS times_used,
idx_tup_read AS tuples_read
FROM pg_stat_user_indexes i
JOIN pg_index USING (indexrelid)
WHERE idx_scan = 0
AND NOT indisunique
AND NOT indisprimary
ORDER BY pg_relation_size(i.indexrelid) DESC;
索引膨胀的管理与 REINDEX
什么是索引膨胀
由于 PostgreSQL 的 MVCC 结构,UPDATE 在内部被处理为 DELETE + INSERT。被删除元组的索引条目会一直占用空间,直到 VACUUM 来清理。这些累积起来,就会出现索引异常肥大的索引膨胀。
膨胀的测量
-- 用 pgstattuple 扩展测量膨胀
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
avg_leaf_density,
leaf_pages,
empty_pages,
deleted_pages,
round(100 - avg_leaf_density, 1) AS bloat_pct
FROM pgstatindex('idx_orders_status_full');
-- bloat_pct 超过 20% 就考虑 REINDEX
REINDEX CONCURRENTLY
在生产环境中,使用可以不锁表就重建索引的 REINDEX CONCURRENTLY。
-- 重建特定索引(不停机)
REINDEX INDEX CONCURRENTLY idx_orders_status_full;
-- 重建表的所有索引
REINDEX TABLE CONCURRENTLY orders;
-- 重建整个 schema 的索引
REINDEX SCHEMA CONCURRENTLY public;
注意事项:
- REINDEX CONCURRENTLY 中途失败时,会残留带
_ccnew后缀的无效索引。必须确认并删除。 - REINDEX CONCURRENTLY 执行期间会占住 xmin horizon,长时间执行会导致其他 VACUUM 的死元组清理被推迟。
-- 确认无效索引
SELECT indexrelid::regclass AS index_name,
indisvalid
FROM pg_index
WHERE NOT indisvalid;
-- 删除无效索引
-- DROP INDEX CONCURRENTLY idx_name_ccnew;
自动化管理策略
-- 自动找出膨胀超过 20% 的索引的监控查询
SELECT
schemaname || '.' || tablename AS table,
indexname,
pg_size_pretty(pg_relation_size(indexname::regclass)) AS size,
round(100 - (pgstatindex(indexname)).avg_leaf_density, 1) AS bloat_pct
FROM pg_indexes
WHERE schemaname = 'public'
ORDER BY bloat_pct DESC NULLS LAST;
各索引类型对比表
| 特性 | B-tree | GIN | GiST | BRIN |
|---|---|---|---|---|
| 最佳数据类型 | 标量值 | JSONB、数组、tsvector | 空间、范围 | 时序、顺序 |
| 索引大小 | 一般 | 大(表的 60-80%) | 一般 | 极小(数 KB) |
| 写入开销 | 低 | 高 | 一般 | 非常低 |
| 点查询 | 最佳 | 支持 | 支持 | 低效 |
| 范围查询 | 最佳 | 不支持 | 支持 | 最佳(相关性高时) |
| 全文检索 | 不支持 | 最佳 | 不支持 | 不支持 |
| 空间查询 | 不支持 | 不支持 | 最佳 | 受限 |
| KNN 检索 | 不支持 | 不支持 | 最佳 | 不支持 |
| 唯一约束 | 支持 | 不支持 | 不支持 | 不支持 |
| 仅索引扫描 | 支持 | 不支持 | 不支持 | 不支持 |
| CONCURRENTLY 创建 | 支持 | 支持 | 支持 | 支持 |
| 需要物理排序 | 不需要 | 不需要 | 不需要 | 必须 |
运维检查清单
创建索引前的检查清单
- 分析查询模式:在
pg_stat_statements里确认高频查询 - 确认目标列的数据类型与运算符(判断 B-tree 是否已经够用)
- 用
EXPLAIN ANALYZE确认当前的执行计划 - 估算表的大小与预期的索引大小
- 制定用 CONCURRENTLY 选项不停机创建的计划
定期监控项目
- 未被使用的索引(
pg_stat_user_indexes.idx_scan = 0) - 索引膨胀比例(
pgstatindex函数) - 索引大小与表大小的比值
- BRIN 索引的相关性(
pg_stats.correlation) - GIN Pending List 大小(
pg_stat_all_indexes.idx_tup_insert)
索引类型选择流程
- 等值/范围/排序查询 -> B-tree
- JSONB 包含检索、数组、全文检索 -> GIN
- 空间数据、范围重叠、KNN -> GiST
- 时序/append-only 大表的范围检索 -> BRIN
- 只频繁查询满足特定条件的行 -> Partial Index
- 基于函数/变换结果的检索 -> Expression Index
结语
PostgreSQL 的高级索引各自都有明确的设计目的与最佳场景。关键在于 根据数据特性与查询模式选择合适的索引类型。
- JSONB 或全文检索较多时可以考虑 GIN,但要把写入开销与索引大小考虑进去。
- 有空间数据或范围重叠查询时,GiST 几乎是必选项。
- 数十亿行的时序表,用 BRIN 可以把索引体积减少 99% 以上。
- Partial Index 与 Expression Index 可以和任意索引类型组合,提供额外的优化空间。
最重要的是,用 EXPLAIN ANALYZE 验证真实的执行计划,并在生产环境持续监控索引膨胀。索引不是建完就可以忘掉的东西,而是需要持续管理的运维对象。