不少团队都经历过这样的时刻:线上数据库CPU突然飙高,慢查询日志里刷出来一条跑了七八秒的 SQL,表面看功能完全正常,返回的数据也挑不出毛病。你把它复制到本地,数据量小,执行计划还算体面,可一上生产就原形毕露,全表扫描、临时文件排序、嵌套连接全都冒出来了。数据库硬件升级了,连接池也调大了,问题还是照样存在——这时候你才意识到,问题不是数据库不行,而是写那条 SQL 的人,从一开始就把优化器的嘴给堵死了。
这类写法我见得太多,也替别人排查过太多。它们不会语法报错,不会让功能失败,甚至在数据量只有几万行的时候非常体面,但只要数据量涨到千万,或者接口并发一上来,立刻变成整个团队的定时炸弹。我把它们归纳成 8 种常见的“专坑同事”写法,逐一拆解背后的原理、触发条件,以及正确的替代方案。如果你是刚接触数据库优化的人,这篇文章能帮你建立一种“条件反射”;如果你已经是团队里的老手,这份清单也能作为代码评审时的参考依据。
1. 慢 SQL 的雷到底埋在哪:优化器的取舍逻辑
1.1 一条查询提交之后,数据库到底在纠结什么
很多开发把 SQL 理解成“我说要什么,数据库就去取什么”,但真实流程完全不是这样。数据库拿到一条 SQL 之后,会先把它解析成语法树,再做逻辑优化和物理优化,最后根据统计信息计算每条执行路径的代价,选一个它认为最便宜的计划去执行。
代价估算的核心依据有几个:表的行数、字段的区分度、索引的分布、是否需要回表,以及排序和临时表的开销。举个例子,一条查询可以用主键定位,也可以全表扫描,优化器会估算“主键定位要读几个索引页、几个数据页”,再估算“全表扫描要读多少页、过滤多少行”,哪个便宜就走哪个。这就是为什么统计信息过期时,执行计划会突然变差的原因——它判断的事依据是真的不准了。
这个机制里有个特别关键的点:优化器只有在条件“能被表达成索引上的边界”时,才会考虑走索引。如果条件本身无法确定边界,就只能把符合条件的每一行都拉出来验证一遍。这种“能作为边界”的特性,业内叫 SARGable,也就是查询条件能否被看成搜索参数。一条 SQL 是快是慢,很多事都取决于条件是不是 SARGable。
1.2 写法是怎么改变执行计划的
理解优化器之后,再看写法的差别就非常清楚了。WHERE age = 25可以直接在age索引上定位;WHERE age + 1 = 26就必须把每一行的 age 取出来做一次加法,再判断结果。多了一个表达式,代价可能就从索引查找退化成全表扫描。就算表里只有 10 万行,这两条 SQL 的实际表现也能有几十倍的差距。
所以,后面要拆解的 8 种写法,本质上都违反了同一个核心原则:让查询条件里的索引列保持裸用。换句话说,索引列不要被函数包着,不要发生类型转换,不要被通配符截断前缀,不要被奇怪的子查询塞进连接逻辑里。很多开发写 SQL 时只考虑“结果对不对”,完全忽略了“这个写法会让数据库付出什么样的代价”。本章先把底层逻辑讲透,后面的具体坑就都好理解了。
2. 三个基础坑:函数包裹列、类型不匹配、左侧模糊
2.1 坑一:索引列上套函数,优化器直接失去“边界感”
这类写法最常出现在统计报表里,比如想查一年内的文章数据,随手就写:
SELECT id, title, created_at FROM article WHERE YEAR(created_at) = 2024;从结果看没错,确实能把 2024 年的数据捞出来。但YEAR(created_at)是一个表达式,created_at列本身被函数包住了。B+ 树索引节点里存的是完整的时间值,比如2024-06-15 10:30:00,而不是年份 2024。优化器无法通过索引直接定位“所有年为 2024 的记录”,因为它没有一个明确的上下边界,只能把索引或表里的每一行都取出来,依次执行YEAR()函数,再比较结果是不是 2024。这个操作一旦落到千万级的大表上,就是几千万行数据的函数调用,CPU 直接被打满。
正确的写法是改成范围条件:
SELECT id, title, created_at FROM article WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';这里的逻辑是让created_at保持裸列,把“年”这个语义转换成起始和结束两个边界。优化器就能在索引上做一次范围扫描,只读取属于 2024 年的那部分索引节点,回表也只有几十万行甚至更少。我每次评审代码时都会提醒一句:索引列就站在比较符左边不动,函数、运算都去处理右边的常量。只要条件里出现DATE()、YEAR()、MONTH()、LEFT()包住索引列的情况,基本就是一个隐藏的全表扫描。
还有一种变体是按时间段做统计时写的WHERE DATE(create_time) = CURDATE(),同样的问题。改成WHERE create_time >= CURDATE() AND create_time < CURDATE() + INTERVAL 1 DAY才有救。这类写法在 BI 报表场景里出现频率极高,一条报表慢查询就能拖垮整个报表平台的数据库连接池。
2.2 坑二:隐式类型转换,最隐蔽的索引失效
手机号、身份证、订单号这类字段,建表时几乎都会用varchar存储。开发在写查询时容易忽略引号:
SELECT id, name, phone FROM user WHERE phone = 13800138000;这里phone是字符串列,右边的13800138000是数值类型。主流数据库处理字符串列和数值常量比较时,有自己的一套类型优先级约定,通常会把字符串转成数值再比较。结果就是,phone列本身被隐式转换了一次,等同于CAST(phone AS unsigned),索引列又成了表达式的一部分。优化器无法在这个条件下直接走索引定位,只能回落到全表扫描。
尤其坑人的是,这种写法如果数据量只有几万行,执行一次也要不了几毫秒,测试环境根本感知不到异常。等用户量涨到百万、千万级,它就会在接口里变成一个稳定出现的慢查询,而且因为查询结果一直是正确的,很难有人想到是“少了一对引号”导致的。正确写法是:
SELECT id, name, phone FROM user WHERE phone = '13800138000';反过来也有一种场景:id是int列,代码里把参数拼成字符串,于是写成WHERE id = '10086'。部分数据库优化器会尝试把右侧常量转成数值类型,匹配左列的类型,这种情况下影响还不算大;可一旦优化器选择把id列转成字符串再比较,那问题就和上面一模一样。最稳妥的规则是:列的类型是数值型,参数就传数值;列的类型是字符串型,参数就一定加上引号。不要去赌优化器的隐式转换方向,类型匹配才是最简单可靠的方式。顺带说一句,字符集不同造成的隐式转换也会让关联查询的索引失效,比如utf8mb4和latin1的两张表做 JOIN,这种坑在正常排查里更容易漏掉。
2.3 坑三:左侧带通配符的 LIKE,索引扫描变成全表裸奔
模糊查询是 LIKE 语句的应用场景,但 LIKE 不等于一定慢。问题只出在通配符的位置:
SELECT id, item_name, price FROM product WHERE item_name LIKE '%保温杯%';LIKE '保温杯%'是前缀匹配,B+ 树的排序是按字符串前缀进行的,优化器能算出匹配的起点和终点,从而走范围扫描。但%保温杯%把通配符放在最前面,等于告诉优化器“记录的开头部分我不确定,请把每一行都拿出来匹配一遍”。于是执行计划从索引范围扫描直接变成全表扫描或全索引扫描,每一行都要做一次字符串模式匹配,CPU 和 IO 双双飙升。
如果业务确实需要中间匹配,比如在商品名里搜“保温杯”三个字,不能在开头指定,那就要面对一个现实:数据库的 B+ 树索引没法直接支持这种检索。可以考虑的方案有几种。数据量小、查询频率低的,配合覆盖索引能缓解回表压力,但扫描本身仍然是全表级的;数据量大、查询频率高的,建议把搜索功能交给全文检索引擎,专门负责分词和倒排索引,数据库只负责按主键回查。还有一个不太常见但确有其效的做法:如果只是后缀匹配,比如日志文件名是test_20240601.log,而你只想查20240601.log结尾的记录,可以在存储时额外冗余一个反向字符串字段,对反向字段做前缀匹配索引。这属于“伪前缀”的变通思路,适合那些不改架构又必须用 LIKE 的场景。总之,见到%xxx%这类写法,默认先标记为全表扫描候选。
2.4 顺手清一个同族问题:OR 条件的索引困局
本节再补充一个经常和索引失效伴生的写法:WHERE status = 1 OR status = 2。如果status列有索引,理论上优化器可以走索引合并,把两个等值条件各自定位之后再合并结果。但现实并不总是如此,尤其当 OR 连接的是不同索引列,或者其中一侧是范围条件时,优化器算来算去发现合并成本太高,干脆选择全表扫描,于是一个简单的等值查询就变成了全表操作。更简洁也更稳妥的表达是WHERE status IN (1, 2),它比 OR 更容易被优化器处理成一次索引范围定位。如果 OR 连接的两侧本身是截然不同的业务条件,适合拆成两条分成 UNION ALL 再合并,而不是用一条 OR 硬扛。很多开发觉得 OR 很直观,但它在数据库优化器眼里的分量并不轻。
3. 子查询和 JOIN 里的性能黑洞:三个老熟人
3.1 坑四:NOT IN 子查询撞上 NULL,结果直接给“清零”
不少人在写“排除黑名单用户”时,会顺手写成:
SELECT id, name FROM user WHERE id NOT IN ( SELECT user_id FROM blacklist );这条 SQL 从语义上看简单直接,但它既可能产生性能风暴,还可能产生严重的数据正确性问题。先从正确性说:只要blacklist.user_id里有任何一条是 NULL,这个查询返回的结果集会直接变成空。原因在于 SQL 的三值逻辑——NULL 既不算真也不算假。NOT IN的含义是“当前值不等于列表中的每一个值”,可一旦列表里出现未知值,整个判断结果也跟着变成未知,“不等于所有值”的整体结果无法判定为真,每一行就都被过滤掉了。
这也意味着,如果你在维护一套用户排除功能,只要黑名单表里误入一条user_id IS NULL的数据,所有用户都会被隐藏,线上业务直接表现为“列表空白”。性能层面的问题也一样明显:优化器倾向于把子查询结果物化成一张临时表,再做逐行判断,消耗大量内存和临时表 IO,和索引定位完全无关。更安全的替代方案是 NOT EXISTS 或 LEFT JOIN:
SELECT u.id, u.name FROM user u WHERE NOT EXISTS ( SELECT 1 FROM blacklist b WHERE b.user_id = u.id );这条写法在语义上等价,但在 NULL 面前不会崩,而且优化器可以走 semijoin 或依赖关联索引,查到一个匹配后就停止。想用 LEFT JOIN 也同样成立,只要保证最后过滤条件是b.user_id IS NULL。我在团队里定过一条约定:凡遇到“排除若干记录”的需求,默认用 NOT EXISTS,而不是 NOT IN。
3.2 坑五:SELECT 字段里塞相关子查询,外层多少行它就查多少次
给用户列表带上“每个用户的最大消费金额”,这个需求太常见了,于是也容易写出这种代码:
SELECT u.id, u.name, (SELECT MAX(p.amount) FROM payment p WHERE p.user_id = u.id) AS max_amount FROM user u WHERE u.status = 1;功能上完全正确,但执行方式极其恐怖:外层用户表先过滤出一批结果,然后每返回一行,数据库就要执行一次内部的子查询。如果u.status = 1过滤出来 10 万行,这条子查询就会执行 10 万次。哪怕每一次只有 0.1 毫秒,加在一起也要 10 秒。你去看执行计划,经常能看到“DEPENDENT SUBQUERY”之类的标记,意思就是这个子查询和外部查询逐行关联。优化器不是没能耐做自动改写,而是这种逐行关联的写法在复杂场景下经常无法被直接改写成一次聚合。
正确的思路是把聚合计算先单独做成一张派生表,让数据库只聚合一次,再和主表关联:
SELECT u.id, u.name, t.max_amount FROM user u LEFT JOIN ( SELECT user_id, MAX(amount) AS max_amount FROM payment GROUP BY user_id ) t ON t.user_id = u.id WHERE u.status = 1;这个版本的执行过程是:先把 payment 表按 user_id 分组,算出每个用户的最大金额,生成一份临时结果;再用这份临时结果和 user 表做连接。子查询只执行一次,而不是外层每一行执行一次。逻辑上同样能得到每个用户的最大消费金额,但代价完全变了。如果数据量再往上走,还能给这个聚合临时表加索引或者缩小范围,可读性和维护性也更好。
3.3 坑六:用 DISTINCT 掩盖 JOIN 膨胀,数据翻倍全靠去重找平
第三种关联层的老熟人是“先 JOIN 出重复行,再用 DISTINCT 把多出来的行抹掉”:
SELECT DISTINCT u.id, u.name, u.phone FROM user u LEFT JOIN orders o ON o.user_id = u.id WHERE o.status = 1;假设一个用户有 20 个符合条件的订单,LEFT JOIN 会先产生 20 行重复结果,DISTINCT 再把其中 19 行去掉。数据库在这期间做的事情包括:生成一个 20 倍的临时结果集,把每列数据复制一遍,然后做一次排序去重或哈希去重。用户数越多,订单数越多,这个膨胀就越离谱。数据量到百万级时,这种查询极其容易触发磁盘临时表,数据库性能直接被打穿。如果 JOIN 条件再写得宽一点,生成的重复行还会更多,慢的效果会来得更早。
更重要的是,这种写法往往掩盖了一个事实:开发真正想要的可能只是“存在下单记录的用户”,而不是“订单和用户的所有组合”。换个表达方式,用 EXISTS 既能准确表达业务意图,又能避免展开重复行:
SELECT u.id, u.name, u.phone FROM user u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 1 );EXISTS 的语义是“只要存在一条满足条件的记录即可”,优化器可以按驱动表的每一行去关联子表,找到第一条匹配记录后立即返回,不会把所有订单都展开。如果业务确实需要返回订单明细,那就老老实实按订单维度去查,明确指定“每个用户取最新一单”之类的规则,而不是让结果先膨胀再靠 DISTINCT 找平。要是在代码评审里看到高频出现的SELECT DISTINCT x.*,几乎可以直接问一句:你是真的需要去重,还是在用去重掩盖 JOIN 产生了多余数据的事实?
4. 排序、随机和深分页:三个“伪需求”拖垮数据库的写法
4.1 坑七:ORDER BY RAND(),把随机数当排序键,全表陪你“摇号”
活动页要随机展示一个 banner,这是最标准的随机取数需求。很多人的第一反应是:
SELECT id, title, image_url FROM banner ORDER BY RAND() LIMIT 1;这条 SQL 的执行过程是:先为 banner 表的每一行生成一个随机数,再把整个结果集按照随机数排序,最后取第一行。如果表里只有几十行,这没有任何问题;可一旦表里有百万行,数据库就要生成一百万个随机数,然后对一百万个值做排序。内存排序放不下就会落到临时文件,一次简单的“随机取一条”直接变成全表扫描加文件排序,耗时可能从 10 毫秒涨到好几个数量级。
这类需求的关键问题是:数据库真的有必要承担这个随机过程吗?如果随机结果不需要绝对均匀,可以在后端维护一个“随机池”:定期把所有候选主键加载到应用缓存,每次请求时在应用层随机挑一个主键,再回到数据库按主键查询一行。这个方案的响应时间无比稳定,完全不会因为表变大而变慢,而且还能顺便做频次控制和去重。如果就想着数据库里一步搞定,也可以用主键范围去猜:
SELECT id, title, image_url FROM banner WHERE id >= ( SELECT FLOOR(RAND() * (SELECT MAX(id) - MIN(id) + 1)) + MIN(id) FROM banner ) LIMIT 1;但这方案在主键稀疏时可能返回空行,需要再做一层兜底,复杂度反而上去了。所以我的排序思路永远是:先确认业务到底需要多“随机”,如果只是“每次看到的不一样”,完全可以把随机这件事从数据库里挪走。业务需求的表象是“随机”,本质可能是“变化”,把伪需求修正了,SQL 自然就轻了。
4.2 坑八:OFFSET 深分页,越往后翻越像一场灾难
分页接口最常见的写法是:
SELECT id, title, content FROM article ORDER BY id DESC LIMIT 20 OFFSET 999980;看起来数据库好像“跳过”了前 999980 行,只返回最后 20 行。实际上它做不到。为了拿到这 20 行,数据库必须按照ORDER BY id DESC的顺序,把前 999980 行的主键扫描出来,然后一页一页地数到第 999981 行才开始返回。意味着页码越深,扫描的行数越多,接口响应时间越长,而且这个劣化是线性的——第 50 万页比第 10 万页慢 5 倍,这不是固定开销能兜住的。
更好的方案是游标分页,也就是 keyset pagination:客户端把上一页最后一条记录的排序值传回来,下一页的条件写成WHERE id < last_id ORDER BY id DESC LIMIT 20。数据库直接从上一页的位置开始读取 20 条,页码的深浅彻底和查询代价解耦。如果排序字段不是主键,比如按sort_time排序,那就用排序字段加主键共同构造游标条件:
WHERE (sort_time < last_sort_time) OR (sort_time = last_sort_time AND id < last_id) ORDER BY sort_time DESC, id DESC LIMIT 20;同时要给(sort_time, id)建联合索引,这个条件才能直接用上 B+ 树定位。需要说明的是,游标分页不支持用户直接跳转第 50 万页,这种需求更像是一个“伪分页”。产品上真要支持深渊跳转,更合理的做法是限制可查询的最大深度,或者干脆换一种交互方式,比如按日期分段翻页。数据库不是用来数一百万行再扔掉的,它应该只负责自己真正需要的数据。
4.3 这三种需求的共同特征:把不必要的压力交给数据库
排序、随机、深分页看起来是三个独立问题,但它们有一个共性:业务对结果精度的要求没那么高,代码却把数据库当成了大型计算中心。很多人天然觉得数据库反正有排序能力,那就让它排;数据库支持RAND(),那我就用它;分页参数传过来多少 offset,就让它翻多少。这些逻辑放到应用层,用缓存、预生成列表、游标指针能做得很漂亮;放到数据库里,就成了每一条请求都要承担重型计算。
我每次做技术方案时会多问自己几个问题:这里的随机真的每次都要变吗?这里的排序能事前排好吗?这里真的允许用户翻到几十万页吗?多问几句以后会发现,很多看似合理的 SQL 其实来自一个未经验证的“伪需求”。优化 SQL 是治标,修正需求才是治本。
5. 把这些坑挡在代码之外:团队自查的五个动作
5.1 用 EXPLAIN 建立“慢 SQL 雷达”
看执行计划是识别这些写法最直观的手段。你不需要精通优化器内部实现,只要掌握几个关键信号就行。type列从好到坏大致是const、eq_ref、ref、range、index、all,看到all就说明是全表扫描,要格外警惕;rows列是优化器估算的扫描行数,如果和表总行数接近,说明这条 SQL 已经退化成了全表操作;Extra列出现Using filesort或Using temporary时,就要思考排序和去重是不是可以避免。如果执行计划里同时出现Using index condition,说明索引下推被启用,通常不算坏信号,但也不能因此忽视最前面的type。
我习惯让团队成员在提交 SQL 前跑一次EXPLAIN,哪怕只是看一眼 type 和 rows。时间久了,大家就会形成一种直觉:哪些写法会导致扫描多少行、哪种执行计划才算正常。不用看整篇调优文档,执行计划本身就是最好的老师。
5.2 慢查询日志是同事写法的“照妖镜”
真等到生产环境出事故再排查,代价就太大了。更好的做法是把数据库慢查询日志打开,阈值设成 200 毫秒或者更保守的数值,每周固定拉一次 TOP 20 慢 SQL,逐个分析。你会发现这些慢 SQL 经常出自同一种风格,高度集中在少数几个反模式里。我实际处理过一条查询,同事用 LEFT JOIN 做一对多关联,又用 DISTINCT 去重,数据量到 200 万行以后接口从 400 毫秒直接飙到 6 秒多;我改成 EXISTS 版本后,耗时稳定在 40 毫秒左右,将近上百倍的提升。慢查询日志不一定是为了追责,它最核心的价值是帮你验证团队里到底谁还在用聚合方式掩盖模型问题。
5.3 把它沉淀成一份“SQL 代码评审清单”
个人经验代替不了团队规范。与其让每个新人都重新趟一遍这些坑,不如把前面说的反模式整理成一份评审清单,挂在代码评审流程里。例如:查询条件里的索引列是否保持裸用?字段类型和参数类型是否一致?LIKE 是否使用了前置通配符?SELECT 字段中是否嵌套了相关子查询?排除逻辑是否使用了 NOT IN?关联查询是否依赖 DISTINCT 来找平?是否存在ORDER BY RAND()?分页是否允许深 OFFSET?这些检查项每一条都可以用“是/否”回答,新人照着打钩很快就能适应,老手看到类似写法也会形成条件反射。
5.4 给容易踩坑的查询建“示例库”
规范文档容易写得抽象,真正有说服力的是反面样例和正面方案的对照。可以整理一个内部文档,把每一种反模式都配上:坏写法、好写法、执行计划对比、在某个数据量下的实际耗时。新人入职不用先去啃整本数据库优化书,先看这份示例库,基本上就能避开 90% 的低级问题。这些样例也是很好的培训素材,比讲抽象理论直接得多。SQL 性能问题大多数时候不是“不会写”,而是“没意识到写法对优化器的影响”,示例库的作用就是把这个意识提前种下去。
5.5 设计层面的提醒:别忘了对应的表结构
写法再标准,如果表结构缺了合适的索引,一样会慢。自查清单的最后一步,永远要回到索引设计上:WHERE、ORDER BY、GROUP BY 里用到的条件字段,有没有设计对应的联合索引?例如查询条件是WHERE status = 1 AND created_at > '2024-01-01' ORDER BY created_at,那就应该考虑(status, created_at)这个联合索引,让它既能过滤又能排序。优化写法和建索引是双管齐下的事,只改 SQL 不改索引,或者只加索引不改写法,都很难拿到理想的效果。
这些坑我自己全踩过,也帮别人擦过好多次屁股。SQL 性能优化的核心真不复杂:写完一条查询,先别急着跑,花十秒钟想一下,这个条件会不会让索引失效,这次排序是不是真的需要数据库来做,这个分页是不是实际会翻到那么深。功能正确只是第一步,写法经得起执行计划推敲才算完整。以后要是再看到同事写出类似的代码,把这篇拆解放给他,也算是一种很温柔的提醒了。