1. 为什么是这四章
事情要从一次数据库崩溃说起。某天夜里我临时接到通知,说业务系统的报表查询把库压死了,慢日志里全是几百秒的大查询。我用 explain 一个个拆,发现大部分问题不是索引没建,而是同事写 SQL 的时候,连最基本的执行逻辑都没想清楚。那次之后我花了两周时间,把自己脑子里的 SQL 知识重新捋了一遍,整理成了四章笔记。
这个“SQL四章笔记”不是一个课程大纲,也不是书目录的搬运,而是我实际排查问题、写接口、做数据分析时反复用到的最小知识集。简单说四章是:基础查询与过滤、多表连接、聚合分组、事务与性能优化。每一章都对应一类最常见的线上事故,或者说,每一个都是能直接救命的工具。
适合谁看?如果你是刚接触数据库的开发者,这四章可以让你少走大半年的弯路;如果你已经写了几年 SQL,但全靠复制粘贴改改条件,这四章可以帮你把经验系统化,以后遇到问题不再是试出来的,而是推断出来的。我尽量不端着讲理论,所有内容都是实操里验证过的,能直接用。
2. 基础查询:别只盯着语法,理解执行顺序才是关键
2.1 SELECT 不只有 SELECT
很多人学 SQL 的第一课就是SELECT * FROM table,然后以为 SQL 就是“查出来给我看”。但实际上,你写下的每一个子句,执行顺序和书写顺序是完全不同的。理解这个顺序,是所有 SQL 优化的地基。
SQL 的逻辑执行顺序大致是:FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT。我见过太多人刚学完就觉得“WHERE 是先过滤,SELECT 是最后选列”,却忽略了这背后的一个重要事实:你写的列别名在 WHERE 里不能用。因为 WHERE 在 SELECT 之前执行,别名根本还没生成。这个问题我在带新人时几乎每次都要强调一次。
再一个基础但很容易被忽略的点:SELECT DISTINCT和GROUP BY去重,在结果等价的情况下,执行路径可能完全不同。Distinct 是对结果集做一次去重操作,而 GROUP BY 通常还会伴随聚合。如果一个查询只是想去重,但把 GROUP BY 加上了不必要的聚合函数,性能上会有明显差异。实测在百万行表上,无意义的MAX(id)会让查询慢上好多。
2.2 WHERE 过滤的性价比差异
WHERE 子句看起来简单,但不同写法的代价天差地别。我把常见写法分成了几类:
- 在索引列上进行函数运算,比如
WHERE DATE(create_time) = '2024-01-01',索引直接失效,全表扫描。 - 使用
LIKE '%keyword%'前缀模糊,索引同样用不上。 - 对可空字段写
WHERE column != 'x',NULL 行会被排除,这往往不是你想要的结果。 - 多条件组合时,条件的书写顺序并不决定执行顺序,优化器会自己重排,但你要保证每条过滤本身是高效的。
我自己最常用的替代方案是,把对列的函数操作改写成范围条件。比如上面的日期过滤,写成WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',执行计划立刻改善。像这种改动,在某些数据库里会从全表扫描变成索引范围扫描,线上效果差距几十倍是很正常的。
2.3 别小看 NULL 和三值逻辑
讲个真事:有次统计订单金额,同事用WHERE amount != 0想把无金额的单子过滤掉,结果发现部分单子靠标记位判断不出来。原因就是amount字段允许 NULL,而 NULL 参与比较时,结果不是 TRUE 也不是 FALSE,而是 UNKNOWN,整行被过滤掉了。很多看似“不可能出错”的业务统计,就是栽在这种小细节上的。
处理 NULL 的正确姿势,要么在建表时加NOT NULL DEFAULT,要么在 WHERE 条件里显式把 NULL 纳入判断。我前几年写 SQL 经常图快不写字段约束,后来统计口径反复对不上,才彻底改了习惯,凡是业务上不允许为空的字段,一律加约束。这种事写进笔记第一页很值得。
3. 多表连接:别看到业务就 JOIN,先想清楚连接方向
3.1 INNER JOIN、LEFT JOIN 到底在干嘛
多表连接是 SQL 里最容易被“用错但结果碰巧对”的部分。INNER JOIN 是取交集,LEFT JOIN 是以左表为准补右表,RIGHT JOIN 用得少是因为你换个顺序就能等价。真正让我警觉的,是很多人在写 LEFT JOIN 时对右表加了过滤条件,比如:
SELECT a.id, b.name FROM orders a LEFT JOIN users b ON a.user_id = b.id WHERE b.status = 1;这个写法的问题在于:一旦 WHERE 里用到右表字段,LEFT JOIN 的外连接语义就被破坏了,结果等同于 INNER JOIN。如果你本意是“所有订单都要,用户信息能取则取”,那这个查询会悄悄把那些没有对应用户的订单滤掉。线上数据量越大,这种 bug 越隐蔽。正确写法是把这个条件挪到 JOIN 子句里:
SELECT a.id, b.name FROM orders a LEFT JOIN users b ON a.user_id = b.id AND b.status = 1;这个例子我几乎每次培训都会讲,因为它是连接语义的经典误用,而且结果不会报错,不会红牌警告,只会静静给你一份缺失数据的报表。
3.2 JOIN 方向选择与结果行数预估
还有一种更容易埋雷的情况:两张表本来是一对多关系,你从多表那边做 LEFT JOIN,结果会产生重复行。比如一个订单有多个支付记录,你要查订单信息再关联支付流水,一单多流水会把订单数据复制多份。如果你的统计维度是“订单数”,直接 COUNT(订单表.id) 就会偏大,因为行数膨胀了。
我处理这类问题,通常先问业务上“主表是哪张、粒度是什么”,再决定连接方向。粒度不清晰,JOIN 的结果就不可信。如果是订单主表做维度,我宁可先把支付流水按订单维度聚合一次,再关联回来,也不冒行数膨胀的风险。这个“先聚合再关联”的思路,在报表查询里极其常用,比盲目 JOIN 要快得多。
另一个实践中常见的场景是分页 JOIN。比如要分页查“订单列表,附带用户昵称”,很多人的写法是先 JOIN 再 LIMIT,这在数据量大时会导致数据库先完成整张大表的连接,再取出分页区间,前几页还好,翻到很后面就明显变慢。更聪明的做法是先从主表查出当前页的主键集合,再用主键去关联其他表取字段。这个技巧我叫它“推迟 JOIN”,实测在深分页场景能显著缩短查询耗时。
3.3 连接条件里的类型隐患
连接条件里字段类型不一致,是索引失效和结果错误的双料元凶。比如a.user_id是 VARCHAR,b.id是 INT,很多数据库会有隐式转换,走不了索引,甚至结果错乱。我遇到过一次同事把用户ID存成字符串,关联时忘了转,直接导致某些 ID 匹配不上,业务侧查了半天还以为是数据问题。
这种事情,写笔记时必须放在显眼位置:连接字段类型必须一致,或者显式 CAST。最稳妥的办法是建表时就把关联字段设计成同类型。在字段命名、类型上多花十分钟,后面能少流很多汗。
4. 聚合分组:核心不是函数怎么用,而是数据维度怎么定
4.1 GROUP BY 的真正语义
很多人把 GROUP BY 理解为“分组”,这没错,但不够深刻。我更喜欢把它理解成“把多行压成一行”,而这个“压”的过程,要求 SELECT 里出现的非聚合列,必须出现在 GROUP BY 里。这个限制不是数据库在给你添麻烦,而是防止你写出语义有歧义的查询。
但需要注意的是,MySQL 曾经有个“宽松模式”,允许 SELECT 里出现不在 GROUP BY 中的列,并且返回的只是这一组里的随机值。这在低版本里是坑,在高版本里默认被禁用。如果你还在维护老项目,尤其要小心这种隐形的非确定性结果。笔记里我专门加了一条检查项:所有 SELECT 中的非聚合列,逐一核对是否在 GROUP BY 中。
4.2 聚合前的过滤与聚合后的过滤
WHERE 和 HAVING 的定位区别,很多人背过“WHERE 在前,HAVING 在后”,但实际写的时候还是会混用。WHERE 是聚合前的行级过滤,HAVING 是聚合后的组级过滤。如果你要“排除某些订单再算金额”,条件是放在 WHERE 里的;如果你要“只保留总金额大于某值的分组”,就得用 HAVING。
从性能角度看,能用 WHERE 完成的过滤就不要留给 HAVING。因为提前过滤掉的数据不会进入聚合计算,能显著降低中间结果集的大小。我见过一个慢查询,就是对几百万行做 GROUP BY 之后再用 HAVING 过滤,如果把条件提前到 WHERE,耗时下降非常明显。这是个性价比极高的优化。
4.3 COUNT、SUM 的隐式陷阱
COUNT(字段) 和 COUNT() 的区别,看起来是常识,但依然常有人中招。COUNT() 是统计行数,COUNT(字段) 是统计该字段非 NULL 的行数。如果你的字段允许 NULL,那这两个结果对不上,统计口径直接出问题。我习惯在统计行数时总是用 COUNT(*),不为别的,就是不给 NULL 留任何操作空间。
SUM 也有类似的问题:字段全为 NULL 时,SUM 返回 NULL 而不是 0。业务里展示“合计金额”时,如果结果集为空,页面显示就可能变成空白。处理方式很简单,用COALESCE(SUM(amount), 0)兜底。这种细节很琐碎,但正是这些琐碎决定了线上报表可不可信。
4.4 分组后的排序与分页
如果是报表场景,分组之后几乎必然要 ORDER BY,比如按销售额倒序取 TOP N。这里容易踩的一个坑是:排序字段是否在 SELECT 里,以及排序是在分组前还是分组后。ORDER BY 的生效范围一定是在分组之后,如果你想“每个类目下取最新一条”,直接用 ORDER BY 加 GROUP BY 是做不到的,得用窗口函数或者子查询先排好序。
聚合分组这一章,我一般建议配合真实业务反复练,单看函数文档很难建立起“维度”这个思维。你脑子里得先有“我要看什么维度、什么度量、粒度是什么”这三个问题,然后才是写 GROUP BY。
5. 事务与性能优化:笔记的终点,是线上稳定
5.1 事务隔离级别的实际影响
事务这章我放在笔记靠后的位置,但它绝对是核心中的核心。很多人知道 ACID,但不太清楚隔离级别对线上行为的影响。以 MySQL 默认的 REPEATABLE READ 为例,它在同一个事务内多次读取同一批数据结果是一致的,这给业务带来了便利,但也意味着你可能读到的是快照数据,而不是最新提交。
在实际开发里,我遇到最多的场景是:一个事务内先查询某个值,再根据这个值去更新。如果隔离级别控制不好或者没加锁,两条并发请求可能都读到旧值,然后各自更新,造成覆盖写入。这类问题比慢查询更危险,因为它是逻辑层面的并发错误,报错都报不出来。解决思路要根据业务来选择:能接受一致性弱一点,就读已提交;必须严格并发控制,就要考虑加锁或使用乐观锁机制。
5.2 索引失效的常见场景与自查清单
性能优化这章,我把索引相关的内容做成了自查清单模式,因为索引问题太常见了,几乎每次排查线上慢查询都要用到。失效的常见场景包括:对索引列使用函数、隐式类型转换、LIKE 左侧模糊、OR 连接非索引条件、在索引列上做计算。这些场景如果避不开,至少要知道它会导致索引失效,从而评估是否值得改写。
另外一个很经典的场景是复合索引的最左前缀原则。要发挥复合索引的作用,查询条件必须从索引的最左列开始匹配。很多人建了(a, b, c)索引,然后只查b和c,索引完全帮不上忙。我见过最可惜的情况是:字段顺序建反了,实际高频查询恰好不走索引,而改一个索引顺序就能解决问题。建索引前,先列一遍业务最常用的几种查询模式,比用默认方式随意加索引靠谱得多。
5.3 EXPLAIN 怎么看,别只盯着 type 和 rows
EXPLAIN 几乎是每个 DBA 都会让开发者先跑一遍的命令,但很多人只会看 type 是不是 ALL。确实,ALL 代表全表扫描,看到了会紧张,但这不是全部。我很看重的还有 key 是否用到了预设索引、rows 的预估行数是否符合预期、Extra 里有没有出现讨厌的Using filesort或Using temporary。
Using filesort意味着排序没有走索引,在这个字段上做排序或分组时,如果有高频业务,建议把排序字段一起放进索引。Using temporary更值得关注,它往往意味着查询过程中建了临时表,这在数据量大时可能导致磁盘临时表,成本飙升。
我自己的排查流程是:先看 type 是否出现 const 或 ref,再看 key 和 rows,最后盯 Extra。三样都过关,再谈其他优化。这样能避免只抓住一个点而漏掉整条查询链路的问题。
5.4 优化不是堆索引,改 SQL 优先于加索引
最后我想说一个思维方式的问题:很多人一遇到慢查询就条件反射“加索引”。但真实场景里,很多慢是因为 SQL 写法本身绕了远路,比如子查询套娃、连接顺序不合理、在循环里逐条查数据库。这些情况下,加索引只能治标,改写法才能治本。
举个例子。我排查过一个统计查询,原 SQL 把数据全部查出后,在应用层循环几千次再去查明细库。当时没有任何单个查询是慢的,但整体接口却慢得离谱。把逻辑改写成一个 JOIN + 聚合后,响应时间从十几秒降到两百毫秒以内。这种优化比加索引有价值得多。所以笔记里我特意留了一段:能改 SQL 写法的,不要先动索引;能减少查询次数的,不要增加查询复杂度。
6. 这套笔记的说是方法,也是避坑手册
回看这四章,本质上就是一套从“能跑”到“跑得好”的进阶路径。基础查询章解决的是语法层次的理解偏差,多表连接章解决的是数据关系上的语义混淆,聚合分组章解决的是统计口径和粒度问题,事务与性能优化章解决的是线上稳定性的底线问题。
如果你现在是一个刚接触 SQL 不久的人,我建议你不要只是读,一定要拿自己的业务数据把每一章的示例跑一遍,尤其是故意踩一遍那些坑,比如在 LEFT JOIN 后加右表过滤,或者在 WHERE 里对索引列做函数计算。很多东西,只有在自己手上翻过车,下次才能彻底长记性。
我个人在实际操作中的体会是:用笔记记录知识只是起点,真正有价值的是把笔记变成自己排查问题的思维习惯。我后来每次写 SQL 都会先问自己几个问题:我要的粒度是什么,连接关系会不会膨胀,过滤条件是否让索引失效,事务里有没并发覆盖的可能。这些问题就是这四章笔记浓缩下来的骨架,每一条背后都是一次线上教训。