SQL 书写习惯避坑清单:10 个让查询慢 10 倍的坏习惯

SQL 书写习惯避坑清单:10 个让查询慢 10 倍的坏习惯
SQL 书写习惯避坑清单10 个让查询慢 10 倍的坏习惯同一个需求10 行 SQL 还是 100 行 SQL0.5 秒出结果还是 5 分钟差距往往不在数据库配置而在你的书写习惯。朱大喜整理了 7 月在 SQL Review 里发现的高频问题。一、SQL 慢第一嫌疑人永远是你自己做数据 4 年看过的小 SQL 没有一万也有几千。有一个规律80% 的慢查询不是因为索引不够、机器不够而是因为 SQL 写法有坑。这些坑往往来自写着方便但运行起来灾难性的习惯。这篇文章里的 10 个例子每个都是 7 月我在 SQL Review 中真实遇到的。为什么 SQL 慢查询这么常见根本原因是SQL 是一种声明式语言你告诉数据库要什么而不是怎么做。数据库自己决定怎么做执行计划。但很多写法会限制优化器的选择或者让优化器选错执行计划。比如WHERE DATE(order_time) 2026-07-27优化器知道order_time上有索引但因为你套了函数它只能放弃索引全表扫描。更隐蔽的问题是 JOIN 顺序、子查询展开、隐式类型转换这些看起来没问题的写法它们不会报错但性能可能差 10 倍。这篇文章就是帮你把这些隐藏的坑挖出来。如何判断你的 SQL 有没有坑最简单的方法在 SQL 前面加EXPLAINMySQL/PostgreSQL或EXPLAIN ANALYZEPostgreSQL看执行计划。重点关注type字段MySQL如果是ALL全表扫描赶紧检查 WHERE 条件有没有用索引rows字段预估扫描行数如果接近全表行数说明索引没用上Extra字段如果有Using filesort或Using temporary说明需要优化排序或分组养成写完 SQL 必 EXPLAIN的习惯比背这 10 个坏习惯更有效。二、10 个坏习惯排排坐坏习惯 1WHERE 里对索引列套函数-- ❌ 坏习惯DATE() 包裹了 order_time索引失效 SELECT * FROM orders WHERE DATE(order_time) 2026-07-27; -- 执行计划全表扫描耗时 12 秒 -- ✅ 正确写法换不等式索引有效 SELECT * FROM orders WHERE order_time 2026-07-27 00:00:00 AND order_time 2026-07-28 00:00:00; -- 执行计划索引范围扫描耗时 0.3 秒 -- 深度解读为什么函数包裹索引列会让索引失效 -- 索引的本质是 B 树树中的节点按照列的原始值排序。 -- 当你写 WHERE DATE(order_time) 2026-07-27 时数据库必须先对每一行的 order_time 执行 DATE() 函数 -- 得到结果后再和 2026-07-27 比较。但索引树中存储的是原始值不是 DATE() 后的结果 -- 所以索引无法用于快速定位只能全表扫描。 -- -- 正确的写法 WHERE order_time 2026-07-27 00:00:00 AND order_time 2026-07-28 00:00:00 -- 利用了索引的有序性所有 2026-07-27 的数据都在索引树的一个连续区间内 -- 数据库只需要定位到区间起点然后顺序扫描到区间终点即可索引范围扫描。 -- -- 类似的索引杀手操作 -- - WHERE YEAR(order_time) 2026 → 改成 WHERE order_time 2026-01-01 -- - WHERE amount * 1.1 1000 → 改成 WHERE amount 1000 / 1.1 -- - WHERE UPPER(user_name) ALICE → 改成 WHERE user_name Alice如果大小写不敏感用 COLLATE坏习惯 2SELECT * 成了肌肉记忆-- ❌ 坏习惯明明只要 3 个字段却拉了 50 个字段 SELECT * FROM users u JOIN orders o ON u.id o.user_id JOIN products p ON o.product_id p.id WHERE o.order_date 2026-07-27; -- 每条结果行拉 120 列数据网络传输 200MB -- ✅ 正确写法只 SELECT 需要的字段 SELECT u.id AS user_id, u.user_name, o.order_amount, o.order_status, p.product_name FROM users u JOIN orders o ON u.id o.user_id JOIN products p ON o.product_id p.id WHERE o.order_date 2026-07-27;为什么 SELECT * 在大数据场景是灾难不是所有列都应该在网络传输中免费旅行。你的 orders 表可能有大字段TEXT、BLOB、JSON这些字段在磁盘上可能单独存储但 SELECT * 时它们不得不加载到内存并通过网络传输。3 张表 JOIN 后返回 10 万行每行多 10 个不必要的列每列平均 100 字节那就是额外 100MB 的数据传输。更重要的是这些多余数据会被带到应用的 ORM 层Java 的 JDBC ResultSet、Python 的 pandas DataFrame 也都要分配内存来存它们。内存胀了 → GC 频繁 → CPU 飙升 → 接口超时 → 线上事故。只取所需不仅仅是为了省带宽更是为了整个数据链路的稳定性。坏习惯 3NOT IN 的子查询里有 NULL-- ❌ 坏习惯NOT IN 子查询有 NULL → 结果为空 SELECT user_id, user_name FROM users WHERE user_id NOT IN ( SELECT user_id FROM blacklist -- blacklist 里有一条 user_id IS NULL ); -- 返回 0 行因为 NOT IN 碰到 NULL 整个条件变 UNKNOWN -- ✅ 正确写法用 NOT EXISTS 或排除 NULL SELECT u.user_id, u.user_name FROM users u WHERE NOT EXISTS ( SELECT 1 FROM blacklist b WHERE b.user_id u.user_id ); -- 或 SELECT u.user_id, u.user_name FROM users u WHERE u.user_id NOT IN ( SELECT user_id FROM blacklist WHERE user_id IS NOT NULL );坏习惯 4大表 JOIN 大表小表没放驱动位-- ❌ 坏习惯1 亿行的 orders JOIN 50 行的 dim_status -- 但 MySQL 优化器不一定能正确选择驱动表 SELECT o.*, d.status_name FROM orders o -- 1 亿行 JOIN dim_status d -- 50 行 ON o.status_code d.status_code WHERE o.order_date 2026-07-27; -- ✅ 正确写法主动控制 JOIN 顺序 -- 用 STRAIGHT_JOIN 强制左表为驱动表仅 MySQL SELECT STRAIGHT_JOIN d.status_name, o.* FROM dim_status d -- 50 行作为驱动表 JOIN orders o -- 1 亿行作为被驱动表走索引 ON d.status_code o.status_code WHERE o.order_date 2026-07-27;为什么驱动表的选择会影响 JOIN 性能 10 倍以上JOIN 的本质是嵌套循环驱动表的每一行去被驱动表找匹配的行。如果驱动表有 50 行内层只跑 50 次索引查找如果驱动表有 1 亿行内层就要跑 1 亿次。50 行 × 1 亿行的 JOIN 和 1 亿行 × 50 行的 JOIN内层循环次数差了 200 万倍。MySQL 5.7 以前靠启发式规则选驱动表往往选错8.0 有了一定改善但依然不可靠。最稳妥的做法是主动把结果集小的表放在 JOIN 左边MySQL 默认左表驱动不确定就用STRAIGHT_JOIN强制或者加EXPLAIN FORMATJSON看nested_loop的rows来验证驱动表选择。坏习惯 5OR 条件让索引全线崩溃-- ❌ 坏习惯多个 OR 条件每个列都有索引但都用不上 SELECT * FROM orders WHERE user_id 12345 -- 有索引 OR order_status paid -- 也有索引 OR amount 1000; -- 也有索引 -- 三个都失效全表扫描 -- ✅ 正确写法拆成 UNION ALL SELECT * FROM orders WHERE user_id 12345 UNION ALL SELECT * FROM orders WHERE order_status paid AND user_id ! 12345 UNION ALL SELECT * FROM orders WHERE amount 1000 AND user_id ! 12345 AND order_status ! paid; -- 每条独立走索引坏习惯 6LIKE %keyword% 前后都有百分号-- ❌ 坏习惯前后模糊匹配B 树索引彻底失效 SELECT * FROM products WHERE product_name LIKE %手机%; -- 全表扫描500 万行 -- ✅ 如果有全文检索需求用 Elasticsearch 或 MySQL 全文索引 -- MySQL 5.7 的 ngram 全文索引适合中文 ALTER TABLE products ADD FULLTEXT INDEX ft_name(product_name) WITH PARSER ngram; SELECT * FROM products WHERE MATCH(product_name) AGAINST(手机 IN BOOLEAN MODE); -- 全文索引毫秒级返回坏习惯 7GROUP BY 的列顺序跟索引不一致-- 假设有联合索引 (user_id, order_date, status) -- ❌ 坏习惯GROUP BY 以 order_date 开头索引只用了一部分 SELECT user_id, order_date, COUNT(*) AS cnt FROM orders GROUP BY order_date, user_id; -- order_date 在前但索引是 user_id 在前 -- 需要额外排序 -- ✅ 正确写法GROUP BY 的列顺序跟联合索引保持一致 SELECT user_id, order_date, COUNT(*) AS cnt FROM orders GROUP BY user_id, order_date; -- 利用索引有序性避免 filesort坏习惯 8在 WHERE 里做了隐式类型转换-- ❌ 坏习惯user_id 是 INT但 WHERE 里用了字符串 SELECT * FROM orders WHERE user_id 12345; -- 字符串 12345 被隐式转了 -- MySQL 会将字符串转为 INT索引仍然有效这个例子不典型 -- 真正危险的给 INT 列传了无法转成数字的字符串 SELECT * FROM orders WHERE phone 13800138000; -- phone 是 VARCHAR索引列被函数包裹 → 失效 -- VARCHAR 和 INT 比较时MySQL 会将 VARCHAR 转成 DOUBLE列上带隐式 CAST -- ✅ 正确写法传入的值和列类型保持一致 SELECT * FROM orders WHERE phone 13800138000; -- 同类型比较索引有效坏习惯 9分页越翻越深-- ❌ 坏习惯OFFSET 100000MySQL 需要扫 100010 行后丢弃前 100000 行 SELECT * FROM orders ORDER BY order_id LIMIT 100000, 20; -- 越来越慢到 10 万页基本不可用 -- ✅ 正确写法基于游标的分页 SELECT * FROM orders WHERE order_id 978345 -- 上一页最后一条的 order_id ORDER BY order_id LIMIT 20; -- 每次只扫 20 行O(1) 复杂度为什么深分页会越来越慢LIMIT 100000, 20对 MySQL 来说不是跳到第 100000 行然后取 20 行而是按 ORDER BY 顺序从头扫描 100020 行丢弃前 100000 行返回后 20 行。这 100000 次扫描和丢弃操作必须完成即使有索引也省不了。页面越深代价越大到 100 万偏移量时几乎不可用。游标分页通过WHERE id last_id告诉数据库从上次停止的精确位置继续数据库直接通过 B 树索引定位到last_id的位置O(log n) 跳过去再向后顺序取 20 条。唯一代价是不能跳页——但现实中用户翻到第 500 页的概率极小这个约束完全可以接受。坏习惯 10COUNT(*) 和 COUNT(col) 不分-- ❌ 坏习惯COUNT(col) 不统计 NULL如果 col 可空结果可能不准 SELECT COUNT(refund_time) FROM orders WHERE order_date 2026-07-27; -- 只统计退款时间不为 NULL 的订单可能和预期不符 -- ✅ 分清场景 SELECT COUNT(*) FROM orders WHERE order_date 2026-07-27; -- 统计所有行不管 NULL SELECT COUNT(1) FROM orders WHERE order_date 2026-07-27; -- 和 COUNT(*) 等价现代 MySQL SELECT COUNT(DISTINCT user_id) FROM orders WHERE order_date 2026-07-27; -- 统计去重后的用户数三、SQL Review 自动化7 月我写了一个简单的 Python 脚本自动扫描 SQL 仓库里的坏习惯import re from typing import List, Tuple # 坏习惯检测规则集 SQL_PATTERNS [ (rSELECT\s\*, ❌ SELECT *建议只 SELECT 需要的列), (rWHERE\s\w\sNOT\sIN\s*\(, ⚠️ NOT IN检查子查询是否有 NULL 值), (rLIKE\s[\]%.%[\], ❌ LIKE %...%前导模糊查询索引失效), (rWHERE\sDATE\(, ❌ WHERE DATE(col)对索引列使用了函数), (rLIMIT\s\d{5,}, ⚠️ 大 OFFSET 分页建议改用游标分页), (r\s*\[^\]\\sAND\s\w\s*\s*\d, ⚠️ 可能的隐式类型转换), ] def audit_sql(sql: str) - List[str]: 对一条 SQL 做自动审计 返回所有不推荐的写法列表 issues [] # 移除注释避免干扰 clean_sql re.sub(r--.*$, , sql, flagsre.MULTILINE) clean_sql re.sub(r/\*.*?\*/, , clean_sql, flagsre.DOTALL) for pattern, message in SQL_PATTERNS: if re.search(pattern, clean_sql, re.IGNORECASE): issues.append(message) return issues # 使用示例 sample_sql SELECT * FROM orders WHERE DATE(order_time) 2026-07-27 AND user_id NOT IN (SELECT user_id FROM blacklist) AND product_name LIKE %手机% LIMIT 100000, 20; issues audit_sql(sample_sql) for issue in issues: print(issue) # 输出: # ❌ SELECT *建议只 SELECT 需要的列 # ❌ WHERE DATE(col)对索引列使用了函数 # ⚠️ NOT IN检查子查询是否有 NULL 值 # ❌ LIKE %...%前导模糊查询索引失效 # ⚠️ 大 OFFSET 分页建议改用游标分页为什么自动化 SQL Review 不是过度工程人肉 Review SQL 的覆盖率极低一个团队每天可能提交几十条 SQLReviewer 不可能逐条 EXPLAIN。关键在于坏习惯检测不是要替代人工而是做第一层过滤。正则扫描可以在毫秒级完成把明显有坑的 SQL如SELECT *、WHERE DATE()、LIKE %x%标记出来Reviewer 只需要重点看这些标记过的 SQL。7 月份这个脚本上线后我们团队一个月拦截了 60 条有索引失效风险的 SQL线上慢查询数量下降了 40%。配合 Git pre-commit hook在提交阶段就能拦下问题 SQL比上线后修复成本低 100 倍。四、习惯改善路径为什么这个审查循环是 SQL 质量的生命线很多工程师把 SQL 优化当成一次性活动——上线前调一调之后再也不看。但业务在变数据量在涨昨天 0.1 秒的查询明天可能变 10 秒。这个写完 → EXPLAIN → 检查 → 重写的循环应该成为肌肉记忆每条新 SQL 都跑一遍。关键是扫描行数 预期这个判断很多时候 SQL 看起来正常但 EXPLAIN 告诉你它扫描了 500 万行而不是预期的 500 行这就是索引没生效的信号。这条循环跑得越勤快线上定位慢查询的半夜电话就越少。五、总结这 10 个坏习惯本质上可以归结为三条底层原则不要阻碍索引— 不在索引列上套函数、不做前导模糊、保持类型一致控制数据量— SELECT 需要的不 SELECT 全部、游标分页替代深分页、NOT EXISTS 替代 NOT IN读懂执行计划— EXPLAIN 是你的第二双眼睛每次写完 SQL 看一眼rows和Extra字段SQL 写得好不好不看出身、不看年限就看你能不能养成写完看一眼 EXPLAIN的习惯。共勉。最后送大家一句话好的 SQL 不是写出来的是 EXPLAIN 出来的。每次多花 30 秒看执行计划能帮你省下以后几小时的排查时间。