PostgreSQL 索引类型深度实战:B-tree 原理、组合索引、GiST/GIN/BRIN 与索引维护

深入解析 PostgreSQL 全家族索引:B-tree 平衡树原理与索引高度、组合索引列顺序与最左前缀、跳跃扫描的真相、GiST 空间与范围检索、GIN 倒排索引(全文搜索/JSONB/数组)、BRIN 块范围索引、部分索引与表达式索引、覆盖索引 INCLUDE、索引膨胀诊断与并发重建。每个索引类型配真实 DDL、EXPLAIN 示例与选型决策表。

索引是 PostgreSQL 查询性能的基石。很多慢查询的根因并不是 SQL 写得不好,而是索引类型选错、组合索引列顺序颠倒、或者索引本身已经膨胀失效。

PostgreSQL 的索引家族远比 CREATE INDEX 一句话复杂:默认的 B-tree 擅长等值与范围查询,GiST 支持空间与范围检索,GIN 是全文搜索与 JSONB 的倒排索引,BRIN 则以极小体积服务超大表。加上部分索引、表达式索引、覆盖索引(INCLUDE)这些"进阶形态",一套完整的索引体系能覆盖绝大多数生产场景。

核心认知:索引不是越多越好,而是"每个查询路径都有对应索引“最好。本文帮你建立从原理到选型的完整决策框架。


一、B-tree:PostgreSQL 的默认索引

1.1 B-tree 原理与索引结构

B-tree(Balanced Tree)是 PostgreSQL 的默认索引类型。当你执行 CREATE INDEX idx ON t(col) 而没有指定 USING 时,创建的就是 B-tree。

B-tree 是一棵自平衡多叉树:

  • 根节点:仅存储键值范围与子节点指针
  • 内部节点:逐层划分键值区间
  • 叶子节点:存储实际索引条目(键值 + heap 行指针 TID),且叶子间以双向链表相连,方便范围扫描

关键特性:所有叶子在同一层,所以任何查找路径长度一致,复杂度稳定为 O(log N)。

1.2 索引高度与 IO 成本

B-tree 的"高度"决定了一次索引查找需要读多少个页面。对于一个 4 层的 B-tree,等值查找最多需要 3 次内部节点 IO + 1 次叶子 IO + 1 次堆表回表。

-- 观察索引被使用的情况
EXPLAIN ANALYZE
SELECT * FROM orders WHERE order_id = 12345;
-- 输出中若出现 Index Scan using orders_pkey,说明命中主键索引
-- 若出现 Bitmap Heap Scan,说明使用了 bitmap 扫描策略
表行数索引条目大小索引高度最坏情况叶子访问
10008 字节22 次 IO
100 万24 字节33 次 IO
10 亿40 字节44 次 IO

结论:即使到 10 亿行,B-tree 的查找成本也只是几次页面读取——这是它成为默认索引的根本原因。

1.3 B-tree 支持的操作

B-tree 内置了对排序与比较操作符(operator class btree_ops)的支持:

谓词 / 操作示例是否走 B-tree
等值col = 42是(Index Scan)
范围col > 42 AND col < 100是(Range Scan)
排序ORDER BY col是(Index Scan 免排序)
前缀模糊col LIKE 'abc%'是(转换为范围查询)
中间模糊col LIKE '%abc%'否(需 pg_trgm GIN)
空值col IS NULL是(PG 默认将 NULL 排在最后)

1.4 B-tree 的适用边界

B-tree 不擅长的场景:

  • 后缀/包含式模糊匹配(LIKE '%xxx')→ 交给 pg_trgm + GIN
  • 数组包含、JSONB 键值包含 → 交给 GIN
  • 空间距离、范围重叠 → 交给 GiST
  • 超大表的列值高度聚集(如时间戳)→ 考虑 BRIN

二、组合索引:列顺序决定一切

2.1 组合索引的创建

组合索引(Composite Index)在一个索引内包含多个列:

CREATE INDEX idx_orders_user_time
  ON orders (user_id, created_at DESC);

user_id 是前导列(leading column),created_at DESC 是第二列,还带了排序方向。

2.2 最左前缀规则

PostgreSQL 只能从最左列开始利用组合索引的多个列。判断一个查询能否用上第二列,取决于第一列是否为等值条件:

-- ✅ 走完整索引:user_id 等值 + created_at 范围
EXPLAIN SELECT * FROM orders
WHERE user_id = 1001 AND created_at > '2026-01-01';

-- ✅ 走索引,但只用于 user_id 过滤,created_at 部分退化
EXPLAIN SELECT * FROM orders
WHERE user_id > 1001 AND created_at > '2026-01-01';

-- ❌ 完全无法使用 idx_orders_user_time
EXPLAIN SELECT * FROM orders WHERE created_at > '2026-01-01';

设计要点:高频等值列放前面,范围/排序列放后面。(user_id, created_at) 能同时服务"某用户的时间段"查询,而 (created_at, user_id) 只能服务全局时间查询。

2.3 跳跃扫描(Skip Scan)的真相

MySQL 8.0 的 Skip Scan 可以"跳过"组合索引的前导列做 IN 查询。PostgreSQL 没有通用的跳跃扫描,但它有一条近似路径——PostgreSQL 11+ 的 Loose Index Scan 能让 SELECT DISTINCT col 跳过重复键值:

-- PostgreSQL 对 DISTINCT 的 loose scan(跳过重复键值)
EXPLAIN SELECT DISTINCT user_id FROM orders WHERE user_id < 1000;
-- 输出:Index Only Scan using idx_orders_user_time ... (skip duplicate)

如果你的场景确实需要"跳跃"语义,可用 WITH RECURSIVE 手工模拟,也可以考虑在应用层先取 distinct 前导值再逐批查询。

2.4 组合索引设计决策表

查询模式推荐索引说明
WHERE a=? AND b=?(a, b)a 等值再 b 等值,顺序影响小
WHERE a=? AND b>? ORDER BY b(a, b)b 既过滤又排序,同向
WHERE a>? AND b=?(b, a)b 等值前置,a 范围在后
WHERE a=? ORDER BY c(a, c)排序列与过滤列合并
低频组合查询多个单列索引让 bitmap AND 合并

三、GiST:空间与范围检索

3.1 GiST 原理

GiST(Generalized Search Tree,通用搜索树)是一种可扩展的平衡树,它不关心键值的具体类型,而是要求操作符类提供**一致性(consistent)与罚分(penalty)**函数。因此 GiST 能服务:

  • 几何类型:点、线、面(PostGIS 的空间索引就是 GiST)
  • 范围类型:int4range、tsrange(时间范围重叠)
  • pg_trgm:文本相似度(可选 GIN)
  • 排除约束(EXCLUDE):如会议室时间不重叠

3.2 GiST 适用场景

-- 1. 范围类型:会议室预订时间不冲突
CREATE TABLE meeting_rooms (
  room_id INT,
  reserved_range TSRANGE NOT NULL,
  EXCLUDE USING GIST (room_id WITH =, reserved_range WITH &&)
);

-- 2. 空间索引:查找附近门店
CREATE INDEX idx_stores_loc ON stores USING GIST (location);

-- 3. 范围重叠查询
SELECT * FROM meeting_rooms
WHERE reserved_range && tsrange('2026-09-27 10:00', '2026-09-27 12:00');

3.3 GiST 操作符类

操作符类服务类型典型操作符
box_ops / point_ops几何&&(相交)、<->(距离)、<<
range_ops范围类型&&、@>、<<、>>
gist_trgm_ops文本相似度<%、%>(相似度阈值)
btree_gist任意标量=、<、>(供 EXCLUDE 使用)

提示:PostGIS 的空间索引也走 GiST,但那是 PostGIS 自带的操作符类;不要与 CREATE EXTENSION postgis 之外的场景混淆。


四、GIN:倒排索引

4.1 GIN 原理

GIN(Generalized Inverted Index,广义倒排索引)适合”一个键值对应多行“的包含类查询。它维护:

  • Entry tree:存储每个独立键值(如分词后的词项、JSONB 的每个键、数组的每个元素)
  • Posting list:指向包含该键值的堆表行

GIN 的经典场景是全文搜索、JSONB 包含查询、数组包含查询。

4.2 GIN 适用场景

-- 1. 全文搜索(tsvector)
CREATE INDEX idx_articles_tsv ON articles USING GIN (tsv);
SELECT * FROM articles WHERE tsv @@ to_tsquery('postgresql & performance');

-- 2. JSONB 包含查询(默认 jsonb_ops 操作符类)
CREATE INDEX idx_users_tags ON users USING GIN (tags);
SELECT * FROM users WHERE tags @> '["vip"]';

-- 3. 数组包含查询
CREATE INDEX idx_orders_skus ON orders USING GIN (sku_array);
SELECT * FROM orders WHERE sku_array @> ARRAY['SKU-1001'];

4.3 GIN 创建与参数

-- 显式指定操作符类
CREATE INDEX idx_jsonb ON docs USING GIN (data jsonb_ops);
-- 更小更快的 jsonb_path_ops(仅支持 @>,不支持 ? 系列)
CREATE INDEX idx_jsonb_path ON docs USING GIN (data jsonb_path_ops);

-- 全文搜索的 fastupdate(延迟合并 posting list,降低写放大)
CREATE INDEX idx_tsv ON articles USING GIN (tsv)
  WITH (fastupdate = on, gin_pending_list_limit = 4096);
参数默认说明
fastupdateon将新增条目暂存 pending list,延迟合并
gin_pending_list_limit4MBpending list 上限,达到后强制合并
gin_fuzzy_search_limit0(不限)限制部分匹配返回的行数

4.4 GIN vs GiST

维度GINGiST
构造速度慢快
索引大小大小
查询速度(包含类)快慢
全文搜索 @@✅ 推荐支持但慢
空间/范围❌✅
典型场景全文、JSONB、数组几何、范围、EXCLUDE

五、BRIN:大表的轻量索引

5.1 BRIN 原理

BRIN(Block Range Index,块范围索引)不记录每一行的值,而是为每个连续的页块范围(默认每 128 个页,即约 1MB)记录该范围内列的最小值与最大值。

  • 体积极小(一个块范围只存两三个值)
  • 查询时先看 BRIN 判断"这个块范围是否可能命中”,不命中直接跳过整个块
  • 完美适用于数据与物理顺序相关的场景(如按时间追加的日志表)

5.2 BRIN 适用场景

-- 时序日志表:created_at 随物理写入顺序单调递增
CREATE TABLE access_logs (
  id BIGSERIAL,
  created_at TIMESTAMPTZ NOT NULL,
  ip INET,
  url TEXT
);

-- 在时间列上建 BRIN
CREATE INDEX idx_logs_time ON access_logs USING BRIN (created_at);

只有当查询范围能裁剪掉大部分块范围时才高效:

-- ✅ 高效:WHERE 条件命中时间范围,BRIN 快速跳过大部分块
EXPLAIN SELECT count(*) FROM access_logs
WHERE created_at > NOW() - INTERVAL '1 hour';

-- ❌ 低效:查询覆盖全表,BRIN 每个块范围都要访问
EXPLAIN SELECT count(*) FROM access_logs WHERE ip = '192.168.1.1';

5.3 BRIN 创建与参数

-- 调整块范围大小(每页 8KB,pages_per_range=128 即约 1MB)
CREATE INDEX idx_logs_time ON access_logs USING BRIN (created_at)
  WITH (pages_per_range = 64);

5.4 BRIN vs B-tree

维度BRINB-tree
索引体积极小(几十 KB)较大(约等于表数据比例)
扫描方式块范围裁剪精确到行
适合数据量千万行以上任意
对数据有序性强依赖不依赖
写入维护几乎无每次 DML 维护
典型场景时间序列、日志常规 OLTP 点查

建议:表超过 5 亿行且列值与物理顺序强相关时,BRIN 是性价比最高的选择;否则优先 B-tree。


六、部分索引与表达式索引

6.1 部分索引(Partial Index)

部分索引只覆盖满足 WHERE 条件的行,体积更小、维护成本更低:

-- 只索引活跃订单(status = 'active')
CREATE INDEX idx_orders_active ON orders (created_at)
  WHERE status = 'active';

-- 查询必须带上相同谓词才能命中
EXPLAIN SELECT * FROM orders
WHERE status = 'active' AND created_at > '2026-09-01';
-- ✅ Bitmap Index Scan on idx_orders_active

6.2 表达式索引(Expression Index)

对函数或表达式的结果建索引,解决"条件里带函数导致索引失效"的问题:

-- 邮箱登录不区分大小写
CREATE INDEX idx_users_lower_email ON users (lower(email));

-- 命中表达式索引
EXPLAIN SELECT * FROM users WHERE lower(email) = 'admin@example.com';
-- ✅ Index Scan using idx_users_lower_email

注意:表达式索引要求查询中的表达式逐字符一致,连空格都不能多。用 \d idx 查看索引定义时,要严格复写表达式。

6.3 场景对照表

场景方案示例
某状态行的子集频繁查询部分索引WHERE status='active'
函数/表达式过滤表达式索引lower(email)
两者结合部分 + 表达式活跃订单按天
历史冷数据无需索引部分索引排除WHERE created_at > '2024-01-01'

七、覆盖索引(INCLUDE)

7.1 原理

覆盖索引让索引额外携带非索引列的值,从而让 Index Only Scan 直接从索引页返回结果,免去回表:

-- 普通索引:查询 SELECT id, user_id, created_at 需回表
CREATE INDEX idx_orders_uid ON orders (user_id);

-- 覆盖索引:把 created_at 也塞进索引叶子
CREATE INDEX idx_orders_uid_cover ON orders (user_id) INCLUDE (created_at);

7.2 Index-Only Scan 验证

EXPLAIN SELECT user_id, created_at FROM orders WHERE user_id = 1001;
-- 输出:Index Only Scan using idx_orders_uid_cover
-- 注意:Heap Fetches 应为 0 或接近 0

覆盖索引不是万能的:INCLUDE 的列不参与排序与过滤,只参与回表消除。

7.3 覆盖索引 vs 普通组合索引

维度组合索引 (a, b)覆盖索引 (a) INCLUDE (b)
b 参与过滤/排序✅❌
b 参与回表消除✅✅
索引体积较大略小
适用a、b 都常作为查询条件仅 a 是条件,b 只是 SELECT 列

八、索引维护与监控

8.1 索引膨胀诊断

频繁 UPDATE/DELETE 会让索引留下大量死条目,占满页面空间(bloat):

-- 方式 1:pgstatindex 扩展(统计每个索引的页与空余率)
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstatindex('idx_orders_uid_cover');

8.2 重建索引(REINDEX CONCURRENTLY)

-- 普通 REINDEX 会持有写锁,阻塞业务
-- REINDEX CONCURRENTLY 不阻塞读写(PG 12+,失败会残留无效索引)
REINDEX INDEX CONCURRENTLY idx_orders_uid_cover;

-- 重建表上全部索引
REINDEX TABLE CONCURRENTLY orders;

8.3 并行创建索引

-- 控制并行 worker 数(依据表大小,PostgreSQL 自动并行)
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;

CREATE INDEX CONCURRENTLY idx_orders_time ON orders (created_at DESC);

CREATE INDEX CONCURRENTLY 不阻塞写入,但会多花时间且分两步构建,事务失败时可能留下无效索引,需用 DROP INDEX CONCURRENTLY 清理。

8.4 pg_stat_all_indexes 分析

-- 找出从未被扫描过的"僵尸索引"
SELECT
  schemaname, relname, indexrelname,
  idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
  AND NOT indisunique
ORDER BY pg_relation_size(indexrelid) DESC;

8.5 索引选型决策表

查询特征推荐索引理由
等值 / 范围 / 排序B-tree默认、全能
多列且含等值 + 范围组合 B-tree列顺序:等值在前
全文搜索 / JSONB 包含 / 数组包含GIN倒排,包含类查询最快
空间 / 范围重叠 / EXCLUDEGiST可扩展操作符类
超大表 + 值随物理顺序有序BRIN体积极小、免维护
只查状态子集部分索引更小更快
查询条件带函数表达式索引使函数查询可走索引
高频 SELECT 少列INCLUDE 覆盖索引Index Only Scan 免回表
索引膨胀/无效REINDEX CONCURRENTLY在线重建

8.6 索引监控 SQL

-- 每个表的索引数量与总大小
SELECT
  t.relname AS table_name,
  count(i.indexrelid) AS index_count,
  pg_size_pretty(sum(pg_relation_size(i.indexrelid))) AS total_idx_size
FROM pg_class t
JOIN pg_index i ON i.indrelid = t.oid
WHERE t.relkind = 'r'
GROUP BY t.relname
ORDER BY sum(pg_relation_size(i.indexrelid)) DESC
LIMIT 20;

常见问题(FAQ)

为什么我建了索引,查询还是走全表扫描?

常见原因有四个:数据量太小(优化器认为全表更便宜)、查询条件与索引列类型不匹配(如隐式类型转换导致索引失效)、表达式查询未建表达式索引、或者统计信息过期(先执行 ANALYZE t)。

组合索引的列顺序怎么定?

等值条件列放前面,范围/排序列放后面。例如 WHERE user_id = ? AND created_at > ? 应建 (user_id, created_at),而不是 (created_at, user_id)——后者只能服务 user_id 的过滤,created_at 部分几乎浪费。

GIN 和 GiST 建全文索引哪个好?

全文搜索用 GIN。GIN 查询更快、更节省时间;GiST 构建更快、体积更小,但查询性能差一档。除非你非常在意写入速度,否则选 GIN。

BRIN 适合我的业务表吗?

BRIN 要求列值与物理存储顺序强相关,最典型的是时间戳追加型日志表。如果你的表频繁 UPDATE 导致行物理顺序混乱,BRIN 会退化得比 B-tree 更差。

怎么安全地清理膨胀索引?

用 REINDEX INDEX CONCURRENTLY(PG 12+)在线重建,不要直接 REINDEX——普通 REINDEX 会持有 ACCESS EXCLUSIVE 锁阻塞整个表。如果构建失败,用 DROP INDEX CONCURRENTLY 清理残留的无效索引。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 数据库安全加固与审计实战:权限最小化、加密、脱敏与合规
  2. 数据库容量规划与资源治理:从评估、监控到扩展路径
  3. 数据库字符集、排序规则与乱码实战:utf8mb4、Collation 选择与排查