别再被SQL卡脖子了——一个增删改查选手的索引觉醒之路
我曾经是一个普通的 CRUD 工程师。每天写 SELECT * FROM users WHERE id = 1,过着幸福美满的生活。直到有一天,测试跟我说:「这个查询要 30 秒。」我说:「不可能,我用的可是 MySQL!」然后我打开了 EXPLAIN,看到了那个词——ALL,全表扫描。
那一刻我知道,我需要谈谈索引了。
索引是什么?别告诉我它是「加速神奇按钮」
网上 $99\%$ 的文章会告诉你:「索引就像书的目录,能快速找到内容。」这个比喻对,但毫无用处。它没有告诉你:索引是有代价的,而且代价不小。
从数据结构角度,MySQL 最常用的索引是 B+Tree。简单说,它是把所有数据指针存在一棵树里,每个节点可以存很多个「键」,树是矮胖的,查询次数从 $O(n)$ 变成了 $O(log\ n)$。
但问题是:每次 INSERT/UPDATE/DELETE,这棵树都要重新调整。你索引建得越多,数据变更的代价就越大。这就是为什么有些人索引加了一堆,写入反而变慢了——你光顾着让 SELECT 快,忘了 INSERT 也是要付出代价的。
-- 查看某张表的索引
SHOW INDEX FROM orders;
-- 一个典型的复合索引
CREATE INDEX idx_user_status ON orders(user_id, status, created_at);
最常见的七种「自杀式建索引」
1. 索引建在区分度低的列上
比如性别,只有「男/女」,你建个索引,MySQL 一看:哎呀,就两种值,这索引命中率也太低了,不如直接全表扫描算了。索引要建在区分度高的列上,ID、手机号、邮箱、订单号这种东西。
-- 区分度低的列(不推荐单独建索引)
CREATE INDEX idx_gender ON users(gender); -- 别建
-- 区分度高的列
CREATE INDEX idx_phone ON users(phone); -- 可以建
2. 复合索引不遵守「最左前缀原则」
这是最多人踩的坑。你建了 (user_id, status, created_at) 这个复合索引,然后写:
SELECT * FROM orders WHERE status = 'paid'; -- 索引失效!
SELECT * FROM orders WHERE created_at > '2026-01-01'; -- 索引失效!
SELECT * FROM orders WHERE user_id = 123; -- 生效
SELECT * FROM orders WHERE user_id = 123 AND status = 'paid'; -- 生效
复合索引是从左到右匹配的。你不写 user_id,直接跳到第二列,MySQL 就不知道该怎么走索引了。就像去医院挂号,你跳过前台直接去诊室,护士不拦你才怪。
3. 在索引列上做运算或函数
-- 这样写,索引完全失效
SELECT * FROM users WHERE YEAR(created_at) = 2026;
-- 改成范围查询,索引正常生效
SELECT * FROM users WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';
还有这种:
-- 索引失效,因为对列做了运算
SELECT * FROM orders WHERE amount + 100 > 500;
-- 应该改成
SELECT * FROM orders WHERE amount > 400;
索引里存的是原始值,你一运算,MySQL 就要把每一行都拿出来算一遍,全表扫描跑不了。
4. 用 OR 连接条件,但 OR 另一边没有索引
-- user_id 有索引,email 没有索引
SELECT * FROM users WHERE user_id = 1 OR email = 'test@example.com';
-- OR 会导致整个查询放弃索引,走全表扫描
-- 正确做法:用 UNION 分开写
(SELECT * FROM users WHERE user_id = 1)
UNION ALL
(SELECT * FROM users WHERE email = 'test@example.com' AND user_id != 1);
5. 用 LIKE %开头
-- 索引失效
SELECT * FROM products WHERE name LIKE '%手机%';
-- 这样可以走索引
SELECT * FROM products WHERE name LIKE '手机%';
MySQL 的 B+Tree 索引是按顺序排列的,% 开头意味着「我不知道开头是什么」,所以只能扫描全部。如果你真的需要全文搜索,请用 Elasticsearch,或者 MySQL 8.0+ 的全文索引,别硬扛。
6. 查询返回的数据量太大
有时候不是索引的问题,是你的 WHERE 条件太宽。30 万行数据,索引找到了 20 万行,MySQL 一看:「索引找出来跟全表扫描差不多,还是直接扫吧。」
-- 如果你要查 2020 年的订单,但表里有 5 年的数据
-- MySQL 觉得扫全表比分批用索引快,索引就白建了
-- 试试强制走索引(不推荐作为首选方案)
SELECT * FROM orders FORCE INDEX(idx_created_at) WHERE created_at > '2020-01-01';
7. 建了索引但不用,以为是 MySQL 抽风
其实 MySQL 比你聪明。它会评估「走索引 I/O 开销 vs 全表扫描 I/O 开销」,如果它觉得全表扫描更快,就不用索引。这是优化器的正常行为。不要质疑优化器,要质疑自己的建索引策略。
实战:如何给一个慢查询「看病」
我见过最典型的慢查询是这样的:
SELECT o.id, o.total, u.name, u.email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
AND o.created_at > '2026-01-01'
ORDER BY o.total DESC
LIMIT 20;
先跑一个 EXPLAIN 看看:
EXPLAIN SELECT o.id, o.total, u.name, u.email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid'
AND o.created_at > '2026-01-01'
ORDER BY o.total DESC
LIMIT 20;
关键看这几个字段:
- type:ALL = 全表扫描,ref/range = 走了索引
- key:实际用的索引名字
- rows:扫描了多少行(越少越好)
- Extra:Using filesort = 需要额外排序,Using temporary = 用了临时表,这两个都是性能杀手
优化思路:
-- 1. 先在 orders 表建复合索引,覆盖 WHERE 和 ORDER BY
CREATE INDEX idx_status_created_total ON orders(status, created_at, total);
-- 2. users 表的主键索引已经够用(通常是 id),不用动
-- 3. 查出来的数据不多,直接 LIMIT 20,MySQL 找到20条就停
-- 如果 Extra 里还有 Using filesort,可以改成覆盖索引避免回表
CREATE INDEX idx_status_created_total_cover ON orders(status, created_at, total, id, user_id);
索引的「三低」人群自查清单
建索引之前,先问自己三个问题:
- 查询频率高吗? 不常用的查询配得上一个索引吗?
- 表的数据量够大吗? 几千行的小表,全表扫描可能比走索引还快。
- 写多还是读多? 写多的话,索引不要建太多,每条写操作都要更新所有索引。
总结一下
索引不是万能的。它是空间换时间的游戏,有建索引的代价,也有维护索引的成本。很多人觉得慢查询优化就是「加索引」,加完索引还是慢,就再加一个——然后数据库越来越大,写入越来越慢,你开始怀疑人生。
真正的优化是:先看慢查询,找到瓶颈,判断是否需要索引,需要什么样的索引。一个精心设计的复合索引,比十个乱七八糟的单列索引强得多。
下次再有人跟你说「给这列加个索引」,先问一句:什么索引?复合索引还是单列索引?查询条件是什么?覆盖了吗?
如果他答不上来,那这个问题,可能比你想象的慢查询还要慢。