第四篇:《索引原理与实战:B-tree、Hash、GIN、GiST、BRIN》

在上一篇文章中,我们掌握了 PostgreSQL 的类型系统------JSONB 存文档、数组存标签、范围类型存区间。但类型选对了,查询不一定快。索引是"用空间换时间"的工程决策,而 PostgreSQL 提供了六种索引类型,每一种都对应完全不同的数据结构和适用边界。用 B-tree 索引 JSONB 的包含查询,它不会生效;用 GIN 索引整数主键的等值查询,它会浪费数倍空间。 本文将逐一拆解 PostgreSQL 六种索引类型的工作原理、支持的操作符和适用场景,深入讲解复合索引的列顺序原则、部分索引与表达式索引的实用技巧,最后通过一个"慢查询索引设计"的实战,帮你建立系统的索引选型能力。

一、索引的本质:用写入换读取

索引的核心逻辑极其简单:在数据之外维护一份有序的、精简的辅助结构,让查询不必扫描全表。代价是每次 INSERT、UPDATE、DELETE 都需要同步更新索引,写入性能下降,存储空间增加。

PostgreSQL 的索引都是独立于表的物理结构。表数据存储在堆(Heap)中,索引存储键值和指向堆中元组位置的指针(TID,即 ctid)。查询时,索引告诉数据库"目标行在哪个物理页的哪个位置",然后数据库去堆中取出完整行。这个过程叫回表(Heap Fetch) 。

但有一种情况可以避免回表:Index Only Scan------当索引包含了查询需要的所有列时,数据库直接从索引返回结果,完全不访问堆表。这要求索引覆盖查询的 SELECT 和 WHERE 中涉及的所有列,并且依赖 visibility map 来判断堆中页面的可见性------而 visibility map 由 VACUUM 维护。如果表长期不 VACUUM,Index Only Scan 会退化为普通 Index Scan,性能优势消失。

理解了回表成本,就能理解索引选型的核心问题:让索引尽可能高效地缩小候选行范围,并尽可能少地回表。

二、六种索引类型全景

PostgreSQL 提供六种内置索引类型:

一个关键认知:CREATE INDEX 不指定类型时,默认创建 B-tree 索引,它"fit the most common situations"。这意味着在大多数场景下,B-tree 是安全且正确的默认选择。只有当你明确知道 B-tree 无法覆盖查询模式时,才需要切换到其他类型。

三、B-tree:默认的"全能选手"

B-tree 是 PostgreSQL 中使用最广泛的索引类型。它能处理可排序数据上的等值查询和范围查询。

3.1 B-tree 支持的操作符和查询模式

B-tree 索引会被优化器考虑用于以下比较操作符:<、<=、=、>=、>。BETWEEN、IN 这些组合操作符也能用 B-tree 实现。IS NULL 和 IS NOT NULL 同样可以走 B-tree。

前缀匹配是 B-tree 的一个重要能力:

sql 复制代码
-- ✅ 可以使用 B-tree 索引
SELECT * FROM users WHERE name LIKE 'Alice%';
SELECT * FROM users WHERE name ~ '^Alice';

-- ❌ 无法使用 B-tree 索引(前导通配符)
SELECT * FROM users WHERE name LIKE '%Alice';

官方文档明确指出:B-tree 可以用于 LIKE 和 ~ 操作符,但模式必须是常量并且锚定在字符串开头------col LIKE 'foo%' 可以,col LIKE '%bar' 不行。对于 ILIKE,只有当模式以非字母字符开头时才能走 B-tree。

B-tree 还能直接返回有序结果,避免额外的排序步骤。如果你经常执行 ORDER BY created_at DESC,一个 created_at 上的 B-tree 索引可以让数据库直接按索引顺序返回数据。

3.2 B-tree 的"隐藏成本":写入放大

B-tree 的每个 INSERT 都可能触发页分裂,每个 UPDATE 如果修改了索引列,实际上是在索引中删除旧条目并插入新条目。索引越多,写入越慢。一个表如果拥有 10 个 B-tree 索引,每次 INSERT 需要更新 10 个索引结构,写入开销可能是无索引时的数倍。

实践原则:索引数量控制在 5 个以内是常见的安全边界。定期用 pg_stat_user_indexes 检查 idx_scan,从未被使用的索引应该删除------它们只消耗写入性能和存储空间,不产生任何读取收益。

四、Hash:极简但边界狭窄

Hash 索引只能处理等值比较。当你对一列使用 = 操作符时,优化器会考虑 Hash 索引。创建方式:

sql 复制代码
CREATE INDEX idx_users_email_hash ON users USING HASH (email);

Hash 索引的适用场景非常有限:

超大型表的等值查询:Hash 索引通常比 B-tree 索引体积更小,在缓存受限的场景下可能有优势。

不涉及范围、排序、前缀匹配:一旦查询涉及 <、>、LIKE、ORDER BY,Hash 索引完全无用。

不推荐将 Hash 作为默认选择:B-tree 在等值查询上性能相近,且额外支持范围、排序和前缀匹配。只有在明确验证 Hash 索引带来显著收益(通常是索引体积和缓存命中率)时,才考虑使用它。

五、GIN:多值数据的倒排索引

GIN(Generalized Inverted Index,广义倒排索引)是 JSONB、数组、全文检索的标配索引。它的结构与书籍末尾的"索引页"类似:每个"值"对应一个列表,记录包含该值的行位置。

官方文档对 GIN 的定义是:"inverted indexes" which are appropriate for data values that contain multiple component values, such as arrays。GIN 适用于"一行包含多个值"的数据------数组的多个元素、JSONB 的多个键、全文检索的多个词元------B-tree 擅长"一行一个键值",而 GIN 擅长"一行多个值"。

5.1 GIN 支持的操作符

5.2 两种 GIN 操作符类:jsonb_ops vs jsonb_path_ops

sql 复制代码
-- 默认:jsonb_ops,支持 @>、?、?|、?&
CREATE INDEX idx_data ON events USING GIN (data);

-- 精简版:jsonb_path_ops,仅支持 @>
CREATE INDEX idx_data_path ON events USING GIN (data jsonb_path_ops);

jsonb_path_ops 的索引体积更小、构建更快,代价是只支持 @> 操作符。如果你的查询模式只需要包含判断(data @> '{"key": "value"}'),jsonb_path_ops 是更优的选择。如果需要 ?(键存在)查询,则必须使用默认的 jsonb_ops。

5.3 GIN 的核心陷阱:@> 有效,->> 无效

这是 JSONB 索引中最常见的性能陷阱:

sql 复制代码
-- ✅ GIN 索引生效
SELECT * FROM events WHERE data @> '{"user_id": 123}';

-- ❌ GIN 索引不生效(对提取后的文本做比较)
SELECT * FROM events WHERE data ->> 'user_id' = '123';

原因在于:GIN 索引索引的是 JSONB 内部的键值对结构,@> 操作符直接在索引层面判断"左边是否包含右边的结构",因此可以走索引。而 ->> 是一个函数调用,它先提取出文本值,再对文本做等值比较------索引无法参与这个提取过程。

如果必须按特定路径等值查询,应该创建 B-tree 表达式索引:

sql 复制代码
CREATE INDEX idx_events_user_id ON events ((data ->> 'user_id'));

-- 现在这个查询可以走 B-tree 索引
SELECT * FROM events WHERE data ->> 'user_id' = '123';

一个通用的判断原则:GIN 索引优化"结构包含"查询,B-tree 表达式索引优化"提取后等值/范围"查询。两者不是互斥的,可以同时存在。

六、GiST:空间与范围的基础设施

GiST(Generalized Search Tree)不是一种单一的索引,而是一个可以承载多种索引策略的基础设施。不同的操作符类(operator class)让 GiST 支持完全不同的数据类型和查询模式。

6.1 GiST 的核心能力

几何空间索引:PostgreSQL 标准发行版包含二维几何类型的 GiST 操作符类,支持 <<(左)、>>(右)、&<、&>、&&(重叠)等空间操作符。

邻最近搜索:GiST 可以优化 ORDER BY location <-> point '...' LIMIT 10 这样的"找最近 N 个点"查询,这是 B-tree 完全无法支持的。

范围排他约束:GiST 是 EXCLUDE USING GIST 约束的底层索引类型。在第三篇文章中我们见过会议室预订的例子:EXCLUDE USING GIST (room_id WITH =, period WITH &&) 保证同一房间的时间段不重叠。这个约束必须依赖 GiST 索引才能实现。

6.2 GiST 的适用边界

GiST 适合需要空间关系、范围重叠、邻最近的查询。对于普通的等值和范围查询,B-tree 是更简单、更高效的选择。GiST 的索引通常比 B-tree 更大,写入开销也更高,不应该用它来替代 B-tree。

七、BRIN:超大有序表的"轻量索引"

BRIN(Block Range Index)的核心思想是:为表中连续的一段物理块(block range)存储摘要信息,而不是为每一行存储索引条目。

BRIN 的体积可以比 B-tree 小几个数量级。对于一个十亿行的日志表,B-tree 索引可能需要数十 GB,而 BRIN 索引可能只有几 MB。

7.1 BRIN 的适用条件

BRIN 只在数据物理有序时有效:

按时间顺序插入的日志表:created_at 列的值与物理存储顺序高度相关。

自增主键:id 列的值与物理顺序相关。

时序数据、事件流。

如果数据物理无序(如随机写入的 UUID、频繁更新的状态列),BRIN 的摘要区间会覆盖几乎全部值域,查询时仍然需要扫描大量数据块,索引形同虚设。

sql 复制代码
-- 按 created_at 范围分区的日志表,BRIN 索引极高效
CREATE INDEX idx_logs_created_brin ON logs USING BRIN (created_at);

-- 查询最近 1 小时的日志
SELECT * FROM logs WHERE created_at > NOW() - INTERVAL '1 hour';

BRIN 的另一个优势是构建速度极快------它只扫描每个块的摘要,不需要逐行排序。

八、复合索引:列顺序决定一切

复合索引(多列索引)的核心问题是:哪一列放在前面。

基本原则:等值条件列在前,范围条件列在后。

sql 复制代码
-- 查询:WHERE user_id = 123 AND created_at > '2026-01-01'
-- ✅ 正确顺序:等值列 user_id 在前,范围列 created_at 在后
CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

-- ❌ 错误顺序:范围列在前,等值列的索引优势被削弱
CREATE INDEX idx_orders_created_user ON orders (created_at, user_id);

为什么:B-tree 复合索引的排序是先按第一列、再按第二列。(user_id, created_at) 的索引可以先通过 user_id = 123 精确定位到一个连续的区间,然后在这个区间内用 created_at 做范围扫描。而 (created_at, user_id) 的索引会先按时间排序,user_id 的过滤只能在每个时间区间内逐个检查,效率大幅下降。

一个重要的补充:复合索引的最左前缀原则------(a, b, c) 的索引可以支持 WHERE a = ?、WHERE a = ? AND b = ?、WHERE a = ? AND b = ? AND c = ?,但无法支持 WHERE b = ? 或 WHERE c = ?(跳过最左列)。

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

9.1 部分索引(Partial Index)

只索引满足条件的行,索引体积大幅减小:

sql 复制代码
-- 只索引未完成的订单(通常只占全部订单的 1%-5%)
CREATE INDEX idx_orders_pending ON orders (user_id) WHERE status = 'pending';

适用场景:表中存在一个高频查询但只涉及少量行的条件。例如"只查未支付订单"、"只查活跃用户"。部分索引的索引体积可能是全表索引的 1/50,写入开销和缓存压力都显著降低。

9.2 表达式索引(Expression Index)

对函数结果建索引,让"函数包裹列"的查询也能走索引:

sql 复制代码
-- 查询:WHERE LOWER(email) = 'alice@example.com'
CREATE INDEX idx_users_email_lower ON users (LOWER(email));

-- 现在这个查询可以走索引
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

在第六篇文章中我们会展开"索引失效的七种场景",表达式索引正是解决"函数包裹列"这一失效场景的标准手段。

十、实战:为一个慢查询设计最优索引组合

场景:电商订单表 orders,有以下查询:

sql 复制代码
-- 查询 1:查询某个用户的待支付订单,按创建时间倒序
SELECT id, total_amount, created_at
FROM orders
WHERE user_id = 123 AND status = 'pending'
ORDER BY created_at DESC
LIMIT 20;

当前状态:表上有 id 主键索引,但 user_id 和 status 无索引。EXPLAIN 显示 Seq Scan 扫描全表。

索引设计方案:

sql 复制代码
-- 方案 A:复合索引,覆盖等值 + 范围 + 排序
CREATE INDEX idx_orders_user_status_created
ON orders (user_id, status, created_at DESC);

-- 方案 B:部分索引,只索引 pending 状态
CREATE INDEX idx_orders_user_pending_created
ON orders (user_id, created_at DESC)
WHERE status = 'pending';

方案对比:

最终选择:如果业务中"待支付订单"是唯一的高频查询,方案 B(部分索引)是最优解------体积小、写入开销低、查询精准。如果其他状态也有类似查询需求,方案 A 更通用。

十一、小结

索引的本质:用写入性能和存储空间换读取性能。索引越多,写入越慢;从未被使用的索引应该删除。

B-tree:默认选择,支持等值、范围、排序、前缀匹配(LIKE 'abc%' 有效,LIKE '%abc' 无效)。

Hash:仅等值查询,体积可能更小,但边界极窄,不应作为默认选择。

GIN:多值数据的倒排索引,是 JSONB、数组、全文检索的标配。@> 有效,->> 无效------后者用 B-tree 表达式索引。

GiST:空间、范围、邻最近搜索的基础设施,是 EXCLUDE 排他约束的底层依赖。

BRIN:超大物理有序表的轻量索引,体积比 B-tree 小几个数量级,但只在数据物理有序时有效。

复合索引:等值列在前,范围列在后;最左前缀原则决定了哪些查询能用索引。

部分索引:只索引高频查询涉及的小部分行,体积和写入开销大幅降低。

表达式索引:解决"函数包裹列"导致的索引失效。

相关推荐
西索斯coding2 小时前
Qwen3-Max 与 Qwen-Plus:60 个真实修复任务评测方案
大数据·网络·算法·ai
INGNIGHT2 小时前
440 · 背包问题(三)(无限次数)
算法·leetcode·动态规划
suhan2 小时前
【数据结构】七、查找:1.查找的概念、线性结构查找(顺序、折半(二分)、插值、稠密、分块、倒排)
数据结构·算法·排序算法
killerbasd3 小时前
总结 10.26
算法
深圳安锐科技有限公司3 小时前
从成像尺度到亚像素解算:变焦视觉位移监测如何让远近断面都稳
图像处理·人工智能·深度学习·算法·计算机视觉·系统架构·结构健康监测
一只旭宝4 小时前
回调函数(Callback)
linux·服务器·算法
秋名RG4 小时前
算法的划分
算法
Tisfy4 小时前
LeetCode 3619.总价值可以被 K 整除的岛屿数目:广度优先搜索BFS
算法·leetcode·bfs·宽度优先
地平线开发者4 小时前
【大模型】EAGLE 与 EAGLE-3:用“特征预测“加速投机解码
算法