6.1 什么是索引
数据库索引就像书的目录:没有目录,你只能从第1页翻到最后才能找到某个主题;有了目录,你直接跳到对应页码。没有索引,数据库需要逐行扫描整张表(全表扫描);有了索引,数据库可以直接定位到目标行。
索引的好处 大幅加快 SELECT 查询(WHERE、JOIN、ORDER BY 条件列);对大表效果尤为显著(百万行表从秒级降到毫秒级)。
索引的代价 每次 INSERT/UPDATE/DELETE 时都需要维护索引,写入性能略降;索引本身占用磁盘空间(可能是原表的 30%~100%)。
6.2 B-Tree 索引原理
MySQL InnoDB 和 PostgreSQL 默认使用 B+ 树(B-Plus Tree)结构存储索引。
- 树形结构:根节点 → 内部节点(只存键值和指针)→ 叶子节点(存键值 + 指向数据行的指针)
- 叶子节点有序:所有叶子节点按键值升序排列,并通过双向链表连接,支持高效范围查询
- 平衡树:任意叶子节点深度相同(通常3~4层),查找任意值的 I/O 次数固定
- 查找时间复杂度:O(log N),N 是数据量
B+ 树查找示例 查找 id = 42 时:从根节点出发,比较键值决定走左/右子树,每层做一次磁盘 I/O,通常只需 3-4 次就能定位到目标行。对比全表扫描可能需要 10,000 次 I/O。
6.3 创建索引的语法
SQL-- 普通索引(允许重复值)
CREATE INDEX idx_users_email ON users(email);
-- 唯一索引(不允许重复值,也是 UNIQUE 约束的底层实现)
CREATE UNIQUE INDEX uq_users_email ON users(email);
-- 复合索引(多列索引)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- 前缀索引(对长字符串只索引前 N 个字符,节省空间)
CREATE INDEX idx_posts_title_prefix ON posts(title(20));
-- 在 CREATE TABLE 中直接定义索引
CREATE TABLE orders (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
status VARCHAR(20) NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_status (status)
);
-- 删除索引
DROP INDEX idx_users_email ON users; -- MySQL
DROP INDEX idx_users_email; -- PostgreSQL(索引独立于表)
-- 查看表上的索引
SHOW INDEX FROM users; -- MySQL
\d users -- psql 元命令
6.4 复合索引的最左前缀原则
对于复合索引 (a, b, c),索引会按 a → b → c 的顺序排列。查询时只有从最左边开始连续使用索引列,才能用到该索引。
| 查询条件 | 能用索引 (a,b,c)? | 原因 |
|---|---|---|
WHERE a = 1 | ✅ 能用(用到 a) | 从最左列开始 |
WHERE a = 1 AND b = 2 | ✅ 能用(用到 a,b) | 连续使用前两列 |
WHERE a = 1 AND b = 2 AND c = 3 | ✅ 完全使用 | 全部三列 |
WHERE b = 2 | ❌ 不能用 | 跳过了 a |
WHERE a = 1 AND c = 3 | ⚠️ 部分(只用 a) | b 中断,c 无法用 |
WHERE a > 1 AND b = 2 | ⚠️ 只用 a | a 是范围查询,b 无法用 |
SQL-- 假设有复合索引 INDEX idx (user_id, status, created_at)
-- ✅ 充分利用索引(三列都用到)
SELECT * FROM orders
WHERE user_id = 1
AND status = 'paid'
AND created_at > '2024-01-01';
-- ✅ 利用索引(前两列)
SELECT * FROM orders
WHERE user_id = 1 AND status = 'paid';
-- ❌ 无法利用索引(跳过 user_id)
SELECT * FROM orders
WHERE status = 'paid'; -- 全表扫描!
6.5 EXPLAIN — 分析执行计划
EXPLAIN 是性能调优的核心工具,显示数据库如何执行你的查询。
MySQL EXPLAIN
SQLEXPLAIN SELECT * FROM orders
WHERE user_id = 1 AND status = 'paid';
| 列名 | 含义 |
|---|---|
type | 访问类型(最重要!见下方说明) |
key | 实际使用的索引名,NULL 表示未用索引 |
key_len | 使用索引的字节数(越小表示只用了部分复合索引) |
rows | 估算需要检查的行数(越小越好) |
filtered | 经过 WHERE 过滤后剩余的行比例 |
Extra | 额外信息("Using index"=覆盖索引好;"Using filesort"/"Using temporary" 需优化) |
type 字段(效率从低到高)
| type 值 | 含义 | 优化建议 |
|---|---|---|
ALL | 全表扫描,性能最差 | 必须添加索引 |
index | 全索引扫描(比 ALL 好,但仍扫描全部) | 考虑更优化的索引 |
range | 索引范围扫描(BETWEEN、>、<、IN) | 可接受 |
ref | 非唯一索引等值匹配(可能返回多行) | 良好 |
eq_ref | 唯一索引等值匹配(最多1行,JOIN 时常见) | 很好 |
const / system | 主键或唯一索引等值匹配(最多1行,最快) | 最优 |
PostgreSQL EXPLAIN ANALYZE
SQL-- EXPLAIN ANALYZE 实际执行查询并显示真实耗时
EXPLAIN ANALYZE SELECT * FROM orders
WHERE user_id = 1 AND status = 'paid';
-- 输出示例:
-- Bitmap Heap Scan on orders (cost=4.57..16.32 rows=3 width=64)
-- (actual time=0.045..0.048 rows=2 loops=1)
-- Recheck Cond: ((user_id = 1) AND (status = 'paid'))
-- -> Bitmap Index Scan on idx_orders_user_status
-- (cost=0.00..4.57 rows=3 width=0)
-- (actual time=0.038..0.038 rows=2 loops=1)
-- Index Cond: ((user_id = 1) AND (status = 'paid'))
-- Planning Time: 0.12 ms
-- Execution Time: 0.08 ms
6.6 哪些情况索引失效
SQL-- ❌ 在索引列上使用函数(索引失效)
SELECT * FROM users WHERE YEAR (created_at) = 2024;
-- ✅ 改写为范围查询(可用索引)
SELECT * FROM users
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- ❌ 隐式类型转换(phone 是 VARCHAR,但传入整数)
SELECT * FROM users WHERE phone = 13812345678;
-- ✅ 传入正确类型
SELECT * FROM users WHERE phone = '13812345678';
-- ❌ LIKE 前缀通配符(以 % 开头无法用索引)
SELECT * FROM users WHERE username LIKE '%bob%';
-- ✅ 后缀通配符可以用索引
SELECT * FROM users WHERE username LIKE 'bob%';
-- ❌ OR 两边列不同(至少一侧没有索引则整体失效)
SELECT * FROM users
WHERE username = 'alice' OR email = 'bob@example.com';
-- ✅ 改写为 UNION ALL(两个查询分别走各自的索引)
SELECT * FROM users WHERE username = 'alice'
UNION ALL
SELECT * FROM users WHERE email = 'bob@example.com';
6.7 慢查询日志
SQL-- MySQL 开启慢查询日志(在 my.cnf 或动态设置)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过 1 秒的查询记录到日志
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 查看慢查询统计
SHOW GLOBAL STATUS LIKE 'Slow_queries';
-- PostgreSQL 慢查询:在 postgresql.conf 中配置
-- log_min_duration_statement = 1000 # 毫秒,超过 1 秒记录
-- log_destination = 'csvlog'
分析工具
- mysqldumpslow:MySQL 自带,按各维度汇总慢查询
- pt-query-digest:Percona Toolkit 工具,更强大的慢查询分析
- pgBadger:PostgreSQL 专用日志分析工具,生成 HTML 报告
6.8 常见优化技巧
覆盖索引(Covering Index)
SQL-- 如果查询的所有列都在索引中,则无需回表查询原始数据
-- 这称为"覆盖索引",EXPLAIN 中 Extra 显示 "Using index"
-- 假设有索引 INDEX idx (user_id, status, total_price)
SELECT user_id, status, total_price -- 这三列都在索引中
FROM orders
WHERE user_id = 1;
-- 不需要回表,直接从索引读取数据,性能极佳
其他优化建议
- 只查需要的列,避免 SELECT *(减少数据传输,可能命中覆盖索引)
- 合理使用
LIMIT,避免一次性取出大量数据 - 对频繁 WHERE/JOIN/ORDER BY 用到的列建立索引
- 复合索引中,区分度高(不重复值多)的列放在最左边
- 单张表索引数量不宜过多(5-6 个是通常上限),索引越多,写入越慢
- 定期用
ANALYZE TABLE(MySQL)或VACUUM ANALYZE(PostgreSQL)更新统计信息
索引设计经验法则 先写出业务查询语句,分析 WHERE、JOIN ON、ORDER BY 涉及的列;通过 EXPLAIN 验证是否用到索引;添加索引后再次 EXPLAIN 对比 type 和 rows 的变化。不要盲目添加索引,要基于实际查询模式决定。
6.9 PostgreSQL 特有索引类型
PostgreSQL 除了默认的 B-Tree 索引,还提供多种专门用途的索引类型,每种针对不同数据特征进行优化:
- B-Tree(默认) 平衡树结构,支持等值查询、范围查询、排序。适用于 =、<、>、BETWEEN、IN 等条件。几乎所有标量数据类型都可用。
- GIN(Generalized Inverted Index) 广义倒排索引。专门为"复合值"类型设计,如 JSONB、数组、全文搜索 tsvector。存储每个"元素"→"包含该元素的行" 的映射,支持 @>(包含)、&&(重叠)等操作符。写入时维护代价较高,但查询极快。
- GiST(Generalized Search Tree) 可扩展的通用搜索树框架。用于几何图形(PostGIS 地理坐标)、范围类型(tsrange、int4range)、全文搜索。支持最近邻搜索(KNN)。
- BRIN(Block Range INdex) 块范围索引。将表按物理存储页分块,每块存储该块内最小值和最大值。索引极小(只存摘要),适合按物理顺序大致有序的超大表(如时间序列、日志表)。不适合随机分布的数据。
- Hash 哈希索引。只支持等值查询(=),不支持范围查询。比 B-Tree 占用空间更小,等值查询速度略快,但使用场景有限。PostgreSQL 10+ 的 Hash 索引支持 WAL 日志(崩溃安全)。
SQL-- GIN 索引:用于 JSONB 字段
CREATE INDEX idx_products_attrs_gin ON products USING GIN (attributes);
-- attributes 是 JSONB 类型
-- 现在可以高效使用 @> (包含) 运算符
SELECT * FROM products
WHERE attributes @> '{"color": "red"}';
-- GIN 索引:用于数组类型
CREATE INDEX idx_posts_tags_gin ON posts USING GIN (tags);
-- tags 是 integer[] 数组类型
SELECT * FROM posts
WHERE tags && ARRAY[1, 2, 3]; -- 有重叠的文章
-- GIN 索引:全文搜索
CREATE INDEX idx_posts_fts ON posts USING GIN (
to_tsvector('chinese', title || ' ' || content)
);
SELECT title
FROM posts
WHERE to_tsvector('chinese', title || ' ' || content)
@@ to_tsquery('chinese', '数据库 & 索引');
-- BRIN 索引:适合时间序列表(按时间顺序插入)
CREATE INDEX idx_logs_created_at_brin ON logs USING BRIN (created_at)
WITH (pages_per_range = 128); -- 每 128 个数据页存一个摘要
-- BRIN 索引文件极小,对亿级时序数据几乎无存储开销
-- GiST 索引:地理坐标(需要 PostGIS 扩展)
-- CREATE INDEX idx_stores_location ON stores USING GIST (location);
-- 支持 <<->> 距离运算符,快速找最近门店
6.10 EXPLAIN ANALYZE 深度解读(PostgreSQL 17)
PostgreSQL 的 EXPLAIN ANALYZE 不仅显示执行计划,还实际运行查询并报告每个节点的真实耗时和行数,是性能调优的核心武器。
SQL-- 使用 BUFFERS 查看缓冲区命中率(PostgreSQL 推荐加上)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, u.username, o.total_price
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
AND o.created_at > '2024-01-01'
ORDER BY o.total_price DESC
LIMIT 20;
-- 示例输出(含注解说明):
--
-- Limit (cost=42.31..42.36 rows=20 width=52)
-- (actual time=2.453..2.461 rows=20 loops=1)
-- Buffers: shared hit=35
-- -> Sort (cost=42.31..42.55 rows=95 width=52)
-- (actual time=2.451..2.453 rows=20 loops=1)
-- Sort Key: o.total_price DESC
-- Sort Method: top-N heapsort Memory: 26kB
-- Buffers: shared hit=35
-- -> Hash Join (cost=8.05..39.24 rows=95 width=52)
-- (actual time=0.521..2.420 rows=127 loops=1)
-- Hash Cond: (o.user_id = u.id)
-- Buffers: shared hit=35
-- -> Bitmap Heap Scan on orders o
-- (cost=4.57..35.02 rows=95 width=24)
-- (actual time=0.085..2.102 rows=127 loops=1)
-- Recheck Cond: (status='paid' AND created_at>'2024-01-01')
-- Buffers: shared hit=28
-- -> Bitmap Index Scan on idx_orders_status_created
-- (actual time=0.071..0.071 rows=127 loops=1)
-- -> Hash (cost=2.30..2.30 rows=130 width=32)
-- (actual time=0.427..0.427 rows=130 loops=1)
-- Buckets: 1024 Batches: 1 Memory Usage: 16kB
-- Buffers: shared hit=7
-- -> Seq Scan on users u
-- Planning Time: 0.312 ms
-- Execution Time: 2.529 ms
关键指标解读
| 指标 | 含义与解读 |
|---|---|
cost=X..Y | 估算代价(任意单位)。X=启动代价(返回第一行前),Y=总代价。数字越小越好,但这是估算值。 |
actual time=X..Y ms | 实测耗时(毫秒)。X=返回第一行耗时,Y=返回全部行耗时。重点关注 Y。 |
rows=N | 行数。cost 旁的是优化器估算行数,actual 旁的是真实行数。两者差距大说明统计信息过期,需 ANALYZE。 |
loops=N | 该节点执行次数(嵌套循环时 >1)。actual time 和 rows 是每次循环的数据,乘以 loops 才是总量。 |
shared hit/read | hit=从缓存读取(快),read=从磁盘读取(慢)。hit 越多越好,read 多说明 shared_buffers 不够大。 |
Sort Method | external merge=磁盘排序(内存不足),quicksort/top-N heapsort=内存排序(好)。 |
cost 估算与实际差距大时的原因
1. 统计信息过期:表数据大量变更后未 ANALYZE,优化器用旧的统计信息估算行数
2. 数据分布不均:某列值高度集中,平均统计信息不准确(可用扩展统计 CREATE STATISTICS 改善)
3. 参数嗅探:PreparedStatement 编译时的参数值与实际值差异很大
解决:运行 ANALYZE tablename 或 VACUUM ANALYZE tablename 更新统计信息。
本章小结
索引是 SQL 性能优化的核心手段。B-Tree 是万用索引,适合等值和范围查询;PostgreSQL 的 GIN 用于 JSONB/数组/全文,BRIN 用于时序数据,GiST 用于空间数据。创建索引要遵循最左前缀原则,避免在索引列上做函数运算、隐式类型转换和前缀 LIKE。EXPLAIN ANALYZE + BUFFERS 是诊断慢查询的标准工具——重点看 actual time、rows 估算偏差、磁盘 hit/read 比例。每张表的索引数量建议不超过 5-6 个,索引越多写入越慢。