前两天线上又报警了,P99延迟飙到3秒。一看监控,一个看似人畜无害的SQL正在疯狂扫表。我盯着它看了半天,终于想起来——这货把 LEFT JOIN 的右表放到了 WHERE 条件里。
不是我菜,是SQL这东西,入门门槛极低,天花板极高。今天不聊索引怎么建(那是基本操作),聊点更隐蔽的——那些我踩过坑、见过别人踩坑、以及差点踩坑的SQL性能问题。
1. SELECT * 是万恶之源,别犟
我知道你要说「我就查这几个字段多麻烦啊」,但 SELECT * 的问题不只是多传了无用数据:
- 无法利用覆盖索引,索引失效回表
- 网络传输量翻倍,字段多的时候很可观
- 表结构改了,你的代码可能悄悄出错
我见过最离谱的一个 SELECT * 来自一个遗留系统,里面藏了个 TEXT 字段,每次查询都触发磁盘临时表,200行的查询愣是跑出2秒来。就因为没人想起来看执行计划——哦对,执行计划我们待会说。
2. LIKE '%keyword%' 让索引直接报废
这个点估计都听出茧子了,但架不住每天都有人继续踩。
-- 索引:[name, status]
-- 这条语句:索引在 name 列失效,全表扫描
SELECT * FROM orders WHERE name LIKE '%test%';
-- 解决方案A:Elasticsearch
-- 解决方案B:如果是前缀匹配,用 LIKE 'test%'
-- 解决方案C:中文全文索引 or 冗余keyword字段
有些场景确实没法避免模糊匹配,这时候就别死磕SQL了,考虑一下搜索基础设施。MySQL 8.0+ 有 IN NATURAL LANGUAGE MODE,凑合能用,但生产级别还是建议上专业工具。
3. 隐式类型转换,索引无声无息就死了
这个坑特别阴险,因为SQL能正常跑,但性能就是差。
-- phone 是 VARCHAR 类型,存的是字符串
-- 你传入的参数是 BIGINT:13800138000
-- 底层做了 CAST(phone AS SIGNED) = 13800138000
-- 每个值都要转一遍,索引白建
SELECT * FROM users WHERE phone = 13800138000;
-- 正确写法
SELECT * FROM users WHERE phone = '13800138000';
我之前排查过一个慢查询,5分钟没找到原因,后来发现是Java层传参把 Integer 类型参数塞到了 VARCHAR 字段上。Oracle 的执行计划里这种隐式转换更隐蔽,有些版本甚至不会在计划里显示类型转换。
4. 多列索引不遵守最左前缀原则,等着全表扫
这个是经典面试题,但工作中照样有人踩:
-- 创建了索引:INDEX idx_user_status_date (user_id, status, created_at)
-- 好使
WHERE user_id = 123
WHERE user_id = 123 AND status = 1
WHERE user_id = 123 AND status = 1 AND created_at > '2026-01-01'
-- 不好使(跳过user_id)
WHERE status = 1
WHERE status = 1 AND created_at > '2026-01-01'
-- 不好使(中间断了)
WHERE user_id = 123 AND created_at > '2026-01-01'
第三种情况最容易被忽略——你写了 user_id = 123 AND created_at > '2026-01-01',感觉应该能用上索引吧?对不起,索引在 status 那里断了,后面的 created_at 就用不上了。除非你的查询能把 status 加上(即使是 status = status 这种冗余条件)。
5. OR 是性能杀手,特别是跨字段的OR
-- users 表有100万数据
-- 假设 email 和 phone 各自都有索引
-- 这个查询:MySQL 很可能选择全表扫描而不是分别用索引再合并
SELECT * FROM users WHERE email = 'test@example.com' OR phone = '13800138000';
-- 解决方案:拆成 UNION
SELECT * FROM users WHERE email = 'test@example.com'
UNION
SELECT * FROM users WHERE phone = '13800138000';
MySQL 5.6及以前版本的查询优化器对OR的处理非常糟糕,5.7之后有改进,但生产环境谨慎依赖。UNION ALL + 去重的方式在大数据量下也更可控。
6. JOIN 的表太多?小心笛卡尔积杀你
JOIN本身不是问题,问题是:
- JOIN顺序错误——小表驱动大表是基本,但你确定优化器理解了你的意图?
- 缺少 JOIN 条件变成笛卡尔积——1000行 × 1000行 = 100万行爆炸
- N+1 问题在 ORM 里极其常见,批量查一下会死吗?
-- 经典 N+1
for (order : orders) {
user = db.query("SELECT * FROM users WHERE id = "+ order.userId); // N次查询!
}
-- 正确做法
userIds = orders.map(o -> o.userId).distinct();
users = db.query("SELECT * FROM users WHERE id IN (...)"); // 1次查询
我见过最夸张的 N+1 是循环里查 Redis——5000次GET请求,每秒处理10个请求,50万次Redis调用。还好没出生产事故,否则都不知道怎么死的。
7. COUNT(*) 和 COUNT(1) 的区别?别纠结,意义不大
这是个老生常谈的话题,但结论可能跟你想的不一样:
- MySQL 5.6之后,
COUNT(*)会被优化器内部转化为COUNT(0),两者完全等价 - 真正影响性能的是 WHERE 条件和是否有合适的索引
- 如果你真的关心性能,试试
EXPLAIN COUNT(*)看执行计划
倒是 COUNT(column) 会跳过NULL值,这才是真正需要注意的区别。
8. 分页 OFFSET 大了怎么办?深度分页是个坑
-- 第10000页,每页20条
-- MySQL 先扫描前10000*20=200000行,然后丢弃前199980行
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 200000;
-- 优化方案:游标分页(keyset pagination)
-- 利用索引:(id) 是主键索引,天然有序
SELECT * FROM orders WHERE id > 200000 ORDER BY id LIMIT 20;
-- 如果必须用OFFSET,先计数:加个乐观锁或者限制最大页数
游标分页的问题是只能跳下一页,不能跳特定页。如果你的产品经理坚持要「跳转第N页」,那就在前端做个限制,超过1000页就提示用户筛选条件收窄一下。
9. 批量插入还在一条条INSERT?你在浪费生命
-- 慢:1000条数据,1000次网络往返
for (item : items) {
db.execute("INSERT INTO orders ... ");
}
-- 快:1次网络往返
INSERT INTO orders (...) VALUES (...), (...), (...);
实测:1000条记录,单条INSERT耗时约1.5秒,批量INSERT(每批100条)耗时约0.3秒。当然,也不是说越大越好——MySQL的 max_allowed_packet 有上限,太大的SQL会直接报错。实测每批500-1000条是性价比最优的选择。
10. 执行计划才是你真正的老师
前面的都是招式,这个是内功。看不懂执行计划,优化SQL就是瞎子摸象。
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE user_id = 123;
-- 重点看这几个字段:
-- type: ALL = 全表扫描,ref/range = 用了索引
-- key: 实际用的索引是哪一列
-- rows: 扫描了多少行(越大越可疑)
-- Extra: Using filesort = 额外排序,Using temporary = 建临时表
我自己的习惯是:任何可能慢的查询,先 EXPLAIN 一下。特别是涉及到 GROUP BY、ORDER BY、DISTINCT 的——这几个关键字特别容易触发 filesort 和临时表。
写了这么多年SQL,我最大的感悟是:数据库是最诚实的,你糊弄它,它就糊弄你。索引建了不用跟没建一样,SQL写错了它不会报错,只会让你线上报警。
下次遇到慢查询,别急着加服务器,先 EXPLAIN 一下。十有八九,你写的SQL跟你以为的不一样。
裤衩保住了吗?希望是的。