你以为数据库慢是数据库的锅?不,慢是你的锅。没写索引、随手一个 SELECT *、在索引列上套函数——这些骚操作能把一台顶配服务器变成废铁。
这篇不讲玄学,只讲我踩过的坑。每一条都能让你的查询从"等一杯咖啡"变成"秒回"。
一、先搞清楚:数据库到底在找什么
你执行一条 SQL,数据库要做的第一件事不是"返回数据",而是"找到要返回的数据在哪"。B+ 树索引做的事,就是把这个"找"的过程从全表扫描变成有方向的定位。
理解索引之前,得先理解一个常被搞混的概念:回表。
二级索引(辅助索引)叶子节点存的不是完整行数据,而是「索引列的值 + 主键值」。MySQL 拿着你的二级索引找到主键之后,还得再回到聚簇索引(主索引)里把整行数据捞出来——这一步就叫回表。
这一句话解释了你见过的很多奇怪现象:
SELECT * FROM user WHERE name = 'x'走了索引,但因为要回表拿所有字段,慢SELECT id, age FROM user WHERE name = 'x'同样走索引,但 age 恰好在索引里(覆盖索引),不用回表,快
所以"走没走索引"这个二元判断是偷懒的。你真正该问的是:这次查询是不是覆盖索引?
EXPLAIN 要看哪几列
EXPLAIN SELECT id, age FROM user WHERE name = 'x';
重点看三列:
- type:从好到差是
const>ref>range>index>ALL。看到ALL基本就是全表扫描了,恭喜,你的中奖机会来了。 - key:实际用到的索引。如果为 NULL,说明优化器放弃了索引。
- Extra:
Using index是好事(覆盖索引),Using filesort和Using temporary是坏消息,通常意味着排序或分组没走上索引。
Using filesort并不等于"用了磁盘文件",它的本意是"需要额外排序",可能全在内存里完成。但在千万级数据上,它依然是性能杀手。
最左前缀原则:索引的组队规则
假设有联合索引 idx(a, b, c),它相当于三本词典按顺序装订:先按 a 排,a 相同再按 b 排,a、b 都相同再按 c 排。
这意味着:
WHERE a = 1 -- ✅ 用到 (a)
WHERE a = 1 AND b = 2 -- ✅ 用到 (a, b)
WHERE a = 1 AND b = 2 AND c = 3 -- ✅ 用到 (a, b, c)
WHERE b = 2 -- ❌ 跳过了 a,索引失效
WHERE c = 3 -- ❌ 直接起飞
WHERE a = 1 AND c = 3 -- ⚠️ 只用到 (a),c 用不上
一句话:联合索引是一条链,断一节后面全废。
那 WHERE a = 1 AND c = 3 怎么办?MySQL 8.0 有"索引跳跃扫描"(index skip scan),但那是兜底,不是保障。老老实实考虑把 c 提到前面,前提是 c 的区分度够高——这就要看下一节了。
二、区分度:建索引前必须算的一笔账
一个索引如果区分度太低,优化器会直接放弃它,因为全表扫描走索引更慢。
SELECT COUNT(DISTINCT column_name) / COUNT(*) FROM table_name;
返回值就是区分度。经验值:
- > 0.9:优秀,性别这种就别建索引了
- 0.5 ~ 0.9:良好,正常建
- < 0.5:垃圾字段,建了也白建
举个真实例子:订单表里 status 字段有 20 个值,但 95% 的订单都是"已完成"。
-- 灾难索引
KEY idx_status (status)
-- 更好的选择(把区分度高的字段放前面)
KEY idx_status_user (status, user_id)
-- 这样查 status='已完成' AND user_id=? 就能完美命中
联合索引的字段顺序有个简单原则:区分度高的靠前,等区分度的按查询频率排。 这两个维度打架时,先看真实查询日志,别拍脑袋。
三、那些让索引"凭空消失"的骚操作
这部分是我的血压来源。
1. 在索引列上使用函数
-- ❌ 索引失效
SELECT * FROM orders WHERE DATE(created_at) = '';
-- ✅ 改成范围查询
SELECT * FROM orders
WHERE created_at >= ' 00:00:00'
AND created_at < ' 00:00:00';
原因很简单:索引里存的是原始值,你在列上套了函数,优化器没法拿你的表达式去和索引比对,只能放弃。
如果业务上必须用函数包裹,考虑函数索引(MySQL 8.0.13+):
CREATE INDEX idx_created_day ON orders ((DATE(created_at)));
-- 查询时也要用完全一致的表达式才能命中
注意最后那句——"完全一致"。写成 DATE(created_at) = ? 和写成 DATE(created_at) = ',前者走索引,后者可能走也可能不走,取决于常量折叠。稳妥起见两边都写一样的形式。
2. 隐式类型转换
-- phone 是 varchar,id 是 int
SELECT * FROM users WHERE phone = 13800138000;
-- ❌ varchar 列 = 数字,MySQL 会把每行的 varchar 转成数字再比较,索引直接报废
反过来,如果列是 int 而你传字符串,转换发生在常量侧,索引还能用。所以永远给参数用正确的类型,别让数据库替你猜。
3. 前导模糊匹配
SELECT * FROM users WHERE name LIKE '%张';
-- ❌ 索引失效
SELECT * FROM users WHERE name LIKE '张%';
-- ✅ 索引有效(最左前缀)
想解决 `%张` 的检索需求?上全文索引或搜索引擎(Elasticsearch / Meilisearch),别指望 B+ 树能倒着走。
4. OR 连接条件
WHERE a = 1 OR b = 2 这种,优化器可能会全表扫描。但如果你给 b 也建了合适索引,MySQL 8.0 的 index merge 通常能救回来。
更稳的写法是改写成 UNION:
SELECT * FROM t WHERE a = 1
UNION
SELECT * FROM t WHERE b = 2;
前提是 a、b 都有索引,并且 union 的结果集不会让 MySQL 去重时炸掉。
5. 字符集不一致
两张表 join,一个用 utf8mb4 一个用 utf8,或者 collation 不同,隐式转换就又来了。跨库 join 前先对一下字符集,这是最容易被忽略的性能陷阱。
四、慢查询日志:别猜,去看
上面这些坑,凭直觉猜是猜不出来的。答案在慢查询日志里。
# my.cnf
slow_query_log = ON
long_query_time = 0.5
slow_query_log_file = /var/log/mysql/slow.log
或者在线临时开:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
有了日志之后,重点看 Query_time 和 Rows_examined 两个字段的比值:
如果一条查询扫了 100 万行只返回 10 行,那它一定有问题。这个比值越大,问题越严重。Rows_examined / Rows_sent 是我判断索引健康度的第一指标。
生产环境别开 long_query_time = 0,日志膨胀能把磁盘写满。我一般设 0.5 秒,配合 pt-query-digest 做定期汇总。
五、分页:别用 OFFSET
这是我见过的最经典的自杀式写法:
-- ❌ 第 10000 页要扫 10000 行全扔掉
SELECT * FROM articles ORDER BY id LIMIT 10000, 20;
正确的姿势是游标分页(Keyset Pagination):
-- ✅ 记住上一页最后一条的 id
SELECT * FROM articles
WHERE id > 10000
ORDER BY id
LIMIT 20;
无论翻到第几页,扫描量都是恒定的 20 行。这里的原理是:联合索引建在 (created_at, id) 上时,定位本身就是一次有序索引扫描,不需要额外排序。
有个坑要注意:如果排序字段不唯一,单纯用 id > ? 会漏数据或重复。得用两个字段做游标:
SELECT * FROM articles
WHERE (created_at, id) > ('', 5000)
ORDER BY created_at, id
LIMIT 20;
MySQL 支持行值比较,会用上 (created_at, id) 这个联合索引。
六、几个立竿见影的优化
覆盖索引:让查询不回表
-- 需要 name 和 age,创建覆盖索引
CREATE INDEX idx_name_age ON user (name, age);
SELECT age FROM user WHERE name = 'x';
-- 命中 idx_name_age,全程不回表
覆盖索引是"读多写少"表型的免费午餐。代价是索引变胖,写入变慢——所以别一口气把所有字段都塞进去。
批量写入:别一条一条 INSERT
-- ❌ 1000 条数据 = 1000 次网络往返 + 1000 次事务提交
-- ✅ 一条语句批量插入
INSERT INTO log (uid, action) VALUES
(1, 'login'), (2, 'logout'), (3, 'click');
更狠一点可以用 INSERT ... ON DUPLICATE KEY UPDATE 做 upsert,避免"先查再插"的两步开销和并发竞态。
事务要短,但也不要太短
有人迷信"事务越短越好",于是把一个业务操作拆成几百个小事务。问题在于:
- 每次事务提交都要 fsync 磁盘,拆太碎,IO 反而爆炸
- 事务太长则锁持有久,死锁概率上升
原则是:只包住必须原子的那部分数据库操作,不要把 HTTP 调用、文件 IO 塞进事务里。
COUNT 别乱用
COUNT(*) -- 精确计数,需要扫行
COUNT(1) -- 和 COUNT(*) 基本一样,MySQL 会优化
COUNT(主键) -- 也是精确计数
COUNT(索引列) -- 非 NULL 值个数,可以用索引
COUNT(DISTINCT 列) -- 更慢
关键区别在于:COUNT(*) 和 COUNT(1) 在 InnoDB 里都要扫行(因为 MVCC 下无法用一个快照值),而 COUNT(索引列) 能在索引里数,不用回表。
如果只是想判断"有没有",用 EXISTS 或 LIMIT 1:
-- EXISTS 找到第一条就停
SELECT EXISTS(SELECT 1 FROM orders WHERE user_id = 5);
-- 比 COUNT 靠谱得多
七、写在最后
数据库优化的核心其实只有一句话:
让数据库做尽可能少的工作。
索引设计得再好,SQL 写得再优雅,如果你的业务逻辑本身就要全表遍历,那谁也救不了你。真正的性能优化发生在设计阶段,而不是调优阶段。
所以我有个不太受欢迎的建议:上线前用生产数据的量级做一次压测。开发环境几百条数据时跑得飞快的东西,放到生产上就是灾难——因为索引选择性、缓存命中率、锁竞争这些因素的比重全变了。
顺带一句:不要迷信"某某数据库就是快"。MySQL 单表千万级数据配合合理索引,性能远超绝大多数应用的实际需求。你项目里 90% 的性能问题,用一条 EXPLAIN 就能定位。剩下的 10%,可能得换掉整个架构。
而在那之前,先去看看你的慢查询日志。它比任何一篇优化文章都更懂你的系统。
祝你的查询快到不需要 EXPLAIN。