SQL笔试题高分攻略:窗口函数与连续登录等高频考点解析
如果你在准备大厂面试SQL笔试题大概率是笔试环节里那位“沉默杀手”。它不像算法题那样有标准模板也不像八股文可以突击背诵但恰恰是它筛掉了不少基本功看似扎实的候选人。我这两年帮学生、朋友复盘过好几批头部科技公司的SQL笔试题也亲自参与过候选人答卷的评审慢慢摸清了这类题目的出题逻辑。这篇文章就把我看到的、以及我自己踩过的坑完整拆给你看。无论你准备的是后端开发、数据开发还是数据分析岗这套SQL笔试背后的考察思路都是相通的。我会用具体的题目形态而不是泛泛而谈的概念带你过一遍高频考点、解题模板、易错点和备考节奏。如果你是初学者可以按章节顺序读如果你马上要笔试建议直接跳到第4部分那里全是血泪教训。1. 出题人视角这类SQL笔试题真正考的是什么很多人以为SQL笔试题考的是“会不会写SELECT”其实远没那么简单。我翻过不少大厂的笔试题发现它们几乎都是把真实业务场景压缩成一张或几张表让你在规定时间内完成取数、统计、分析或者建模。表面上考语法实际考的是三样东西数据敏感度、逻辑拆解能力、工程思维。先给一张考点地图这几类几乎覆盖了90%的题目考点维度常见题型考察目的基础查询与过滤WHERE、DISTINCT、ORDER BY是否具备最基础的取数能力聚合与分组SUM、AVG、GROUP BY、HAVING是否理解数据汇总的粒度变化多表关联INNER JOIN、LEFT JOIN、全连接是否理解业务表之间的关联关系窗口函数ROW_NUMBER、RANK、LAG、SUM OVER能否处理排序、排名、前后值、累计等分析问题复杂业务建模留存、会话、树形结构、最大连续能否把业务问题翻译成SQL模型性能与优化EXISTS与IN、索引、避免笛卡尔积是否有基本的性能意识注意看前两行是基本功后四行才是真正的分水岭。基本功大家都会背但一到真实场景就犯迷糊。1.1 大部分候选人挂在“业务建模”上举一个很多候选人复盘时会惊叹的题目统计每个部门薪资排名前3的员工。这题看起来简单但有人用GROUP_CONCAT排序后截断有人用自连接比较工资个数还有人直接ORDER BY后LIMIT 3完全忽略了“每个部门”。出题人给这个题并不是真想让你写一行ROW_NUMBER()而是想看你在面对“每个部门”这种分组诉求时能不能立刻想到分区窗口函数。如果只想到全局排序说明你还不理解PARTITION BY的意义。如果想到自连接说明你学过但不懂为什么要用窗口函数。只有基础扎实、有实战经验的人才会在第一反应里写出SELECT dept_id, name, salary, emp_rank FROM ( SELECT dept_id, name, salary, DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS emp_rank FROM employee ) t WHERE emp_rank 3;这题还藏着一个细节题目说的是“薪资排名前3的员工”那如果第三名有并列两个人怎么办如果用ROW_NUMBER()并列的两人会随机选一个这在业务上通常是不对的。所以我在辅导候选人时总是反复强调先搞清楚题目对并列的处理偏好。一般“前N名”用DENSE_RANK()或RANK()更稳妥除非题目明确说“取前3名员工”且不关心并列。1.2 语法细节越严苛越能筛掉“只会用工具点”的人现在很多企业的SQL笔试题都支持跑真实数据库你写的SQL会被真实执行这就意味着语法细节逃不过去。比如MySQL的ONLY_FULL_GROUP_BY模式下SELECT的列必须出现在GROUP BY里或者是聚合函数。很多人平时用Navicat之类工具写查询表数据量小不报错就完事但一旦笔试环境默认开启严格模式马上原形毕露。再比如窗口函数不能直接放在WHERE子句中需要包一层子查询HAVING和WHERE的执行顺序不同LEFT JOIN后如果对右表字段加WHERE条件可能导致左表行被过滤掉。这些看起来都是“小坑”但在笔试成绩上就是天壤之别——因为大厂笔试自动判题时差一行结果都是零分。2. 高频必练题型TopN、连续N天、累计聚合如果你时间有限只够练三类题那我的建议是TopN、连续N天、累计聚合。这三类几乎每年都出现在各大厂笔试里而且它们背后代表的正好是窗口函数最核心的三种能力分组排序、行间偏移、框架聚合。2.1 TopN问题四种解法为什么窗口函数是首选上面已经给了TopN的标准写法。我再说说其它解法理解它们你才能真正领悟窗口函数的优势。解法一老MySQL用用户变量按部门排序再外层过滤。这种写法依赖于变量赋值顺序很容易出错可读性也很差。解法二自连接子查询找那些“在部门内薪资比它高的人少于3个”的员工SELECT e1.dept_id, e1.name, e1.salary FROM employee e1 WHERE ( SELECT COUNT(DISTINCT e2.salary) FROM employee e2 WHERE e2.dept_id e1.dept_id AND e2.salary e1.salary ) 3 ORDER BY e1.dept_id, e1.salary DESC;这个写法能正确处理并列薪资因为它统计的是“比当前薪资高的不同薪资个数”。如果并列薪资都是最高那它们的排名都是1都会入选。逻辑没问题但问题在于子查询对每一行都要扫描整个部门数据量一上去就非常慢。解法三窗口函数则把排名计算放在一次扫描中完成性能高、可读性好。笔试中选它不仅答案正确还显得你很专业。不过在写窗口函数时一定要搞清楚RANK()、DENSE_RANK()、ROW_NUMBER()的区别函数排名是否跳跃并列处理典型场景ROW_NUMBER()不跳跃并列时随机/按规则给不同序号取物理前N条RANK()跳跃并列占同一排名下一个跳过体育比赛名次DENSE_RANK()不跳跃并列占同一排名下一个连续“前N名”业务需求我通常建议只说“取前N条记录”用ROW_NUMBER()说“排名前N”用DENSE_RANK()说“排序用RANK()”要小心因为它会留坑。2.2 连续N天登录问题这题背后是“分组定界”连续性问题是我见过刷掉人数最多的一类。题目形态通常是有一张用户登录表login_log(user_id, login_date)找出连续登录至少3天的用户。很多人会尝试用LAG(login_date)判断前后日期差是否为1但这思路在面对“连续5天”时会变得很啰嗦。有一个非常优雅的通用解法日期减去行号连续的日期会落到同一个小组里。先对每个用户去重日期、按日期排序、加上行号然后用login_date减去ROW_NUMBER()对应的天数。如果日期是连续的比如1号、2号、3号而行号是1、2、3那么1号减1天得到0号2号减2天得到0号3号减3天得到0号这样它们就归到了同一个分组grp。一旦中间断档比如1号、3号那1号减1天是0号3号减2天是1号分组不同就识别出了断档。完整SQL如下WITH dedup AS ( SELECT DISTINCT user_id, login_date FROM login_log ), ranked AS ( SELECT user_id, login_date, DATE_SUB(login_date, INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date) DAY) AS grp FROM dedup ) SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS consecutive_days FROM ranked GROUP BY user_id, grp HAVING COUNT(*) 3;这里有个关键点必须先对(login_date)去重因为同一天登录多次会干扰行号。另外如果数据量大这个分组逻辑也能高效执行。如果你接触的是Hive日期减法可以用DATE_SUB。不同数据库的差异不大核心就是那个“日期减行号”的思维。2.3 累计求和、同比环比窗口函数的框架也能解决累计求和的典型场景是“求每个产品每个月截止到当前的累计销售额”。表结构大概是sales(month, product_id, amount)。SELECT month, product_id, amount, SUM(amount) OVER (PARTITION BY product_id ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumulative_amount FROM sales;注意窗口函数里的ORDER BY会改变SUM的默认计算范围。如果不写ROWS BETWEEN很多数据库的默认框架是从第一行到当前行所以累计求和如果不小心加了PARTITION BY但漏了ORDER BY就会变成全部分区总和。这个细节在笔试中很容易被忽略尤其是当你看到结果里所有行的累计值都一样时八成就是框架问题。同比环比也经常考。比如计算每个月的环比增长率SELECT month, product_id, amount, LAG(amount, 1) OVER (PARTITION BY product_id ORDER BY month) AS prev_amount, (amount - LAG(amount, 1) OVER (PARTITION BY product_id ORDER BY month)) / NULLIF(LAG(amount, 1) OVER (PARTITION BY product_id ORDER BY month), 0) AS mom_growth FROM sales;这里用LAG取上个月的值然后做差比。NULLIF可以避免除零报错这也是一个容易被忽略的细节。3. 复杂业务场景题留存、会话、层级递归如果TopN和连续题算“基础套路”那留存、会话识别和树形递归就是加了业务难度的“升级版题目”。它们的特点是代码量不大但要把业务逻辑翻译成SQL非常考验抽象能力。3.1 次日留存率数据分析面试的标配题目一般是给用户活跃日志active_log(user_id, dt)统计每日新增用户中次日仍然活跃的比例。注意这里“新增用户”如何定义如果业务有注册表可以直接从注册表取如果没有通常把每个用户的最早活跃日期视为“新增日”。先求出每个用户的最早活跃日期作为首日再关联次日活跃WITH first_active AS ( SELECT user_id, MIN(dt) AS install_dt FROM active_log GROUP BY user_id ), active_retention AS ( SELECT fa.install_dt, COUNT(DISTINCT fa.user_id) AS install_cnt, COUNT(DISTINCT CASE WHEN al.dt DATE_ADD(fa.install_dt, INTERVAL 1 DAY) THEN fa.user_id END) AS next_retain_cnt FROM first_active fa LEFT JOIN active_log al ON fa.user_id al.user_id GROUP BY fa.install_dt ) SELECT install_dt, install_cnt, next_retain_cnt, ROUND(next_retain_cnt / install_cnt, 4) AS next_day_retention_rate FROM active_retention;为什么要用LEFT JOIN而不是INNER JOIN因为在算留存时很多次日没有回来的用户也必须保留在结果里否则留存人数会被错误放大。用INNER JOIN的话你没活跃就是不产生关联行那next_retain_cnt自然少了看起来没问题但install_cnt也会被过滤掉那些没有次日活跃的用户吗不会因为install_cnt来自first_active如果INNER JOIN那么首日活跃但次日未活跃的用户在关联时根本没有al记录整行消失install_cnt就被算小了。所以LEFT JOIN才是正确选择。还有一个细节活跃日志可能有同一用户同一天多次活跃所以统计“人数”时一定要COUNT(DISTINCT user_id)不能直接COUNT(*)。3.2 会话识别用SQL给用户行为“分段”会话识别在用户行为分析里非常常见。比如用户点击日志click_log(user_id, click_time)要求把连续30分钟内无操作视为不同会话每次有操作时如果距离上次操作超过30分钟就算一个新会话。核心思路先按用户和时间排序用LAG取上一次点击时间判断当前时间与上一次的间隔是否超过30分钟打一个标记0/1。标记为1的行代表新会话起点然后对这个标记做累计求和得到会话ID。WITH clicks AS ( SELECT user_id, click_time, CASE WHEN TIMESTAMPDIFF(MINUTE, LAG(click_time, 1) OVER (PARTITION BY user_id ORDER BY click_time), click_time) 30 THEN 1 ELSE 0 END AS is_new_session FROM click_log ) SELECT user_id, click_time, SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY click_time ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id FROM clicks;这个解法很妙因为它把“间断点识别”和“分组编号”结合在了一起。以后你遇到任何“把连续行为切分成段”的需求比如电量充满中断、乘车行程分段、异常时间段划分都可以用这个套路。注意不同数据库计算两个时间差的函数不太一样MySQL用TIMESTAMPDIFFPostgreSQL用EXTRACT(EPOCH FROM ...)Hive用datediff或unix_timestamp做差。笔试时先确认环境再写不要默认为MySQL函数。3.3 树形结构递归组织架构、商品类目这类题目一般出现在难度较高的笔试题里比如给员工表emp(emp_id, emp_name, mgr_id)要求输出每个员工的完整汇报链路。比如某员工A其经理是BB的经理是C最后链为“C B A”。用递归CTE是最清晰的做法。以MySQL 8.0为例WITH RECURSIVE emp_tree AS ( -- 锚点找到顶级管理者没有上级的人 SELECT emp_id, emp_name, mgr_id, CAST(emp_name AS CHAR(500)) AS path FROM emp WHERE mgr_id IS NULL UNION ALL -- 递归将员工接到上级链上 SELECT e.emp_id, e.emp_name, e.mgr_id, CONCAT(et.path, , e.emp_name) FROM emp e JOIN emp_tree et ON e.mgr_id et.emp_id ) SELECT * FROM emp_tree;锚点成员先找出树根递归成员负责把下一层节点拼接到已有链上。停止条件就是没有更多员工满足mgr_id等于当前emp_id。但这里有一个坑如果组织架构中存在循环汇报比如A的上级是B、B的上级是A递归没有停止条件就会死循环。实际笔试中数据一般不会这么恶心但你还是要在写完后检查一下是不是所有非顶级节点最终都能追溯到根如果不能可能需要临时加一个路径深度限制。这个问题在面试聊起来时很加分说明你考虑过真实数据质量问题。4. 笔试答题时我踩过的坑和自查清单我在帮人复盘卷子时发现很多错误并不是“不会写”而是“写得太急”导致的低级失误。下面这些坑我基本都亲眼见过自己也掉进去过。4.1 结果集多一行、少一行的隐蔽原因先说最常见的LEFT JOIN之后在WHERE里对右表字段做条件过滤把左表行给过滤掉了。比如SELECT u.user_id, o.amount FROM user u LEFT JOIN order o ON u.user_id o.user_id WHERE o.amount 100;如果用户没有订单o.amount是NULLNULL 100的结果是NULL不满足WHERE所以整行被过滤LEFT JOIN退化成INNER JOIN。正确做法是把过滤条件放进ON子句SELECT u.user_id, o.amount FROM user u LEFT JOIN order o ON u.user_id o.user_id AND o.amount 100;这个差别在笔试里非常常见而且很多人很难一眼发现。建议写完SQL后用一个小数据集手推一遍例如输入两个用户其中一个无订单看结果是否符合预期。另一个产生多余行的坑是关联字段不唯一。比如两张表一张是用户维度一张是订单事实如果直接JOIN user_id而右侧该用户有多条订单结果行数会翻倍。如果想保留左表一行就必须先聚合右表或者用DISTINCT ONPostgreSQL。笔试题目如果没说清楚一般默认是多对一关系但你最好在注释里说明你的假设。4.2 窗口函数里的排序字段有重复值小心结果“随机”如果窗口函数ORDER BY的字段有大量重复值比如按薪资排序时有100人薪资相同ROW_NUMBER()到底给谁1、谁2数据库没有明确保证。某些笔试平台会按物理顺序赋值那你的排名结果可能不稳定。所以在要求“并列相同”时用DENSE_RANK或RANK在确实需要唯一序号时ORDER BY里可以补一个主键字段如ORDER BY salary DESC, emp_id。我还见过有候选人把窗口函数写在WHERE子句里然后报语法错误。记住窗口函数只能在SELECT列表和ORDER BY中使用如果想基于窗口结果过滤必须包一层子查询或CTE。4.3 时间分配和读题顺序笔试的隐形竞争SQL笔试题一般给60到120分钟2到3道题。我的建议是拿到题先全部读一遍标出每道题用到的表和字段特别是确认日期字段的格式、是否存在重复数据。然后从自己最熟练的题型开始做把有把握的分数先拿稳。如果一道题卡了超过15分钟立刻跳下一题。SQL笔试不像算法题很多解法你一眼就知道卡住往往是因为漏看条件或者表理解错了。跳题后回来重读一遍很可能突然开窍。做完全部题目后一定要留出5到10分钟做自查。自查链检查每张表的“每一行”是否会被过滤掉检查GROUP BY后SELECT的字段是否合法检查窗口函数的PARTITION BY和ORDER BY是否写反检查日期函数前后单位是否一致。5. 考前一个月我这样刷题最后聊点备考策略。很多人喜欢在笔试前疯狂刷题但市面上题目质量参差不齐。我自己摸索出一套分阶段练习法效果还不错分享给你。5.1 分三阶段基础-套路-限时模拟第一个阶段第1周 把SQL基础语法过一遍重点是JOIN和GROUP BY因为这两个是几乎每道题都会用到的地基。不要只看博客要在本地数据库里把left join、inner join、full join、cross join的区别都测一遍观察行数变化。第二个阶段第2周 按题型专项练。建议总结一个“题型模板表”比如“看到每个部门→PARTITION BY”“看到最近N天→LAG/LEAD”“看到累计→SUM OVER”“看到留存→先求首日再关联”等等。我不反对背模板但要背着模板去理解题目而不是套模板。第三个阶段第3-4周 找在线SQL平台或者自己造数据严格按笔试要求限时做题。每次模拟120分钟3道题一道基础聚合、一道窗口函数、一道业务建模。模拟的目的不是刷题量而是练时间感和考场心态。我通常模拟完不会马上看答案而是先复盘自己哪一步卡住了再回去补对应的知识点。5.2 学会“读题拆题”把业务句子翻译成SQL骨架看到一个题目不要急着写SQL。先用中文把题目拆成几步要查的表是哪张筛选条件是什么分组的维度是什么输出的每一个字段怎么算如果需要关联关联键是什么关联方向是什么举个例子题目说“统计每个部门平均薪资高于公司平均薪资的部门”。这句话拆开就是先算公司平均薪资一个标量再把部门作为分组算每个部门平均薪资最后HAVING比较。于是骨架自然浮现SELECT dept_id, AVG(salary) AS dept_avg FROM employee GROUP BY dept_id HAVING AVG(salary) (SELECT AVG(salary) FROM employee);每一步都有据可依就不容易漏条件。5.3 本地造数据千万别偷懒我个人最喜欢的方法是在本地装一个MySQL或PostgreSQL亲手建一套面试常考的表比如员工表、订单表、登录表、点击日志表然后往里面插入50到100条模拟数据再围绕它们自己给自己出题。为什么要自己造数据因为你在网上找的题库往往只有查询语句没有完整的建表插入语句你根本不知道那些表字段是什么类型、有哪些脏数据。自己造数据能逼着你思考数据的真实形态日期会不会重复金额能不能为负外键是否缺失这些问题在笔试中恰好是出题人爱埋的雷。我自己的习惯是在纸上画一个简单的ER图模拟各个表之间的关系然后写建表语句。写完再针对每张表写至少5条查询包括离线数据探索时的常用查询。等把这一套下来你去看大厂笔试题通常会感觉“这些场景我都见过了”。最后再分享一个小技巧笔试前把窗口函数的几种框架写在一张纸上。比如SUM(amount) OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN ...)里那个ROWS BETWEEN的几种边界写法你真的分清了吗如果分不清就把它背熟。很多时候面试官并不指望你在5分钟内憋出一个神级解法他更希望看到你写出的SQL是清晰、准确、可维护的。能做到这一点你已经超过了大多数人。