第二章:索引工程——让查询快 100 倍
第二章:索引工程——让查询快 100 倍
数据库慢,90% 的时候是索引的问题。但大多数开发者对索引的理解停留在"加一个 CREATE INDEX",然后发现没有变快,甚至变慢了。
一、索引的本质
索引是一个独立的数据结构,用来加速查询。代价是:
- 占用额外存储空间
- 每次 INSERT / UPDATE / DELETE 都要维护索引(写操作变慢)
所以索引不是越多越好——正确的索引是那些能让最慢的查询变快,同时写入开销可以接受的索引。
二、PostgreSQL 索引类型详解
2.1 B-Tree——默认索引,适用 80% 场景
B-Tree(平衡树)是 PostgreSQL 的默认索引类型。
-- 这两个语句等价
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_email ON users USING BTREE(email);
适用操作:=, <, >, <=, >=, BETWEEN, IN, IS NULL, LIKE 'xxx%'(前缀匹配)
不适用:LIKE '%xxx'(后缀匹配)、全文搜索、数组包含、JSONB 查询
-- 有效利用 B-Tree 索引的查询
SELECT * FROM orders WHERE user_id = 123; -- = 精确匹配 ✅
SELECT * FROM orders WHERE created_at > '2024-01-01'; -- 范围查询 ✅
SELECT * FROM users WHERE email LIKE 'john%'; -- 前缀模糊 ✅
-- 无法使用 B-Tree 索引的查询
SELECT * FROM users WHERE email LIKE '%@gmail.com'; -- 后缀模糊 ❌
SELECT * FROM users WHERE LOWER(email) = 'john@example.com'; -- 函数变换 ❌
2.2 GIN——用于多值类型(数组、JSONB、全文搜索)
GIN(Generalized Inverted Index,广义倒排索引)。把每个元素映射到包含它的行,类似搜索引擎的倒排索引。
-- JSONB 字段的 GIN 索引
CREATE INDEX idx_products_specs ON products USING GIN(specs);
-- 支持的查询操作
SELECT * FROM products WHERE specs @> '{"category": "electronics"}'; -- 包含 ✅
SELECT * FROM products WHERE specs ? 'discount'; -- 键存在 ✅
-- 数组字段的 GIN 索引
CREATE INDEX idx_posts_tags ON posts USING GIN(tags);
SELECT * FROM posts WHERE tags @> ARRAY['postgresql']; -- 数组包含 ✅
SELECT * FROM posts WHERE tags && ARRAY['sql', 'database']; -- 数组交集 ✅
-- 全文搜索的 GIN 索引
CREATE INDEX idx_articles_search ON articles USING GIN(to_tsvector('english', content));
SELECT * FROM articles
WHERE to_tsvector('english', content) @@ plainto_tsquery('english', 'postgresql');
GIN vs B-Tree:
- GIN 索引构建慢,占用空间大
- GIN 查询速度快(对多值查询)
- 写入时维护成本高
优化技巧:频繁写入的表上的 GIN 索引,使用 gin_pending_list_limit 参数缓冲写入:
-- 设置 GIN Pending List 大小(默认 4MB)
SET gin_pending_list_limit = 64MB;
2.3 GiST——用于范围和几何类型
GiST(Generalized Search Tree)适合需要"重叠"或"距离"语义的查询。
-- 范围类型索引
CREATE INDEX idx_reservations_during ON reservations USING GIST(during);
-- 查询重叠的时间段
SELECT * FROM reservations
WHERE during && '[2024-01-15, 2024-01-20)'::tstzrange; -- 使用 GiST ✅
-- 地理信息索引(PostGIS)
CREATE INDEX idx_stores_location ON stores USING GIST(location);
-- 距离查询
SELECT name
FROM stores
ORDER BY location <-> ST_MakePoint(-122.4, 37.78) -- <-> 是 KNN 距离算子
LIMIT 5;
GiST vs GIN:
- GiST 支持更多算子(包括最近邻 KNN 搜索)
- GIN 查询一般比 GiST 快(精确匹配场景)
- GiST 更新比 GIN 快
2.4 BRIN——超大表的轻量索引
BRIN(Block Range Index)只记录每个数据块范围内的最小值和最大值,不记录具体位置。
-- 适合:数据有物理排序特征的大表(如按时间插入的日志表)
CREATE INDEX idx_logs_created_at ON logs USING BRIN(created_at)
WITH (pages_per_range = 128);
-- 索引大小对比(1亿行的 created_at 列)
-- B-Tree: ~2.1 GB
-- BRIN: ~48 KB(!!!)
适用场景:
- 数据按时间顺序插入的表(日志、事件、订单)
- 表非常大(> 1 亿行)
- 主要做时间范围扫描(不是精确匹配)
不适用场景:
- 数据插入顺序和查询字段无关
- 需要精确查找单行
三、覆盖索引——Index Only Scan 的威力
普通索引包含键值 + 行指针(CTID),查到行指针后还需要回表读取完整数据。
覆盖索引把查询需要的所有列都包含在索引里,完全不需要回表。
-- 普通索引:需要回表
CREATE INDEX idx_orders_user ON orders(user_id);
-- 查询:需要先用索引找到行,再回表读取 amount
SELECT user_id, amount FROM orders WHERE user_id = 123;
-- 执行计划:Index Scan(需要回表)
-- 覆盖索引:包含查询所需的所有列
CREATE INDEX idx_orders_user_cover ON orders(user_id) INCLUDE (amount, created_at);
-- 执行计划:Index Only Scan(不需要回表,速度提升 3-10 倍)
EXPLAIN SELECT user_id, amount, created_at FROM orders WHERE user_id = 123;
查看是否使用了 Index Only Scan
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, amount FROM orders WHERE user_id = 123;
-- 输出示例(好的情况)
-- Index Only Scan using idx_orders_user_cover on orders
-- Index Cond: (user_id = 123)
-- Heap Fetches: 0 ← 0 表示完全没有回表
四、部分索引——只索引你需要的行
大多数查询只关心表的一个子集,但普通索引包含所有行。部分索引(Partial Index)只索引满足条件的行。
-- 订单表:90% 是已完成订单,但查询几乎都是"待处理"订单
CREATE INDEX idx_orders_pending ON orders(created_at)
WHERE status = 'pending';
-- 等价查询:比全表索引快(索引更小,查询命中率更高)
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01';
-- 用户表:软删除,大量已删除用户,但查询通常只查活跃用户
CREATE UNIQUE INDEX idx_users_email_active ON users(email)
WHERE deleted_at IS NULL;
-- 同时还保证了活跃用户 email 唯一(已删除用户可以复用 email)
五、函数索引——索引计算后的值
当查询对列使用了函数转换,普通索引无效,需要函数索引:
-- 问题:这个查询无法使用普通索引
SELECT * FROM users WHERE LOWER(email) = 'john@example.com';
-- 解决:创建函数索引
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- 现在这个查询可以使用索引
SELECT * FROM users WHERE LOWER(email) = 'john@example.com'; -- ✅
-- 时间函数索引
CREATE INDEX idx_orders_date ON orders(DATE(created_at));
-- 查询某天的所有订单(可以使用索引)
SELECT * FROM orders WHERE DATE(created_at) = '2024-01-15'; -- ✅
六、EXPLAIN ANALYZE——读懂执行计划
EXPLAIN ANALYZE 是优化查询的最重要工具:
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, COUNT(o.id) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 10;
读懂输出
Sort (cost=1234.56..1234.61 rows=10 width=40) (actual time=45.123..45.125 rows=10 loops=1)
Sort Key: (count(o.id)) DESC
Sort Method: top-N heapsort Memory: 25kB
-> HashAggregate (cost=1200.00..1220.00 rows=2000 width=40) (actual time=44.800..44.950 rows=2000 loops=1)
Group Key: u.id, u.name
-> Hash Join (cost=500.00..1100.00 rows=20000 width=16) (actual time=12.500..38.000 rows=18500 loops=1)
Hash Cond: (o.user_id = u.id)
-> Seq Scan on orders o (cost=0.00..400.00 rows=20000 width=8) (actual time=0.100..8.000 rows=20000 loops=1)
-> Hash (cost=450.00..450.00 rows=4000 width=20) (actual time=11.000..11.000 rows=3800 loops=1)
Buckets: 4096 Batches: 1 Memory Usage: 240kB
-> Index Scan using idx_users_created on users u (cost=0.43..450.00 rows=4000 width=20) (actual time=0.050..9.500 rows=3800 loops=1)
Index Cond: (created_at > '2024-01-01')
关键指标:
cost=X..Y:估算成本(X=启动成本,Y=总成本)actual time=X..Y:实际执行时间(毫秒)rows=N:实际行数loops=N:执行次数(嵌套循环时 > 1)
危险信号:
-- 1. 估算行数与实际行数差距大(统计信息过期)
(cost=... rows=100 ...) (actual time=... rows=50000 ...)
-- 解决:ANALYZE table_name
-- 2. Seq Scan on 大表(全表扫描)
Seq Scan on orders (cost=0.00..45000.00 rows=1000000 ...)
-- 需要加索引
-- 3. Hash Batches > 1(work_mem 不足,溢出到磁盘)
Batches: 8 Memory Usage: 128MB
-- 解决:增大 work_mem
可视化工具
-- 输出 JSON 格式,粘贴到 explain.dalibo.com 或 pgMustard.com 可视化
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT ...;
七、索引膨胀与维护
PostgreSQL 的索引因为 MVCC 会产生膨胀:删除或更新的行在索引中留下"死项"(dead tuple),需要 VACUUM 清理。
-- 查看索引大小和膨胀估算
SELECT
schemaname,
tablename,
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;
-- 查找从未被使用的索引(可以考虑删除)
SELECT schemaname, tablename, indexname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE 'pk_%' -- 排除主键
ORDER BY pg_relation_size(indexrelid) DESC;
-- 重建膨胀的索引(不锁表)
REINDEX INDEX CONCURRENTLY idx_orders_user;
-- 注意:CONCURRENTLY 需要 PostgreSQL 12+
八、实战:如何给慢查询加索引
步骤一:找到最慢的查询
-- 启用 pg_stat_statements 扩展
CREATE EXTENSION pg_stat_statements;
-- 查找最耗时的查询
SELECT
query,
calls,
round(total_exec_time / calls) AS avg_ms,
round(total_exec_time) AS total_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
步骤二:EXPLAIN ANALYZE 分析
EXPLAIN (ANALYZE, BUFFERS)
-- 把最慢的查询粘贴到这里
步骤三:识别问题模式
| 执行计划关键词 | 含义 | 解决方案 |
|---|---|---|
Seq Scan on large table |
全表扫描 | 加 B-Tree 索引 |
Seq Scan 但 rows 很少 |
统计信息不准 | ANALYZE 表 |
Nested Loop 大行数 |
嵌套循环性能差 | 增加 work_mem,或加连接字段索引 |
Hash Batches > 1 |
work_mem 不足溢出磁盘 | 增加 work_mem |
Index Scan + Heap Fetches 多 |
需要覆盖索引 | 加 INCLUDE 列 |
步骤四:验证改进
-- 对比加索引前后
-- 注意:需要在真实数据量上测试,测试数据可能失效(PG 可能选择 Seq Scan)
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
关键认知
索引不是越多越好:每个索引都会减慢写入速度。一个有 15 个索引的表,INSERT 操作需要更新 15 个独立数据结构。
合适的索引策略:
- 主键和外键:必须索引
- 高基数的过滤列(用户 ID、邮箱):B-Tree
- JSONB 和数组字段:GIN
- 大表时间列(日志):BRIN
- 90% 场景下的部分数据:部分索引
- 查询只需少量列:覆盖索引
“数据库优化不是玄学,是科学。EXPLAIN ANALYZE 给你所有需要的信息,你只需要学会读懂它。”