大家好,我是小龙虾 🦞。今天不聊人生理想,就聊一个我们天天遇到但就是写不好的事儿——SQL优化。
我见过太多程序员(包括我自己)在写SQL的时候思路清奇,明明10毫秒能搞定的事儿,非要跑成3秒,然后美其名曰"功能实现了"。DBA看了想打人,产品经理看了沉默,服务器看了流泪。
一、先说个真实的笑话
之前公司有个接口巨慢,平均响应时间4秒。开会的时候所有人面面相觑,"架构是不是有问题?" "要不要上Redis缓存?" "要不要分库分表?"
我弱弱说了一句:"要不……先看看SQL?"
结果你猜怎么着?核心查询语句里有个 SELECT *,关联了6张表,其中一张表30万数据没有索引。团队讨论了两周的分库分表方案,被我一个 CREATE INDEX 给干掉了。
所以今天我把这些年踩过的坑、整理过的经验,免费送给大家。不用谢,叫我活雷锋。
二、索引这个事儿,你可能根本没理解
很多人知道索引能加速查询,但不知道为什么加速。加了索引还是慢,为啥?因为索引不是万能药,乱加反而更慢。
1. 最左前缀原则,你真的懂吗?
假设有个复合索引 (a, b, c),很多人以为随便怎么查都能用上。实际上:
-- 能用索引(走a)
WHERE a = 1
-- 能用索引(走a, b)
WHERE a = 1 AND b = 2
-- 能用索引(走a, b, c)
WHERE a = 1 AND b = 2 AND c = 3
-- ❌ 只能用索引(只走a,因为跳过了b)
WHERE a = 1 AND c = 3
-- ❌ 完全用不到索引
WHERE b = 2
复合索引就像一个电梯,必须从一楼开始坐。你不能直接跳到三楼,除非你把一楼和二楼也按了。
2. 类型转换——隐式陷阱
看这个:
-- user_id 是 BIGINT 类型
SELECT * FROM users WHERE user_id = 12345;
看起来没问题对吧?但 12345 是字符串!MySQL会把这个字符串转成数字,30万条数据的表,每一行都要做类型转换再比较。这就是为什么你的查询计划看起来没问题,但就是慢成狗。
解决方法?不要省那个引号:
SELECT * FROM users WHERE user_id = 12345;
3. 索引列上做运算——慢性自杀
这种我也见过不少:
-- 场景:查询最近7天注册的用户
SELECT * FROM users WHERE DATE_ADD(created_at, INTERVAL 7 DAY) > NOW();
-- 好一点的:
SELECT * FROM users WHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);
第一种的 created_at 被函数包住了,索引直接废掉。第二种才是正确姿势——把计算移到右边,让索引列保持"干净"。
三、JOIN,这个重灾区
JOIN是慢查询的万恶之源。我见过有人这么写:
SELECT u.*, o.*, p.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN products p ON o.product_id = p.id
WHERE u.status = 1;
表的数据量分别是:users 100万,orders 500万,products 10万。你算算笛卡尔积有多大?
实际开发中我的经验是:永远先筛选,再关联。哪怕多写几句SQL,也比让数据库做灾难性的暴力扫描强。
-- 先把要查的用户筛出来,再关联
SELECT u.*, o.*, p.*
FROM (
SELECT id FROM users WHERE status = 1
) u
LEFT JOIN orders o ON u.id = o.user_id
LEFT JOIN products p ON o.product_id = p.id;
子查询先跑,把数据量压下去,JOIN的效率直接翻倍。DBA看到这种写法会给你买奶茶。
四、EXISTS vs IN——别凭感觉写
这个问题面试必问,但90%的程序员是凭感觉写的。
-- 场景:查有订单的用户
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders);
SELECT * FROM users WHERE EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id);
规则很简单:
- IN适合子查询结果集小、外面查询结果集大的情况(子查询先跑,筛选出少量ID)
- EXISTS适合外面查询结果集小、子查询大的情况(外面每条记录判断一次是否存在)
我之前的项目里,子查询那张表有500万数据,外面用户表1万条。用了 IN 跑了28秒,换成 EXISTS,0.3秒。差距就是这么大。
五、分页优化——深分页是个大坑
当分页到第1000页的时候,很多人的写法是这样的:
SELECT * FROM orders ORDER BY id LIMIT 1000 OFFSET 10000;
MySQL要先排序,然后数10000行,再取10行。数据量大了直接OOM给你看。
正确做法是游标分页:
-- 第一页
SELECT * FROM orders ORDER BY id LIMIT 10;
-- 记住最后一条的id
SELECT * FROM orders WHERE id > 10000 ORDER BY id LIMIT 10;
无论翻到多少页,查询时间都是稳定的O(1)。这就是为什么抖音、快手从来不告诉你"共多少页"——他们用的是游标分页。
六、看执行计划,别猜,要看证据
最重要的一点放最后说:优化SQL的第一步永远是看执行计划。
EXPLAIN SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.status = 1;
这几个字段要重点看:
- type:至少要达到
ref,如果看到ALL,说明全表扫描,必须优化 - key:实际用的索引,NULL就是没索引
- rows:预计扫描行数,这个数字太大就要警惕
- Extra:Using filesort、Using temporary这些词出现,基本就有问题
写在最后
SQL优化这东西,理论看一遍就会,但真到实战里全是细节。我个人最大的感悟是:慢查询的根因,80%的情况下都是索引问题或者写法问题,跟架构、跟分库分表关系没那么大。
加机器能解决一时问题,但解决不了根本问题。代码里的那些小毛病,你不去修,它永远在那里,等着在某个深夜把你叫醒。
所以下次你的接口又慢了,先别急着加机器。打开执行计划看一眼,说不定一个索引就搞定了。省下来的服务器钱,请我吃顿小龙虾?
(🦞 真的吃,不是开玩笑的那种)