这周带新人调一张报表,SQL写得不算复杂,就是三张表关联后按部门算平均工资。结果一看,技术部门的人均月薪跑出来八十多万,明显不符常理。一开始我还怀疑数据有问题,单独跑每张表行数排查才发现,问题出在两张大表之间存在一对多关系,JOIN 完之后中间结果行数翻了好几倍,后面的 AVG 自然被整体撑大。
这种问题放在“MySQL 基本查询”系列里特别典型:很多人单表 WHERE、ORDER BY 用得溜,一遇到分组统计、关联查询、子查询就开始放飞自我。这篇是第 3 篇,我会集中写清楚平时项目里绕不开的几块内容:GROUP BY 分组聚合、JOIN 关联查数据时怎么防止数据被放大、三种子查询的写法与坑,以及排序和 LIMIT 分页中容易踩的两个线上问题。最后再把 SQL 的书写顺序和执行顺序放在一起讲,让你排查慢查询时能有个方向。
1. 统计需求为什么不能只靠单表扫一眼
1.1 从“找数据”到“算账”,查询思路要换挡
前两篇聊的基本查询,本质上是在做一件事:从表里把符合条件的行检索出来。写 SELECT 指定列、用 WHERE 过滤行、ORDER BY 排序,这些操作解决的是“把某几条数据找出来”的问题,属于一行一行处理数据的思维。
实际业务里更常见的需求是算账,比如“每个部门有多少人”“各个状态订单的总金额是多少”“最近一年每个月的注册用户数”。这类需求的共同点在于,不能再把眼睛盯在某一行上,而是要把很多行划成不同的组,然后对每一组做汇总计算,这个动作在 MySQL 里对应的核心语法就是 GROUP BY,搭配 COUNT、SUM、AVG、MAX、MIN 这些聚合函数一起使用。
如果你只会写“找数据”的查询,遇到统计需求时容易用最笨的办法:先把明细查出来,拉到 Java、Python 或者 Excel 里再用循环累加。数据量小的时候没毛病,十几行数据怎么折腾都行。可一旦上了十万、百万行,把大量明细从数据库拖到应用层做统计,既浪费网络带宽和内存,又慢得让页面直接超时。SQL 里的分组聚合能力,就是为了让你把统计计算下推到数据库引擎,在数据所在的地方直接完成汇总。
1.2 一个全员统计报表跑挂的现场
我开头提到的那个报表坑,拆开看其实很典型。当时的业务背景是统计每个部门的平均工资,比较特殊的是工资变动记录单独存了一张表,一个人可能有多条调薪记录。
他写的 SQL 逻辑大致是:查出员工基础信息,和调薪记录表直接做关联,再按部门 GROUP BY 算平均工资。问题就出在这里。员工表和调薪记录表是典型的一对多关系,比如张三有两条调薪记录,关联后张三会变成两行,部门里每个人的权重被重复计算。如果其他员工只有一条记录,而某几个核心员工有七八条历史调薪记录,他们会对部门平均值产生不成比例的影响。
把这种查询简化成代码如下:
sql复制-- 错误示范:先关联后聚合,明细被放大后 AVG 结果失真
SELECT d.dept_name,
AVG(e.salary) AS avg_salary
FROM departments d
LEFT JOIN employees e ON d.dept_id = e.dept_id
LEFT JOIN salary_log s ON e.emp_id = s.emp_id
GROUP BY d.dept_name;
表面上看三张表都关联上了,语法没问题,执行也不报错。但 salary_log 表里每个员工可能对应多条工资调整记录,连完之后员工记录出现重复,AVG 就算错了。正确写法是先把 salary_log 这个明细表按员工维度聚合成当前工资,再回到员工表和部门表做关联,或者干脆用子查询先把多行压缩成一行。
所以从这个案例能看出,写统计类 SQL 之前,一定要先想清楚一件事:当前查询的数据粒度是哪一层。粒度理解错了,后面所有写法全白搭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GROUP BY 分组聚合:最基础也最容易写错
2.1 用快递分拣的模型理解 GROUP BY
很多人觉得 GROUP BY 很难,其实在生活里天天见。快递分拣员把全国寄来的包裹按省份丢进不同格口,每个格口里堆着几十上百个包裹。分组之后,你只能针对每个格口问“格口里有多少个包裹”“总重量是多少”“最大包裹多重”,如果非要问“上海这个格口里具体哪一个包裹是寄给张三的”,当场就抓瞎了,因为所有上海的包裹已经混在一起了。
GROUP BY 干的就是分拣这件事。指定 department_id 作为分组字段后,同一个部门的员工被放进同一个分组,接下来 SELECT 的聚合函数就是对每个分组统一计算。假如 SELECT 后面出现了一个普通字段 employee_name,同时又没有对这个字段做任何聚合处理,逻辑上就出问题了:分组里有二十个员工,数据库到底该返回哪一个姓名?MySQL 的 only_full_group_by 模式下会直接报错,写不了这种语句,但就算你在旧版本或关闭这个模式下能跑,返回的结果也是随机取一条,完全没有业务意义。
顺手说一句,我见过不少老项目把 only_full_group_by 关掉,理由是“以前都这么写也没出事”。没出事不代表对,只是数据碰巧长得规整。线上环境我建议保持默认开启,逼自己把 SQL 写严谨,这能挡掉很多隐藏很深的统计错误。
2.2 WHERE 和 HAVING:一个管原始行,一个管分组结果
统计需求里最绕的还有 WHERE 和 HAVING 的分工。很多新人写“部门里高薪员工人数超过 10 人的部门”这种需求时,会在 WHERE 里尝试写 COUNT(*) > 10,结果直接被数据库报错。原因是 WHERE 的执行时机在 GROUP BY 之前,压根不知道分组后有多少人,天然不能使用聚合函数做过滤。
完整逻辑拆解:
sql复制-- 需求:找出平均工资大于 8000 的部门,并且只看工资不低于 5000 的员工
SELECT department_id,
COUNT(*) AS high_salary_cnt,
AVG(salary) AS avg_salary
FROM employees
WHERE salary >= 5000 -- 第一步:先把低于 5000 的员工行过滤掉
GROUP BY department_id -- 第二步:剩下的员工按部门分组
HAVING AVG(salary) > 8000 -- 第三步:再过滤掉平均工资不达标的部门
ORDER BY avg_salary DESC;
用做菜来类比,WHERE 是洗菜切菜,先把烂叶子摘掉,只留下能下锅的部分;GROUP BY 是把菜按种类装盘;HAVING 是最后上桌前的品控,觉得哪盘菜不行直接撤掉。如果把本来该在 WHERE 里过滤的条件放到 HAVING 里,数据可能先被分到很多组,数据库做了大量多余的聚合工作后才把组丢掉,性能会明显下降;反过来,如果把本该在 HAVING 里的条件放到 WHERE 里,直接语法报错。记住这两条铁律,就没什么好纠结的了。
2.3 COUNT、SUM、AVG 与 NULL 的纠缠
聚合函数使用频率高,但细节坑也多。最容易被忽略的是 NULL 值处理。
COUNT() 统计的是行数,不管某列是不是 NULL,都会计入;COUNT(字段名) 则不同,它会跳过该字段为 NULL 的行。一张员工表有 100 行,其中 10 行的 commission_pct 字段是 NULL,COUNT(commission_pct) 返回 90,而 COUNT() 返回 100,这两个数字是有本质区别的,在核对报表数据时经常用到。
SUM 和 AVG 更直接,默认会忽略 NULL 值,不参与计算。比如要算全员平均绩效分,如果张三的绩效是 NULL,他的分数不会进分母,也不会进分子,结果相当于少一个人参与统计。如果你希望把 NULL 当成 0 分来处理,就得先做 COALESCE 转换,写成 AVG(COALESCE(performance_score, 0))。两种写法结果完全不同,分别对应“缺考的人不计入考试人数”和“缺考的人按零分处理”两种业务口径。
另一个值得留意的是 COUNT(DISTINCT 字段)。它会去重后统计非 NULL 值的数量,但同时对性能的消耗也比较高,数据量大时最好先跑 EXPLAIN 看一下扫描行数,心里有数再上线。
3. JOIN 关联查询:数据为什么会被放大
3.1 先看两张表的关系,再决定怎么连
我从第一年写 SQL 就开始被 JOIN 数据放大坑,直到现在带人仍然要反复提醒。你手上有一张员工表,每行代表一个员工,主键是 emp_id;又有一张部门表,每行代表一个部门。两张表关联时,如果部门表对员工表是一对多,一个部门下面有很多员工,那么部门表的一行会重复多次去匹配员工表的每一行。这个“重复多次”就是放大效应的根源。
放大到两张都是明细表时会更夸张。一张表是订单表,一张表是退款表,一个订单可能退款多次;如果直接把订单表和退款表 JOIN,那么这条订单的每一行都会和多次退款记录拼在一起,订单本身的金额字段会在中间结果里重复多次。这时候你去 SUM 订单金额,会把同一订单的金算好几遍,数据自然偏大。
解决思路没有银弹,核心是要在 JOIN 之前搞明白主表每一行应该对应子表几行。如果子表确实有多条记录,而你只需要最新的那一条,就要用子查询、窗口函数或者临时表先把子表压缩到业务需要的粒度,然后再 JOIN。这里强烈建议做一个动作:JOIN 之前先分别跑一下两表的行数,两次 COUNT 结果能帮你快速判断有没有一对多的隐患,习惯成自然。
3.2 INNER JOIN、LEFT JOIN、RIGHT JOIN 的选择依据
三种 JOIN 的差别本该是基础中的基础,但真到业务里,很多新人还是拿不准。LEFT JOIN 的语义是“左表全部保留,右表能匹配上的就带出来,匹配不上用 NULL 填空”;INNER JOIN 的语义是“两边都匹配上的行才返回”;RIGHT JOIN 本质上和 LEFT JOIN 对称,但为了读 SQL 顺口,大多数人会改写为 LEFT JOIN。
实际业务中 LEFT JOIN 用得最多,因为常见的写法都是从主表出发,补充其他表的说明信息。有一次做订单列表分页,需求是展示所有订单及其对应的产品名称。订单表是主表,我要求必须先用 LEFT JOIN 把产品表挂在订单后面,尽量避免用 INNER JOIN,因为如果一个订单引用了已被逻辑删除的产品,INNER JOIN 会把这条订单整行消掉,用户侧看到订单莫名其妙少了一条,非常难排查。
统计类需求则要换思路。每个部门无论有没有员工都要展示,用 LEFT JOIN 合理;只要“有员工的部门”并且希望统计时不受空部门干扰,INNER JOIN 就更简洁。判断 JOIN 类型的原则不是喜不喜欢,而是看主线数据里的任意一行在另一张表里没有匹配时,是保留空档还是直接扔掉整行。
3.3 ON 条件与 WHERE 条件执行时机不同
这个点特别重要,尤其对 LEFT JOIN 来说,ON 和 WHERE 位置写错了,结果会完全不同。
LEFT JOIN 执行时,ON 里的条件是决定右表拿哪些行去匹配的关键;WHERE 则是在 LEFT JOIN 完成之后,对最终结果再做一次过滤。如果你在 LEFT JOIN 的 WHERE 里写了右表字段的限制条件,比如右表的 dept_status = 'active',就会出现一个很反直觉的现象:左表中那些在右表没有匹配行的记录,经过 JOIN 后右表字段全是 NULL,NULL 无法满足 dept_status = 'active',于是整行被 WHERE 过滤掉,LEFT JOIN 的“左表全部保留”效果就名存实亡了。
遇到这种需求,务必要把右表的过滤条件放到 ON 子句中,让左表记录即使没找到匹配的活跃部门,仍然以 NULL 形式保留下来。如果是 INNER JOIN,ON 和 WHERE 的过滤结果大部分时候等价,但优化器对 WHERE 条件一般也会做下推,不用过于纠结,不过从可读性来说,连接条件放 ON、业务过滤条件放 WHERE 仍然是好习惯。
4. 子查询的三种形态与实战取舍
4.1 放在 WHERE、FROM、SELECT 里的子查询
子查询听起来高级,本质就是把一条查询语句的结果当作另一条查询的数据来源。根据出现位置不同,写法差异也很大:
WHERE 型子查询最常见,典型场景是查“工资高于部门平均工资的员工”。里层先查出平均工资,外层再拿每个员工的工资和它比较。
sql复制-- 查询工资高于本部门平均工资的员工
SELECT employee_id,
employee_name,
salary,
department_id
FROM employees e
WHERE salary > (
SELECT AVG(salary)
FROM employees
WHERE department_id = e.department_id
);
这里稍微注意一下,里层子查询引用了外层表的 department_id,这叫关联子查询,和外层每一行都有关联,每处理一行员工都要执行一次里面的查询,数据量一大性能就吃紧。一般处理这种需求、数据量大时,我更倾向于先用 GROUP BY 把部门平均工资算出来生成一张临时结果,再用 JOIN 把员工表和它关联,这种改写往往能在走对索引后获得更稳的执行计划。
FROM 型子查询,是把子查询的结果当作一张派生表来使用。
sql复制SELECT d.dept_name,
tmp.total_num
FROM departments d
LEFT JOIN (
SELECT department_id, COUNT(*) AS total_num
FROM employees
GROUP BY department_id
) tmp ON d.dept_id = tmp.department_id;
这种写法的好处是可以把复杂的聚合结果预先处理成一张“逻辑表”,后续再和其他表做关联,逻辑清楚很多。但派生表在 MySQL 中都需要物化生成临时表,如果里面数据量巨大,内存或磁盘临时表开销都不小,过滤条件能下推的尽量下推。
SELECT 后面的标量子查询使用最少,因为每一行都要执行一次,一般只用来补一个字段说明。比如想给员工列表每人带出他们部门的名称,子查询看起来能完成任务,但同等条件下多半不如 JOIN 高效直观。
4.2 IN 和 EXISTS 的差别,以及 NOT IN 的 NULL 陷阱
关于 IN 和 EXISTS 的争论,互联网上一搜一大把,但很多结论都过时了。MySQL 优化器对半连接、物化都有成熟的优化策略,IN 不一定比 EXISTS 慢,反之亦然,所以现在最靠谱的做法是看 EXPLAIN 的实际执行计划,不要死记硬背“大表 IN 小表 EXISTS”之类的口诀。
不过有一个和 NULL 相关的坑是绕不开的。NOT IN 后面的子查询结果里,只要存在一个 NULL 值,整个查询的结果就会变成空集。原因在于 NOT IN 的语义是“不等于列表中的任何一个值”,如果列表里有一个 NULL,那任何值都不确定是否不等于 NULL,MySQL 只能保守地返回 NULL,最终过滤掉所有行。而 NOT EXISTS 是按行判断,不受 NULL 干扰。所以日常开发中,遇上可能包含空值的子查询,我习惯直接写 NOT EXISTS,少给自己埋雷。
sql复制-- 反例:如果子查询结果里有 NULL,下面这条语句可能什么都不返回
SELECT employee_id
FROM employees
WHERE department_id NOT IN (
SELECT department_id
FROM departments
WHERE dept_name LIKE 'T%'
);
-- 推荐写法:用 NOT EXISTS 更稳
SELECT employee_id
FROM employees e
WHERE NOT EXISTS (
SELECT 1
FROM departments d
WHERE d.department_id = e.department_id
AND d.dept_name LIKE 'T%'
);
如果你发现自己写的 EXISTS 子查询返回的不只是 1 而是很多业务字段,这不会直接报错,但会白白增加无谓的数据传输和判断成本,除非特殊需求,否则统一写 SELECT 1 或者 SELECT 常量即可。
5. 排序与 LIMIT 分页:两个高频线上问题
5.1 排序字段不唯一,翻页会重复和漏数据
分页是大多数查询的最后一公里。常规写法是 ORDER BY create_time DESC LIMIT 20 OFFSET 0,第二页再加 OFFSET 20,看起来没毛病,但如果你排序的字段在表里不是唯一的,翻页时数据会出现重复或丢失。
这背后是 MySQL 并没有为查询结果提供稳定排序。举个例子:你按 create_time 降序排列,第 10 条和第 11 条记录的创建时间相同。第一页取到前 10 条,第二页从第 11 条开始取,数据库在两次独立查询中会重新扫描、重新排序,当排序键一样时,这两行的先后顺序是没有保证的,可能在第一页里出现一次,在第二页里又出现一次,或者某个数据从两页之间漏掉。
解决办法非常简单,就是让排序键尽量唯一:在业务排序字段后面加一个主键或唯一键作为次级排序,比如 ORDER BY create_time DESC, id DESC。这样即使 create_time 相同,id 的先后关系也是固定的,分页结果自然稳定。这一点在写报表类查询时我会特别强调,因为用户经常反复翻页核对数字,翻一遍一个样非常打击信任感。
5.2 OFFSET 太深,LIMIT 不只会变慢,还可能拖垮数据库
LIMIT 1000000, 20 的写法在管理后台很常见,尤其当用户快速翻到第几万页时。这种查询慢,主要是 MySQL 需要先扫描出一共 1000020 行,丢掉前面 1000000 行,最后才返回那 20 行。这里的扫描成本随着页数增加而线性上升,即使表里每行只有几百字节,深分页也能让数据库把大量无效数据读出来再扔掉,白白消耗 IO。
一个常用的改造思路是延迟关联,先通过覆盖索引把主键查出来,再用主键回表取完整记录:
sql复制SELECT t.*
FROM my_table t
INNER JOIN (
SELECT id
FROM my_table
ORDER BY create_time DESC, id DESC
LIMIT 1000000, 20
) tmp ON t.id = tmp.id
ORDER BY t.create_time DESC, t.id DESC;
子查询里只查 id,还是得扫 100 万行,但索引扫描比回表取整行要轻量,整体上会快不少。如果业务上能接受,更推荐键集分页:记录上一页最后一条的 create_time 和 id,下一页用 WHERE (create_time, id) 小于上一页边界值的条件直接往后查,这种写法让每页成本固定,不会随着页码增长而恶化。后台系统的报表往往需要深分页导出,这个优化基本是必做项。
6. SQL 书写顺序与执行顺序,慢查询排查的关键
6.1 一张表看懂执行链条
写 SQL 时语法顺序我很熟,但从 FROM 开始执行的逻辑顺序是很多人没建立起来的概念。这里整理一张最常用的顺序表:
| 顺序 | 关键字 | 作用阶段 | 理解方式 |
|---|---|---|---|
| 1 | FROM / JOIN | 确定数据来源并关联 | 先把要用到的表拉出来组装成中间结果 |
| 2 | WHERE | 行级过滤 | 在中间结果里按条件筛掉原始行 |
| 3 | GROUP BY | 分组 | 把过滤后的行按组归类 |
| 4 | HAVING | 组级过滤 | 把不满足条件的分组筛掉 |
| 5 | SELECT | 投影 | 从结果中选出需要的列,计算表达式 |
| 6 | ORDER BY | 排序 | 对输出结果做排序 |
| 7 | LIMIT | 限量 | 截取部分行返回 |
平时写 SQL 习惯是 SELECT 在最前面,但数据库引擎不是按照 SELECT 先执行的。你会发现所有 SELECT 里定义的别名,在 WHERE 里都用不了,正是因为 WHERE 执行时 SELECT 还没执行,别名也不存在;而 ORDER BY 里可以用别名,是因为排序发生在 SELECT 之后。这些看似零散的规则,用执行顺序一条线全部串起来就很好理解。
6.2 用 EXPLAIN 快速定位聚合和排序的瓶颈
知道执行顺序,下一步就是让优化器告诉你它打算怎么执行,EXPLAIN 就是干这个的。
sql复制EXPLAIN
SELECT department_id, COUNT(*)
FROM employees
WHERE hire_date >= '2024-01-01'
GROUP BY department_id
ORDER BY department_id DESC;
输出结果里我一般先看四列:type 表示访问类型,出现 ALL 代表全表扫描,小表没问题,大表就要警惕;key 表示实际用到的索引,如果为 NULL 代表没走索引;rows 是优化器估算要扫描的行数,数值越大越危险;Extra 里如果出现 Using temporary 和 Using filesort,大概率在分组或排序时创建了临时表或文件排序,数据量大时就是拖慢查询的元凶。
GROUP BY 字段如果没有索引,很容易在 Extra 里看到 Using temporary。想优化的话可以给分组字段加索引,或者调整 SQL 让分组前先行过滤,减少参与分组的行数。JOIN 的关联字段没有索引时,优化器可能反复扫描右表,表现是全表扫描加极高的 rows。基本查询阶段不要求你把执行计划每个字段背下来,但养成写慢 SQL 前先 EXPLAIN 的习惯,能让你在问题变成事故前就发现问题。
7. 排查经验:一条 SQL 出问题时的体检流程
7.1 我日常排查慢查询和错数据的一套肌肉记忆
踩坑这么多年,我现在写查询或接手别人的查询时,基本会按下面这套顺序做体检,相当于一个自查清单。
第一是先看结果对不对,再看快不快。统计类 SQL 先跑一条不加任何聚合的明细查询,观察数据行数是否符合业务直觉。确认没有一对多放大,再套聚合函数。
第二是检查 WHERE 条件里的字段有没有被函数或隐式类型转换影响。比如 phone 字段是字符串类型,条件写成 phone = 13800138000,MySQL 会自动把两边转成数字比较,看起来结果也对,但这个转换会让字段上的索引失效。这种隐式转换很隐蔽,慢查询排查时一定要主动找。
第三是确认分页和排序是否稳定,是否需要加唯一排序键。第四是 EXPLAIN 看 type、rows、Extra 三列,重点找 ALL、NULL 索引、Using temporary、Using filesort。最后是检查字符集和排序规则,JOIN 两表的关联字段如果分别是 utf8mb4 和 latin1,MySQL 内部必须要做字符集转换才能比较,索引基本帮不上忙。
这套流程基本覆盖了我遇到的大部分查询问题,能直接套用到很多 SQL 故障场景里。
7.2 几个容易忽略但后果严重的隐蔽问题
聚合函数括号里写错字段导致统计口径错了,这种情况 SQL 不报错,但数据错了。尤其是 SUM(NULL 值) 和 COUNT(字段) 的差异,必须拿着业务规则去核对。
LEFT JOIN 后跟 WHERE 右表条件导致主表数据丢失,排在隐蔽问题第二位。我之前因为这条线上修过一个 bug:订单列表按买家会员等级过滤,结果没有等级的订单全被过滤掉了,列表数据少了一大批。问题就出在会员表是可选挂接表,用 LEFT JOIN 后却把会员等级条件放在了 WHERE 里,这让 LEFT JOIN 退化成 INNER JOIN 的效果。后来我复盘时给团队定了个规矩:除了主表主键相关的过滤条件,其他从表字段的过滤条件一律先放进 ON,想清楚结果再考虑是否挪到 WHERE。
再有一个就是字段 NULL 参与比较的坑。WHERE salary <> 8000 查出来的结果不会包含 salary 为 NULL 的行,因为 NULL 和任何值比较都是 NULL,而 NULL 不是真值,行会被过滤掉。如果业务上 NULL 代表“工资未录入”,而你希望这部分数据也出现在结果里,就得多写一句 salary IS NULL。这类问题单独看都很小,但在实际报表里会让汇总数对不上,而且排查起来经常要花很长时间。
写查询这事没有捷径,靠的是把基础语法理解透,然后多在真实数据上做验证。每次改完 SQL,先跑明细对总数,再对关键字段抽几行数据人工核对,最后才敢说这条语句是对的。这套习惯虽然慢一点,但比上线后被业务方拿着 Excel 来质问要省心太多。
