你有没有遇到过这种场景:某天早上部署完新功能,运营同事发来一张截图说后台报表转圈圈,你打开数据库监控一看,某条原本跑几十毫秒的查询现在直接把 CPU 拉满,连接数飙升,整个系统跟着遭殃。查到最后发现,就是某位同事最近提交的几条 SQL 写法看着挺聪明,实际却在拿全表当沙包打。这类“专坑同事”的 SQL 写法在真实项目里一点都不罕见,而且很多时候不是数据量真的到了无法处理的程度,纯粹是写法上埋了雷,导致性能断崖式下滑。
这篇文章就把我在实际开发和性能排查里见过、踩过、帮别人擦过屁股的 8 种典型反模式整理出来,每一种都有具体的现象、背后的原理和可落地的改写方案。适合刚从 CRUD 走向复杂查询的开发同学,也适合那些需要做 code review 或者负责线上 SQL 优化的朋友。看完你就能在代码评审里精准识别这些“坑”,也能在性能告警时快速定位是不是写法的锅。
1. 为什么烂 SQL 能把性能拖低 100 倍
先说点反直觉的事。很多刚接触数据库优化的人会以为,处理慢查询要靠加索引、加缓存、升级硬件。但我在实际排查里发现,相当一部分慢 SQL 问题根本不是“数据太多”,而是“读写路径上做了太多无用功”。一个查询从 10 毫秒变成 1000 毫秒,可能只是因为某个函数让索引失效,或者优化器在 OR 条件下放弃了最优执行计划。100 倍的差距,在多数情况下不是量变,是质变。
1.1 慢 SQL 的代价,不只是慢
在很多团队里,慢 SQL 的杀伤力是“连带式”的。我们遇到过这样一个场景:某张订单表只有几百万行,对数据库来说根本不算大。但某个统计接口里的 SQL 写得不讲究,直接全表扫描加文件排序,单次执行 2 秒多。平时流量低还看不出来,一到月底导出报表,几十个并发请求同时撞上来,数据库连接池瞬间被占满,后续所有请求排队,然后超时,导致整个服务雪崩。
更隐蔽的坑在于,这类 SQL 不会在测试环境暴露问题。本地数据量只有几千行,怎么写都快;到了生产环境数据分布一变,问题就炸了。所以判断一个 SQL 写得好不好,不能只看它能不能出结果,还要看它在目标数据量、目标并发下的执行计划长什么样。这也是为什么后面我会反复强调用 EXPLAIN 看执行计划,而不是靠肉眼猜。
1.2 数据库优化的本质:减少无效工作量
我们可以把数据库当成一个按“行”和“页”工作的处理工厂。每读一行数据、每回一次表、每排一次序,都是有成本的。一条好的 SQL,本质上是让这个工厂尽可能少的搬运数据。而烂 SQL 恰恰相反,它会让数据库把大量无关的数据搬进内存,做完过滤再丢掉,白白消耗 CPU、内存、IO 和网络带宽。
拿 100 倍这个量级来说,常见触发原因是索引完全失效。比如在索引列上套函数、做隐式类型转换,或者用了前导通配符。这种情况下,数据库只能放弃索引,选择全表扫描。一个 1000 万行的表,用索引读取可能只需要扫几千行,全表扫描则是 1000 万行,这中间的差距就是几个数量级。理解了这条主线,你就会发现下面列出的 8 种写法虽然形态各异,根源都是一样的:让数据库多做无用功,甚至把能快速定位数据的“目录”给废掉。
2. 8 种典型“坑同事”写法拆解
接下来进入正题。这 8 种写法我按实际出现频率排序,每一种都给出典型的代码形态、为什么慢的原理,以及你能观察到的现象。为了好记,我给它们起了个外号,方便在 code review 的时候直接拿出来说。
2.1 第一种:SELECT * 满天飞,宽表拉到怀疑人生
先看最常见的:
SELECT * FROM orders WHERE user_id = 12345;这条 SQL 本意是查某个用户的订单。问题在于,orders表如果有 50 个字段,其中 40 个是TEXT或者JSON类型的冗余配置、扩展信息,SELECT *就会把这 40 个字段的完整数据全部读出来。哪怕你只需要订单号和金额,数据库也不得不做整行数据的磁盘 IO、网络传输,以及后续可能发生的临时表排序。
实际项目里我们查过一次慢查询日志,有一个导出功能就是把一张宽表整个拉下来,里面有十几个大字段,单行数据好几 KB。结果导出 10 万行,数据量直接上 GB,应用服务器内存都扛不住。而真正用到的字段只有 5 个。
这种写法的隐藏成本还有一点:它让所有索引覆盖失效。明明在user_id上建了索引,但如果索引里没包含你需要的全部字段,数据库就得每找到一行就回表取完整行数据。如果只查少数几个字段,建立一个覆盖索引就能完全避免回表。
2.2 第二种:函数套在索引列上
再看这种:
SELECT * FROM orders WHERE DATE(created_at) = '2025-01-01';这条 SQL 想查某一天创建的订单,看着没毛病。但只要created_at上有索引,这个写法就会让索引失效。原因是你在索引列上套了DATE()函数,数据库无法直接利用created_at里保存的原始值进行范围比较,只能把每行的created_at都取出来算一遍,再跟右边的值比对。这就把一个本该走索引范围扫描的查询,硬生生变成了全表扫描。
类似的问题还常见于YEAR()、MONTH()、SUBSTR()、CONCAT()、算术运算等。凡是在索引列上做函数、计算或类型转换,都有可能导致索引不可用。
这里有个容易混淆的点:不是说所有函数都会让索引失效,像DATE_FORMAT作用于查询常量是可以的,但作用于列就不行。原则很简单,让列独立在比较符的一侧,不要给它“穿衣服”。
2.3 第三种:隐式类型转换,让索引“自废武功”
举一个很典型的例子:
SELECT * FROM users WHERE phone = 13800138000;假设phone字段在表里是VARCHAR类型,存储的是带区号的手机号字符串,而你在查询里写的是数字。MySQL 在比较时会先把字段值隐式转换成数字,然后在索引列上做转换操作,索引失效,全表扫描。
类似的坑还有字符串和日期比较、字符集不一致导致无法使用索引的 join 等。有些时候类型转换并不会让整个表扫描,但会产生额外的计算开销。最稳妥的办法是保证查询参数的类型与字段类型完全一致。判断方法很直接,用EXPLAIN看执行计划的type是不是从ref变成了ALL,以及Extra里有没有出现Using where但key为空的情况。
2.4 第四种:OR 条件下索引失效
现在来说一条经常把优化器难住的写法:
SELECT * FROM orders WHERE status = 'PAID' OR user_id = 12345;假设表上既有status索引也有user_id索引,理论上两个条件都可以走索引。但 MySQL 的优化器在处理OR时,未必能把两个索引合并起来高效地使用,很多时候会退化成全表扫描,把每一行都拉出来判断是否满足其中一个条件。相比之下,把它拆成两个查询再合并结果,或者用UNION,往往能更好地利用索引。
有人问,用UNION ALL是不是一定更好?不一定,要看数据分布。但至少如果你发现OR查询在真实数据量下走全表扫描,改写是值得尝试的方向。IN和OR在某些数据库里等价,但在索引利用和优化器统计上可能有差别。实际项目中,我看到太多 OR 条件导致慢查询的案例,而且很多人根本没有意识到问题出在哪。
2.5 第五种:前导通配符 LIKE '%xxx'
这条几乎是经典中的经典:
SELECT * FROM products WHERE name LIKE '%保温杯%';数据库要查找包含“保温杯”的商品名称。由于通配符在开头,索引树的结构决定了它无法利用 B+ 树的顺序查找,只能全表逐行扫描,而且因为 LIKE 条件的计算代价不低,这种情况下 CPU 飙得很快。
如果业务确实需要模糊搜索,正确的解法一般是用全文索引(FULLTEXT)或者搜索引擎。如果只是要用通配符但数据量不大,至少把通配符放在末尾,LIKE '保温杯%'还能勉强走索引。但要注意,仅对前缀匹配有效,想实现任意位置匹配,数据库原生能力并不支持,需要依靠外部组件。
2.6 第六种:在循环里逐条执行 SQL
这种属于“应用层写法”的坑,不一定发生在单条 SQL 上,但性能影响远大于前几种。代码写出来是这样:
for user_id in user_ids: result = db.query("SELECT * FROM orders WHERE user_id = %s", user_id)假设user_ids有 1000 个,那这条查询就会被执行 1000 次。每次都有连接池分配、SQL 解析、执行计划生成、网络往返的开销。即便单条查询只要 1 毫秒,1000 次就是 1 秒,而且数据库还要频繁处理 session 状态,整体成本远高于一次性传入 1000 个 ID 执行WHERE user_id IN (...)。
有些场景下循环不可避免,比如需要按规则逐个做复杂计算,但绝大多数“查关联数据”的循环其实都可以改成 JOIN 或者批量查询。这是我在很多项目里发现的最容易被忽略的性能杀手,因为它不体现在单条慢 SQL 日志里,而是表现为数据库 QPS 虚高、CPU 占用率持续居高不下。
2.7 第七种:不加 LIMIT 的大范围更新/删除
这类写法的杀伤力在于锁和事务。想象一条更新语句:
UPDATE orders SET status = 'EXPIRED' WHERE created_at < '2024-01-01';如果这个条件命中了 100 万行,数据库会在一次事务里尝试对这 100 万行加锁,更新过程中产生的 undo 日志、redo 日志都会暴涨,同时阻塞其他会话对这些行的读写。在一个高并发的线上系统里,这种操作很可能直接拖垮主库,甚至触发主从延迟。
正确的做法是分批处理,每次只更新一小批,比如每次 1000 行,通过LIMIT控制,或者按主键范围循环执行。删除同理,大范围 DELETE 要拆成小事务,避免一次性锁太多资源。
2.8 第八种:视图嵌套套娃,查个东西绕地球一圈
最后一种在数据仓库或者报表系统里特别常见。有人喜欢创建一层又一层的视图,A视图引用B视图,B视图引用C视图,最后报表只需要查两个字段,却要从最底层开始把几千万行数据全部跑一遍:
CREATE VIEW v_report AS SELECT u.name, o.amount FROM v_orders o JOIN v_users u ON o.user_id = u.id;如果视图内部的过滤条件没有被下推,数据库为了生成最终结果,需要先把底层视图的所有数据物化出来,再一层层往上漏斗。中间任何一层没有过滤条件,整个链路都会变成巨大的临时表操作。性能差到 100 倍完全不夸张。
这种写法的根因是把“查询封装”和“数据筛选”混为一谈。视图确实能简化开发,但如果滥用,就是给数据库挖坑。更合理的方式是使用物化视图(如果数据库支持)、把过滤条件下推到最底层的表,或者直接用一条明确的 SQL 完成查询,而不是套多层视图。
3. 改写方案与优化实操:别只会发现问题
知道哪些写法会坑同事只是第一步,真正有价值的是能给出可落地的改写。这一节我把上面 8 种写法逐个给出优化方案,并且补充一些我在实际优化中用过的验证方法。你可以在自己的项目里直接套用。
3.1 从 SELECT * 到明确列名与覆盖索引
先从这个最简单的开始。讲原则:
- 只查询业务需要的字段
- 如果频繁查询固定的列组合,考虑建立“覆盖索引”
比如业务只需要查询某用户的订单号和金额:
SELECT order_id, amount FROM orders WHERE user_id = 12345;然后在orders表上建立联合索引:
ALTER TABLE orders ADD INDEX idx_user_amount (user_id, order_id, amount);这样查询可以直接从索引中获取所有需要的字段,连回表都省了。我在一个项目里把某个高频接口的查询从SELECT *改成只查 3 个字段,再配上覆盖索引,同样数据量的响应时间从 80 毫秒降到了 5 毫秒。
3.2 别碰索引列,让函数离开 WHERE
对DATE(created_at)这种写法,改成范围查询:
SELECT * FROM orders WHERE created_at >= '2025-01-01 00:00:00' AND created_at < '2025-01-02 00:00:00';这样created_at索引就能正常走范围扫描。核心思路是把“对列做变换”改成“对查询条件做变换”,始终让列以原生形态参与比较。如果你确实需要按年、按月分组统计,可以先算出时间范围,再用BETWEEN或>=/<限定,函数只用在SELECT列表或GROUP BY上。
3.3 类型对齐,别让数据库猜
解决隐式类型转换最粗暴有效的方法就是让查询参数与字段类型一致。如果phone是VARCHAR,查询时就用字符串:
SELECT * FROM users WHERE phone = '13800138000';如果历史代码里有很多类似问题,可以通过EXPLAIN看到key_len和type的变化来验证是否修复。还有一个技巧,使用EXPLAIN EXTENDED或者 MySQL 的SHOW WARNINGS查看优化器重写后的 SQL,可以看到隐式转换是不是被打到了索引列上。
3.4 用 UNION ALL 替代 OR
针对 OR 导致的索引失效,先看数据分布。如果两个条件各自能利用索引,可以用拆分查询再合并的思路:
SELECT * FROM orders WHERE status = 'PAID' UNION ALL SELECT * FROM orders WHERE user_id = 12345 AND status <> 'PAID';这里再加上status <> 'PAID'是为了避免两个查询结果重复。如果业务允许,直接用UNION去重也问题不大,但UNION ALL更高效,因为省去了排序去重的步骤。在某些数据库里,也可以用UNION让优化器分别走索引。但记住,不要盲目改写,先用EXPLAIN验证改写前后是否真的走索引了。
3.5 后缀通配符和全文索引
如果业务允许,把前导通配符改成前缀匹配:
SELECT * FROM products WHERE name LIKE '保温杯%';但多数搜索需求是任意匹配,这时候LIKE '%保温杯%'没救,只能用全文索引:
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name); SELECT * FROM products WHERE MATCH(name) AGAINST ('保温杯' IN NATURAL LANGUAGE MODE);不过全文索引也有词语解析和最小长度限制,数据量大的时候,更靠谱的做法是接搜索引擎。没有搜索引擎的情况下,可以考虑拆词表或者使用数据库的全文索引缓解。最重要的是不要在核心业务接口上做大范围 LIKE 扫描。
3.6 批量操作替代循环
把循环改成批量查询,是最容易产生“线性到常数”提升的点。原来 1000 次查询变成一次:
sql = "SELECT * FROM orders WHERE user_id IN (%s, %s, ...)"如果数据量超过数据库IN列表上限(比如几千个),可以分批,比如每批 500 个,再用UNION或者应用层合并。批量操作的收益不仅是减少网络往返,还能减少 SQL 解析次数。对写入场景也是一样,用批量插入替代逐条插入,在 MySQL 里用一条多 VALUES 的 INSERT,对 Postgre 可以用UNNEST或者 COPY。
3.7 分批更新,避免锁范围过大
针对大范围 UPDATE/DELETE,我推荐用主键范围循环。以更新过期订单为例:
-- 每次取 1000 个主键 SELECT id FROM orders WHERE status <> 'EXPIRED' AND created_at < '2024-01-01' LIMIT 1000 FOR UPDATE; -- 然后用这 1000 个 id 去更新 UPDATE orders SET status = 'EXPIRED' WHERE id IN (...);或者用存储过程循环。关键点有三个:每次要重新获取下一个批次的起点;每批之间加SLEEP()短暂停顿,让主从同步跟上;尽量在业务低峰执行。如果是在线上操作,还要先观察主从延迟,一旦超过阈值就自动暂停。
3.8 视图拆解与物化
对嵌套视图,我的处理方式分三步:
- 查看视图定义,画出完整的依赖链路
- 找到最底层的过滤条件,看能不能下推到每一层
- 如果底层查询特别大且频繁使用,建立物化视图或定期写入汇总表
例如把v_orders里原本无过滤的几十万行加上时间范围条件,结果集缩小到几千行,整个查询就快了。如果数据库不支持物化视图,就用定时任务把结果写入一张汇总表,查询直接走汇总表。应用层按需查询,完全绕开视图套娃。
4. 快速定位慢 SQL 的排查方法与套路
就算你把上面 8 种写法都记住了,实际工作里还是可能遇到别人写的另一个变体。所以除了知道“哪些写法坑”,你还需要一套系统性的排查方法。下面是我在实际项目里反复使用的套路。
4.1 用 EXPLAIN 看执行计划,别猜
无论你怀疑哪条 SQL 慢,第一件事就是查看它的执行计划。MySQL 里直接:
EXPLAIN SELECT ...;关键要看几个字段:
type:从const、eq_ref、ref、range到ALL,性能依次变差。出现ALL基本就是全表扫描。key:实际用到的索引。如果是NULL,说明索引没生效。rows:预估扫描行数。这个数字如果远高于最终结果行数,就有优化空间。Extra:出现Using filesort或Using temporary提示排序或临时表操作,需要警惕;Using index则说明覆盖索引生效。
我见过太多人只看 SQL 执行时间来判断性能,这是不全面的。执行时间受缓存影响很大,同一条 SQL 第一次跑 5 秒,第二次跑 10 毫秒,不代表它变快了。执行计划是相对稳定的,能让你看透 SQL 的本质。
4.2 慢查询日志与监控
生产环境定位慢 SQL,最直接的方式是开启慢查询日志。MySQL 可以设置阈值:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;这样所有超过 1 秒的 SQL 都会记录到日志。然后定期分析日志,找出执行次数多、平均耗时长的语句。更好的做法是接入可观测系统,把慢查询指标按接口聚合。比如某个接口一调用就触发某条慢 SQL,监控能直接关联到应用链路。
这里分享一个技巧:如果你发现某条 SQL 只在高峰期慢,平时都快,不要急着去改 SQL。先看是不是并发问题,比如连接数打满、行锁等待,或者是缓存命中率下降导致的 IO 压力。用 MySQL 的SHOW ENGINE INNODB STATUS看锁等待,用performance_schema看等待事件,再决定是优化 SQL 还是优化资源。
4.3 常见问题速查表
为了方便排查,我整理了一张对照表,遇到性能退化时可以直接对照方向去检查。这张表来自我过去多次排查慢 SQL 的经验汇总,不是学院派理论,基本都能命中。
| 现象 | 可能原因 | 快速验证方法 | 优化方向 |
|---|---|---|---|
大量ALL全表扫描 | 索引缺失、索引失效、隐式转换 | EXPLAIN 看 type/key | 建索引、改写函数条件 |
| 频繁回表 | 索引不含查询字段 | 看 Extra 是否 Using index | 建立覆盖索引 |
| CPU 飙高但 IO 不高 | 循环查询、函数运算 | 看数据库连接 QPS / session 数量 | 批量改写、函数下推 |
| 锁等待严重 | 大范围 UPDATE 或 DELETE | SHOW ENGINE INNODB STATUS | 拆分事务 |
| 临时表巨大 | 多表 join 无序、视图嵌套 | EXPLAIN 看 Using temporary | 优化关联顺序、物化 |
| 主从延迟 | 大量写操作、无索引的更新条件 | 查看 slave status | 分批更新、索引优化 |
| 网络传输大 | SELECT * 拉取宽表 | 比较返回行大小 | 只查必要字段 |
这张表不能覆盖所有情况,但能帮你建立条件反射:看到什么现象,优先查什么。
5. 一段不能不说的实操复盘:我是怎么被“坑”到长记性的
以前有个数据报表项目,每天凌晨跑统计任务。某一阵子任务经常超时,有时候要跑到中午才能出结果。查下来发现,报表底层用了一个嵌套了三层的视图,最里面那层是一张近 2000 万行的明细表,而且视图里没有加任何时间过滤条件。上层视图做聚合的时候,数据库不得不把整张明细表当作临时表来处理,还要再 JOIN 两张维度表,性能直接爆炸。当时负责这块开发的同事认为“视图封装能提高复用”,但实际效果就是每跑一次报表,相当于在数据库里做了一次全量 ETL。
后来我们把视图拆开,最底层查询直接加上时间范围过滤,只取最近 90 天的数据,结果集瞬间缩到 300 万行,再配合合理的 JOIN 顺序和索引,整个跑批从 2 个多小时缩短到 15 分钟。这个案例让我意识到,性能问题很多时候不是靠加机器解决的,而是靠“减少处理的数据量”解决的。
5.1 一套简单的 code review 检查清单
为了避免以后再有人写出类似“坑同事”的 SQL,我在团队里定了一个 review 清单。不复杂,但是每一条都是真实项目里踩出来的。你可以直接拿去用:
- WHERE 条件里是否对索引列做了函数或运算?
- 查询参数类型是否和字段类型完全一致?
- 是否用了
SELECT *?能否只列出必要字段? - 复杂查询是否用 EXPLAIN 确认没有
ALL或Using filesort? - 循环内是否有 SQL 查询?能否改成批量或 JOIN?
- 更新、删除语句是否带上了合理的条件?是否一次影响太多行?
- 是否有多层视图嵌套?能否提前过滤?
- OR 条件是否可以改写为 UNION ALL 且执行计划变好?
- LIKE 是否以
%开头?是否考虑全文索引或搜索引擎?
5.2 优化 SQL 的心态与习惯
最后我想谈一点更虚但很重要的东西:很多人写 SQL 只关心“能不能查出结果”,很少关心“这条 SQL 在数据库内部是怎么执行的”。这个认知差,正是烂 SQL 滋生的土壤。数据库跟应用程序不一样,它有一套自己的执行策略。你以为的“按逻辑处理”,在数据库里可能是通过全表扫描加临时表排序来实现的。
我在实际工作中养成了一个习惯:每写一条稍微复杂的 SQL,都会自觉跑一次EXPLAIN,扫码一下扫描行数和执行类型,确认没有明显风险再提交。如果扫描行数超过预期,就停下来想想有没有更好的写法。这个习惯看起来微不足道,但长期下来避免了大量线上问题。数据库资源是共享的,一条烂 SQL 消耗的不仅是它自己的时间,还有整个系统所有查询的可用资源。你今天偷的懒,很可能就是明天整个团队一起扛的故障。
写到这里,8 种典型写法、对应的优化方案和排查套路都讲完了。如果你之前写过类似的 SQL,也不用太焦虑,谁还没在数据库里“踩过雷”呢?关键是下次踩到的时候,能一眼认出来,并且知道怎么绕开它。