打开表结构,14 个索引,其中 6 个是单列索引,还有几个前缀完全重复。这张 2000 万的表写入从 2000 TPS 掉到 600,磁盘占用比数据本身还大——索引比数据多。
索引不是越多越好。每多一个索引,每次 INSERT/UPDATE/DELETE 都要多维护一棵 B+ 树,写入成本实实在在增加,而查询收益要看它到底有没有被用上。
先看看哪些索引根本没用
MySQL 5.6+ 有 performance_schema 可以统计索引使用情况:
SELECT
object_schema, object_name, index_name,
count_star, count_read
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'shop' AND object_name = 'orders'
ORDER BY count_star;
count_star = 0 的就是建了从没用过的。注意这个统计是内存里的,实例重启会清零,所以要看稳定运行一段时间之后的数据,别刚重启完就下结论。
另一个方法是慢查询日志配合 EXPLAIN,但那个是"查得慢的",不是"没用的",两者不重叠。
那次最后删了 5 个零使用的索引,写入 TPS 回来了一大截。
联合索引的顺序比数量重要
这是最常被搞错的地方。假设有这两个查询:
-- 查询 A:按用户查订单
SELECT * FROM orders WHERE user_id = 100;
-- 查询 B:按用户 + 状态查
SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID';
很多人会给 user_id 和 status 各建一个单列索引。但更好的做法是一个联合索引:
CREATE INDEX idx_user_status ON orders(user_id, status);
这一个索引能同时服务两个查询(因为最左前缀),而且比两个单列索引省空间、写入更快——MySQL 5.6 之后的 index merge 虽然能合并多个单列索引,但效率和可预测性都不如联合索引。
列的顺序怎么定,三条经验:
-
等值条件放前面,范围条件放后面。因为范围列后面的列用不上索引。
-- 查询:user_id = ? AND create_time > ? -- 正确顺序 CREATE INDEX idx_user_time ON orders(user_id, create_time); -- 反了的话,create_time 是范围查询,后面的列就失效了 -
区分度高的放前面(在同为等值条件时)。区分度 =
COUNT(DISTINCT col) / COUNT(*),越接近 1 越好。SELECT COUNT(DISTINCT status) / COUNT(*) AS status_sel, COUNT(DISTINCT user_id) / COUNT(*) AS user_sel FROM orders;不过这条要小心:如果查询里某列总是等值匹配,区分度的影响没那么大,优先满足第 1 条。
-
考虑排序。如果查询带
ORDER BY,让索引顺序和排序顺序一致,能省掉 filesort:-- 查询:WHERE user_id = ? ORDER BY create_time DESC LIMIT 10 CREATE INDEX idx_user_time ON orders(user_id, create_time); -- 索引本身有序,取前 10 条直接扫描,不用排序
最左前缀到底卡在哪
联合索引 (a, b, c) 相当于建了 (a)、(a,b)、(a,b,c) 三个索引的查询能力。但必须从最左边开始连续匹配:
-- 索引 (user_id, status, create_time)
WHERE user_id = 1 -- ✅ 用上 user_id
WHERE user_id = 1 AND status = 'PAID' -- ✅ 用上前两列
WHERE user_id = 1 AND status='PAID' AND create_time > ? -- ✅ 三列
WHERE user_id = 1 AND create_time > ? -- ⚠️ 只用上 user_id(status 断了)
WHERE status = 'PAID' -- ❌ 没从最左开始,全表扫
WHERE user_id > 1 AND status = 'PAID' -- ⚠️ 范围之后失效,只用 user_id
最后一条最容易踩:user_id > 1 是范围,后面 status 就用不上了。如果需要 status 也走索引,考虑调整顺序或者用 IN 把范围变成等值。
还有几种索引直接失效的写法,见到就该改:
WHERE YEAR(create_time) = 2026 -- ❌ 列上套函数
WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01' -- ✅
WHERE amount + 0 > 100 -- ❌ 列参与运算
WHERE amount > 100 -- ✅
WHERE phone = 13800138000 -- ❌ 类型不匹配(phone 是 varchar)
WHERE phone = '13800138000' -- ✅
WHERE name LIKE '%abc' -- ❌ 前缀通配符
WHERE name LIKE 'abc%' -- ✅
隐式类型转换那条最隐蔽:phone 是 varchar,传数字进去,MySQL 会给列加转换函数,索引直接废掉,而 SQL 看着完全没问题。
覆盖索引:省掉回表
回表是什么:二级索引的叶子节点存的是主键值,通过二级索引找到主键后,还要再查一次主键索引(聚簇索引)拿完整行数据——这多出来的一次就是回表。
如果索引里已经包含查询需要的全部列,就不用回表了,这叫覆盖索引。EXPLAIN 的 Extra 里会显示 Using index。
-- 查询只需要 user_id 和 status
SELECT user_id, status FROM orders WHERE user_id = 100;
-- 索引 (user_id, status) 就是覆盖索引,Extra: Using index
回表的代价:随机 IO。2000 万的表,命中 1 万行就要 1 万次随机主键查找,这在 HDD 上能差出几十倍。把常用查询改成覆盖索引,是投入产出比最高的优化之一。
EXPLAIN 里看到 Using index condition 是另一回事(索引下推 ICP),Using index 才是覆盖索引。
用 EXPLAIN 判断索引有没有生效
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'\G
看这几列:
| 列 | 看什么 |
|---|---|
type |
从好到坏:system > const > eq_ref > ref > range > index > ALL。至少要到 ref/range,出现 ALL(全表扫)就要处理 |
key |
实际用到的索引。possible_keys 是"候选",key 才是真用的 |
key_len |
实际用了索引的多少字节。联合索引只看这个就知道用到几列 |
rows |
预计扫描行数,和实际差得越多说明统计信息越不准 |
Extra |
Using index 覆盖索引;Using filesort 要排序(可能要优化);Using temporary 用了临时表;Using where 正常 |
统计信息不准时(比如刚大批量导入后),优化器可能选错索引:
ANALYZE TABLE orders; -- 重新收集统计信息
-- 紧急情况下强制指定(不建议长期用,数据分布变了就失效)
SELECT * FROM orders FORCE INDEX (idx_user_status) WHERE ...;
那次最后怎么改的
14 个索引砍到 5 个:
- 删掉 5 个零使用的
- 两个前缀重复的单列索引(
user_id和(user_id, status))合并成一个联合索引 - 把一个区分度极低的单列索引(
status只有 3 个值,区分度 0.0001)删掉——这种列单独建索引几乎没用,除非配合覆盖索引 - 新增两个覆盖索引,覆盖最高频的两个查询
结果:写入 TPS 从 600 回到 1800,磁盘占用降了 40%,那两个高频查询因为覆盖索引反而更快了。
几条经验
- 建索引之前先想清楚这个查询会不会真的用上它,别凭感觉
- 单列索引能合并成联合索引的尽量合并,注意顺序
- 定期(比如每季度)用
performance_schema查一次零使用索引,及时清理 - 区分度极低的列(性别、状态)单独建索引基本没意义,做联合索引的后缀列或放进覆盖索引才行
- 索引是写放大,每加一个都要问一句"写入变慢能接受吗"
- 主从架构下,从库的查询模式和主库不同,索引可以不一样——但要注意主从切换后会怎样

