数据量达到百万级后,不合适的索引会让查询慢如蜗牛。本章将系统学习 PostgreSQL 的索引原理和性能优化方法。

为什么需要索引?

没有索引的查询需要全表扫描——逐行检查数据。就像一本没有目录的书,要找一句话就得从头翻到尾。

索引的本质是一个排序过的数据结构(B-Tree),让数据库能在 O(log n) 时间内找到数据,而不是 O(n)。

创建练习数据

-- 创建一张大表来演示索引效果
CREATE TABLE big_orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL,
    product_name VARCHAR(100),
    amount NUMERIC(10,2),
    status VARCHAR(10),
    order_date DATE,
    created_at TIMESTAMP DEFAULT NOW()
);

-- 插入 100 万条数据
INSERT INTO big_orders (user_id, product_name, amount, status, order_date)
SELECT 
    (random() * 100000)::INT + 1,
    CASE (random() * 5)::INT
        WHEN 0 THEN '笔记本电脑'
        WHEN 1 THEN '手机'
        WHEN 2 THEN '耳机'
        WHEN 3 THEN '运动鞋'
        ELSE 'T恤'
    END,
    (random() * 5000 + 100)::NUMERIC(10,2),
    CASE (random() * 4)::INT
        WHEN 0 THEN '已完成'
        WHEN 1 THEN '已取消'
        WHEN 2 THEN '退款中'
        ELSE '已完成'
    END,
    DATE '2024-01-01' + (random() * 730)::INT
FROM generate_series(1, 1000000);

-- 确认数据已插入
SELECT count(*) FROM big_orders;

EXPLAIN 读懂查询计划

-- 查看查询计划
EXPLAIN SELECT * FROM big_orders WHERE user_id = 42;

EXPLAIN ANALYZE SELECT * FROM big_orders WHERE user_id = 42;

EXPLAIN 输出解读:

Seq Scan on big_orders  (cost=0.00..18334.00 rows=1 width=57)
  Filter: (user_id = 42)
  • Seq Scan:全表扫描(效率低)
  • cost=0.00..18334.00:估算成本(启动..总成本)
  • rows=1:估算行数
  • width=57:每行字节数

EXPLAIN ANALYZE实际执行查询,显示真实耗时:

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM big_orders WHERE user_id = 42;

创建索引

-- 创建 B-Tree 索引(PostgreSQL 默认)
CREATE INDEX idx_orders_user_id ON big_orders(user_id);

-- 创建多列索引(复合索引)
CREATE INDEX idx_orders_user_date ON big_orders(user_id, order_date);

-- 创建唯一索引
CREATE UNIQUE INDEX idx_orders_unique ON orders(order_id);

验证索引效果

-- 先清缓存并做无索引查询
\timing on

-- 全表扫描(应该在无索引时运行)
SELECT count(*) FROM big_orders WHERE user_id > 50000 AND user_id < 50010;

-- 创建索引后重新查询
CREATE INDEX idx_big_orders_user ON big_orders(user_id);
SELECT count(*) FROM big_orders WHERE user_id > 50000 AND user_id < 50010;

复合索引与列顺序

-- 复合索引的列顺序非常重要!
CREATE INDEX idx_orders_user_status ON big_orders(user_id, status);

-- 这个查询能用上索引(前导列 user_id 匹配)
EXPLAIN ANALYZE SELECT * FROM big_orders 
WHERE user_id = 42 AND status = '已完成';

-- 这个查询可能用不上索引(没有前导列 user_id)
EXPLAIN ANALYZE SELECT * FROM big_orders 
WHERE status = '已完成';

列顺序原则

  1. 等值条件列(WHERE col = ?)放前面
  2. 范围条件列(WHERE col > ?)放后面
  3. 高选择性的列(唯一值多)放前面

部分索引

只索引表中满足条件的一部分行,体积更小、维护成本更低:

-- 只索引活跃用户(status = 'active' 的只占一小部分)
CREATE INDEX idx_users_active ON users(status) WHERE status = 'active';

-- 只索引已完成的订单(用于统计报表查询)
CREATE INDEX idx_orders_completed ON orders(order_date, total_amount) 
WHERE status = '已完成';

覆盖索引(Index-Only Scan)

索引中包含了查询需要的所有列,数据库只需读索引,不需要回表:

-- 如果经常这样查询
SELECT user_id, order_date, status FROM big_orders 
WHERE user_id BETWEEN 100 AND 200;

-- 创建覆盖索引(包含查询需要的所有列)
CREATE INDEX idx_orders_covering ON big_orders(user_id, order_date, status);

-- 查询计划会显示 "Index Only Scan"
EXPLAIN ANALYZE SELECT user_id, order_date, status 
FROM big_orders WHERE user_id BETWEEN 100 AND 200;

其他索引类型

唯一索引

-- 确保两列组合唯一
CREATE UNIQUE INDEX idx_user_email ON users(email);

表达式索引

-- 经常按小写查询邮箱
CREATE INDEX idx_users_email_lower ON users(LOWER(email));

-- 查询时必须使用同样的表达式
SELECT * FROM users WHERE LOWER(email) = 'test@example.com';

部分唯一索引

-- 只允许一条活跃记录有某个邮箱(软删除场景)
CREATE UNIQUE INDEX idx_users_active_email 
ON users(email) WHERE status = 'active';

常用性能诊断查询

-- 查看表大小
SELECT pg_size_pretty(pg_total_relation_size('big_orders')) AS 表大小;

-- 查看索引大小
SELECT pg_size_pretty(pg_indexes_size('big_orders')) AS 索引大小;

-- 查找未被使用的索引
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan AS 使用次数
FROM pg_stat_user_indexes
ORDER BY idx_scan;

-- 查看慢查询(需要启用 pg_stat_statements)
SELECT 
    query,
    calls,
    total_exec_time / calls AS avg_ms,
    rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

常见性能问题

1. 索引选择性太差

-- 对布尔列建索引效果不佳(只有 true/false)
CREATE INDEX idx_orders_status ON big_orders(status); 
-- 如果 status 只有 3 个值,这个索引帮助有限

2. 在索引列上做运算

-- ❌ 无法使用索引
SELECT * FROM big_orders WHERE EXTRACT(YEAR FROM order_date) = 2025;

-- ✅ 可以使用索引
SELECT * FROM big_orders 
WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';

3. LIKE 无法使用索引

-- ❌ 无法使用普通索引
SELECT * FROM products WHERE name LIKE '%手机%';

-- ✅ 可以使用索引(前缀匹配)
SELECT * FROM products WHERE name LIKE '手机%';

-- 全文搜索场景用 gin 索引

性能优化原则

查询优化

  1. SELECT 只查需要的列,不要 SELECT *
  2. WHERE 条件用索引列
  3. JOIN 的关联列要有索引
  4. 避免在 WHERE 中对列做函数运算
  5. 聚合前先用 WHERE 过滤
  6. 大表分页用键集分页,不用 OFFSET

配置优化

-- 查看关键配置
SHOW shared_buffers;         -- 共享缓冲区(标准建议:25% 内存)
SHOW work_mem;              -- 排序/哈希内存(每个查询)
SHOW maintenance_work_mem;  -- 维护操作(如 VACUUM、CREATE INDEX)
SHOW effective_cache_size;  -- 系统缓存估算值
SHOW random_page_cost;      -- 随机 I/O 成本

VACUUM 维护

-- 查看表的膨胀和死元组
SELECT 
    schemaname,
    tablename,
    n_dead_tup AS 死元组数,
    n_live_tup AS 活跃元组数,
    last_autovacuum AS 上次自动清理时间
FROM pg_stat_user_tables
WHERE tablename = 'big_orders';

-- 手动清理无用的死元组
VACUUM big_orders;

-- 清理并回收空间
VACUUM FULL big_orders;

-- 更新统计信息(让查询计划更准确)
ANALYZE big_orders;

小结

索引是 SQL 性能优化最重要的工具。理解 B-Tree 索引原理、复合索引列顺序、以及 EXPLAIN 的读法,能让你在百万级数据下写出高效的查询。下一篇文章将学习事务与并发控制。

Summary: 索引原理、EXPLAIN 读法、复合索引与性能优化原则。