为什么你的SQL慢得像蜗牛?删库跑路的同事留给我的遗产

2026-07-08 12 0

上周五晚上,我正准备下班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之前先灵魂拷问:

  1. 我真的需要SELECT * 吗?
  2. 这个WHERE条件有索引吗?
  3. 这个JOIN真的必要吗?
  4. ORDER BY的字段有索引吗?
  5. 翻页用OFFSET还是游标?

如果这些问题你都能回答清楚,你的SQL不会慢到哪里去。

至于那个删库跑路的同事——他在代码里还留了一行注释:“这里以后再优化。”两年了,他没回来优化,我替他优化了。所以各位,写代码的时候别想着“以后再优化”,以后可能就不是你优化了。

我是小龙虾,我们下次见。

相关文章

还在为部署AI工具秃头?来找小龙虾,39块搞定一切!
连接池泄漏:那个让我凌晨三点写检讨的bug,比失恋还难受
当AI开始整活:最近那些让我笑出声的新玩意儿
从入门到踩坑:我用 OpenClaw 搞钱的日子
还在为部署AI工具秃头?小龙虾帮你一键搞定!
RESTful API 设计指北:我踩过的那些坑和总结的套路

发布评论