如果你在准备大厂面试,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用TIMESTAMPDIFF,PostgreSQL用EXTRACT(EPOCH FROM ...),Hive用datediff或unix_timestamp做差。笔试时先确认环境再写,不要默认为MySQL函数。
3.3 树形结构递归:组织架构、商品类目
这类题目一般出现在难度较高的笔试题里,比如给员工表emp(emp_id, emp_name, mgr_id),要求输出每个员工的完整汇报链路。比如某员工A,其经理是B,B的经理是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是NULL,NULL > 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 ON(PostgreSQL)。笔试题目如果没说清楚,一般默认是多对一关系,但你最好在注释里说明你的假设。
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是清晰、准确、可维护的。能做到这一点,你已经超过了大多数人。