RSS
菜单
全部文章快讯开发科技深度热点

第 46 章 查询写法踩坑(一)

内容摘要

同样一张表、同样的索引,SQL 写法不同,性能可以差几十上百倍。本章聚焦"查询写法"本身最常见的 12 个坑(上篇),覆盖 SELECT *、无 LIMIT、深分页、UNION、DISTINCT、超时、NULL 比较、IN 列表与事务边界。示例基于 MySQL 8.0。读完你能避开那些"语法没错、却把库拖慢"的写法。

📖 本篇简介:同样一张表、同样的索引,SQL 写法不同,性能可以差几十上百倍。本章聚焦"查询写法"本身最常见的 12 个坑(上篇),覆盖 SELECT *、无 LIMIT、深分页、UNION、DISTINCT、超时、NULL 比较、IN 列表与事务边界。示例基于 MySQL 8.0。读完你能避开那些"语法没错、却把库拖慢"的写法。

目录

  • 坑 1:随手写 SELECT *
  • 坑 2:查询不带 LIMIT
  • 坑 3:深分页 LIMIT 1000000,20
  • 坑 4:UNION 当 UNION ALL 用
  • 坑 5:用 DISTINCT 去重掩盖问题
  • 坑 6:查询不设超时
  • 坑 7:以为 WHERE 书写顺序影响走索引
  • 坑 8:用等号与空值比较
  • 坑 9:IN 列表拼得过长
  • 坑 10:在大事务里夹一堆查询
  • 坑 11:ORDER BY 随机/无索引字段
  • 坑 12:把业务计算塞进 SQL 里
  • 速查表:查询写法速记
  • 注意事项:慢 SQL 自查清单
  • 自测:你能避坑吗

坑 1:随手写 SELECT *

场景 / 错误示范:

-- 列表页只需要 id 和 name,却写了 SELECT *
SELECT * FROM goods WHERE category_id = 5;
-- 把 content(TEXT 大字段)、cover_url 全查出来,网络和内存都白吃

为什么是坑:SELECT * 把整行所有列都拉回来,包括你根本用不到的 TEXT/BLOB 大字段。这会放大网络传输、内存占用,还让覆盖索引彻底失效(要回表取所有列)。应用层多传无用数据,GC 和带宽都吃亏。

正确做法:

-- 只取需要的列
SELECT id, name, price FROM goods WHERE category_id = 5;
-- 配合覆盖索引 (category_id, name, price),连回表都省了

加分点:永远写明确列名。除了性能好处,表结构加列时你的 SQL 不会意外拉来新大字段,也更可读。

坑 2:查询不带 LIMIT

场景 / 错误示范:

-- 后台导出/调试,一把查全表
SELECT * FROM order_t WHERE status = 1;
-- 表里 500 万行,全拉到应用内存里,OOM

为什么是坑:没有 LIMIT 的查询会把命中的所有行都返回。调试时手一抖,就是几百万行往应用里灌,轻则慢,重则 OOM、拖垮网络。很多线上事故就是"后台忘了加 LIMIT 的查询"。

正确做法:

-- 调试、后台查询一律带 LIMIT
SELECT * FROM order_t WHERE status = 1 LIMIT 100;

-- 真要导出全量,用分批游标,不要一把拉
SELECT * FROM order_t WHERE status = 1 AND id > ? ORDER BY id LIMIT 1000;

加分点:养成"查询必带 LIMIT"的肌肉记忆,是最便宜的防事故手段。导出走专门的导出任务,别让在线查询扛全量。

坑 3:深分页 LIMIT 1000000,20

场景 / 错误示范:

-- 翻到第 5 万页:OFFSET 巨大
SELECT * FROM order_t ORDER BY id LIMIT 1000000, 20;
-- MySQL 要先扫描并丢弃前 100 万行,再取 20 行,越翻越慢

为什么是坑:LIMIT offset, n 的 offset 是"跳过前 offset 行"。offset 越大,MySQL 要扫描和丢弃的行越多,这些扫描全是浪费。深分页是后台报表、大数据量列表页的经典慢查询,页码越深越慢,线性恶化。

正确做法:

-- 游标分页(记住上一页最后一条的 id)
SELECT * FROM order_t WHERE id > 上次最后id ORDER BY id LIMIT 20;
-- 直接从 id 位置开始,跳过的行数几乎为零

-- 或者用"子查询先定位主键"
SELECT t.* FROM order_t t
JOIN (SELECT id FROM order_t ORDER BY id LIMIT 1000000, 20) x
  ON t.id = x.id;

加分点:游标分页要求前端"只下一页、不跳页"。需要跳页的场景用延迟关联(上面子查询写法),让外层用主键回表,比直接 LIMIT offset,n 快得多。

坑 4:UNION 当 UNION ALL 用

场景 / 错误示范:

-- 合并两个结果集,随手写 UNION
SELECT id FROM a
UNION
SELECT id FROM b;
-- UNION 会对合并结果去重,要建临时表排序,慢

为什么是坑:UNION 会把两个结果集合并后做一次全局去重(DISTINCT),需要临时表和排序。如果业务上两个结果集本来就不会重复(或重复也无所谓),这个去重纯属浪费。很多人不知道 UNION 默认去重,白白付了一次排序代价。

正确做法:

-- 确定不需要去重时,用 UNION ALL,快很多
SELECT id FROM a
UNION ALL
SELECT id FROM b;
-- 不去重、不排序,直接拼接

-- 真需要去重,再用 UNION

加分点:默认用 UNION ALL,需要去重才用 UNION。这个小差别在大数据量下能差好几倍性能。

坑 5:用 DISTINCT 去重掩盖问题

场景 / 错误示范:

-- JOIN 出来重复行,不查为什么重复,直接加 DISTINCT
SELECT DISTINCT u.id, u.name FROM user u JOIN order_t o ON u.id = o.user_id;
-- 一对多 JOIN 导致行数膨胀,DISTINCT 硬去重,还要临时表排序

为什么是坑:DISTINCT 是"先算出全部、再去重"。如果 JOIN 本来就一对多导致行数膨胀,正确做法是改 JOIN 逻辑(拆子查询、加条件),而不是用 DISTINCT 把膨胀的结果抹平。DISTINCT 还要额外排序去重,慢且掩盖了数据模型问题。

正确做法:

-- 先想清楚:为什么会重复?一对多就先聚合再 JOIN
SELECT u.id, u.name, o.cnt
FROM user u
JOIN (SELECT user_id, COUNT(*) cnt FROM order_t GROUP BY user_id) o
  ON u.id = o.user_id;
-- 先把 order_t 收敛成一行/用户,再 JOIN,不膨胀

加分点:看到 SQL 里的 DISTINCT,多问一句"为什么会重复"。多数时候根因是 JOIN 条件或子查询写法,DISTINCT 只是创可贴。

坑 6:查询不设超时

场景 / 错误示范:

# 应用发起一个慢查询,忘了设超时
cursor.execute("SELECT * FROM huge_t WHERE ...")   # 跑了 5 分钟还没完
# 这个连接一直被占着,连接池被打满,整个服务雪崩

为什么是坑:MySQL 查询默认没有强制超时(受 wait_timeout 影响,那是空闲超时不是执行超时)。一条失控的慢查询会一直跑下去,占着连接、吃着 CPU。应用层如果也不设 socket/statement 超时,故障就从"一条慢 SQL"放大成"整个服务不可用"。

正确做法:

# 连接层设置语句超时(MySQL 8.0 的 max_execution_time)
cursor.execute("SET SESSION MAX_EXECUTION_TIME = 2000")   # 单条 SELECT 最多 2 秒
cursor.execute("SELECT ...")

# 应用层也要设 socket 超时,双保险
# pymysql: connect(..., read_timeout=3, write_timeout=3)
-- 全局设置 SELECT 超时(毫秒)
SET GLOBAL max_execution_time = 3000;

加分点:max_execution_time 只对 SELECT 生效。应用层连接超时 + 数据库语句超时,两层一起上,才能防止慢查询拖垮服务。

坑 7:以为 WHERE 书写顺序影响走索引

场景 / 错误示范:

-- 以为把"高区分度条件"写在 WHERE 前面就走索引
SELECT * FROM t WHERE low_col = 1 AND high_col = 2;
-- 纠结 low_col 和 high_col 谁写在前

为什么是坑:MySQL 的优化器会自动重排 WHERE 条件、选择最优执行顺序,和你写 SQL 时条件的先后顺序完全无关。很多人花精力调 WHERE 里条件的排列,其实毫无意义。真正决定走不走索引的是"有没有合适的联合索引、条件是否能命中",不是书写顺序。

正确做法:

-- 条件顺序随便写,优化器会自己重排
SELECT * FROM t WHERE low_col = 1 AND high_col = 2;
-- 想让查询快,去建 (high_col, low_col) 联合索引,而不是调 WHERE 顺序
CREATE INDEX idx_high_low ON t(high_col, low_col);

加分点:类似误区还有"FROM 表的顺序影响 JOIN 性能"——8.0 优化器会自动决定驱动表,手写顺序基本不影响。把精力放在索引和重写上,别放在 SQL 排版上。

坑 8:用等号与空值比较

场景 / 错误示范:

-- 想查出 phone 为空的用户
SELECT * FROM user WHERE phone = NULL;
-- 永远查不到任何行!NULL = NULL 的结果是 NULL(非真)

为什么是坑:NULL 是"未知",任何值(包括另一个 NULL)和它做 =、!=、< 比较结果都是 NULL,不是真也不是假,WHERE 只留结果为真的行。所以 phone = NULL 永远空集。这是 SQL 三值逻辑最经典的坑。

正确做法:

-- 用 IS NULL / IS NOT NULL
SELECT * FROM user WHERE phone IS NULL;
SELECT * FROM user WHERE phone IS NOT NULL;

-- 排 NULL:
SELECT * FROM user WHERE NOT (phone <=> '13800000000');  -- <=> 是 NULL 安全等于

加分点:呼应第 42 章——业务列尽量 NOT NULL DEFAULT,从源头减少 NULL,就少踩这类坑。NULL <=> NULL 在 MySQL 里为真,可用于 NULL 安全比较。

坑 9:IN 列表拼得过长

场景 / 错误示范:

# 应用查出几万个 id,拼进 IN
ids = ",".join(str(i) for i in huge_id_list)   # 5 万个 id
cursor.execute(f"SELECT * FROM t WHERE id IN ({ids})")
# SQL 文本超长,解析慢,优化器也吃力

为什么是坑:IN 列表过大,SQL 文本本身就很长,MySQL 解析、优化、执行计划生成都变慢。而且大 IN 往往是"先查一次拿 id、再用 id 反查"的两段式查询,数据在应用层和数据库之间来回倒,本身就是低效模式。

正确做法:

-- 小批量:IN 列表控制在几百以内
SELECT * FROM t WHERE id IN (1,2,3,...,500);

-- 大批量:改用 JOIN 临时表,或用表值构造
-- 先把这批 id 写进一张临时表,再 JOIN
CREATE TEMPORARY TABLE tmp_ids(id BIGINT);
INSERT INTO tmp_ids VALUES (1),(2),...;
SELECT t.* FROM t JOIN tmp_ids ON t.id = tmp_ids.id;

加分点:IN 列表上千就要警惕。能一次 JOIN 解决的,别拆成"查 id 再查详情"两轮。

坑 10:在大事务里夹一堆查询

场景 / 错误示范:

-- 一个事务里做好多件事,中间还夹杂慢查询
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 中间夹了一个要跑 10 秒的统计查询
SELECT COUNT(*) FROM huge_log WHERE ...;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 这 10 秒里,行锁一直被持有,其他事务全堵在后面

为什么是坑:事务持有的锁,要到 COMMIT 才释放。事务里夹慢查询,等于把锁的持有时间拉长十倍,并发上来后锁等待雪崩。事务应该"短平快",只包真正需要原子性的写操作。

正确做法:

-- 事务只包核心写操作,快进快出
START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 慢统计查询挪到事务外,甚至走从库
SELECT COUNT(*) FROM huge_log WHERE ...;   -- 不占锁

加分点:事务里不要夹 RPC 调用、不要夹慢查询、不要等人。长事务是锁问题和主从延迟的温床,详见事务篇。

坑 11:ORDER BY 随机/无索引字段

场景 / 错误示范:

-- 列表按一个没索引的字段排序
SELECT * FROM t ORDER BY some_column LIMIT 20;
-- 没索引 -> filesort,命中行一多就慢

为什么是坑:ORDER BY 的字段没有合适索引时,MySQL 要把结果集捞出来在排序缓冲里排。结果集大时排序缓冲装不下,还要落磁盘临时文件,CPU 和 IO 双吃。这在第 45 章提过,这里强调写法上要避免"无索引排序大结果集"。

正确做法:

-- 为排序字段建联合索引(配合过滤条件)
CREATE INDEX idx_status_xx ON t(status, some_column);
SELECT * FROM t WHERE status = 1 ORDER BY some_column LIMIT 20;

-- 无法建索引时,加 LIMIT 限制参与排序的行数
SELECT * FROM t ORDER BY some_column LIMIT 100;

加分点:凡是 ORDER BY ... LIMIT 的列表页,几乎都该有一个"过滤+排序"联合索引,否则翻页必慢。

坑 12:把业务计算塞进 SQL 里

场景 / 错误示范:

-- 在 SQL 里做复杂字符串处理、多层 CASE WHEN
SELECT
  id,
  CASE WHEN status=1 THEN '已支付' WHEN status=2 THEN '已发货' ... END AS status_text,
  CONCAT(SUBSTRING(name,1,3), '***') AS masked_name
FROM order_t;
-- 数据库干了一堆应用层该干的格式化活,SQL 又长又难优化

为什么是坑:数据库擅长存取和过滤,不擅长格式化、业务规则计算。把 CASE WHEN 映射、字符串脱敏这类业务逻辑塞进 SQL,让 SQL 又长又难优化,还把业务规则散落在数据库里,应用层无法复用、无法单测。SQL 应该只负责"取数据","加工数据"交给应用层。

正确做法:

-- SQL 只取原始字段
SELECT id, status, name FROM order_t WHERE ...;
# 应用层做映射和格式化
STATUS_MAP = {1: '已支付', 2: '已发货'}
for row in rows:
    row['status_text'] = STATUS_MAP.get(row['status'], '未知')
    row['masked_name'] = row['name'][:3] + '***'

加分点:SQL 越"瘦"越好优化。把业务计算上移到应用层,数据库专注它擅长的事,性能和可维护性双赢。

速查表:查询写法速记

| 写法 | 问题 | 正确做法 |

|---|---|---|

| SELECT * | 多拉无用大字段、失效覆盖索引 | 写明确列名 |

| 无 LIMIT | 拉全表 OOM | 调试必带 LIMIT |

| LIMIT 1000000,20 | 深分页慢 | 游标分页 WHERE id>? |

| UNION | 多余去重排序 | 不需去重用 UNION ALL |

| DISTINCT | 掩盖 JOIN 重复根因 | 改 JOIN 逻辑 |

| 无超时 | 慢查询拖垮连接池 | 设 max_execution_time |

| 纠结 WHERE 顺序 | 优化器自动重排 | 精力放索引 |

| = NULL | 永远空集 | IS NULL |

| IN 列表上万 | SQL 解析慢 | 改临时表 JOIN |

| 事务夹慢查询 | 拉长锁持有 | 事务短平快 |

| 无索引排序 | filesort | 建过滤+排序联合索引 |

| SQL 做业务计算 | SQL 臃肿难优化 | 计算上移应用层 |

注意事项:慢 SQL 自查清单

拿到一条慢查询,按下面顺序自查,比上来就改要高效。

1. SELECT 列:是不是 SELECT *?有没有取了用不到的大字段?

2. 行数:有没有 LIMIT?命中行数预估多少?

3. 索引:EXPLAIN 看 type、key、rows,索引用上了吗?

4. 排序:Extra 有没有 filesort、Using temporary?

5. JOIN:关联列有索引吗?是不是一对多膨胀?

6. 超时:这条 SQL 最坏会跑多久?有没有超时保护?

7. 事务:它是不是裹在大事务里,白白持锁?

一句话:慢 SQL 多半不是"数据库不行",而是写法在某一步做了多余的全表扫、回表或排序。逐条自查,对症下药。

常见误区补充:

  • 看到 DISTINCT 就先怀疑是不是 JOIN 重复了,而不是直接接受它。
  • 看到慢查询先 EXPLAIN,别凭感觉猜是索引问题还是写法问题。
  • SQL 里尽量少做 CASE WHEN 业务映射,这类逻辑放应用层更好维护。
  • 线上后台查询一定加 LIMIT 和超时,这是事故防火墙。

自测:你能避坑吗

题 1:LIMIT 1000000, 20 为什么慢?怎么改?

<details>

<summary>参考答案</summary>

MySQL 要扫描并丢弃前 100 万行再取 20 行,offset 越大越慢。改成游标分页 WHERE id > 上次最后id ORDER BY id LIMIT 20,直接从位置开始,跳过成本几乎为零;需要跳页时用延迟关联。

</details>

题 2:UNION 和 UNION ALL 有什么区别?什么时候用哪个?

<details>

<summary>参考答案</summary>

UNION 会对合并结果全局去重(临时表+排序),UNION ALL 直接拼接不去重。确定不需要去重时用 UNION ALL,性能好得多;确实要去重才用 UNION。

</details>

题 3:WHERE phone = NULL 为什么查不到数据?

<details>

<summary>参考答案</summary>

NULL 是"未知",= NULL 的结果是 NULL(非真),WHERE 只保留真,所以永远空集。要用 phone IS NULL。业务列应尽量 NOT NULL DEFAULT 从源头避免。

</details>

题 4:事务里夹一个 10 秒的统计查询有什么风险?

<details>

<summary>参考答案</summary>

事务持有的锁要到 COMMIT 才释放,中间这 10 秒其他事务都被堵在锁等待上,高并发下雪崩。事务应短平快,只包必要写操作,慢统计挪到事务外或走从库。

</details>

题 5:为什么说 SELECT * 会让覆盖索引失效?

<details>

<summary>参考答案</summary>

覆盖索引的前提是"查询需要的列都在二级索引里"。SELECT * 要返回所有列,包括不在索引里的列,就必须回主键聚簇索引取整行,覆盖索引就失效了。只查需要的列才能走覆盖索引。

</details>

题 6:IN 列表拼了 5 万个 id 会有什么问题?怎么改?

<details>

<summary>参考答案</summary>

SQL 文本超长,解析和优化都变慢,两段式"查 id 再反查"还让数据在应用和库之间来回倒。应改小批量分批,或把这批 id 写进临时表再 JOIN。

</details>

题 7:为什么说"WHERE 条件的书写顺序"不影响走索引?

<details>

<summary>参考答案</summary>

MySQL 优化器会自动重排 WHERE 条件、选择最优执行路径,和 SQL 里条件写在前还是写在后无关。决定性能的是有没有合适的联合索引,而不是条件的排版顺序。

</details>

题 8:应用层不设超时,一条失控慢查询为什么会演变成整个服务雪崩?

<details>

<summary>参考答案</summary>

慢查询一直占着连接不释放,连接池被耗尽,新请求拿不到连接,整个服务对外不可用。必须双保险:数据库设 max_execution_time 限制 SELECT 执行时长,应用层设 socket read/write timeout。

</details>

题 9:一对多 JOIN 出来行数翻倍,开发者直接加 DISTINCT 去重,问题在哪?

<details>

<summary>参考答案</summary>

DISTINCT 是"先膨胀再去重",还要额外排序,慢且掩盖了根因。正确做法是先在多的那一侧聚合收敛(子查询 GROUP BY),再 JOIN 一侧,让结果集不膨胀,从根本上避免重复。

</details>

题 10:为什么 SQL 里尽量不要写复杂的 CASE WHEN 业务映射?

<details>

<summary>参考答案</summary>

数据库擅长存取过滤,不擅长格式化和业务规则计算。把映射逻辑塞进 SQL 让 SQL 臃肿难优化,业务规则散落在数据库里无法复用和单测。SQL 只取原始数据,格式化和映射交给应用层。

</details>

下一篇预告:第 47 章 查询写法踩坑(二)

— 全文完 —回到顶部 ↑
下载推广海报

文章推广海报

《第 46 章 查询写法踩坑(一)》完整推广海报
DISCUSSION

文章回复

0 条公开回复
未登录回复需要审核后公开
还没有回复,欢迎参与讨论。