3. 复合索引设计:最左前缀原则、索引列顺序对查询性能的影响、冗余索引的识别与清理

复合索引(多列索引)是 PostgreSQL 中应对复杂查询条件最有力的工具,但设计不当反而会拖慢写入、浪费存储。本章从最左前缀原则出发,深入分析索引列顺序对查询性能的影响,并给出冗余索引的识别与清理方法。

3.1 最左前缀原则

PostgreSQL 的 B-Tree 复合索引按照索引定义中列的顺序构建一棵排序树。查询条件只有从索引的最左列开始匹配,才能有效利用该索引,这就是最左前缀原则。

核心规则:复合索引 (a, b, c) 可以加速的条件组合包括:
  • WHERE a = ?
  • WHERE a = ? AND b = ?
  • WHERE a = ? AND b = ? AND c = ?
  • WHERE a = ? AND b > ? AND c = ?(b 上的范围查询会阻断后续列的等值匹配)
无法使用索引的条件:
  • WHERE b = ?(跳过了最左列 a)
  • WHERE c = ?(跳过了 a 和 b)
实践建议:在创建复合索引前,先列出业务中高频出现的 WHERE 条件组合,确保最左列是查询频率最高、选择性最好的列。
最左前缀原则匹配流程(复合索引 (a, b, c)) 复合索引 (a, b, c) WHERE a = ? ✅ 完全匹配 WHERE a = ? AND b = ? ✅ 完全匹配 WHERE a = ? AND b = ? AND c = ? ✅ 完全匹配 WHERE b = ? ❌ 无法使用索引

3.2 索引列顺序对查询性能的影响

复合索引中列的顺序不仅决定哪些查询能走索引,还直接影响索引的过滤效率和排序能力。

3.2.1 等值条件优先原则

将等值条件(=)的列放在范围条件(>、<、BETWEEN)之前,可以最大化索引的过滤粒度。

索引定义 查询条件 索引使用情况 性能说明
(status, created_at) WHERE status = 'active' AND created_at > '2024-01-01' 完全匹配 先过滤 status,再范围扫描 created_at,高效
(created_at, status) WHERE status = 'active' AND created_at > '2024-01-01' 部分匹配 created_at 范围扫描后,status 无法利用索引过滤,需回表
(user_id, order_date) WHERE user_id = 123 ORDER BY order_date DESC 完全匹配 + 索引排序 索引天然有序,避免额外排序操作
常见误区:不要将选择性低的列(如性别、状态码)放在复合索引的最左列,除非查询条件中该列是必选且等值条件。选择性低的列放在前面会导致索引树过早分叉,降低后续列的过滤效率。

3.2.2 排序与分组优化

如果查询包含 ORDER BY 或 GROUP BY,将排序列放在复合索引的末尾,且与 WHERE 条件中的列顺序一致,可以避免额外的排序操作。

-- 高效:索引 (department_id, hire_date) 同时满足 WHERE 和 ORDER BY
CREATE INDEX idx_emp_dept_hiredate ON employees (department_id, hire_date);
EXPLAIN ANALYZE
SELECT * FROM employees
WHERE department_id = 10
ORDER BY hire_date DESC;
-- 输出:Backward Index Scan using idx_emp_dept_hiredate(无需 Sort 节点)

-- 低效:索引 (hire_date, department_id) 无法直接用于排序
CREATE INDEX idx_emp_hiredate_dept ON employees (hire_date, department_id);
EXPLAIN ANALYZE
SELECT * FROM employees
WHERE department_id = 10
ORDER BY hire_date DESC;
-- 输出:Index Scan + Sort(额外排序开销)
索引列顺序对查询路径的影响对比 高效索引 (status, created_at) 步骤1:status = 'active' 精确匹配 步骤2:created_at 范围扫描(已缩小范围) 结果:少量回表,IO 开销低 低效索引 (created_at, status) 步骤1:created_at 范围扫描(大量数据) 步骤2:逐行检查 status(无法利用索引) 结果:大量回表,IO 开销高

3.3 冗余索引的识别与清理

冗余索引不仅浪费磁盘空间,还会拖慢 INSERT、UPDATE、DELETE 操作的性能,因为每次数据变更都需要同步维护所有索引。

3.3.1 冗余索引的常见类型

冗余类型 示例 说明
前缀冗余 idx_a (a) 与 idx_ab (a, b) idx_a 的功能完全被 idx_ab 覆盖,可以删除
重复索引 idx_a_b (a, b) 与 idx_b_a (b, a) 列顺序不同,但可能部分功能重叠,需根据查询模式判断
未使用索引 创建后从未被查询计划使用 通过 pg_stat_user_indexes 检查使用频率

3.3.2 使用系统视图识别冗余索引

-- 1. 查找未被使用的索引(idx_scan = 0 表示从未被扫描)
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- 2. 查找前缀冗余索引(使用 pg_index 系统表)
SELECT
    a.indrelid::regclass AS table_name,
    a.indexrelid::regclass AS redundant_index,
    b.indexrelid::regclass AS covering_index,
    pg_size_pretty(pg_relation_size(a.indexrelid)) AS redundant_size
FROM pg_index a
JOIN pg_index b
    ON a.indrelid = b.indrelid
    AND a.indkey < b.indkey
    AND a.indkey @> b.indkey  -- a 的前缀包含 b 的所有列
WHERE a.indisprimary = false
  AND b.indisprimary = false
  AND a.indisunique = false
  AND b.indisunique = false;

-- 3. 结合 pg_stat_user_indexes 确认冗余索引是否真的被覆盖
-- 如果 covering_index 的 idx_scan 远高于 redundant_index,则冗余索引可安全删除
清理策略:
  1. 在测试环境先执行 DROP INDEX ... CONCURRENTLY(不阻塞写操作)
  2. 观察一周业务运行情况,确认无性能回退
  3. 在维护窗口执行正式删除,并回收磁盘空间

3.3.3 冗余索引清理的注意事项

  • 唯一约束索引不可随意删除:即使功能上被覆盖,唯一索引还承担着数据完整性约束。
  • 外键关联索引:PostgreSQL 不会自动为外键创建索引,但外键列上的索引对 ON DELETE CASCADE 等操作至关重要,不要误删。
  • 部分索引(Partial Index):如果冗余索引是部分索引,其过滤条件可能无法被普通复合索引替代,需单独评估。
最佳实践总结:
  • 复合索引设计时,遵循等值列在前、范围列在后、排序列在最后的原则。
  • 每张表的复合索引数量控制在 3~5 个以内,避免索引维护开销过大。
  • 每季度执行一次冗余索引审计,使用 pg_stat_user_indexes 和 pg_index 系统视图辅助分析。