ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SQL每日一题:去重、窗口函数与慢查询优化的实战进阶

SQL每日一题:去重、窗口函数与慢查询优化的实战进阶 每天睡前十分钟我都会刷一道 SQL 题这个习惯坚持了好几年。最开始是因为面试受挫后来纯属上瘾——那种把一个模糊需求转成一条干净查询的过程比写业务 CRUD 有意思多了。也正是这种“ SQL 每日一题 ”的节奏让我从只会 SELECT FROM WHERE 的菜鸟变成了能应付复杂统计、慢查询优化、甚至帮同事排查线上 SQL 问题的老手。这篇文章就把我这几年练题、拆题、复盘的经验整理出来。无论是准备面试、想系统提升 SQL 水平还是工作中经常被各种报表需求折磨应该都能从里面拿走点东西。1. 为什么坚持“每日一题”我的练习方法论1.1 每日一题不是堆题量而是建立 SQL 思维反射SQL 是声明式语言和 Java、Python 这类命令式语言的思考方式完全不一样。写命令式代码时你关心的是“怎么一步步做”写 SQL 时你关心的是“描述出我要什么结果”。这两种思维的切换光靠看书学不会必须靠练。但练也不是乱练我自己的体会是一次刷 50 道题不如每天只做 1 道但把这道题翻来覆去琢磨透。每日一题真正练的是“模式识别”。比如看到“分组取 TopN”“每组最新一条”“按部门排名”你大脑里应该立刻跳出来窗口函数。看到“统计 XX 次数”应该立刻想到 GROUP BY COUNT。看到“两表关联取差异”应该立刻想到 LEFT JOIN IS NULL而不是 NOT IN。这些反射都是用一个个碎片化的小题目喂出来的。我现在带过几个新人他们的问题几乎都一样语法都会一遇到真实需求就卡住。卡住的原因不是不会写 JOIN而是不知道这个需求应该用哪套 SQL 模式去套。所以每日一题真正练的不是语法是“需求转 SQL 模式”的映射能力。这个能力没有任何捷径只能靠重复积累。1.2 题目来源与筛选标准很多人问我去哪找题。其实渠道非常多LeetCode 的数据库题库、牛客的 SQL 专项练习、各大厂的面试真题整理还有像 SQLZoo、HackerRank 这些在线平台质量都不错。但这里有个关键问题不是所有题目都值得做。我筛题有几个标准第一考点要交叉。一道好题往往同时包含了窗口函数、去重、空值处理、关联查询中的至少两个考点。只考单个语法点的题做多了提升非常有限。第二能延伸到性能讨论。那种十万行和十亿行结果一样的题目参考价值不大。真正好的每日一题做完之后你会忍不住想如果这张表有 5000 万行这个写法会不会慢成狗怎么改第三有真实业务场景。比如“统计每个用户最近一次登录的设备”“找出连续三天登录的用户”“计算每个业务线的留存率”。这类题做完之后能直接用到工作上练习的动力会强很多。我复盘一道题至少问自己三个问题这道题有没有第二种写法如果表数据量从一万变成一亿这条 SQL 还能跑吗业务需求一变比如去重逻辑改成保留最新一条改动是一行还是十行这三个问题问下来一道简单的题也能挖出不少东西。强烈建议准备一个错题本不用很复杂手机备忘录就行记录那些憋了半天才写出来的题每隔一周翻一遍效果非常好。2. 去重题永远是基本功从 DISTINCT 到窗口函数“SQL 语句去重”在热搜里常年霸榜是有原因的。去重看似简单但几乎所有复杂查询里都有它的影子。很多人在这一步翻车不是因为不会写 DISTINCT而是不清楚不同去重方式之间的边界。2.1 三种去重方式的适用边界去重这件事实际上有三类写法对应三种不同的需求需求推荐写法理由查询结果整体去重不看明细SELECT DISTINCT最简单直接输出列完全相同的行合并去重后每组保留一条且可以用聚合函数控制保留哪一条GROUP BY MAX/MIN/SUM可以在分组内做聚合控制去重后的展示值去重后按组保留指定条数比如第 1 条、第 5 条或带序号窗口函数 ROW_NUMBER()最灵活可以精细控制每组保留哪一行我举个例子你就明白了。有一张订单表 order_info包含字段user_id用户、order_id订单号、order_amount订单金额、create_time下单时间。业务需求一统计有哪些用户下过单。这种就是典型的使用 DISTINCT 的场景因为只需要一个用户 ID 列表重复的只看一次。SELECT DISTINCT user_id FROM order_info;业务需求二统计每个用户的首单金额。注意这里要“每个用户一条”且要的是最早一单的金额。单纯 DISTINCT 做不到因为除了 user_id其他列都不一样没法“去重”。这时候用 GROUP BY然后配合 MIN(create_time) 找首单再关联回原表取金额。不过这里有个更漂亮的写法等会儿讲窗口函数时会提到。业务需求三找出每个用户的最近一笔订单要求保留完整的订单信息。这种需求用 DISTINCT 和 GROUP BY 都会很别扭最自然的就是 ROW_NUMBER() 窗口函数。所以记住一个判断顺序先问自己“是只要不重复的标的值还是要去重后保留指定明细”再选择工具。别上来就 DISTINCT。2.2 用 ROW_NUMBER 去重的经典套路窗口函数去重是我在每日一题里最常用到的套路之一尤其是“每组取一条”的需求几乎成了肌肉记忆。先看一个真实业务场景有一张用户登录日志表 user_login_log字段为user_id、device_type、login_time。业务方问每个用户最常用的登录设备是什么这个需求严格说应该是“每个用户最近一次登录所用的设备”因为“最常用”意味着聚合那是另一个问题。我们按常见的“取每个用户最新一条登录记录”来解WITH ranked AS ( SELECT user_id, device_type, login_time, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rn FROM user_login_log ) SELECT user_id, device_type, login_time FROM ranked WHERE rn 1;拆开看这个 SQL 的每一步意图。PARTITION BY user_id 是“给每个用户单独分组”ORDER BY login_time DESC 是“组内按下单时间倒序排名”于是最新一条的序号就是 1再外层用 WHERE rn 1 把每组最大的一条筛出来。整个过程不需要 JOIN不需要 GROUP BY执行计划也比“先聚合再回表”的方案干净。这个模板可以套用到大量场景每个用户最近一单、每个商品最新价格、每个班级最高分、每个部门最新一期绩效考核……全部一个套路。只需要注意一点当存在完全相同的 login_time 时ROW_NUMBER() 的排序是不确定的后两次运行可能选出不同行。如果业务要求绝对的确定性排序字段末尾要加一个唯一字段兜底比如 ORDER BY login_time DESC, id DESC。2.3 关于 NULL 和去重的几个坑去重题目里NULL 是个神奇的坑很多人栽在里面过。热搜词里“sql去除空值”也是一个常见搜索这里集中说一下。最经典的坑COUNT(DISTINCT 字段) 会忽略 NULL。你现在有一张表里面有 10 行数据其中 3 行的 user_id 是 NULL。你写 COUNT(DISTINCT user_id)结果不是 7也不是 10而是 7 没错——因为 NULL 根本不会被计数。这在统计有效用户数时反而是你要的效果但如果你以为 COUNT(DISTINCT) 是“看这个表有几行”那就错了。想统计含 NULL 在内的总数得用 COUNT(*)。这两个函数差之毫厘、谬以千里。第二个坑DISTINCT 和 ORDER BY 的组合。有些数据库比如 SQL Server要求 ORDER BY 的列必须出现在 SELECT 列表中否则直接报错。你写 SELECT DISTINCT city FROM users ORDER BY ageSQL Server 就报错因为输出结果里没有 age。这背后是逻辑问题对结果去重之后同一城市可能对应多个 ageSQL 不知道该按哪个 age 排序。遇到这种情况要么把 age 也放进 SELECT要么改用 GROUP BY MAX(age)。第三个坑先 DISTINCT 再 JOIN 可能导致数据膨胀。原因很隐蔽——如果两张表关联的字段在明细层重复DISTINCT 之后再去 JOINJOIN 的过程中重复可能又回来了。更稳妥的做法是先用 ROW_NUMBER 把重复行的逻辑固定住再 JOIN保证每个组只保留确定的一条。说白了去重不是一句 DISTINCT 那么简单你要明确“去重”发生在哪个阶段以及是否影响后续的关联。3. 窗口函数题把“分组排名”练成肌肉记忆窗口函数是 SQL 题里最值钱的知识点之一也是面试的高频区。因为它能优雅地解决很多以前要用子查询绕来绕去的问题。3.1 窗口函数和 GROUP BY 的本质区别很多人第一次接触窗口函数时容易和 GROUP BY 搞混。我的理解方法是做一个类比GROUP BY 就像是把一堆卡片按颜色分成几摞分完之后每摞卡片被压平最终只留下一摞卡片上的“汇总信息”窗口函数则像是给每张卡片贴上一张新标签标签上写着“你属于 XX 组你在组内的排名是 XX全组总和是 XX”等等。原来的每张卡片都在一行都不少只是每行多了一些“窗口内”的信息。这个区别直接导致了写法的不同。比如一道经典面试题“查询每个部门和该部门的平均工资并保留员工姓名。”如果你用 GROUP BY根本没法把员工姓名和部门平均工资放在同一行结果显示因为 GROUP BY 之后非聚合字段必须进分组否则报错。你必须先 GROUP BY 算平均值再 JOIN 原表。而用窗口函数SELECT emp_name, dept_id, salary, AVG(salary) OVER(PARTITION BY dept_id) AS dept_avg_salary FROM emp;一行都不用 JOIN每个员工旁边直接带上部门平均工资。这就是窗口函数“查询所有明细同时附带组内聚合”的能力。刷题的时候凡是遇到“既要明细、又要组内指标”的需求第一反应应该是窗口函数而不是 GROUP BY JOIN。3.2 排名三件套 RANK、DENSE_RANK、ROW_NUMBER 的选择窗口函数里有三个长得几乎一模一样的排序函数但处理并列时的行为完全不同。以考试成绩为例假设三个学生都得了 100 分ROW_NUMBER()纯粹的编号即使并列也会随机定顺序结果可能是 1、2、3。RANK()并列名次相同但会留下空位结果是 1、1、1、4。DENSE_RANK()并列名次相同但连续不跳号结果是 1、1、1、2。函数并列时表现典型业务场景ROW_NUMBER()必须分出先后随机或按其他字段排取每组 Top 1、分页、去重标记RANK()并列跳过排名跳号暴露并列数量竞赛排名、业绩排名并列多少一目了然DENSE_RANK()并列不跳号等级计算、积分等级需要连续序号每日一题刷到这个知识点时我建议给自己出一道“找连续三天登录用户”的题。这题表面考时间序列实际上考察的是把登录日期去重后用日期减去 ROW_NUMBER() 生成的序号连续日期会落到同一个差值上。这个思路很经典属于窗口函数的高级应用——“以序号为参照识别连续性”。不会做的先不要紧关键是做过一遍之后你会对窗口函数的能力边界有一个全新的认识。3.3 滑动窗口与累计计算从每日一题走向复杂报表窗口函数不止能做排名还能做累计计算。比如“统计每日销售额并追加一列当年累计销售额”这几乎是所有报表系统的标配需求。写法如下SELECT sales_date, sales_amount, SUM(sales_amount) OVER(ORDER BY sales_date) AS cum_sales FROM daily_sales;注意这里没有 PARTITION BY直接 OVER(ORDER BY sales_date)意思是“从最早日期到当前日期逐行累加”。如果需要按年份分组累计就加 PARTITION BY YEAR(sales_date)。如果要做移动平均比如最近 7 天平均就需要窗口帧的定义AVG(sales_amount) OVER(ORDER BY sales_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS avg_7d窗口帧有几种常见写法UNBOUNDED PRECEDING从组内开头、CURRENT ROW当前行、n PRECEDING往前 n 行、n FOLLOWING往后 n 行。它们组合起来能实现很多复杂的统计需求。我的建议是刷题时遇到窗口函数别只停留在排名多练习 SUM/AVG 配 OVER 的用法这在真实业务中比排名用得更频繁。4. 慢 SQL 优化题用执行计划回答“为什么慢”热搜词里“慢 sql 优化”的搜索量一直很高说明这已经是生产环境的刚需。慢 SQL 优化这块每日一题最值得练的是“读执行计划”的能力因为几乎所有优化都要靠执行计划来说话。4.1 优化前先问三件事拿到一条慢 SQL我从来不急着加索引。加索引像吃止痛药症状可能暂时消失但病因未必解决。我一般先问三件事这条 SQL 全表扫描了多少行如果只要 100 行加不加索引根本无所谓问题可能在别处。排序是在内存做的还是磁盘做的如果 ORDER BY 引发了文件排序可能要考虑排序字段的索引。有没有产生临时表一旦出现 using temporary意味着一大块内存/磁盘开销基本可以确定性能瓶颈在这里。这三件事用 MySQL 的 EXPLAIN、SQL Server 的 SET STATISTICS IO ON 图形化执行计划都能看个大概。慢 SQL 优化没什么玄学就是先定位瓶颈再对症下药。如果你在做每日一题时遇到一个慢查询题第一步不是猜而是把执行计划拿出来读一遍。4.2 索引失效的常见场景我总结了几个实际工作中踩过无数次的索引失效场景也是面试官喜欢拿来出题的地方在索引列上做函数运算。比如 WHERE DATE(create_time) 2024-01-01索引会被函数破坏。正确写法是范围查询 WHERE create_time 2024-01-01 AND create_time 2024-01-02。隐式类型转换。比如用户表 mobile 字段是 VARCHAR你传参时写成 WHERE mobile 13812345678数字数据库会做隐式转换索引失效。前导模糊查询 LIKE %keyword。以通配符开头的模糊匹配无法走索引这是物理特性决定的。可以考虑改用全文索引或者做成“前缀匹配”LIKE keyword%。OR 连接的多个条件里只要有一个字段没索引整个查询可能就退化成全表扫描。索引列参与计算。WHERE salary * 2 10000 跟 WHERE salary 5000 语义相近但前者会让索引失效应该改成后者。这些坑看起来是“语法问题”本质上是“优化器能不能高效地利用索引”的问题。每日一题如果遇到这类题我建议把对应的执行计划截图存下来复习的时候一眼就能想起坑在哪。4.3 用执行计划验证优化效果优化不是写完就完了关键是验证。MySQL 看 EXPLAIN 的关键列是 type、rows、Extra。type 从好到差大致是system const eq_ref ref range index ALL。看到 ALL 说明全表扫描至少先换个方式消除它rows 是预估扫描行数明显缩小意味着优化有效Extra 出现 Using temporary 或 Using filesort 时要特别注意排序和临时表问题。在 SQL Server 里我习惯先开 SET STATISTICS IO ON 看逻辑读次数这个数字往往比你觉得“快了很多”更可靠。一条 SQL 优化前后逻辑读从 10 万降到 200比什么主观感受都有说服力。SQL Server Management Studio 里还能直接看图形化执行计划侧重点看索引查找Index Seek和索引扫描Index Scan的比例。Index Seek 多就是好兆头Index Scan 太多就要警惕全表扫描了。每日一题练优化时一个很好的自我训练方式改写前后都跑一次执行计划对比扫描行数、临时表、排序开销。没有执行计划对比的 SQL 优化都是自我安慰。5. 注入题与安全防线看懂攻击才能写出安全 SQL热搜词里“sql注入”和“sql注入万能密码绕过”出现频率不低甚至有 CTF 比赛相关的词条。这里我不展开讲攻击技巧那是安全领域的专业范畴我从日常写 SQL 的视角讲一讲为什么拼接字符串是危险行为以及怎么写才能真正安全。5.1 注入产生的本质语义被篡改SQL 注入的本质是语义被篡改。SQL 本身是文本用户输入也是文本当用户输入被直接拼接到 SQL 字符串里时数据库没办法区分哪一段是程序员写的“逻辑”哪一段是用户填的“数据”。只要输入里出现引号、分号、注释符这类特殊字符整个 SQL 的语义就可能被改写。举一个非常经典的登录场景。原本要执行的逻辑是验证用户名和密码是否匹配。但如果把用户输入直接拼进字符串输入内容中的特殊字符就可能让“验证密码”这个步骤干脆不生效数据库直接判定为合法用户。这就是所谓“万能密码绕过”。你再怎么从业务层面加验证码、加次数限制都拦不住语义层的漏洞。所以我把 SQL 注入当成一道每日一题来看待重点不是“怎么绕”而是“为什么能绕”。理解了语义被篡改这个本质你就自然明白防御的重心应该放在哪里。5.2 参数化查询为什么是标准答案我说句直接的话参数化查询预编译语句是防注入的第一道防线也是最重要的一道防线。它的原理并不复杂数据库先接收到 SQL 的骨架结构把参数的位置标记为占位符而后再接收参数值参数值会被当作纯粹的数据而不是 SQL 的一部分。相当于先定好了这句话的语法结构后续填进来的任何东西都只是“名词”不会变成“动词”。拿 Python 的 pymysql 举个例子# 危险写法直接把用户输入拼进 SQL sql SELECT * FROM users WHERE username username AND password password cursor.execute(sql) # 安全写法参数化 sql SELECT * FROM users WHERE username %s AND password %s cursor.execute(sql, (username, password))第二种写法里就算用户输入了带引号、分号的内容数据库也只会把它当成一个字符串参数SQL 的结构不会变。Java 的 PreparedStatement、C# 的 SqlCommand 加上参数、Python 的 execute 占位符都是同一个道理。ORM 框架之所以相对安全很大程度上也是因为它默认帮你做了参数化处理。5.3 从 CTF 题到生产环境的迁移思考CTF 比赛里的 SQL 注入题能让人看到很多刁钻的绕过姿势但对生产环境写代码的人来说重点不是学会这些姿势而是要建立一个纵深防御的思维。CTF 是“找到一个洞打进去”工程是“把洞都堵上让攻击者无洞可打”。生产环境的防护应该是一套组合参数化查询解决大部分注入问题输入校验解决部分边界异常最小权限原则保证数据库账号只拥有业务所需的最小权限日志审计让异常行为有迹可循WAF 作为最后的兜底。每一层都可能有疏漏但叠加在一起安全性会几何级上升。我平时写 SQL 相关代码时给自己立了一条规矩凡是用户输入内容要进数据库一律参数化凡是排序字段、表名、列名这种没法参数化的部分一律走白名单校验坚决不直接拼接。6. 面试题怎么答把每日练习变成表达优势6.1 面试官真正想听的是什么面试中考察 SQL 时面试官大多不是想要一个“正确答案”而是想看你的思考链路。举个例子同样是“查每个部门工资最高的员工”两个候选人的回答可能完全不同。第一个直接背出窗口函数的答案说明他刷过题第二个会先说“如果最高工资有多人业务上要不要都保留如果都要我需要用 DENSE_RANK如果只要一个代表用 ROW_NUMBER。”这个回答展示了他能处理业务边界的模糊性这才是资深工程师和熟练工种的区别。每日一题刷多了之后你会发现很多题目本身是有业务背景的模糊描述比如“取每个用户最近一条记录”——“最近”是按什么时间字段“一组”是按什么维度分组这些都不明确。只有把这些边界条件确认清楚你写出来的 SQL 才是真正可用的。所以我每次复盘每日一题一定会额外问一句这个需求如果扔到真实业务里哪些条件是需要产品经理确认的6.2 一个可复用的答题框架面试时面对 SQL 题我习惯用下面这个框架你也可以直接拿去用第一步复述并澄清需求。比如“按部门分组取最高工资如果有并列是否都保留”这句话一出来面试官就知道你不是背题的。第二步给出最直接的方案。哪怕是 GROUP BY JOIN 这种笨办法先让人看到你能用正确的方式解决问题。第三步主动做性能分析。随口提一句“如果这张表数据量很大这个子查询可能会产生临时表我会改用窗口函数避免二次扫描”。能说出这句话已经把大多数候选人甩在身后了。第四步有条件就验证。说出执行计划里可能出现的关键指标比如“窗口函数避免了一次 JOIN减少了回表”。第五步收尾提边界条件。比如空值怎么处理、时间字段要不要截断到天。这个框架的好处在于它把一道题回答成了一次小型的方案评审而方案评审能力正是面试官在高级岗位中真正想看的。6.3 每日一题如何沉淀成简历上的项目经验很多人刷了几百道题简历上却依然写不出任何和 SQL 相关的亮点非常可惜。我建议你把刷题中的典型场景包装成一个具体的“查询治理”或“报表优化”案例写进项目经历里。注意不要造假而是把你真实做过的事情结构化描述。比如你公司有一个订单查询页面很慢你通过每日一题中练过的窗口函数思路把原本“GROUP BY 子查询 JOIN”的三步查询改写成一步窗口函数并补充了联合索引。这个优化前后查询耗时从 3 秒降到 200 毫秒。这样一段经历比“精通 SQL”这种废话简历有说服力得多。你需要的就是一个真实的业务痛点、一条优化前后的耗时对比、以及你能讲清楚原理。这三点都来自平时每日一题积累的思路和执行力。坚持了一年每日一题之后我最大的收获反而不是记住了多少函数而是养成了一种条件反射拿到任何数据需求先拆边界、再选模式、然后想性能、最后才动手写。如果你也想提升 SQL不妨从今天开始每天只做一道题但把这道题彻底吃透——一周之后你会回来感谢自己的。
返回列表