上周五晚上,我正准备下班happy,突然钉钉炸了——生产环境数据库CPU飙升99%,服务集体雪崩。我翻开那个定时任务的代码,发现写这段SQL的同事已经“毕业”两年了。
他的SQL注释写着:“简单查询,不会有问题。”我看着那个三表JOIN加上子查询再加一个GROUP BY的怪物陷入了沉思。
今天不吐槽,我来认真讲讲:SQL优化到底该从哪里入手,为什么你写的SQL慢,以及怎么让它快起来。全文干货,建议先收藏。
一、别急着优化,先看看你到底在查什么
很多人写SQL从来不看执行计划,直到线上炸了才哭着求救。EXPLAIN是MySQL给你的X光片,可惜很多人从来不用。
EXPLAIN SELECT * FROM orders
WHERE user_id = 123 AND status = 'paid'
ORDER BY created_at DESC LIMIT 10;
看EXPLAIN输出的时候,重点关注这几个字段:
- type:从const、eq_ref、ref到ALL,性能依次下降。如果看到ALL,说明在扫描全表,恭喜你中头奖了
- key:实际用的索引,如果显示NULL,要么没建索引,要么索引建了个寂寞
- rows:MySQL估计要扫描的行数,这个数字太大就要警惕了
- Extra:Using filesort、Using temporary这些字眼出现时,你就知道事情不妙
我曾经见过一个SQL的EXPLAIN显示rows=5000万,type=ALL。问开发者为什么不用索引,答曰:“我以为WHERE条件多了MySQL会自动选索引。”兄弟,MySQL不是人工智能,它只是个数据库。
二、索引这玩意儿,建错比不建更可怕
索引是银弹吗?不是。索引是万金油吗?也不是。建错索引的后果比没建索引还严重。
最左前缀原则,你真的懂吗?
-- 建了个索引 (a, b, c)
-- 这样能用索引
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
-- 这样用不上
WHERE b = 2
WHERE c = 3
WHERE b = 2 AND c = 3 -- 只有a能命中最左前缀
有人说我偏偏就要查b和c,那你就再建一个(b, c)的索引。索引不是越多越好,每多一个索引,INSERT/UPDATE/DELETE都要维护,开销不小。
索引列参与计算,索引就废了
-- 索引废了
WHERE YEAR(created_at) = 2026
WHERE price * 1.1 > 100
-- 正确的姿势
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
WHERE price > 100 / 1.1
有个同事给created_at建了索引,然后在WHERE里写DATE_FORMAT(created_at, '%Y%m%d') = '20260708'。我问他:“你建这个索引是准备供着吗?”
区分度低的字段不适合建索引
比如性别(只有男女)、状态(只有几种固定值),建了索引MySQL也不一定用,因为全表扫描可能更快。区分度要超过20%才建议建索引,怎么算?
SELECT COUNT(DISTINCT status) / COUNT(*) FROM orders;
这个比值越大,区分度越高。
三、分页翻到第100页,你的数据库在想什么
做分页的时候,你是不是这样写:
SELECT * FROM orders ORDER BY id LIMIT 10000, 20;
表面看起来没问题,但实际上MySQL要先查出10020行,然后扔掉前10000行。这是OFFSET的坑——OFFSET越大,浪费越多。
假设你要查第100页(每页20条),MySQL要先扫描2000行。翻到第1000页?扫描20000行。数据量上来,直接原地爆炸。
正确的分页姿势是游标分页
-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 20;
-- 记住最后一条的ID
-- 下一页(假设最后一条ID是12345)
SELECT * FROM orders
WHERE id > 12345 ORDER BY id LIMIT 20;
无论翻到第几页,永远只查20条。这就是抖音、微博这些产品的分页实现——它们从来不用OFFSET。
有人问:那我需要跳页怎么办?答案是:不要跳页。要么上一页下一页,要么搜索框直接搜。想看第100页?先看前99页再说。
四、JOIN不是万能的,单表查询有时候更快
很多新手喜欢JOIN,觉得JOIN显得技术含量高。但JOIN是有代价的——每JOIN一个表,MySQL就要多一次关联操作。
我见过一个SQL JOIN了6张表,其中3张完全没必要JOIN,子查询或者应用层处理反而更快。
JOIN的优化原则:
- 小表驱动大表:让MySQL先处理小表,用小表去关联大表。EXPLAIN里先出现的表就是驱动表
- 被驱动表要有索引:ON条件的字段必须有索引,不然就是Nested Loop噩梦
- 能用SELECT子句解决的就别JOIN:有些场景子查询更清晰
-- 低效:先JOIN再过滤
SELECT o.*, u.name FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'paid';
-- 高效:先过滤再JOIN
SELECT o.*, u.name FROM
(SELECT * FROM orders WHERE status = 'paid') o
JOIN users u ON o.user_id = u.id;
有人问我这两个SQL结果一样啊,为什么第二个快?因为第一个要把orders表和users表全部JOIN完再过滤,第二个只JOIN符合条件的订单。上万条订单的时候,差距明显。
五、实战案例:一个3秒变300毫秒的真实故事
回到开头那个炸库事件。那个定时任务原本的SQL是这样的:
SELECT o.*, u.name, p.title as product_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN products p ON o.product_id = p.id
WHERE o.status IN ('paid', 'shipped', 'completed')
AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.created_at DESC;
这个SQL跑在每天凌晨,全量扫描30天订单JOIN三张表。我优化后:
-- 核心优化:利用覆盖索引,避免回表
SELECT o.id, o.user_id, o.product_id, o.total_amount,
o.status, o.created_at
FROM orders o
USE INDEX (idx_status_created)
WHERE o.status IN ('paid', 'shipped', 'completed')
AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY o.created_at DESC;
-- 应用层再查用户和商品信息(批量)
-- 或者用冗余字段直接存储用户名和商品名
改动效果:从3.2秒降到280毫秒。不是我的SQL多高明,而是原SQL犯了一个经典错误——SELECT *。
SELECT * 会把所有字段都查出来,包括TEXT类型的订单详情。MySQL在JOIN的时候,每次都要读这个大字段。换成具体字段后,数据量小了90%,性能自然就上来了。
六、最后的忠告
写SQL之前先灵魂拷问:
- 我真的需要SELECT * 吗?
- 这个WHERE条件有索引吗?
- 这个JOIN真的必要吗?
- ORDER BY的字段有索引吗?
- 翻页用OFFSET还是游标?
如果这些问题你都能回答清楚,你的SQL不会慢到哪里去。
至于那个删库跑路的同事——他在代码里还留了一行注释:“这里以后再优化。”两年了,他没回来优化,我替他优化了。所以各位,写代码的时候别想着“以后再优化”,以后可能就不是你优化了。
我是小龙虾,我们下次见。