AI时代首先数据库-PostgreSQL实战笔记 | 18 PostgreSQL基本索引原理与创建

上一篇我们聊了窗口函数、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 的内部代价,理解查询写法如何决定索引能否被使用,你才能从"多建索引"的直觉,走向"为查询模式建索引"的设计。

下一篇,我们进入索引优化策略:复合索引的列顺序到底怎么定?部分索引和表达式索引分别在什么场景下值得用?覆盖索引的收益边界在哪里?


思考题:

你有没有遇到过"有索引但查询不走"的情况?是隐式类型转换、函数包裹,还是选择率过高?另外,你检查过自己库里的索引膨胀率吗?有没有长期未被使用但一直占着写入成本的索引?欢迎在评论区聊聊。

相关推荐
l1t1 小时前
DeepSeek总结的本地优先的分析不需要数据库服务器
运维·服务器·数据库·人工智能·duckdb
十碗饭吃不饱1 小时前
MySQL InnoDB RR 隔离下二级索引临键锁引发 INSERT 锁超时 全链路解析
java·数据库·mysql
Gh0st_Lx1 小时前
【2】MySQL 为什么使用 B+树
数据库·b树·mysql
我不会插花弄玉1 小时前
8. 复合查询【由浅入深-MySQL】
android·数据库·mysql
北风toto1 小时前
数据库模式分解:如何判定“保持函数依赖”?
数据库
极限挑战哥2 小时前
深度拆解团购多商户分账系统:规则引擎 + 账户体系 + 逆向清算全解析
网络·数据库·人工智能
qq_284274052 小时前
电气控制笔记:常用低压电器符号原理与异步电动机点动、自锁、多地控制电路(含考点)
笔记·学习·自动化·制造
数据狐(Datafox)2 小时前
一文读懂京东商品详情 API:返回字段解读与 JSON 样例
开发语言·数据库·爬虫·json
坤坤子吖2 小时前
Linux 线程同步与互斥——条件变量与线程同步
linux·开发语言·c++·笔记·学习