☰
SQL每日一题:从窗口函数到执行计划,练成数据查询硬功夫
2026/10/1 11:45:07 网站建设 项目流程

1. 为什么我会坚持做“sql每日一题”

说实话,一开始并不是什么宏大计划。那时候团队晨会前总有十几分钟空档,几个人刷手机也是刷手机,我就随手往小群里丢了一道SQL题,说“今天有空的人写一下,晚上对答案”。结果第一天就炸出好几个解法,有人用GROUP BY,有人用DISTINCT,还有人用子查询,吵得有来有回。后来这个习惯就留下来了,一天一道,雷打不动,慢慢攒成了一个大系列。很多我认识的人学SQL最大的问题不是不会写,而是不动手,语法书、视频教程看了无数遍,真到了“给你两张表,自己查”的时候就傻眼。sql每日一题想解决的就是这个问题,用高频、轻量的节奏,把真实的查询场景拆成小题目,逼着人每天亲手在键盘上敲一遍。

这个系列适合谁?三类人。第一类是刚学完SQL语法、但不知道怎么用的小白,第二类是写了好几年SQL、天天在做“复制粘贴工程师”的同行,第三类是准备面试、想系统梳理知识点的学生。每一道题的解析里,我不光会给标准答案,还会讲清楚为什么这么写、有没有更好写法、换了数据库要怎么调整。原因很简单,SQL的方言差异实在太大了,同一个需求在MySQL、SQL Server、PostgreSQL、Oracle里能长出完全不同的写法,很多新人一看报错就懵,其实就是方言在作怪,这东西不靠日积月累根本学不扎实。

我另外还发现,把这个系列坚持下来以后,我在平时工作里遇到复杂数据需求的第一反应明显变快了。以前看到“这个报表要按部门算累计占比”会愣一下,现在脑子里直接浮现出一个窗口函数的骨架。这不是天赋,就是每天喂一道题喂出来的条件反射。所以这篇文章我把自己做这个系列的全套思路、题目布局、经典题拆解、踩过的大坑都翻出来,给想动手的人一份可以直接抄走的参考。

2. 题型布局:我的每日一题分四个阶段

日更最忌讳的就是今天想到什么出什么,东一榔头西一棒子。我吃过这个亏,后来老老实实按能力维度把题目排成专题,每两周一个主题,中间穿插复习。整个选题体系大体分四块,基础语法、进阶函数、性能优化、安全边界。这四块不是割裂的,而是层层递进的关系,新手先啃前两块,老手可以直接跳到后两块去磨练功力。

2.1 基础语法区,练的是肌肉记忆

基础题最容易被低估。就拿“去重”来说,你以为有 DISTINCT 就完事了?真落到业务里,什么时候用 DISTINCT、什么时候用 GROUP BY、什么时候用 ROW_NUMBER() 保留唯一一条,很多人其实分不清。我专门出过一组三连题,第一道是统计用户表里有多少个不同的城市,这个直接SELECT COUNT(DISTINCT city) FROM users就完事。第二道是统计每个城市的用户数,那就得SELECT city, COUNT(*) FROM users GROUP BY city,DISTINCT 已经搞不定了。第三道难度上去,要按城市分组取每个城市中年龄最小的那个用户的完整信息,这是很典型的“分组内取Top1”,不能靠去重硬凑,必须用子查询或者窗口函数。结果让我很意外,不少做了两三年业务开发的同事,前两题秒过,第三题写出来的嵌套又长又错,这说明基础语法真的需要反反复复练,肌肉记忆不是说看书能练出来的。

类型转换也是这个阶段的常客,而且它不像去重那么显眼,却是无数报错的源头。SQL Server 里的CONVERT(VARCHAR(10), order_date, 120)可以把日期转成yyyy-MM-dd的字符串,第三个参数是风格码,不写明白很容易转出离谱结果。MySQL 里更习惯用DATE_FORMAT(order_date, '%Y-%m-%d')。同样是转日期,两边写法南辕北辙。我出这种题不是为了刁难人,是因为实际项目里到处是两个系统做数据交换,日期格式不统一简直是家常便饭。今天不练,明天上线准出状况。

除此之外,空值的处理我也坚持放在基础区反复敲打。SQL 的NULL和任何值比都是未知,NULL = NULL的结果不是真而是未知,这个特性直接决定了很多查询行为的怪异性。我用题让大家体会“为什么这里要写IS NULL而不是= NULL”,以及聚合函数为什么经常自动把NULL忽略掉。这些细节不通过具体题目遇到,光看文档是记不住的。

2.2 进阶函数区,重在培养分析思维

如果说基础语法是手艺,那窗口函数就是一道分水岭。我自己的体会是,窗口函数把 SQL 从一个“只能看结果”的语言变成了“能看过程又能看结果”的语言。传统的GROUP BY会把多行压成一行,想看到明细还想保留分组结果,就得把表连接好几遍,性能和可读性都让人头疼。窗口函数可以在不用合并行的前提下,对每一行计算一个基于分组的值,典型的像ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC),它的含义是“在每个部门内部按工资降序编号”,这个编号作用到每一行上,既保留了明细,又给出了分组排名,一条语句就能完成以前三条语句的活。

这个专题我会出各种变体,累计求和、移动平均、同比环比、分组排名,每道题都逼着大家去想“窗口到底是怎么划分的”“ORDER BY 在这里到底影响了什么”。很多人学窗口函数老是把ROW_NUMBER、RANK、DENSE_RANK三个函数混在一起,其实区别就在并列名次的处理上。用题来区分它们,比背定义管用一百倍。

2.3 性能优化区,练的是执行思维

题目练到一定量之后,会写不是终点,写得好才是。性能优化区的第一课是教大家看执行计划。MySQL 里一条EXPLAIN SELECT ...会把查询的执行方式摊开,能清楚地看到是不是全表扫描、是不是走了索引、有没有产生临时表。SQL Server 的图形化执行计划更是直观,鼠标一放就能看到每个步骤的 IO 开销。我让大家养成一个习惯,写完一条复杂度稍微上去的 SQL,顺手执行计划扫一眼,看到红色警告就先别急着交差。这个习惯一开始会觉得很麻烦,但练久了,你写 SQL 的第一稿就会自动避开那些明显费资源的写法。

这个区域最常练的主题是慢SQL识别与改写。比如SELECT *为什么不好,因为它会把根本用不到的字段也捞出来,尤其是在宽表上,白白增加网络传输和内存消耗。再比如在索引列上套函数,像WHERE DATE_FORMAT(order_time, '%Y-%m-%d') = '2025-01-15',表面看没问题,但数据库对这类写法往往无能为力,只能放弃索引扫描。真正合适的写法是把函数挪到另一边,写成WHERE order_time >= '2025-01-15' AND order_time < '2025-01-16',既保留了语义,又让索引有机会生效。这种经验光背“索引失效的几种情况”是完全不够的,必须在具体题目里撞一次南墙才记得住。

2.4 安全基线区,练的是防御本能

这块我是在几次事故之后才坚决加进来的。很多人以为写后台查询接口用不到防御,直到有一天发现输入的查询条件被拼进字符串,别人只要在参数里动点手脚,SQL 语句的语义就可能被改写。这不是科幻片,这是每天发生在大量站点上的现实。我出这类题的目标非常明确,让大家识别“拼接 SQL 字符串”的经典模样,以及如何用参数化查询来替代它。参数化的原理其实不复杂,把 SQL 骨架和参数值分开传给数据库,数据库把它们当成语句结构和普通数据分别处理,结构永远不可能被参数里的内容篡改。像 Python 里的cursor.execute("SELECT * FROM users WHERE name = %s", (name,)),Java 里的PreparedStatement的?占位符,都是这个思路。我反复在题里强调,讨论安全问题永远是从防御者的角色出发,我们写出的每一段代码都应该是修复问题的,而不是制造问题的。

3. 让我印象最深的几道题拆解

这个系列做了这么久,有几道题每次翻出来都觉得特别经典。它们未必复杂,但每一道都能牵出一串知识点,是从新手到熟练工都值得反复咀嚼的。

3.1 去重三兄弟:DISTINCT、GROUP BY、ROW_NUMBER

题目背景很简单,一张订单表,字段有order_id、user_id、order_time、amount。问题是“统计每个用户的订单数和总金额”。这个题看着基础,但一深挖就发现很多人不知道 GROUP BY 之后到底能选哪些字段。在 MySQL 里,由于默认配置的原因,你甚至可以选一个不在 GROUP BY 里的字段,结果出来是乱猜的;而在 SQL Server 和 PostgreSQL 里,这种写法直接报错,提示你“选中的列必须出现在 GROUP BY 中或者聚合函数里”。一道题同时牵出 SQL 语法规则和数据库方言差异,非常典型。

然后我把题目升级成“查询每个用户最近的一笔订单的完整信息”,这时 DISTINCT 用不上,GROUP BY 也选不出整行,只能靠子查询或者ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC)。我推荐的写法是:

SELECT * FROM ( SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_time DESC) AS rn FROM orders o ) t WHERE t.rn = 1;

这个写法的好处在于思路直线,先给每个用户内部的订单按时间排序编号,再把编号为 1 的行留下来,既保留了完整字段,又有很强的扩展性。如果哪天需求改成“取每个用户第二新的订单”,只需要把rn = 1改成rn = 2,连大结构都不用动。做这类题的时候我总会提醒大家,遇到“分组内取前几行”的需求,第一反应就应该是窗口函数,而不是去写各种复杂的自连接。

3.2 分组拼接:GROUP_CONCAT 和它的兄弟们

这道题源自一个真实需求,一套系统要导出每个班级的学生名单,格式是“班级名: 张三, 李四, 王五”,一个班一行,名字之间用逗号拼起来。MySQL 里的标准答案就是GROUP_CONCAT:

SELECT class_name, GROUP_CONCAT(student_name ORDER BY student_name SEPARATOR ',') FROM students GROUP BY class_name;

注意GROUP_CONCAT内部还可以写ORDER BY,控制拼接顺序,这个细节很多人不知道。SQL Server 里没有GROUP_CONCAT,SQL Server 2017 之后的写法是STRING_AGG(student_name, ','),它同样支持WITHIN GROUP (ORDER BY ...)来控制顺序。PostgreSQL 用的是STRING_AGG,写法更接近 SQL Server。这个题的价值就在于让你意识到,数据库没有提供“同名函数”并不可怕,怕的是你只背过一种数据库的方言就以为全世界都这样。掌握一个需求在不同数据库里的映射关系,才是真正通用型的 SQL 能力。

3.3 累计求和:自连接、变量和窗口函数

累计求和是分析报表里的高频需求。比如销售表sales(month, amount),要算每个月以及之前所有月份的累计销售额。老一代 SQL 工程师可能会用自连接,每个月的累计就是把所有月份小于等于该月的记录全部加起来。但自连接的写法在月份多的时候是灾难性的,复杂度直线上升。后来有人用 MySQL 变量写,效果不错,但可读性很差,逻辑藏在@running_total := @running_total + amount里,一个变量初始化出错就满盘皆输。

窗口函数出现之后,这个问题的标准解非常简洁:

SELECT month, amount, SUM(amount) OVER (ORDER BY month) AS cumulative_amount FROM sales ORDER BY month;

没有一个 GROUP BY,没有一个表连接,却能在每一行上保留“截至当前所有行”的合计数。我拿这道题出来,是想让大家真切感受到窗口函数带来的思维升级。别急着背语法,先理解窗口函数是“在结果集的每一行上开一个可见的窗口”,窗口边界由ORDER BY决定,这种看问题的角度才是它真正值钱的地方。

3.4 在 Spark SQL 里找 GROUP_CONCAT 的替代品

有一段时间团队在做数据平台迁移,好多 MySQL 上的查询要改写成 Spark SQL 作业。社区里被问爆的一个问题就是“Spark SQL 里有没有类似 MySQL GROUP_CONCAT 的函数”,答案是直接使用GROUP_CONCAT是不行的,Spark SQL 的替代方案是用collect_list配合concat_ws:

SELECT class_name, CONCAT_WS(',', COLLECT_LIST(student_name)) AS student_names FROM students GROUP BY class_name;

collect_list会把分组内的字段收集成一个数组,concat_ws再用逗号把数组里的元素拼成字符串。如果想去掉重复名字,就用collect_set,它会自动去重,但代价是顺序不保证,这时如果必须保序,就得先对子查询排序或者用sort_array。这个题的价值在底层逻辑,SQL 的“分组聚合”思想在整个数据处理领域是通用的,但每一种引擎给出的具体函数可能完全不一样,迁移到新的计算引擎时,别抱怨“怎么连个 GROUP_CONCAT 都没有”,先想想这个函数背后的本质是什么,再去目标引擎里找对应能力,久而久之反而学得更扎实。

3.5 一条慢SQL的优化全过程

性能区的题目不能用纸面解析应付,我都让大家把真实数据灌进来对比。经典案例是一张日志表,数据量一千多万,每天跑统计经常把库拖垮。原始写法里有一个特别明显的问题,在查询条件中对索引列做了隐式类型转换,字段本身是字符串类型的用户编号,参数传进去时却是数字,数据库为了比较,可能把字符串列整体转成数字,索引就废了。发现这个问题的路径很简单,EXPLAIN里看到type=ALL(全表扫描),再用SHOW WARNINGS看数据库改写后的 SQL,就会看到它偷偷加了 CAST 操作。

这类题目我一般分三步带大家做。第一步先看执行计划,找出全表扫描、临时表、文件排序这些标志性关键词。第二步看 SQL 有没有对字段做加工,比如函数包裹列、隐式转换、模糊匹配前置通配符。第三步是结合业务想改写方案,比如把LIKE '%keyword%'改成全文索引方案,把深分页的LIMIT 1000000, 20改成基于上一页最大 ID 的分页方式。这三步走完,一条慢 SQL 基本无所遁形。

3.6 为什么拼字符串会出事:一道改写题的教训

安全类的题目我从来不搞花架子。题目是给一段有问题的代码,要求在不改变功能的前提下修复它。那段代码是这样的:

# 问题代码:直接拼接 sql = "SELECT * FROM users WHERE name = '" + user_input + "'"

表面看需求很简单,用户输入一个名字,查出来就好。但问题在于 user_input 里万一带着单引号和其它 SQL 片段,整个语句的结构就会被带偏。这个题的正确修法是把输入当成参数而不是语句的一部分:

# 修复:参数化查询 sql = "SELECT * FROM users WHERE name = %s" cursor.execute(sql, (user_input,))

为了让这个道理变得深刻,我带大家做了一次简单实验,在本地库里分别用两种方式执行同一段伪造输入,拼接版的语句直接可以被解析成完全不同的查询,而参数化版本永远只会把输入当作一个普通字符串值来比较。整个实验没有任何攻击演示的成分,纯粹是从数据库的服务端视角理解“结构”与“数据”的区别。做完之后大家都有一个共识,SQL 语句的结构部分永远只能由开发者在代码里写死,任何来自外部的输入都只准出现在参数位置。

4. 一次完整的“每日一题”日课是怎么跑起来的

很多人问我,你们每天发一道题,到底是怎么坚持下来的?答案是我把整个流程做成了固定节奏,到点就干,不用想今天到底要不要更,这样才有持续的动力。

4.1 早上出题:核心是“小步高频”

每天上午我花十分钟把题目发到群里,题目一定配建表语句和样例数据,这样不管大家用 MySQL 还是 SQL Server 或者 SQLite,都能把环境跑起来。不给出完整环境、只丢一句文字描述的题,等于白出,因为每个人会为环境差异浪费大量时间,真正练到手的反而不多。题目难度控制在新手需要思考五到十分钟、老手能顺手秒掉的程度。如果一道题让老手都觉得费劲,说明它更适合当周赛题,硬塞进每日一题里容易挫伤积极性,反而不利于坚持。

4.2 白天动手:关键在“先写自己的版本”

我不建议大家上午看了题,晚上直接对标准答案,那样印象最浅。白天有空的时候哪怕只是写个开头,试着建个临时表、把字段列出来,也是在脑子里种了一颗种子。到了晚上对答案的时候,你再看别人的解法,就会有“原来这一步是这么处理”的恍然大悟,而不是“哦,反正答案是这样的”的过眼云烟。很多人坚持打卡却感觉没什么长进,根本原因就是只看不做,省了想的过程,也就省掉了长进的过程。

4.3 晚上对答案:重点不是谁对谁错

晚上八点统一对答案时,最热闹的是解法大比拼。同一道题经常能收到三四种不同思路,有人用子查询,有人用 JOIN,有人用窗口函数。我会把每种解法都拿出来看一遍,分析各自适用场景,比如小数据量怎么写都无所谓,大数据量时窗口函数和 JOIN 的差异就值得讨论。这种讨论比标准答案有用得多,因为实际工作里根本不存在唯一正确答案,重要的是理解不同写法的代价和收益。所以我经常说,每日一题的“题”只是载体,“讨论”才是精华。

4.4 一周复盘:把零散的知识串起来

每周五我不出新题,专门做复盘。把这一周的五道题放到一起看,找出共同点。比如某周做过日期格式化、字符串截取、类型转换,复盘时就会总结出“做数据清洗时,先统一字段类型再处理业务逻辑”这条规律。这样坚持几周后,知识点不再是孤岛,慢慢就织成了一张网。

5. 我做“每日一题”踩过的坑和避坑心得

做这个系列这么久,我自己也踩了不少坑,有些教训如果不说出来,新人大概率还会再踩一遍,所以我把它们整理成几条注意事项,就当是朋友之间的提醒。

5.1 同一条SQL在不同数据库里的方言坑

有一期我用了 SQL Server 的TOP语法取前五条,结果群里用 MySQL 的同学全在报错。MySQL 用的是LIMIT 5,SQL Server 是SELECT TOP 5,Oracle 则用FETCH FIRST 5 ROWS ONLY,PostgreSQL 支持LIMIT也支持FETCH FIRST。这道题让我彻底改变出题方式,此后每一题都在题目里注明“默认使用 MySQL 8.0,如果你用的是 SQL Server 或 Oracle,请自行转换语法”。这个教训搬到实际工作中也一样成立,跨库迁移的时候,不要天真地以为 SQL 是通用语言,先查方言差异,否则上线前一晚有你受的。

5.2 去重不只有 DISTINCT,先想清楚业务要什么

DISTINCT 是最好用的去重,也是最容易用错的去重。它是对整个返回结果的所有列一起去重,如果查询的列里包含订单编号这类唯一字段,那 DISTINCT 基本形同虚设。有一次我让大家统计“有哪些用户下过单”,很多人直接SELECT DISTINCT user_id FROM orders,这没问题。第二天我把题改成“每个用户的第一笔下单时间”,还有人试图用 DISTINCT 解决,结果自然是做不出来的。去重的本质是先想清楚“重复”的定义,是同一个用户算重复,还是同一张订单算重复,或者是同一个用户同一产品算重复?定义不同,SQL 写法天差地别,这比背多少个函数都重要。

5.3 学习SQL别停留在“会写”,要懂得看执行计划

很多同学写 SQL 报错少了就觉得毕业了,但我的看法是,SQL 不报错只是及格线,写得慢照样会被业务投诉。看执行计划这件事,是区分“会用 SQL”和“懂 SQL”的重要标志。我建议大家把执行计划当成体检报告,EXPLAIN一下,看看有没有全表扫描,有没有 Using filesort,有没有产生临时表。每个关键词背后都对应一个可以优化的具体动作。我当时为了给自己培养这个习惯,定了一条规矩,凡是练习题里涉及两张表以上的查询,必须写一行注释说明大致执行思路。一开始很别扭,两个月后成了本能,现在看到别人写的长 SQL,脑子里会自动模拟执行计划。

5.4 安全红线,别让业务为你的习惯买单

写代码可以有个性,但在安全红线面前没有借口。我见过一些老系统里全是拼接 SQL,一问为什么这么写,回答说“以前一直这么干的”“数据量小没关系”。要知道一条恶意输入可能根本与数据量无关,它改变的是语句语义,这个后果是任何性能优化都救不回来的。所以做每日一题这个系列,我特意把它放在最后压轴的位置,提醒每一位读者,写完 SQL 之后再多问一句:这里有没有拼接外部输入?如果有,立刻改成参数化。这个习惯不需要天赋,只需要你在每一次练习里都刻意遵守。

6. 一点个人体会

坚持做下来之后,最有意思的变化不是技术上的,而是思维方式上的。以前看到业务需求,脑子里第一个念头是“这个功能用什么框架写”,现在看到数据需求,下意识会先拆解成“数据落在几张表、关联关系是什么、筛选条件怎么传参”。这种切换很难通过读一本书完成,它就是靠一天一天一道题一道题磨出来的。所以如果你也想做这个练习,我建议不要追求一天刷十道题,那是突击不是积累,真正有效的是每天和 SQL 打个照面,保持手感。你可以从最简单的单表查询开始,也可以直接拿手头工作里一个写得不顺的查询来开刀。今天一道,明天一道,一个月后你再翻回第一天做的题,一定会看到明显的不同。这个系列我会继续做下去,也希望读到这里的你,从今天就开始写上第一条 SQL。

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

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

立即咨询