多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SQL刷题进阶指南:窗口函数、自连接与性能优化的核心要点

SQL刷题进阶指南:窗口函数、自连接与性能优化的核心要点 最近抽空把LeetCode上的SQL题重新刷了一遍趁着知识点还热乎把整个练习过程中的思路、踩坑和总结整理成这份记录。说实话很多人刷LeetCode只盯着算法题对SQL板块不太当回事觉得SQL嘛写几个SELECT就完事了。但实际刷下来你会发现这里的SQL题虽然叫easy、medium真正动手写的时候坑比想象中多得多——尤其是那些业务里天天写SQL的人反而最容易在看似简单的题上翻车。这份记录主要写给三类人准备数据开发或后端岗位面试的人、日常写业务SQL但想系统补一下薄弱环节的人、以及刚入门SQL想找点练习题巩固基础的初学者。我不会只贴题目答案而是把每类题型的思考路径、判断依据和容易出错的细节讲清楚配合实际可跑的SQL语句照着练就能看到效果。1. 为什么刷SQL题这件事值得认真对待1.1 刷题和写业务SQL中间隔着一条认知鸿沟日常工作中我们写的SQL大多是业务查询比如查订单、统计用户数、关联几张表出报表。这种SQL写多了人会形成一种够用就行的惯性能用子查询就用子查询能多写几步就多写几步反正数据量不大、跑得也不慢结果对就行。但LeetCode的SQL题不一样。它逼着你在一个非常受限的题目描述里用尽量简洁、正确的SQL去解决问题。题目不会告诉你你可以先建个临时表再查也不会说用程序代码处理一下也行。你只能靠纯SQL把逻辑表达出来。这恰好暴露了一个问题很多写了好几年SQL的人对JOIN、GROUP BY、窗口函数这些核心语法其实是半懂不懂的状态。会用但不知道为什么这么用能跑出结果但边界条件一加就挂。我刷题过程中最大的感受就是业务SQL的能用和刷题SQL的正确是两个层次的东西。业务SQL只要结果对过程丑一点无所谓的但刷题SQL要求你完全掌控每一步的语义包括NULL怎么处理、去重按什么标准去、排序的并列情况怎么算这些在业务里经常被忽略的细节恰恰是面试官最喜欢挖的地方。1.2 不同基础的人刷题的目标不一样如果你是SQL新手刷LeetCode SQL题的主要价值在于强制你把手动查询的逻辑翻译成SQL语法。你不需要纠结题目难不难先按自己的理解写写错了再看讨论区这个过程本身就是语法和思维的快速磨合。如果你是有经验的开发或数据分析师刷题的重点就不是语法了而是查漏补缺和思维升级。比如我这次刷题最大的收获之一就是彻底把窗口函数的应用场景给捋顺了。以前在业务里遇到分组后取每组Top N这种需求我总想着用自连接或者临时表去搞窗口函数一行就解决了。这种原来还能这么写的顿悟只有通过集中的题目训练才能密集出现。刷题是为了什么不是去背题而是通过题目这个载体把SQL的各个语法模块在脑子里形成一个有结构的认知地图。下次遇到需求的时候你能在第一时间想到对应的解法而不是在百度里搜SQL 分组取最大值怎么实现。2. 高频题型拆解关联、聚合、排名三类问题的通用套路2.1 多表关联JOIN条件的边界感LeetCode的SQL题里多表关联是最基础也是最常考的一类。题目描述通常给两张或三张表让你查有订单的用户没买过东西的客户工资比经理高的员工等等。这种题的核心就一句话搞清楚两表之间的关联键和保留方向。INNER JOIN保留两边的匹配记录LEFT/RIGHT JOIN保留一侧的全部记录FULL JOIN保留两边的全部记录。LeetCode里考得最多的是LEFT JOIN因为它天然适合查A表里有但B表里没有的场景。举一个经典的例子从不订购的客户LeetCode 183:SELECT c.name AS Customers FROM Customers c LEFT JOIN Orders o ON c.id o.customerId WHERE o.customerId IS NULL;这题的关键不在LEFT JOIN本身而在WHERE条件必须过滤右表主键为空。我见过不少人写成SELECT c.name AS Customers FROM Customers c LEFT JOIN Orders o ON c.id o.customerId WHERE o.id IS NULL OR o.customerId IS NULL;两个条件的效果其实一样因为只要右表有一列为空整条关联记录基本就是无匹配的。但更常见的错误是写成WHERE o.customerId NULL这是SQL初学者必踩的坑——NULL不能跟任何值用等号比较必须用IS NULL。还有一个边界情况值得注意JOIN条件里的字段如果本身允许NULL那LEFT JOIN之后过滤就要格外小心。比如关联键是a.id b.user_id如果b.user_id存在NULL值那么这些NULL值在JOIN时不会匹配到任何左表记录但LEFT JOIN后它们会保留在左表侧此时用WHERE b.some_field IS NULL做过滤可能会把原本有匹配但匹配字段恰好为NULL的记录也筛掉。所以刷多了你会发现设计表结构时尽量避免关联键为NULL这是很多线上系统会额外加非空约束的原因。2.2 聚合分组WHERE和HAVING的分工聚合类的题目在LeetCode里出现频率也很高。核心考点就两个GROUP BY的分组粒度以及WHERE和HAVING到底哪个先执行。先明确执行顺序WHERE是对原始行做过滤过滤完之后才进行GROUP BY分组和聚合计算HAVING是对分组后的结果做过滤。所以如果一个条件针对的是某一行放在WHERE里针对的是聚合结果比如COUNT(*) 1、SUM(amount) 100放在HAVING里。看一个典型的例子至少有五名直接下属的经理LeetCode 570SELECT m.name FROM Employee e JOIN Employee m ON e.managerId m.id GROUP BY m.id, m.name HAVING COUNT(e.id) 5;这里用自连接把员工和经理关联起来按经理分组然后用HAVING过滤出直接下属数量大于等于5的经理。如果把HAVING换成WHERESQL直接报错——因为聚合函数不能出现在WHERE子句里。这是很硬性的语法规则初学者经常在这里卡住。聚合的另一个容易坑人的点是GROUP BY后面的字段和SELECT后面非聚合字段的对应关系。MySQL有一个比较宽松的模式允许SELECT里出现没有被GROUP BY的字段但结果是不确定的其他数据库比如SQL Server、PostgreSQL会直接报错。建议练习时严格遵守SELECT里出现的非聚合字段必须全部出现在GROUP BY中这个规范养成好习惯。2.3 排名与去重ROW_NUMBER、DISTINCT、GROUP BY怎么选排名和去重是SQL题里的高频钉子户。第N高的薪水LeetCode 176、分数排名LeetCode 178、部门工资前三高的员工LeetCode 185这类型的题目考的就是对去重和排名函数的理解深度。先说去重。SQL里去重有三种思路DISTINCT、GROUP BY、ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)。很多人只知道DISTINCT但遇到按某个字段去重取另一个字段最大的那条记录这种需求时DISTINCT就无能为力了。举个例子每个部门工资最高的员工LeetCode 184。最直观的思路是先用GROUP BY departmentId找到每个部门的MAX(salary)再关联回原表SELECT d.name AS Department, e.name AS Employee, e.salary AS Salary FROM Employee e JOIN Department d ON e.departmentId d.id WHERE (e.departmentId, e.salary) IN ( SELECT departmentId, MAX(salary) FROM Employee GROUP BY departmentId );这里用了一个多字段IN的写法等价于匹配部门ID和最高薪水的组合。这种写法不算最优但非常直观而且不容易出逻辑错误。还有一种写法是自连接把两张Employee表按部门关联再通过HAVING salary MAX(salary)来过滤。两种思路都可行重点是你得能说出为什么需要两步而不是一步——因为先求最大值和再找对应员工是两个独立的逻辑步骤。至于排名LeetCode里同时会用到三个函数ROW_NUMBER()、RANK()、DENSE_RANK()。三者的区别必须烂熟于心函数并列处理排名是否跳号典型场景ROW_NUMBER()相同分数随机/按附加顺序编号不并列编号连续取分页、取第N条RANK()并列占用同一名次会跳号如1,1,3体育竞赛排名DENSE_RANK()并列占用同一名次不跳号如1,1,2部门工资前三高LeetCode 185 要求部门工资前三高的所有员工因为涉及并列要用DENSE_RANK()SELECT Department, Employee, Salary FROM ( SELECT d.name AS Department, e.name AS Employee, e.salary AS Salary, DENSE_RANK() OVER (PARTITION BY e.departmentId ORDER BY e.salary DESC) AS rk FROM Employee e JOIN Department d ON e.departmentId d.id ) t WHERE rk 3;子查询里算排名外层过滤rk 3。我以前刚开始刷题时总想着把排名条件塞到WHERE里直接报错——窗口函数在WHERE之后执行所以必须包一层子查询。这个知识点几乎每次遇到窗口函数题都会用到属于必背结论。3. 刷题过程中最容易被扣分的几个思维死角3.1 SELECT各子句的实际执行顺序很多教程讲SQL都是按SELECT → FROM → WHERE → GROUP BY → HAVING → ORDER BY这个顺序教的。但实际执行顺序完全不同如果脑子里没有这个执行顺序的模型很多题你会觉得我逻辑没问题啊为什么结果不对。标准SQL的逻辑执行顺序是FROM确定数据源执行JOINWHERE按条件过滤原始行GROUP BY按指定列分组HAVING过滤分组结果SELECT计算目标列、别名、聚合ORDER BY排序LIMIT / OFFSET分页我举一个非常典型的例子上升的温度LeetCode 197。表结构很简单Weather(id, recordDate, temperature)要求查出所有比前一天温度高的记录日期ID。常规思路是利用自连接把今天的记录和昨天的记录拼在一起SELECT w1.id FROM Weather w1 JOIN Weather w2 ON DATEDIFF(w1.recordDate, w2.recordDate) 1 WHERE w1.temperature w2.temperature;JOIN条件用了DATEDIFF 1表示今天 昨天1天。这里如果不懂执行顺序可能会纠结 WHERE 应该先过滤日期还是先比较温度——其实不影响JOIN已经把符合条件的记录拼好了WHERE只是做最终过滤。但真正容易踩坑的是如果表里同一天有多条记录或者日期跨度不连续DATEDIFF 1这种硬性差一天的条件就会失效。更稳妥的写法是用窗口函数LAG取前一天温度SELECT id FROM ( SELECT id, temperature, LAG(temperature) OVER (ORDER BY recordDate) AS prev_temp, LAG(recordDate) OVER (ORDER BY recordDate) AS prev_date FROM Weather ) t WHERE temperature prev_temp AND DATEDIFF(recordDate, prev_date) 1;窗口函数先按日期排序再取上一行的温度和日期最后外层过滤。这种写法比自连接更接近业务直觉也更不容易漏数据。但前提是同一个日期只有一条记录如果同一天有多条两个写法都会出问题需要额外按日期去重——这就是刷题复盘的价值你能提前意识到这些边界问题。3.2 NULL不是一个值它是一个状态SQL 里 NULL 可能是最反直觉的东西了。它不是一个具体的值而是未知或缺失的标记。因为它是未知的所以NULL NULL的结果不是TRUE而是NULL即不是真也不是假是未知。这导致了很多经典错误。最常见的错误是WHERE column NULL这个条件永远不会匹配任何行。正确写法永远是IS NULL或IS NOT NULL。这个坑在从不订购的客户这类LEFT JOIN题里几乎必踩。还有一个更隐蔽的坑COUNT(column)会忽略NULL值而COUNT(*)不会。如果某字段很多NULLCOUNT(column)的结果会比实际行数小。面试经常拿这个来盘你一张表有100行某字段有20个NULLCOUNT(*)、COUNT(字段)、COUNT(DISTINCT 字段)分别返回多少答案是100、80、以及去重后的非NULL值数量。刷题如果遇到涉及统计的题要多想一层题目到底要数所有行还是非NULL的行。此外NULL在排序中也很有讲究。默认升序时SQL Server 和 MySQL 把 NULL 排在最前面PostgreSQL 和 Oracle 把 NULL 排在最后面。LeetCode 的判题系统在不同数据库下结果可能不同所以遇到ORDER BY排序题时建议显式处理 NULL 的位置比如ORDER BY column ASC NULLS LAST确保逻辑在哪个数据库下都是确定性的。3.3 自连接的典型用法与笛卡尔积事故现场自连接指的是表和自己做JOIN。LeetCode 里考经理工资比员工高连续出现的数字游戏玩法分析这类题时自连接几乎是标配。但自连接的代价是容易产生笛卡尔积一旦JOIN条件写松了数据量会爆炸性增长。拿连续出现的数字LeetCode 180来说题目要求找出所有至少连续出现三次的数字。思路之一就是三次自连接用l1.id l2.id - 1 AND l2.id l3.id - 1来限定相邻记录SELECT DISTINCT l1.num AS ConsecutiveNums FROM Logs l1 JOIN Logs l2 ON l1.id l2.id - 1 JOIN Logs l3 ON l2.id l3.id - 1 WHERE l1.num l2.num AND l2.num l3.num;JOIN条件必须精确到id相邻否则一张大表自连接三次中间结果集可能是原始行数的三次方量级线上这么写会直接把数据库拖垮。我当时第一次写这个题没加id的递进条件只写了WHERE l1.num l2.num AND l2.num l3.num结果跑出来一堆重复记录不说执行时间直接拉满。这是因为没限定JOIN的行间关系所有相同数字的行都互相匹配了。自连接的正确姿势是先把连接条件限定到最小范围相邻、同组、日期差1等再用WHERE过滤业务条件。连接的目的是把横向的记录变成一行里可比较的字段条件写精确了结果才可控。4. 窗口函数从“看得懂”到“写得出”的跨越4.1 窗口函数解决的经典问题分组TopN与累计计算如果你在网上搜SQL优化的文章经常看到别人推荐用窗口函数替代子查询和自连接。窗口函数确实强但它最擅长的其实是两类问题分组TopN和累计/移动计算。分组TopN的代表作就是部门工资前三高的所有员工LeetCode 185上一节已经写过。窗口函数在这一类问题上的语法非常统一ROW_NUMBER() / RANK() / DENSE_RANK() OVER ( PARTITION BY 分组字段 ORDER BY 排序字段 )PARTITION BY负责分组ORDER BY负责组内排序函数整体负责生成组内序号。只要记住这个固定句式绝大多数TopN问题都能套。累计计算的代表作比如游戏玩法分析中的每位玩家第一次登录平台的日期LeetCode 511虽然这题用聚合更简单但更典型的累计场景是每日新增用户累计数。这类题用窗口函数的SUM() OVER (ORDER BY 日期)就能逐行累加SELECT date, daily_new_users, SUM(daily_new_users) OVER (ORDER BY date) AS total_users FROM daily_stats;这里的逻辑就是SUM配合ORDER BY生成一个从第一行累加到当前行的累计值。如果加上PARTITION BY还能做到分组内累计。日常做报表统计时这种写法比每条记录再连一张子查询求小于当前日期的总和要高效得多一条SQL搞定可读性也好。4.2 PARTITION BY和ORDER BY的配合技巧窗口函数里最容易搞混的是PARTITION BY和ORDER BY的组合效果。一句话总结PARTITION BY决定窗口怎么划分ORDER BY决定每个窗口内部怎么排序以及窗口从哪一行开始算到哪一行结束。没有ORDER BY时整个PARTITION是一个整体窗口函数的值在组内是同一个比如全组求和、全组最大。有了ORDER BY后默认窗口是从分区第一行到当前行相当于一个累计范围。这就是为什么SUM(amount) OVER (PARTITION BY user_id ORDER BY date)会得到每个用户按日期累计的消费额而不是每个用户的总消费额。如果你想在窗口里明确指定范围可以用ROWS BETWEEN子句。比如计算最近3天的移动平均SELECT date, amount, AVG(amount) OVER (ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS avg_3d FROM sales;ROWS BETWEEN 2 PRECEDING AND CURRENT ROW的意思是当前行往前推两行到当前行形成一个三行的滑动窗口。这种语法在LeetCode的medium难度题里偶尔出现业务里做数据分析和指标计算时会很常用。我建议在刷题时遇到一次就专门练透因为这种写法用熟了之后回来看自连接版本简直觉得是在绕远路。窗口函数还有一个很容易被忽略的细节在同一个查询里可以定义多个窗口并且窗口之间可以独立指定。比如既想按地区分组算排名又想按地区分组累加销量就可以写两个OVER子句互不影响。这在业务里做排行榜 累计趋势二合一报表时非常实用。5. 从LeetCode走向实战SQL练习之外的自我检视5.1 慢SQL优化思路从执行计划到索引刷题刷到一定量之后你会发现LeetCode上的SQL题多数只关心逻辑正确对性能要求不高。但回到实际生产环境SQL写得再对跑不动一样白搭。所以刷题之外我还建议你专门花时间补一下慢SQL优化这件事。慢SQL优化的起点是先看执行计划不要凭空猜。MySQL 用EXPLAINSQL Server 用显示估计的执行计划PostgreSQL 用EXPLAIN ANALYZE。执行计划里最核心的几项是有没有走索引type字段是不是const/ref/range而不是ALL、扫描的行数rows、有没有Using filesort或Using temporary。举一个最常见的优化场景一张订单表有几百万行你按用户的注册时间过滤再按订单时间排序取最近10条。如果两个过滤条件都有单独的索引MySQL会选其中一个然后对结果做排序如果数据量一大、排序字段没索引就会出现Using filesort性能直接掉一个数量级。优化方案通常是建复合索引把过滤字段和排序字段放进同一个索引例如(user_id, order_time)然后只按user_id过滤、按order_time排序。这样索引天然有序排序步骤就省掉了。LeetCode里虽然不太考执行计划但你把刷题时常用的JOIN、GROUP BY、窗口函数拿到EXPLAIN下跑一遍能直观看到不同写法在数据量放大后的差距。比如前面的自连接三连如果id字段有主键索引执行计划里是三个index lookup如果没索引直接就是三个全表扫描嵌套灾难级别。所以我一直觉得刷题是练逻辑EXPLAIN是练手感两个都别落下。5.2 数据库方言差异同样一句SQL在不同库里的表现LeetCode 的判题主力是MySQL但实际工作里你可能会用到SQL Server、Oracle、PostgreSQL、Hive等。热词榜里SQL Server相关的搜索量一直不低说明很多人在本地练习时会选SQL Server作为环境。这里就有个很常见的坑MySQL能跑的SQL换到SQL Server未必能跑通。我举几个典型的方言差异字符串拼接MySQL 用CONCAT()或双竖线||需要开PIPES_AS_CONCAT模式SQL Server 用Oracle 和 PostgreSQL 用||。分页MySQL 用LIMIT offset, countSQL Server 用OFFSET ... FETCH或TOPOracle 老版本用ROWNUM。取当前日期MySQL 是CURDATE()SQL Server 是GETDATE()PostgreSQL 是CURRENT_DATE。字符串截取MySQL 是SUBSTRING(str, pos, len)SQL Server 也是SUBSTRING但Oracle 用SUBSTR且下标从1开始大家都从1开始但某些老库有0的情况。空值排序前面提过MySQL默认NULL排最前PostgreSQL默认NULL排最后。自增主键MySQL 是AUTO_INCREMENTSQL Server 是IDENTITY(1,1)PostgreSQL 是SERIAL或IDENTITY。刷题时如果你只用MySQL在线评测有些题目偷懒用MySQL的方言写法也能过但建议还是尽量写标准SQL。标准SQL在绝大多数数据库里都能直接跑或改个函数名就能跑迁移成本低。我个人的习惯是MySQL和PostgreSQL本地各装一套遇到拿不准的写法两个库都验证一遍这种习惯在实际工作中写跨库同步脚本时帮了我大忙。5.3 写出可维护SQL的职业习惯最后聊一点刷题之外、但对实际工作很有用的东西——SQL代码的可维护性。我在SQL Server环境下排查慢SQL时经常看到一长串动辄几百行的SQL没有格式化、没有注释、子查询嵌套七八层。这种SQL逻辑再正确后续接手的人根本不敢动。LeetCode的题虽然简单但它给了你一个很好的机会去养成好习惯第一缩进和关键字大写保持一致。SELECT、FROM、WHERE、JOIN这些关键字统一大写字段和表名小写一眼就能分清语法骨架和业务字段。第二复杂逻辑用通用表表达式CTE拆开。不要把所有逻辑堆在一个子查询里。比如先算paid_users再算active_users最后JOIN。每一层CTE只干一件事命名清楚别人读你的SQL就像读文章段落。第三JOIN条件写在ON里过滤条件写在WHERE里。虽然把过滤条件写到ON里对于INNER JOIN来说结果一样但LEFT JOIN的时候区别很大写在WHERE里会变成先关联再过滤导致左表保留的记录减少语义完全变了。规范统一能避免很多低级事故。第三点值得展开说。比如你要查所有用户及其订单金额有些订单可能已作废要排除。如果写成SELECT u.id, o.amount FROM Users u LEFT JOIN Orders o ON u.id o.user_id AND o.status valid;这个查询会保留所有用户无效订单显示为NULL但如果写成SELECT u.id, o.amount FROM Users u LEFT JOIN Orders o ON u.id o.user_id WHERE o.status valid;同样只有INNER JOIN的效果没有有效订单的用户直接不出现了。语义完全不同。刷题时多练习这种ON和WHERE分职责的写法实际工作中能少返工很多次。6. 一些刷题过程中的个人习惯与工具推荐最后顺手分享几个我刷题时积累的小工具和习惯不算什么高级技巧但对效率提升挺明显。本地环境选型。LeetCode的SQL题在线编译器很方便但有个问题它只能验证结果看不到执行计划也不能自由造数据。我刷到中后期凡是遇到窗口函数、性能对比这种需要反复试验的题就直接在本地开SQL Server或MySQL跑。本地库有个好处是可以自己造边界数据比如插入重复记录、NULL记录、同一天多条记录看看自己的SQL在脏数据下还能不能出正确结果。这个习惯让我提前发现了很多题解的隐性假设。维护一个错题文档。我刷题不是每题都过凡是第一次没写对、或者看了题解才恍然大悟的题都会单独记录在一个Markdown文档里内容包括题目编号、我当时的错误写法、错误原因分析逻辑错误还是SQL特性不了解、正确解法、以及本题涉及的知识点标签。比如176题——考点NULL处理、LIMIT、这行记录后续复习时扫一眼就能串起知识脉络。按知识点刷不按难度刷。LeetCode的SQL题是按easy、medium、hard分级的但同一个知识点可能分散在多个难度里。我建议第一遍按标签分类刷比如把JOIN相关的题都找出来集中做做完对比它们之间的异同比一天刷十道不同知识点的题效果好得多。等知识点都过了一遍第二遍再按难度刷相当于一次综合考试。多写几个版本再对比优劣。同一道题子查询能写自连接能写窗口函数也能写。我刷题时要求自己至少写出两种解法然后分析它们的可读性、性能和适用边界。比如每个部门工资最高的员工我用过IN嵌套子查询、自连接 MAX、窗口函数三种写法。实际面试的时候面试官不一定要求你写最优解但如果你能主动说另一种写法更利于大表查询这会是明显的加分项。SQL的学习路径其实很线性语法基础打牢然后刷题练逻辑再回到生产环境练性能。LeetCode只是这条路径的中间一环既不神秘也不高深但对把SQL从会写推向写得好确实是一条捷径。希望这份练习记录能给你提供一点参考也欢迎你拿着题来和我讨论更好的解法。
返回列表