Skip to content
Published on

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

分享
Authors
PostgreSQL Advanced Indexing

引言

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
支持的运算符@>, ?, ?|, ?&, @@, @?@>, @@, @?
索引大小大(键+路径全部索引)小(只存路径哈希)
键存在性检查可以不可以
包含检索速度更快
-- 全文检索用的表以及 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 (磁盘)

索引未被使用的原因诊断

索引明明存在却不被使用,主要原因有:

  1. 统计信息不准:没有执行 ANALYZE,优化器估算出错误的基数
  2. 选择性偏低:结果超过表的 10-15% 时,Sequential Scan 反而更高效
  3. 类型不匹配:查询条件的数据类型与索引列的类型不同
  4. 表达式不一致:Expression Index 与查询中的表达式没有完全一致
  5. 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-treeGINGiSTBRIN
最佳数据类型标量值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

索引类型选择流程

  1. 等值/范围/排序查询 -> B-tree
  2. JSONB 包含检索、数组、全文检索 -> GIN
  3. 空间数据、范围重叠、KNN -> GiST
  4. 时序/append-only 大表的范围检索 -> BRIN
  5. 只频繁查询满足特定条件的行 -> Partial Index
  6. 基于函数/变换结果的检索 -> Expression Index

结语

PostgreSQL 的高级索引各自都有明确的设计目的与最佳场景。关键在于 根据数据特性与查询模式选择合适的索引类型

  • JSONB 或全文检索较多时可以考虑 GIN,但要把写入开销与索引大小考虑进去。
  • 有空间数据或范围重叠查询时,GiST 几乎是必选项。
  • 数十亿行的时序表,用 BRIN 可以把索引体积减少 99% 以上。
  • Partial Index 与 Expression Index 可以和任意索引类型组合,提供额外的优化空间。

最重要的是,用 EXPLAIN ANALYZE 验证真实的执行计划,并在生产环境持续监控索引膨胀。索引不是建完就可以忘掉的东西,而是需要持续管理的运维对象。