☰
SQL进阶必备:窗口函数、事务锁与慢SQL优化实战解析
2026/10/9 3:40:28 网站建设 项目流程

“Day5”这个编号,我太熟悉了。不管你是跟着培训机构的教学大纲走,还是自己在B站、Udemy上按计划啃教程,第5天都是个坎。前4天你刚搞定了环境、基础增删改查、连表查询和分组聚合,自我感觉良好,觉得SQL也不过如此。然后第5天,啪,课程标题甩过来一个“SQL-4”,内容直接从“怎么写”跳到了“怎么写好、写稳、写得快”。

这篇文章就把Day5-MySQL-SQL-4这节课的核心脉络拆开揉碎。我会把这一阶段最值钱的几个知识点,包括去重与排序的底层逻辑、窗口函数的实战场景、事务与锁的并发控制原理、慢SQL的定位与优化思路,还有新手最常见的几个坑,一次讲清楚。适合正在跟课自学、准备面试突击、或者刚上手写业务SQL没几天就被复杂查询折磨过的同学。

1. 内容整体设计与思路拆解

很多自学的人到了这个阶段会突然卡住,原因很简单:前几课的SQL是“单行道”,每条语法对应一个明确动作,WHERE就是过滤,JOIN就是连表,SELECT就是取列。到了SQL-4,你会发现同一个需求,十条SQL都能写出来,但结果可能完全不一样,有的跑20秒,有的跑0.03秒;有的逻辑一眼能看懂,有的嵌套五层子查询,过两周自己都看不懂。

这个阶段的核心矛盾,不是“不会语法”,而是“缺乏判断力”。所以课程在设计上,一般会把内容分成三个递进板块。

第一板块是查询细节的深挖,专门针对DISTINCT去重、ORDER BY排序、各类内置函数的使用场景。这些语法本身简单,难的是“什么时候用哪个”。比如去重,有人不管三七二十一就写DISTINCT,结果数据量一大,性能肉眼可见地往下掉。你得清楚DISTINCT和GROUP BY的执行逻辑差异,才能做出正确选择。

第二板块是窗口函数。这是SQL从“及格”到“优秀”的分水岭。传统GROUP BY会把多行压成一行,丢失了细节;窗口函数则能在不合并行的前提下,对每一行做基于分组或排序的计算。它的出现,让排名、同比、环比、累计值这些复杂分析变得极其简洁。

第三板块是事务、锁和性能优化。到了这一步,SQL不再只是“查询语言”,而是“数据可靠性语言”和“性能调控语言”。你需要理解ACID是什么,为什么转账必须用事务;你需要知道MySQL默认的可重复读隔离级别到底隔离了什么;你还要学会看EXPLAIN执行计划,搞清楚一条慢SQL到底慢在哪个环节。

我当时学到这里最大的感受就是:前4天学的是套路,第5天才开始学思路。套路背得再熟,换个业务场景就抓瞎;思路一旦建立,往后写任何SQL都有章法。

2. 查询语句核心细节:去重、排序与函数

这一部分属于“看似简单,实则暗藏杀机”的典型。我先说去重,因为热搜词里“sql语句去重”出现了好几次,数据清洗场景里尤其高频。

2.1 DISTINCT去重与GROUP BY去重的选择

DISTINCT的语法极其简单,SELECT DISTINCT user_id FROM orders,对user_id去重。但有几个细节是新手容易忽略的。

第一,DISTINCT作用于所有选中的列,不是只作用于第一列。SELECT DISTINCT user_id, order_date去重的是这两个字段的组合,不是单独把user_id去重。第二,DISTINCT对NULL的处理方式是:所有NULL值被视为相同,所以去重后只保留一个NULL。第三,也是最重要的,DISTINCT的实现方式是排序去重,当处理大表且去重字段没有索引时,它会触发文件排序,性能可能非常难看。

那什么时候用GROUP BY替代DISTINCT?比如你要同时查询去重后的用户数和他们的首单时间,DISTINCT就无能为力了,你得这样写:

SELECT user_id, MIN(order_date) AS first_order_date FROM orders GROUP BY user_id;

还有一个典型场景:在去重的同时需要取每个分组里的最新状态或最大金额。比如一个用户有多条欠款记录,你想取每个用户金额最大的一条,这时候DISTINCT完全使不上力,要么用窗口函数,要么用关联子查询。

2.2 排序细节

ORDER BY这里有个出镜率极高的坑:数字和字符串混排。比如你的order_no字段是VARCHAR类型,存的值是"1"、"2"、"10"、"20",直接用ORDER BY order_no排,结果可能是"1, 10, 2, 20",因为字符串排序是按字典序来的。解决办法是加CAST转换:

SELECT order_no FROM orders ORDER BY CAST(order_no AS SIGNED);

另外MySQL的排序默认把NULL排在前面,和Oracle恰恰相反。如果业务上要求NULL排在最后,需要写ORDER BY order_date IS NULL, order_date。这种细节,文档里不会特意提醒你,只有被业务方怼过的人才知道。

2.3 高频内置函数

这个阶段必须掌握的函数类别,我列一张速查表:

函数类别常用函数典型场景
日期函数DATE_FORMAT、DATEDIFF、DATE_ADD统计报表按天/周/月分组
字符串函数CONCAT、SUBSTRING_INDEX、REPLACE清洗脏数据、拼接地址
聚合函数COUNT、SUM、MAX、MIN、AVG基础统计
条件函数IF、CASE WHEN逻辑分桶、状态判断
转换函数CAST、CONVERT类型不匹配时强制转换

日期函数是个重点,因为业务里“查最近30天”“查上个月”“查今年至今”这些需求太常见了。一个我用了很多年的写法:

SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) AS order_cnt FROM orders WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d');

注意GROUP BY后面跟的是DATE_FORMAT表达式,不是别名day。有些MySQL版本开了ONLY_FULL_GROUP_BY模式,用别名会直接报错。这个坑,我身边至少有三个同事踩过。

3. 窗口函数:复杂统计的利器

窗口函数是Day5-SQL-4这堂课含金量最高的部分。我甚至可以说,如果你只从这节课带走一个知识点,那必须得是窗口函数。

3.1 窗口函数到底是什么

先用大白话解释。普通聚合函数,比如SUM,是把多行合并成一行,输出一个总数,原来的行就没了。窗口函数不是这样,它也在内部做分组求和,但求完的结果会“贴回”到原来的每一行上,行数不减。

举个例子,你想看每个订单金额占当天总金额的百分比。用GROUP BY你得先按天求和,再JOIN回原表。用窗口函数,一句搞定:

SELECT order_id, amount, SUM(amount) OVER (PARTITION BY DATE(create_time)) AS day_total, amount / SUM(amount) OVER (PARTITION BY DATE(create_time)) AS pct FROM orders;

代码里的OVER (PARTITION BY ...)就是窗口函数的核心语法。PARTITION BY指定分组维度,ORDER BY指定组内排序,两者可以同时存在,也可以只用其中一个。

3.2 三类核心窗口函数

我在实际项目中用得最多的窗口函数,按使用频率排,是这三类:

第一类,排序类。ROW_NUMBER()、RANK()、DENSE_RANK()。三者的区别必须搞清楚:ROW_NUMBER给每一行一个唯一递增序号,即使值相同序号也不同;RANK遇到相同值会并列,但会跳号,比如1、1、3;DENSE_RANK并列不跳号,1、1、2。面试和笔试最爱考这个区别。

经典场景是分组TopN,比如“查每个部门工资最高的前3名员工”:

SELECT department_id, employee_name, salary FROM ( SELECT department_id, employee_name, salary, ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn <= 3;

注意这里的子查询嵌套。为什么不能直接在WHERE里写rn <= 3?因为WHERE的执行时机在窗口函数之前,你写完窗口函数再想过滤,MySQL已经在算完了,只能把结果包一层子查询。这个执行顺序问题,是窗口函数最常见的新手误区。

第二类,聚合类。SUM、AVG、COUNT加OVER。最典型的用法是计算累计值。比如查每天营业额和截至当天的累计营业额:

SELECT create_date, daily_amount, SUM(daily_amount) OVER (ORDER BY create_date) AS cumulative_amount FROM daily_sales;

这里没有写PARTITION BY,窗口就是全表,ORDER BY create_date表示从第一天开始累加到当前行。这就是滚动求和,报表里做趋势图太好用了。

第三类,偏移类。LAG和LEAD。它们的作用是取某一行的前一行或后一行的值。做同环比分析,比如计算每个月的营业额环比增长率,就得用LAG取上个月的值,再做差值运算。

3.3 窗口函数与GROUP BY混用的心理阴影

新手最爱踩的另一个坑,是在同一句SQL里既用窗口函数又用GROUP BY,然后得到一堆看不懂的结果。我的建议很简单:窗口函数的PARTITION BY和GROUP BY职责不同,GROUP BY负责汇总,窗口函数负责在明细基础上做计算。同一个SQL里混用,逻辑上很容易打架,不如拆成子查询。宁可多写一层嵌套,也别让代码读起来像天书。

还有一个经验之谈:窗口函数写起来爽,但执行性能并不总是最优。在千万级大表上做全表窗口计算,照样会慢。一般建议先在WHERE里把数据范围缩到足够小,再上窗口函数,别让数据库把全表数据都卷入窗口计算。

4. 事务、锁与并发控制

SQL-4的课程走到这里,开始脱离“查询”层面,进入“数据安全与并发”的地界。热搜词里“mysql事务处理”“mysql锁的分类”都是高频词,说明这确实是大家普遍关注、普遍搞不清的重点。

4.1 事务ACID到底在解决什么问题

一句话回答:保证多个数据库操作要么全部成功,要么全部失败,并且并发执行时不互相干扰。最经典的场景就是转账:A账户扣100块,B账户加100块,中间任何一个步骤失败,整个操作必须回滚,不允许出现钱扣了没加上的情况。

MySQL中InnoDB引擎默认是自动提交的,也就是每一条语句单独成一个事务。要手动控制多条语句组成一个事务,需要这样操作:

START TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; UPDATE accounts SET balance = balance + 100 WHERE account_id = 2; COMMIT;

如果中间某一步出错了,要把COMMIT换成ROLLBACK,让两条UPDATE全部撤销。这里有个实操细节,我在生产环境踩过跟头:先判断影响行数,再决定COMMIT还是ROLLBACK。很多新手直接连发两条UPDATE,不检查第一条到底更新了几行,结果条件写错,把不该扣的人的钱扣了,ROLLBACK已经来不及了。

4.2 MySQL的四种隔离级别

事务并发时会有脏读、不可重复读、幻读三类问题。为此SQL标准定义了四种隔离级别,MySQL默认是REPEATABLE READ,也就是可重复读。

隔离级别脏读不可重复读幻读
读未提交可能可能可能
读已提交不会可能可能
可重复读不会不会可能(InnoDB已解决)
串行化不会不会不会

这里有个知识点,很多人背了隔离级别却不知道怎么选。我的原则是:默认的可重复读级别在绝大多数业务下不用改。可重复读意味着你在一个事务里多次查询同一条件,结果始终一致,这对报表、对账这类需求非常友好。如果追求更大的并发性能,可以改成读已提交,牺牲一部分一致性换取更低的锁竞争。

4.3 锁的分类与死锁避让

MySQL的锁按粒度分,有表锁和行锁;按属性分,有共享锁和排他锁;InnoDB还有记录锁、间隙锁、临键锁。行锁是InnoDB和MyISAM最大的区别之一,MyISAM只支持表锁,所以并发写性能远不如InnoDB。

间隙锁这个概念比较难理解。它锁的不是某一行,而是“某个范围里不存在的行”。目的是防止幻读。比如事务A锁定id大于100的记录,事务B想插入id=101的记录,会被间隙锁挡住。这在可重复读级别下是安全的保证,但也是高并发下死锁的常客。

新手的核心诉求其实就一句话:怎么避免死锁。我的实操经验有三条。第一,多个事务更新多条记录时,保持相同的更新顺序。比如都先更新id=1的账户再更新id=2的账户,不要反过来。第二,事务尽量短小,减少锁持有时间。第三,更新前先精确缩小范围,能走索引就尽量走索引,让锁落在更少的行上。

4.4 MVCC的快照读与当前读

顺带说一下MVCC,多版本并发控制。InnoDB靠它实现了读写不互相阻塞。普通SELECT走的是快照读,看到的是事务开始时的数据版本,不加锁。而UPDATE、DELETE这些走的是当前读,必须拿到最新版本,并且加锁。

这意味着,在不同隔离级别下,两条查询同一张表的并发事务,可能看到完全不同的结果。理解这一点,很多看似“灵异”的并发问题就有了合理解释。

5. 慢SQL优化:从看懂EXPLAIN到实操调优

热搜词里“慢sql优化”赫然在列,可见这确实是全人类共同的痛。我见过太多开发同学,功能跑通了就算完事,压根不关心SQL性能。直到线上CPU报警,DBA甩过来一条慢查询日志,才开始慌。

5.1 快速定位慢SQL

MySQL默认开了慢查询日志吗?不同版本行为不一样,但一般建议显式开启。可以这样配置:

SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;

long_query_time设为1,意思是执行时间超过1秒的SQL都会被记录下来。日志路径用SHOW VARIABLES LIKE 'slow_query_log_file'查看。线上排查时,这个日志就是你的第一手证据。

5.2 EXPLAIN执行计划读法

拿到一条慢SQL,第一步永远是EXPLAIN SELECT ...,不要急着改。执行计划里我通常只看四个字段:type、key、rows、Extra。

type字段从好到差大概是:system > const > eq_ref > ref > range > index > ALL。看到ALL,说明全表扫描,这是性能杀手。key字段是实际用到的索引,如果是NULL,说明索引没生效。rows是预估扫描行数,这个数字越小越好。Extra里看到Using filesort或Using temporary,说明排序和分组没走索引,通常是优化的重点。

举一个我实际优化过的例子。有一条订单列表查询,WHERE条件里有WHERE create_time >= '2024-01-01' AND status = 1 ORDER BY id DESC LIMIT 10。EXPLAIN结果type是ALL,rows扫描了80万行。原因在于create_time和status各有单列索引,MySQL只能选一个,选了create_time索引后还要回表过滤status和排序,选型器干脆选择了全表扫。

优化方式很朴素:建一个联合索引(status, create_time, id),让WHERE条件、排序都能走索引。建完再EXPLAIN,type变成ref,rows降到几百,查询从1.2秒降到20毫秒。就是这个常见的操作,很多人不会做。

5.3 索引失效的几大陷阱

这里列几个我每次培训都必讲的索引失效场景:

  • 对索引列做了函数运算,比如WHERE DATE(create_time) = '2024-01-01',索引直接失效。改成范围查询create_time >= '2024-01-01' AND create_time < '2024-01-02'即可。
  • 隐式类型转换。字符串列和数字比较,比如WHERE varchar_col = 123,MySQL会把字符串转成数字,索引失效。
  • LIKE通配符开头的模糊查询,WHERE name LIKE '%张',索引失效。反过来'张%'是可以走索引的。

5.4 深分页问题

LIMIT 1000000, 20这种写法,MySQL要先把前100万行找出来扔掉,再取20行,性能极差。优化思路有两个。

一个是延迟关联,先用覆盖索引查出主键,再回表取完整数据:

SELECT t.* FROM orders t INNER JOIN ( SELECT id FROM orders ORDER BY id LIMIT 1000000, 20 ) tmp ON t.id = tmp.id;

另一个是游标分页,也就是记录上一页最后一条的id,下一页直接WHERE id > last_id LIMIT 20。在能保证id连续性或按主键排序的场景下,这种方式是终极杀器。

6. 常见问题与排查技巧实录

最后一个部分,我把自学和工作中高频出现的问题整理成速查表,再补充几个我自己的排查习惯。

6.1 高频问题速查

问题原因解决方法
去重后数据还是不对DISTINCT只作用于选中全部字段明确去重维度,或改用GROUP BY
排序结果和预想不符字符串按字典序排序用CAST转成数字再排
执行SQL脚本报语法错误脚本里混用了MySQL与SQL Server语法确认当前连接的数据库类型
事务更新后想撤销但数据没变忘了开启事务,或已自动提交用START TRANSACTION手动控制
窗口函数运行极慢窗口范围内数据量过大先用WHERE缩小范围
日期比较报错,类似Conversion failed字符串与日期类型隐式转换失败统一用STR_TO_DATE或CAST转换

热搜词里出现了“清洗---sql语句去重”,这其实对应的是数据清洗场景。做数据清洗时我常用的套路是:先查重看脏数据分布,再决定去重策略。比如:

-- 查看重复情况 SELECT user_id, COUNT(*) AS cnt FROM user_info GROUP BY user_id HAVING cnt > 1;

先确认重复的数量和字段组合,再写DELETE或者建临时表保留一条。绝对不要直接在生产表上DELETE,我一般会把不重复的数据先INSERT到临时表,确认无误后TRUNCATE原表再转回来。原因很简单:DELETE在InnoDB下不会立即释放磁盘空间,TRUNCATE可以;更重要的是,先备份再清理,出了事故还能回滚。

6.2 存储过程的调试心得

SQL-4的课里如果涉及存储过程,新手最容易卡的是“明明代码看着没问题,但执行就是报错”。我的建议是:先在存储过程里临时加SELECT调试变量,逐步执行;确认无误后再注释掉。另外MySQL的存储过程默认每条语句自动提交,如果想整体回滚,需要在存储过程里显式声明事务处理,并用DECLARE EXIT HANDLER捕获异常。

6.3 SQL注入的防护思路

这个话题必须要讲,但我从防御角度说。所谓的SQL注入,本质是用户输入被拼接进了SQL语句,改变了原始语意。比如登录时把用户名字段直接拼进WHERE里,攻击者输入一段注释符和恒真条件,就可能绕过校验。

防护方案很成熟:使用参数化查询或预编译语句。MyBatis里用#{}代替${},JDBC里用PreparedStatement代替Statement,ORM框架基本天然防注入。关键是别在代码里手动拼字符串SQL,那等于把数据库裸奔在公网。凡是需要动态排序字段名、表名这类无法参数化的场景,必须用白名单校验,比如只允许传入固定的几个字段名,否则直接拒绝。

6.4 我自己的排错顺序

最后分享一个私人排错习惯。遇到SQL问题,我从来不看消息报错就去改语句,而是按这个顺序来:先看表结构和索引情况,再走EXPLAIN看执行计划,确定是不是索引问题;然后缩小数据范围复现问题;最后再准备改动方案。80%的慢SQL问题出在索引,90%的“查不出来”“查不对”问题出在条件和连接逻辑,而不是语法本身。

写在最后

Day5-MySQL-SQL-4,说白了就是从“会写SQL”走向“懂SQL”的一道门。跨过去之后,你再看那些动辄几十行嵌套子查询的复杂报表,心里会安稳很多,因为你已经知道用什么工具去拆解它,更知道每一步操作背后数据库在做什么。

我个人带新人的经验是:这个阶段不要急着刷所谓的“高级写法”,而是把一个复杂的查询需求反复用不同方案实现几遍,再去对比执行计划。对比得多了,你对索引、对执行顺序、对窗口函数和JOIN的适用边界,就有了肌肉记忆。

最后再送你一个小技巧:遇到需要先分组再去重的统计需求,别在脑子里空想,先写一段子查询把数据范围圈出来,再在外面嵌套聚合或窗口函数。所有的高手,都是这样一行一行试出来的。SQL-4难吗?不难,难的是你愿不愿意慢下来,把每一个执行计划都看明白。

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

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

立即咨询