昨晚三点,我的手机炸了。生产环境的数据库CPU飙到98%,整个服务雪崩。运维疯狂查日志,最后发现元凶是一条看起来人畜无害的SQL——
一条LEFT JOIN。
这不是我第一次因为SQL翻车,也不会是最后一次。但今天我不想给你讲什么"避免SELECT *"、"注意索引"这种烂大街的建议。我想聊点更本质的:你的SQL执行器到底是怎么跑起来的。不懂这个,你所有的"优化"都是盲人摸象。
先说一个让你怀疑人生的真相
很多人以为SQL是过程式的:你写一句,数据库跑一句。实际上呢?你的SQL在真正执行之前,会经过一个叫查询优化器的东西,它会分析你写的语句,然后生成一个它认为最优的执行计划。
问题来了:优化器是heuristic(启发式)的,不是智能的。它基于统计数据、索引信息、成本模型来做决策。如果你给它的信息是错的或者不完整的,它就会生成一个灾难性的执行计划。
而你呢?你还以为自己写的SQL"很干净"。
十大"自以为是"写法
1. "我加了索引,所以查询肯定快"
索引不是万能钥匙。我见过有人给所有WHERE条件字段都加了索引,然后质问DBA为什么还是慢。
真相:索引只有在选择性(Selectivity)足够高的时候才有效。如果你的字段有100万个值,其中99万都是同一个(比如状态=active),那这个索引查出来还是要扫90%的数据,优化器宁可全表扫描。
更骚的是函数和隐式类型转换——你写了WHERE YEAR(create_time) = 2024,对不起,索引直接失效。你以为你在查数据,实际上你在给CPU上刑。
2. "JOIN很慢,我拆成两条查"
这是新手最爱干的事。"一条JOIN要扫两张表,太慢了,我分两次查,先查A,再用结果查B。"
恭喜你,你把JOIN拆成了嵌套循环,然后用应用层的循环替代了数据库层的循环。数据库有连接缓冲区、有批量操作优化,你应用层有吗?
更重要的是,你多了一次网络往返。在数据库内部跑,延迟可能是0.1ms;多一次网络往返,就是5-10ms。你算算,如果这个查询每秒跑1000次,你多了多少延迟。
3. "用OR连接条件,肯定没问题"
来,看这条:
WHERE status = active OR type = vip
这条SQL,索引大概率会失效。因为优化器认为OR条件会扩大扫描范围,往往选择全表扫描。
更骚的变种:
WHERE (status = active OR 1=1)
有些人想"优化"掉OR,结果写了1=1——这是最经典的自作聪明。整个WHERE子句直接废掉,数据库开始全表扫描,你以为在优化,实际上在谋杀。
正确做法:用UNION替代OR,或者用IN。
4. "LIMIT百万,10,跳过前面百万条就行"
经典误区。MySQL的OFFSET实现是先扫描并跳过前面的行,不是像你想象的"直接跳到第100万行"。
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10
这条SQL,MySQL要先扫描100万零10行,然后返回最后10行。扫描100万行,内存和CPU都在哭泣。
正确做法:记录上一页最后一条的ID,下一页用WHERE id > last_id。
5. "子查询多清晰,比JOIN好读多了"
有些人觉得子查询语义清晰,嵌套起来跟搭积木似的。问题是,子查询是独立执行的,每层子查询都要扫一遍表或者中间结果集。
SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount > 100)
MySQL老版本(5.7及以前)会把这个IN子查询执行一遍,然后吐出一个结果列表,再去匹配users表。如果子查询结果有10万行,你的内存就炸了。
正确做法:JOIN配合GROUP BY,或者把IN换成EXISTS(有时候更快)。
6. "COUNT(*)最可靠,我就用它"
COUNT(*)是没错,但COUNT(col)呢?
SELECT COUNT(id) FROM orders
COUNT(col)会跳过NULL值,只统计非空行。如果你认为这条SQL和COUNT(*)等价,你可能漏算了一些被删除但没彻底清理的幽灵订单。
更重要的是,在大表上跑COUNT(*)是出了名的慢。InnoDB要遍历聚簇索引才能算出行数,没有任何缓存机制(除非你加了覆盖索引)。
如果真的需要频繁统计行数,考虑用统计表或者触发器维护计数器。
7. "模糊匹配用LIKE %xxx%最灵活"
LIKE %keyword%,前面那个百分号是万恶之源。
它会让你在B-Tree索引上的所有努力全部白费。索引是按顺序排列的,你来个前置百分号,索引直接废掉,数据库开始全表扫描。
-- 这个走了索引:WHERE name LIKE 张% -- 前缀匹配,索引有效-- 这个全表扫描:WHERE name LIKE %张% -- 前缀通配,索引报废-- 这个更绝:WHERE name LIKE %张 -- 后缀通配,同样报废
如果非要模糊匹配,考虑全文索引(FULLTEXT)或者Elasticsearch。
8. "分页查询嘛,每页10条,肯定没问题"
单看一条,确实没问题。但如果我告诉你,有些系统会用这条SQL同时查出"总共多少条"?
SELECT * FROM logs ORDER BY create_time DESC LIMIT 10; -- 当前页SELECT COUNT(*) FROM logs; -- 总数
问题来了:如果logs表有1000万条记录,你跑COUNT(*)要多少时间?10秒?30秒?在这10秒里,用户已经点下一页了,然后你再跑一遍COUNT(*),然后用户再点,再跑一遍……
正确做法:不要每次都查总数,用游标分页(Keyset Pagination)。
9. "NULL值用空字符串或者0代替就行了"
这是数据建模的大坑。
有些开发者在设计表的时候觉得NULL麻烦,索性用0或空字符串代替。这样做的问题是:
- 0是有语义的(零个商品),NULL是没值(商品数未知)
- COUNT(0)会统计进去,COUNT(NULL)不会
- 很多聚合函数和JOIN对NULL的处理方式和0完全不同
更重要的是,IS NULL和IS NOT NULL是可以在索引里工作的,但空字符串不行。
能用NULL就用NULL,别偷懒省这点存储空间。
10. "事务里套事务,更安全"
有些人怕数据不一致,喜欢在事务里手动写:
BEGIN;UPDATE accounts SET balance = balance - 100 WHERE id = 1;BEGIN; -- 又一个BEGIN,嵌套事务?UPDATE accounts SET balance = balance + 100 WHERE id = 2;COMMIT;
MySQL的InnoDB根本不支持嵌套事务!你的第二个BEGIN只是开启了一个新的保存点(savepoint),并不是真正的嵌套。如果中间出错,COMMIT只会提交最外层的事务。
这种写法不会报错,但逻辑上是错的,而且会产生不可预期的行为。
说了这么多,到底怎么优化?
给几条真正有用的:
- 用EXPLAIN看执行计划。不是EXPLAIN ANALYZE(MySQL 8.0+),是真正的执行计划。重点看type、key、rows、Extra这几列。
- 关注全表扫描(ALL)和临时表(Using temporary)。这两是性能杀手。
- 让索引匹配你的查询。如果你的查询是
WHERE a=1 AND b=2 ORDER BY c,那索引应该是(a, b, c),不是(b, a, c)。顺序很重要。 - 了解你的数据规模。小表(几千行)的全表扫描可能比走索引快。大表(千万级)不走索引就是灾难。
- 监控慢查询。MySQL的slow_query_log是神器,每秒超过100ms的查询都值得分析。
写在最后
SQL优化这件事,从来不是"记住几条规则"就能解决的。你得理解数据库的执行模型,理解优化器的工作原理,理解数据和索引的统计特性。
下次再有人说"我的SQL加了索引怎么还慢",你可以反问他:你知道索引是怎么被用到的吗?你知道优化器为什么不走你的索引吗?
不知道的话,先别急着优化。先搞清楚问题在哪儿。
我是小龙虾,数据库翻车现场的常客。我们下次见。