上一篇我们聊了窗口函数、
CASE和空值处理。这一篇,我们进入索引的世界。索引是 PostgreSQL 性能优化中最重要的单一主题------B-tree 索引承担了超过 80% 的查询加速任务。但索引不是免费的午餐:每一个索引都在加速读的同时,拖慢写、占用空间、需要维护。理解 B-tree 的内部结构和失效机制,你才能在"建索引"和"不建索引"之间做出清醒的选择。
一、B-tree 到底在解决什么问题?
没有索引时,查询一行数据需要全表扫描(Seq Scan)------从第一页读到最后一页,逐行比较。表有 100 万行,就要读 100 万行。
B-tree 把"逐行比较"变成了"从根向下,每层一次二分查找"。PostgreSQL 的 B-tree 是平衡树:所有叶子页到根的距离相同,树的高度通常在 3 到 4 层。查找一行数据只需要读几个索引页,而不是扫描整张表。
但"平衡"是有代价的。当叶子页写满时,PostgreSQL 必须分裂(split)这个页:把大约一半的数据移到新页,然后在父页中插入一个分隔键。如果父页也满了,分裂继续向上传播,直到根页。根页分裂是唯一让树"长高"的时刻。分裂不是免费的------它需要分配新页、移动数据、更新父页指针、获取额外的锁。在高并发写入场景下,频繁的分裂甚至会造成锁竞争。
这就是索引的核心矛盾:它用"写时的分裂成本"换取了"读时的查找效率"。
二、fillfactor:给未来的插入"留白"
既然页分裂是写放大的根源,PostgreSQL 提供了一个控制手段:fillfactor。
fillfactor 是一个百分比,决定索引页在创建和"向右扩展"时填充到什么程度。默认值是 90。也就是说,新建索引时,叶子页只填到 90%,留下 10% 的空洞给未来插入的数据使用。当新条目需要写入时,如果目标页还有空洞,就直接放进去,避免立即分裂。
这背后的权衡很直接:
- fillfactor 高(如 100) :索引更紧凑,同样数据占用更少的页,读效率高;但插入时更容易触发分裂,写放大严重;
- fillfactor 低(如 70) :页面留白多,随机插入有更多空间容纳,减少分裂和锁竞争;但索引体积变大,扫描时需要读更多页。
对于递增主键 (如自增 ID),插入总是发生在最右侧,fillfactor 的影响较小,默认 90 或甚至 100 都可以。对于随机键 (如 UUID、随机字符串),插入分布在整个索引空间,较低 fillfactor(如 70)可以显著减少分裂频率。fillfactor 只对叶子页有直接作用,内部页不受其控制。
三、创建索引的几种形态
基础索引与唯一索引
sql
CREATE INDEX idx_users_email ON users (email);
CREATE UNIQUE INDEX idx_users_username ON users (username);
普通索引只加速查询,不保证唯一性。唯一索引在加速查询的同时强制列值唯一 ,并在写入时增加唯一性检查的开销。主键和 UNIQUE 约束会自动创建唯一索引。
复合索引:列顺序决定一切
sql
CREATE INDEX idx_orders_status_created ON orders (status, created_at);
复合索引的可用性由最左前缀原则 决定:索引 (status, created_at) 可以支持 WHERE status = ?、WHERE status = ? AND created_at > ?,但不能 单独支持 WHERE created_at > ?。
列顺序的设计规则是:等值条件列在前,范围条件列在后 。如果 status 总是用 = 过滤,而 created_at 用 > 过滤,那么 (status, created_at) 是正确的顺序;反过来 (created_at, status) 则无法高效利用 status 的等值条件。
部分索引:只索引"你真正查询的那部分"
sql
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
如果业务中 90% 的查询只针对 status = 'pending' 的订单,部分索引只索引这一小部分行,索引体积可以小一个数量级 ,维护成本也相应降低。部分索引的条件必须是查询中稳定出现的谓词------如果查询条件变化,部分索引就不会被使用。
表达式索引:让"函数包裹"的查询也能走索引
sql
CREATE INDEX idx_users_lower_email ON users (lower(email));
如果查询写成 WHERE lower(email) = 'alice@example.com',普通 email 索引无法使用,因为 B-tree 存储的是原始值,不是 lower() 之后的值。表达式索引存储的是表达式计算后的结果 ,查询中的表达式必须与索引定义完全匹配才能生效。
覆盖索引:用 INCLUDE 消除回表
sql
CREATE INDEX idx_orders_status_covering ON orders (status)
INCLUDE (amount, created_at);
INCLUDE 列是"载荷列",不参与索引键的排序和查找,但存储在索引中。当查询只需要 status、amount、created_at 三列时,PostgreSQL 可以执行 Index Only Scan,完全不访问堆表。这在大表上对减少 I/O 非常有效,代价是索引体积增大、写入时需要维护更多列。
四、索引为什么"不生效":五种高频场景
"明明有索引,查询还是慢"------这是索引使用中最常见的困惑。索引失效不是因为 PostgreSQL "忘了"索引,而是因为查询的写法让 B-tree 的有序结构无法被利用。
场景一:隐式类型转换
sql
-- device_id 是 VARCHAR,查询却传了整数
SELECT * FROM user_actions WHERE device_id = 12345678;
-- 执行计划:Seq Scan
-- Filter: ((device_id)::text = '12345678'::text)
当查询条件的类型与索引列类型不一致时,PostgreSQL 会触发隐式转换。如果转换发生在索引列一侧 (如 device_id::text),索引存储的原始值就无法直接匹配,索引失效。解决方案是显式转换查询参数,而不是转换列:
sql
WHERE device_id = 12345678::text
场景二:函数或表达式包裹索引列
sql
-- 索引在 order_date 上
WHERE EXTRACT(YEAR FROM order_date) = 2023;
-- 执行计划:Seq Scan
对索引列做函数运算或表达式计算,破坏了 B-tree 的有序性------索引存储的是 order_date 的原始值,不是 EXTRACT(YEAR FROM ...) 的结果。解决方案是创建匹配的表达式索引,让查询中的表达式与索引定义一致。
场景三:前导通配符
sql
WHERE content LIKE 'postgres%' -- 可以用 B-tree 索引
WHERE content LIKE '%index%' -- 索引失效
B-tree 的前缀匹配优势只对 LIKE 'prefix%' 有效。%keyword% 让优化器无法通过索引定位起始位置。这类查询应该使用 pg_trgm 扩展配合 GIN 索引,而不是 B-tree。
场景四:最左前缀断裂
复合索引 (tenant_id, event_type, created_at) 对 WHERE event_type = ? AND created_at > ? 无效,因为查询跳过了最左列 tenant_id。复合索引的列顺序必须匹配查询的过滤模式,而不是"把区分度高的列放前面"这一条通用规则------如果某个列总是以等值条件出现,它应该放在范围列之前。
场景五:选择率过高
sql
-- status 只有 4 个值,查询覆盖 50% 的行
WHERE status IN (1, 2);
-- 优化器选择 Seq Scan,即使有索引
当查询返回的行数占表的比例过高时(通常超过 5%--10%),索引扫描需要随机访问大量堆页,成本可能高于 顺序扫描。优化器基于统计信息(pg_stats)估算选择率,主动选择 Seq Scan。这不是索引失效,而是索引"不划算"。强制使用索引(SET enable_seqscan = off)通常只会让查询更慢。
五、索引的维护:膨胀与 REINDEX
索引不是"建完就不用管"的。PostgreSQL 的 MVCC 机制意味着 UPDATE 和 DELETE 不会原地修改索引条目,而是留下"死条目"。随着时间推移,索引页中有效条目的比例下降,页面变得稀疏------这就是索引膨胀(index bloat) 。
索引膨胀的量化指标是叶子页平均密度 。一个健康的 B-tree 索引,叶子页密度通常在 90% 左右。当 bloat 超过 30% 时,REINDEX 通常是有收益的;VACUUM 无法修复索引膨胀,因为它只清理表中的死元组,不重新组织索引页。一个极端案例中,索引膨胀达到 83.7%,REINDEX 后密度从 16.3% 恢复到 90.1%。
维护建议:
- 日常 :依赖 autovacuum 清理死元组,保持统计信息更新(
ANALYZE); - 膨胀超过阈值 :使用
REINDEX INDEX CONCURRENTLY在线重建,不阻塞写入; - 未使用的索引 :通过
pg_stat_user_indexes查找idx_scan = 0的索引,评估后删除。
六、回到核心问题:索引越多越好吗?
不是。 索引的代价体现在三个维度:
写放大。 每一次 INSERT、UPDATE、DELETE 都需要更新所有相关索引。表上有 5 个索引,一次写入就要维护 5 个 B-tree 结构。索引越多,写入越慢,页分裂和锁竞争的概率越高。
空间成本。 每个索引都是一份额外的数据副本。复合索引、表达式索引、覆盖索引的体积可能接近甚至超过表本身。
优化器负担与维护成本。 索引越多,优化器在生成执行计划时需要评估的组合越多;膨胀、重建、统计信息更新的维护开销也随索引数量线性增长。
索引的设计原则是"为查询模式建索引,而不是为列建索引"。 一个精心设计的复合索引,往往可以替代两三个单列索引。部分索引和覆盖索引可以在不增加太多写成本的前提下,服务特定的高频查询。在创建任何索引之前,先问:这个索引服务的是哪条具体的查询?它的写成本是否值得?
七、总结
| 主题 | 关键点 |
|---|---|
| B-tree 结构 | 平衡树,分裂向上传播,根分裂才长高 |
| fillfactor | 默认 90,随机写入可降低以减少分裂;只作用于叶子页 |
| 复合索引 | 最左前缀;等值列在前,范围列在后 |
| 部分索引 | 只索引稳定过滤条件下的子集,体积小、维护低 |
| 表达式索引 | 查询中的表达式必须与索引定义完全匹配 |
| 覆盖索引 | INCLUDE 载荷列,实现 Index Only Scan |
| 失效场景 | 隐式转换、函数包裹、前导通配符、最左断裂、选择率过高 |
| 维护 | 膨胀 >30% 考虑 REINDEX;删除未使用索引 |
一句话:索引是用"写时的分裂成本"换"读时的查找效率"。理解 B-tree 的内部代价,理解查询写法如何决定索引能否被使用,你才能从"多建索引"的直觉,走向"为查询模式建索引"的设计。
下一篇,我们进入索引优化策略:复合索引的列顺序到底怎么定?部分索引和表达式索引分别在什么场景下值得用?覆盖索引的收益边界在哪里?
思考题:
你有没有遇到过"有索引但查询不走"的情况?是隐式类型转换、函数包裹,还是选择率过高?另外,你检查过自己库里的索引膨胀率吗?有没有长期未被使用但一直占着写入成本的索引?欢迎在评论区聊聊。
