第 50 章
索引优化建议与工具
索引优化建议与工具
索引是 MySQL 性能优化最重要的手段,但索引不是越多越好:每个索引都会增加写操作开销和存储空间。本章系统讲解如何科学地设计、评估和管理索引。
索引选择性
选择性(Selectivity)= 不重复值数量 / 总行数,范围 (0, 1]。选择性越接近 1,索引区分度越高,效果越好。
-- 计算列的选择性
SELECT
COUNT(DISTINCT status) / COUNT(*) AS status_selectivity, -- 可能 0.001 (差)
COUNT(DISTINCT user_id) / COUNT(*) AS user_id_selectivity, -- 可能 0.9 (好)
COUNT(DISTINCT order_no) / COUNT(*) AS order_no_selectivity, -- 接近 1.0 (唯一)
COUNT(DISTINCT LEFT(email, 8)) / COUNT(*) AS email_prefix_selectivity -- 前缀索引评估
FROM orders;
-- 一般认为 selectivity > 0.1 才值得单独建索引
-- status 等低选择性列不适合单独建索引,但可以放复合索引末位
前缀索引长度选择
-- 评估不同前缀长度的选择性
SELECT
COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS sel5,
COUNT(DISTINCT LEFT(email, 8)) / COUNT(*) AS sel8,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS sel10,
COUNT(DISTINCT email) / COUNT(*) AS sel_full
FROM users;
-- sel5=0.72, sel8=0.92, sel10=0.98, sel_full=1.0
-- 使用 8 个字符的前缀索引(0.92 接近完整列,空间减少约 60%)
CREATE INDEX idx_email_prefix ON users (email(8));
复合索引设计原则
最左前缀原则
-- 假设索引: INDEX idx_a (a, b, c)
-- ✅ 能使用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 AND b > 5 -- a 精确匹配,b 范围查询
WHERE a = 1 ORDER BY b -- a 精确,b 排序
-- ❌ 不能使用索引(跳过了前缀)
WHERE b = 2 -- 没有 a
WHERE b = 2 AND c = 3 -- 没有 a
WHERE a = 1 AND c = 3 -- 跳过了 b(c 无法使用)
列顺序设计原则
| 原则 | 说明 | 示例 |
|---|---|---|
| 等值在前,范围在后 | 等值条件列放前面,范围条件列放后面 | (status, user_id, created_at) WHERE status=20 AND user_id=? AND created_at>? |
| 高选择性列在前 | 区分度高的列放前面 | (user_id, status) 而非 (status, user_id) |
| ORDER BY 列放最后 | 避免 filesort | (user_id, status, created_at DESC) |
| GROUP BY 列优先于 ORDER BY | GROUP BY 需先聚合 | 视具体查询决定 |
实战案例:订单列表查询
-- 查询: 用户订单列表,按状态过滤,时间倒序
SELECT id, order_no, total_amount, status, created_at
FROM orders
WHERE user_id = 12345 AND status IN (20, 30)
ORDER BY created_at DESC
LIMIT 20;
-- 索引设计 1: (user_id, status, created_at)
-- - user_id 等值 → 快速定位
-- - status IN(20,30) → 范围,但只有 2 个值
-- - created_at 排序 → 可能仍需 filesort(因为 status 不是等值)
-- 索引设计 2: (user_id, created_at)
-- - 适合不带 status 过滤的场景
-- 最终方案: 根据实际 status 查询频率,若 status 高频过滤:
CREATE INDEX idx_user_status_time ON orders (user_id, status, created_at);
-- 若不带 status 过滤也常见:
CREATE INDEX idx_user_time ON orders (user_id, created_at);
覆盖索引设计
覆盖索引(Covering Index)让查询只需访问索引,不需要回表读取数据行,是减少 I/O 最有效的手段。
-- 原始查询(需回表)
EXPLAIN SELECT order_no, total_amount, status
FROM orders WHERE user_id = 100 AND status = 20;
-- type=ref, Extra=NULL(需回表读 order_no, total_amount)
-- 添加覆盖索引(把 SELECT 的列也加进索引)
ALTER TABLE orders ADD INDEX idx_cover
(user_id, status, order_no, total_amount);
-- 再次 EXPLAIN
-- Extra: Using index ← 覆盖索引,不回表!
-- 注意:覆盖索引的列宽不能太大,否则索引文件过大
-- VARCHAR(500) 等宽列不应放入覆盖索引
不可见索引(MySQL 8.0+)
不可见索引(Invisible Index)是 MySQL 8.0 的安全删除索引利器:把索引设为不可见,观察业务影响,确认无问题后再删除,零风险。
-- 创建时设为不可见
CREATE INDEX idx_test ON orders (remark) INVISIBLE;
-- 把现有索引设为不可见(测试删除索引的影响)
ALTER TABLE orders ALTER INDEX idx_old_index INVISIBLE;
-- 验证:EXPLAIN 中该索引不会出现在 key 列
-- 如果业务无影响,再真正删除
DROP INDEX idx_old_index ON orders;
-- 紧急恢复:发现问题立刻重新可见
ALTER TABLE orders ALTER INDEX idx_old_index VISIBLE;
-- 查看表上的不可见索引
SELECT INDEX_NAME, IS_VISIBLE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'orders';
函数索引(MySQL 8.0.13+)
-- 对函数表达式建索引,解决函数调用导致索引失效问题
ALTER TABLE users ADD INDEX idx_lower_email ((LOWER(email)));
-- 查询时使用相同表达式
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- type=ref, Extra=Using index condition ← 使用了函数索引
-- 常用场景
ALTER TABLE orders ADD INDEX idx_year_month
((YEAR(created_at)), (MONTH(created_at)));
-- JSON 字段上建索引
ALTER TABLE products ADD INDEX idx_json_price
((CAST(attrs->>'$.price' AS DECIMAL(10,2))));
找出无用索引
无用索引浪费存储空间、拖慢写入,应定期清理。
-- 方法 1: performance_schema 查找未使用的索引(需运行足够长时间)
SELECT
object_schema AS db,
object_name AS tbl,
index_name,
count_read,
count_fetch
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema NOT IN ('mysql', 'performance_schema', 'information_schema')
AND index_name IS NOT NULL
AND count_star = 0 -- 从未被访问过
ORDER BY object_schema, object_name;
-- 方法 2: sys schema(更友好的视图)
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema = 'mydb';
-- 方法 3: pt-duplicate-key-checker(找重复索引)
pt-duplicate-key-checker --host=localhost --user=root --password=pass --database=mydb
-- 常见重复索引场景
INDEX (a) ← 被 INDEX (a, b) 包含,可以删除
INDEX (a, b)
UNIQUE KEY (id) ← 主键已经是唯一索引,显式 UNIQUE 是重复的
PRIMARY KEY (id)
sys Schema 索引诊断
-- 找出全表扫描最多的查询(需要加索引)
SELECT * FROM sys.statements_with_full_table_scans
WHERE db = 'mydb'
ORDER BY no_index_used_count DESC
LIMIT 10;
-- 找出没有被使用的索引
SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'mydb';
-- 找出重复/冗余的索引
SELECT * FROM sys.schema_redundant_indexes WHERE table_schema = 'mydb';
-- 查看索引统计信息(基数)
SELECT TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, CARDINALITY
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'orders'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
第三方索引建议工具
| 工具 | 功能 | 使用方式 |
|---|---|---|
| MySQL Workbench Performance Reports | 可视化索引使用报告 | GUI 工具,连接后分析 |
| pt-duplicate-key-checker | 找重复/冗余索引 | 命令行,Percona Toolkit |
| pt-index-usage | 分析慢日志中的索引使用率 | pt-index-usage mysql-slow.log |
| Percona PMM Query Analytics | 可视化查询分析+索引建议 | Web UI |
| GitHub gh-ost | 在线 DDL 添加索引(零停机) | 命令行工具 |
# pt-index-usage: 分析哪些索引被实际使用
pt-index-usage --host=localhost --user=root --password=pass \
--database=mydb /var/log/mysql/mysql-slow.log
# 输出: 每个索引的使用次数,0 次的可以考虑删除
索引决策树
新查询是否很慢?
├── 是 → EXPLAIN 查看 type
│ ├── ALL (全表扫描) → 添加 WHERE 条件列的索引
│ ├── range/ref 但 rows 很大 → 调整复合索引列顺序
│ └── 有索引但 Extra=Using filesort → 把 ORDER BY 列加入索引
├── 否 → 检查是否有重复索引 (pt-duplicate-key-checker)
│ └── 清理未使用的索引 (sys.schema_unused_indexes)
添加索引前检查:
1. 该列的选择性 > 0.1 ?
2. 是否与已有索引重复 ?
3. 写入 TPS 是否很高?(高频写入表慎加索引)
4. 能否改写 SQL 使用已有索引?
索引管理规范:每张表索引数量建议不超过 5-8 个;索引命名规范(如 idx_{列名} 或 uk_{列名});添加索引前用 pt-osc 或 gh-ost 在线添加,避免锁表;每季度用 sys.schema_unused_indexes 清理一次无用索引。