☰
SQL五章笔记:从基础查询到索引优化实战精讲
2026/10/19 10:40:22 网站建设 项目流程

1. 为什么我要整理这套SQL五章笔记

先交代一下背景。这些年我接触过不少写SQL的人,有刚入行的新人,也有写了几年业务查询但始终停留在"能跑就行"阶段的老哥。大家的需求其实高度一致:想系统地把SQL学扎实,但市场上要么是碎片化教程,要么是厚得像砖头的官方文档,真正能让人一章一章吃透、还能直接用到工作里的并不多。

"SQL五章笔记"这套东西,最初是我给自己沉淀的复习提纲,后来逐步补充了实操案例和踩坑记录,慢慢形成了一个比较完整的学习闭环。整套笔记按五个核心章节展开:基础查询与过滤、多表关联、聚合与分组、子查询与窗口函数、事务与索引调优。这五块基本覆盖了日常开发中百分之九十以上的SQL使用场景,也对应着面试里最高频的考察范围。

适合谁看呢?如果你是刚接触SQL的初学者,这套笔记能从零搭起你的执行逻辑,搞清楚一条SELECT语句到底是怎么一步步跑出结果的;如果你已经写了一两年SQL但总觉得自己是"面向搜索引擎编程",那这套笔记能帮你补上那些知其然不知其所以然的原理盲区;哪怕你是个老手,第五章索引与事务部分也值得留个存档,毕竟生产环境里被慢查询反复折磨的经历,谁都逃不掉。

这篇文章我不会只把笔记目录平铺给你看,那没意义。我会把每一章的核心知识点、学习思路、容易踩的坑,还有我实际排查过的案例全部拆开讲一遍。你把这篇文章看完,相当于跟着我的笔记框架把SQL梳理了一遍,之后无论是去啃官方文档还是应对面试,都能有个清晰的地图在手。

2. 第一章与第二章:查询基础和多表关联,地基必须打牢

2.1 基础查询的思维模型:先想清楚执行顺序

很多新人学SQL有一个共同的误区:一上来就背关键字,SELECT、FROM、WHERE、GROUP BY背得滚瓜烂熟,真到了写复杂查询的时候还是懵。原因很简单,你背的是书写顺序,但数据库执行查询的顺序跟书写顺序完全是两回事。

一条标准的SQL查询,它的逻辑执行顺序是:FROM、WHERE、GROUP BY、HAVING、SELECT、ORDER BY、LIMIT。这意味着什么?意味着你写WHERE的时候,数据库是先确定了从哪张表取数据,再逐行过滤;你写SELECT里的别名,如果想着在WHERE里引用,不好意思,那时候别名根本还不存在。

我用一个具体例子说明。假设有一张订单表orders,字段包括order_id、user_id、amount、created_at。你要统计2024年每个用户的订单总金额。

SELECT user_id, SUM(amount) AS total_amount FROM orders WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' GROUP BY user_id ORDER BY total_amount DESC;

这段SQL的执行过程是:先定位orders表,然后按时间条件过滤每一行,接着按user_id分组并对每组做SUM聚合,最后按合计金额降序排列。如果你先写SELECT再倒推FROM,就会被别名的坑绊住——比如你想在WHERE里过滤total_amount大于1000的用户,直接写WHERE total_amount > 1000是报错的,正确做法是用HAVING,或者套一层子查询。

这一章笔记我额外加了一个小结:优先掌握WHERE条件的书写技巧。等值判断、范围判断、模糊匹配、空值处理,这四类条件覆盖了绝大多数过滤场景。特别是NULL的判断,很多人会写WHERE amount = NULL,这是经典的错误写法,NULL跟任何值做等值比较结果都是UNKNOWN,必须用IS NULL或IS NOT NULL。

还有一点值得单独说:字符串比较和日期比较的写法在不同数据库里差异很大。MySQL里字符串可以用单引号包起来直接比较,日期也可以用字符串形式比较,因为MySQL会自动做隐式转换;但PostgreSQL对类型要求严格得多,日期字段就得用DATE类型或to_date函数转换。笔记里我把常见数据库的差异列了个对照表,让读者在不同环境切换时不至于抓瞎。

2.2 多表关联的JOIN选型与关联陷阱

多表关联是SQL学习的分水岭。INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL JOIN,每种JOIN的行为差异如果只看图解,理解是肤浅的,必须落到数据量的变化上。

INNER JOIN取两表交集,结果的每一行必须同时在两张表里找到匹配记录。LEFT JOIN以左表为基准,左表每一行都会保留,右表没有匹配就补NULL。业务里最常用的就是LEFT JOIN,比如你要统计每个用户最近的订单,主表是用户表,订单表通过LEFT JOIN挂上去,没有订单的用户也要出现在结果里。

关联查询最坑的地方有三类。

第一类是关联条件写错导致数据膨胀。如果两张表关联的字段不是唯一键,一对多甚至多对多的情况会产生笛卡尔积式的行数爆炸。比如用户表和订单表通过user_id关联,一个用户有十张订单,关联之后这个用户就从一行变成了十行。如果你前面没意识到这一点,直接对关联结果做COUNT或SUM,统计出来的数字全错。

第二类是关联字段存在NULL或重复值。两个表关联字段只要有一方出现NULL,这一行就匹配不上;出现重复值,行数就会翻倍。我实际处理过一个案例:某业务表的历史数据里user_id字段被错误地存成了字符串,前后多了空格,关联时怎么都匹配不上,排查了很久才发现是脏数据问题。所以我会在笔记里强调,做多表关联前先看一眼两张表的关联字段的基数情况,用DISTINCT查一下有没有重复,用LENGTH查一下有没有格式问题。

第三类是LEFT JOIN后使用WHERE条件误杀左表记录。这个坑尤其隐蔽。你LEFT JOIN右表之后,如果想在WHERE里过滤右表的条件,比如只想保留有支付记录的订单,写成WHERE payment.status = 'paid',会把那些右表没有匹配、status为NULL的行也过滤掉,导致结果从"左表全部记录"悄悄变成了INNER JOIN的效果。解决方法就是把右表过滤条件放在JOIN的ON子句里,或者用AND连接。

2.3 实战案例:从用户表与订单表构建报表

我把这个思路串成一个完整的实战案例。需求是:统计2024年1月到3月期间,每个用户的订单数、订单总金额,以及用户注册日期。用户表users有user_id、user_name、created_at;订单表orders有order_id、user_id、amount、order_date。

第一步,确定主表。用户要全量展示,所以用LEFT JOIN,主表是users,右表是orders。第二步,确定过滤条件。订单时间范围放在JOIN的ON条件里,避免右表过滤条件影响左表记录保留。第三步,确定分组粒度。GROUP BY要包含users表中的所有非聚合列,这里就是user_id、user_name、created_at。第四步,确认聚合逻辑。

SELECT u.user_id, u.user_name, u.created_at, COUNT(o.order_id) AS order_count, COALESCE(SUM(o.amount), 0) AS total_amount FROM users u LEFT JOIN orders o ON u.user_id = o.user_id AND o.order_date >= '2024-01-01' AND o.order_date < '2024-04-01' GROUP BY u.user_id, u.user_name, u.created_at ORDER BY total_amount DESC;

这里有两个细节值得留意。COUNT(o.order_id)按右表主键计数,没有订单的用户会显示0,这是查"有没有订单"的标准写法,不能用COUNT(1)替代,因为COUNT(1)会把左表保留下来的NULL行也算进去。SUM(o.amount)对无订单用户是NULL,业务上往往需要显示0,所以用COALESCE兜底。

这套笔记的基础章节我花了很大篇幅强调"先定位主表,再确定过滤位置,最后确认分组粒度"的三步法。因为曾经带过一个新人,他写关联查询时习惯性地把过滤条件全部堆在WHERE里,导致报表数据跟业务方手工统计的数字始终对不上。后来我们一起逐条核对,发现就是LEFT JOIN语义被WHERE破坏的问题。这个教训记录在笔记里,比单纯讲JOIN类型更贴近实战。

3. 第三章:聚合与分组,统计口径决定一切

3.1 聚合函数的执行逻辑与NULL处理

聚合是SQL里最容易"看起来会了其实没会"的部分。COUNT、SUM、AVG、MAX、MIN,五个基础聚合函数,真正用对的人不多。

以COUNT为例,COUNT()统计行数,COUNT(字段)统计该字段非NULL值的个数。在LEFT JOIN场景里,COUNT()会把右表无匹配的行算进去,而COUNT(右表主键)则不会。AVG则直接忽略NULL值,如果你想将NULL当0参与平均,必须先用COALESCE转换。SUM对所有值求和,忽略NULL,如果整列都是NULL,SUM返回NULL而不是0,这部分也常用COALESCE兜底。

一个生产环境的经典问题:统计用户平均订单金额,如果一个用户没有任何订单,AVG结果是否为0?答案是NULL。这在实际报表里会导致展示异常,你必须在SELECT层做处理:

SELECT u.user_id, AVG(COALESCE(o.amount, 0)) AS avg_amount -- 将无订单视为0 FROM users u LEFT JOIN orders o ON u.user_id = o.user_id GROUP BY u.user_id;

还要提醒一点:聚合后参与运算的字段,如果是先聚合再加减乘除,写法和优先级要小心。比如SUM(amount) * 0.9表示总金额打九折,SUM(amount * 0.9)表示每单打九折再合计,两者结果不同,业务语义也不同。这类细节我笔记里专门用红色标注过,看多了你会发现SQL聚合的错误大多是语义偏差而不是语法错误。

3.2 GROUP BY的易错点与HAVING的正确使用

GROUP BY的核心逻辑是按指定列把多行合并成一组,每组输出一行。易错点集中在"SELECT中出现的非聚合列必须全部出现在GROUP BY中"。这在SQL标准里是硬性要求,但MySQL有一项宽松处理,默认只写了部分列也能通过,这时返回的列值取自组内随机一行,拿到结果你都不知道是哪条,线上用这种写法等于埋雷。

HAVING和WHERE的最大区别是执行时机不同。WHERE在分组前过滤原始行,HAVING在分组后过滤组。所以过滤条件里如果用到聚合结果,必须写在HAVING里。比如订单数大于5的用户:

SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id HAVING COUNT(*) > 5;

把COUNT(*) > 5写在WHERE里是行不通的,因为WHERE执行时分组还没发生,聚合值还不存在。另外,HAVING里可以使用SELECT中的别名,而WHERE不可以,这对应着执行顺序,理解了顺序这一点就不用死记。

还有分组时容易被忽略的一个场景:GROUP BY多列时,粒度是多个列的组合。比如按user_id和order_date分组,分组粒度就是同一天同一用户,这就为后续统计"日活用户"或"每日订单明细"打好了基础。写GROUP BY时先问自己一句"我需要观察的最小粒度是什么",粒度错了,统计出来的所有指标都白搭。

3.3 聚合场景的排查实录

分享一个我排查过的实际案例。某天业务方反馈后台报表里"高价值用户数"从前一天的500人骤降到80人,怀疑数据问题。我拿到查询语句一看,统计口径是"订单总金额超过1万的用户数"。

SELECT COUNT(DISTINCT user_id) FROM orders WHERE amount > 10000;

初看好像没毛病。但拆解一下就知道问题出在了WHERE过滤行级金额,而不是聚合后的总金额。需求明明是想"总金额超过1万",这里却把每张订单金额都超过1万作为条件,一个用户如果有十张五千块的订单,总金额明明远超1万,却被过滤掉了。正确写法是:

SELECT COUNT(*) FROM ( SELECT user_id FROM orders GROUP BY user_id HAVING SUM(amount) > 10000 ) t;

这个案例的核心启发是:聚合与过滤的关系,必须先明确统计粒度。是"单笔订单金额"还是"用户累计金额",一字之差,结果天差地别。我在笔记里专门列了一个"聚合场景自检清单":第一,统计粒度是什么;第二,过滤发生在分组前还是分组后;第三,NULL对聚合结果的影响是什么。这三个问题想清楚,聚合类的SQL基本不会写错。

4. 第四章:子查询与窗口函数,从嵌套到逐行计算

4.1 子查询的三种形态与性能感知

子查询可以出现在WHERE、FROM、SELECT三个位置,行为差异很大。

WHERE后的子查询常用于过滤条件,比如找出订单金额高于平均值的订单。这里的子查询返回的是标量或集合,写法比较简单,但性能问题也最突出。如果子查询没有好的索引支撑,外层每一行都可能触发一次子查询执行,这种场景叫相关子查询,数据量大时性能极差。我见过有人用相关子查询查一万条数据的大表,语句跑了十几秒,后来改写为JOIN加GROUP BY,秒级完成。

FROM后的子查询叫派生表,相当于把一段查询结果包装成一个临时表,供外层继续查询。这种写法的好处是逻辑清晰,先做内层聚合再在外面做过滤,符合人类思考习惯。比如要统计每个用户首次订单日期,可以先在子查询里聚合出首单日期,再和原表关联。

SELECT后的子查询通常是标量子查询,用于补充展示字段。这种写法要格外小心,因为结果集每行都会执行一次子查询,如果涉及大表扫描,性能影响非常大。能用JOIN表达的尽量用JOIN。

我在笔记里给了一个适用性建议:子查询适合逻辑分层清晰的场景,JOIN适合性能敏感的多表关联场景,窗口函数适合需要逐行保留上下文的统计场景。三者没有绝对的优劣,但理解了各自的执行语义,就能选对工具。

4.2 窗口函数的三大类:排序、聚合、偏移

窗口函数是SQL进阶的必学内容,也是面试高频考点。它和普通聚合最大的区别是:普通聚合每组输出一行,窗口函数每行输出一行,同时保留该行原有的所有字段。

第一类是排序窗口函数。ROW_NUMBER、RANK、DENSE_RANK三者都用于排名,但处理并列的方式不同。ROW_NUMBER并列时随机排先后,编号不重复;RANK并列时编号重复,且后面跳号;DENSE_RANK并列时编号重复,但不跳号。业务里最常见的取分组Top N,就用ROW_NUMBER结合PARTITION BY。

举一个"查询每个品类销量前三的商品"的例子:

SELECT category_id, product_id, sales, rn FROM ( SELECT category_id, product_id, sales, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales DESC) AS rn FROM product_sales ) t WHERE rn <= 3;

这里内层用窗口函数给每个品类内的商品按销量排名,外层再过滤出前两名。注意:窗口函数是在WHERE之后执行的,所以不能直接卸载WHERE里,必须像这样通过派生表包一层。

第二类是聚合窗口函数。SUM、AVG等普通聚合函数加上OVER子句后,可以实现累计求和、移动平均等逐行动态计算。比如统计用户每次下单后的累计消费金额:

SELECT user_id, order_date, SUM(amount) OVER (PARTITION BY user_id ORDER BY order_date) AS cumulative_amount FROM orders;

这条SQL跑出来的结果,每一行都带着之前所有订单的累计值,做分析报表、增长曲线非常方便。

第三类是偏移窗口函数。LAG和LEAD分别取当前行之前和之后的某行值,适合计算环比、同比、会话时长等。比如要计算用户相邻两次下单的时间间隔:

SELECT user_id, order_date, LAG(order_date, 1) OVER (PARTITION BY user_id ORDER BY order_date) AS prev_order_date, DATEDIFF(order_date, LAG(order_date, 1) OVER (PARTITION BY user_id ORDER BY order_date)) AS gap_days FROM orders;

这类需求用传统的自关联也可以做,但代码复杂且性能差,窗口函数几乎就是为此而生的。

4.3 窗口函数的框架定义与性能注意事项

窗口函数语法里,OVER后面可以跟两类子句:PARTITION BY指定分区维度,ORDER BY指定分区内排序。两都都省略时,整个结果集就是一个大分区,很少这么用,因为没有分区意味着全表一起算,数据量大时会产生巨大排序压力。

性能方面有几点实操经验。第一,窗口函数通常需要在分区列和排序列上建立合适索引,否则数据量大时执行计划会出现额外的排序操作,甚至把临时数据落到磁盘。第二,能先过滤再开窗就不要先开窗再过滤,因为窗口函数计算的是过滤后的结果集。第三,不要在一个查询里堆五六个窗口函数,每个都有独立的PARTITION BY和ORDER BY,执行引擎可能分别计算,复杂度翻倍。

窗口函数的学习建议是:先手工模拟执行一遍,把每一行对应的窗口范围画出来,再去验证结果。窗口范围的理解远比语法本身重要,理解之后,RANGE和ROWS两种框定方式的区别也就迎刃而解了。

5. 第五章:事务与索引调优,从会写到写得好

5.1 事务的ACID特性与隔离级别

事务是SQL从查询走向数据一致性的分界线。四条特性必须吃透:原子性、一致性、隔离性、持久性。原子性保证事务内操作要么全成功要么全回滚;一致性保证事务执行前后数据满足所有约束;隔离性保证并发事务之间互不干扰;持久性保证已提交事务的修改永久生效。

四个隔离级别应对着不同的并发异常。读未提交可能读到脏数据,读已提交解决了脏读但仍可能不可重复读,可重复读避免了不可重复读,串行化杜绝了幻读。MySQL默认的可重复读级别里,通过间隙锁等手段实际上也能较大程度地避免幻读,理解这一层对排查线上死锁和并发写问题非常有用。

我处理过一个经典问题:下单扣库存场景,两个并发事务同时扣减同一件商品的库存,结果出现超卖。排查后发现业务代码只是在应用中做了数量判断,却没有在数据库层用事务和锁来保护。先用SELECT查库存,再UPDATE扣减,中间没有任何事务包裹和锁机制。正确的做法是使用原子更新:

UPDATE inventory SET stock = stock - 1 WHERE product_id = 123 AND stock >= 1;

条件里带上stock >= 1,由数据库保证原子性,就不会出现超卖。

5.2 索引选择的底层逻辑

索引是SQL性能的核心,也是普通从业者最容易用错的部分。

B+树索引的底层结构决定了它的两个特性:叶子节点有序存储、索引查找定位速度快。当你对一张表建立索引后,查询如果能走索引,就避免了全表扫描。但索引不是越多越好,每多一个索引,写入时多一份索引维护成本。

什么时候建索引?高频过滤字段、常用关联字段、排序字段,这三类字段优先建立索引。比如订单表的user_id和order_date,几乎每条业务查询都会用到,就该建联合索引。

联合索引有个著名的"最左前缀原则":索引(a, b, c)可以匹配(a)、 (a, b)、 (a, b, c)三种条件的查询,但不能匹配(b, c)。所以联合索引的字段顺序,应该按照查询频率从高到低排列。如果你常用order_date和user_id组合查询,联合索引就应建在(order_date, user_id)上,而不是反过来。

一个容易踩的坑是函数操作导致索引失效。比如对索引列使用函数DATE(order_date) = '2024-01-01',数据库无法直接使用B+树索引进行定位,只能在索引扫描后逐行应用函数,效果等同于全表扫描。正确写法是改写为范围条件order_date >= '2024-01-01' AND order_date < '2024-01-02'。

另一个坑是隐式类型转换。如果索引字段是字符串,查询条件写成数字,数据库做了类型转换后,索引也会失效。比如WHERE user_id = 123456而user_id实际是VARCHAR类型,MySQL会把字段转成数字再比较,索引用不上。

5.3 慢查询排查的实操步骤

线上出现慢SQL时,我的排查思路固定在下面这几步。

第一步,用执行计划工具看关键指标。重点看type这一列,从好到坏的顺序是system、const、eq_ref、ref、range、index、ALL。ALL意味着全表扫描,绝大多数慢查询的起点。

第二步,看rows预估行数,与实际返回行数对比。如果预估上百万行,而实际结果只有几十行,大概率是索引没走对,或者是过滤条件写得不够紧。

第三步,检查Extra列。如果出现Using filesort或Using temporary,说明排序或去重操作没有用上索引,数据量大时会非常慢。优化方向往往是调整索引顺序或者改写查询结构。

举个例子来说明排查过程。某条查询延迟飙升,定位到语句为:

SELECT * FROM orders WHERE status = 'completed' ORDER BY created_at DESC LIMIT 20;

orders表有上千万行,status字段有索引但区分度不够,created_at字段也单独建了索引。执行计划显示走了status索引过滤后再排序,排序阶段没有索引可用,触发Using filesort,全量数据落盘排序。

我的优化方案是改成联合索引(status, created_at),让过滤和排序在同一条索引上完成,避免排序操作。修改后语句从1.8秒优化到30毫秒。这个案例被完整记在笔记第五章里,用来说明索引设计不只要考虑过滤条件,还要考虑排序字段。

6. 学习路径建议与自测方法

把五章笔记完整过一遍之后,很多人会问一个问题:接下来该怎么巩固?我的建议是给自己设计一套渐进式的自测练习。

第一层,基础还原。不查任何资料,完整写出单表查询的所有执行顺序,并用EXPLAIN验证自己的理解。第二层,场景题练习。自己设计一个包含用户、订单、商品、品类四张表的业务模型,按"每个品类销量TOP3的商品""连续三个月都有订单的用户"这类真实业务需求编写SQL。第三层,性能优化实战。找一条慢查询,通过索引调整、JOIN改写、子查询转窗口函数等手法逐步优化,每次优化都记录执行时间的变化。

我还建议养成一个习惯:每写完一条SQL,至少看一眼执行计划。执行计划就是SQL的体检报告,不会看执行计划的人,写一百条SQL和写一条SQL的成长速度是天壤之别。这个习惯一旦养成,你对SQL执行逻辑的理解会突飞猛进。

7. 我的实际感受

整套笔记从零散片段整理成五章结构,花了我不少业余时间,但带来的收益非常直接。以前遇到复杂查询我习惯"先跑通再说",整理笔记之后变成了"先想清楚执行顺序再写"。犯错率下降是一方面,更重要的是排查问题时有了框架:先看执行计划,再定位索引和关联策略,最后检查聚合粒度。

如果你是刚开始学SQL,不用急着把五章一口气学完。先吃透前两章,把单表查询和多表关联写到滚瓜烂熟,再往后推。如果你是已经工作了一阵子的开发,可以直接挑第四章和第五章深挖,这两块对日常开发效率和数据一致性帮助最大。

最后分享一个小技巧:把笔记里的每个案例都改成自己业务里的真实数据跑一遍,哪怕只是把订单表换成你们的业务表,语法不变,理解也会深一个层次。SQL这玩意儿,看起来是语法,实际上是逻辑,练得多了,自然会形成肌肉记忆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询