SQL 数据分析 · 指南 · 2026年10月29日 · 12 分钟阅读
数据分析师 SQL 面试题:20 道常考题及参考答案

数据分析师的 SQL 面试一般考两样东西:你是否理解 SQL 是怎样执行一个查询的(概念题),以及你能否在时间压力下把业务问题写成正确的查询(现场题)。下面是 20 道 SQL 面试题,两类都有,风格贴近分析岗面试的真实提问,每题附简短的参考答案。概念题的答案是你可以直接说出口的内容,现场题的答案是你要写出来的查询。
现场题使用 Nomto SQL 课程里的航空公司数据库:airports、aircrafts、seats、flights(2026 年 7 月至 9 月的 33,121 个航班)、bookings、tickets、ticket_flights(每位乘客每个航段一行,amount 单位为美元)、boarding_passes,以及会员计划表 ticket_members。数据快照截取于 2026 年 8 月 11 日 18:00。所有查询都是 PostgreSQL 语法,文中的结果都是真实输出,你可以在 Nomto 的 SQL 练习场里自己运行。
一个适用于所有现场题的小技巧:动笔之前,先说清楚「结果里的一行代表什么」以及「每张表的一行代表什么」。面试中大多数错误答案,都源于这两者对不上。
概念题
1. SQL 按什么顺序执行一个 SELECT 查询?
FROM 和 JOIN、WHERE、GROUP BY、聚合函数、HAVING、窗口函数、SELECT(包括别名)、DISTINCT、ORDER BY、LIMIT。这个顺序解释了三件事:为什么 WHERE 里不能用聚合函数,为什么不能在 WHERE 里筛选窗口函数的结果,以及为什么 ORDER BY 可以用列别名而 WHERE 不行。
2. WHERE 和 HAVING 有什么区别?
WHERE 在分组之前筛选行,不能使用聚合函数;HAVING 在聚合之后筛选分组。WHERE status = 'Arrived' 决定哪些航班参与计数,HAVING COUNT(*) > 1000 决定哪些机场出现在结果里。详细讲解见 GROUP BY 和 HAVING 的区别。
3. 解释 INNER JOIN、LEFT JOIN 和 FULL OUTER JOIN
INNER JOIN 只保留两边都能匹配上的行。LEFT JOIN 保留左表所有行,匹配不到时右边填 NULL。FULL JOIN 两边匹配不上的行都保留。好的回答会顺带说出后果:在我们的数据里,aircrafts JOIN flights 只返回 8 种机型,因为 Airbus A320-200 没有任何航班;aircrafts LEFT JOIN flights 则返回全部 9 种。更多内容见 SQL JOIN 用法详解。
4. COUNT(*)、COUNT(column) 和 COUNT(DISTINCT column) 有什么区别?
COUNT(*) 数行数,COUNT(column) 数非 NULL 值的个数,COUNT(DISTINCT column) 数不同的非 NULL 值的个数。
SELECT COUNT(*) AS all_rows,
COUNT(actual_departure) AS departed,
COUNT(DISTINCT flight_no) AS flight_numbers
FROM flights;
| all_rows | departed | flight_numbers |
|---|---|---|
| 33121 | 16773 | 710 |
共 33,121 个航班,其中 16,773 个有实际起飞时间,一共用了 710 个航班号。
5. NULL 在比较中会怎样?
任何与 NULL 的比较结果都是「未知」,而不是「真」,所以 WHERE x = NULL 一行都匹配不到:
SELECT COUNT(*) AS flights FROM flights WHERE actual_departure = NULL; -- 0
SELECT COUNT(*) AS flights FROM flights WHERE actual_departure IS NULL; -- 16348
要用 IS NULL / IS NOT NULL。除了 COUNT(*) 以外的聚合函数都会忽略 NULL;如果 NULL 的含义其实是 0,用 COALESCE(x, 0) 替换。
6. 为什么 NOT IN 搭配子查询有风险?
只要子查询返回哪怕一个 NULL,x NOT IN (...) 就永远不可能为真,整个查询一行都不返回。在我们的数据里,aircrafts WHERE aircraft_code NOT IN (SELECT aircraft_code FROM flights) 正确地返回 1 种机型;但只要在子查询结果里混进一个 NULL,同样的查询就返回 0 行。改用 NOT EXISTS,它没有这个陷阱。
7. UNION 和 UNION ALL
UNION 会去重,UNION ALL 保留每一行,因为省去了去重步骤,所以开销更小。
SELECT COUNT(*) FROM (
SELECT departure_airport AS airport FROM flights
UNION
SELECT arrival_airport FROM flights
) u;
结果是 104(不重复的机场数)。换成 UNION ALL 则返回 66,242,也就是每个航班两行。除非确实需要去重,否则用 UNION ALL。
8. ROW_NUMBER、RANK 和 DENSE_RANK
三者都按某个排序给行编号,区别在于并列时的处理:ROW_NUMBER 仍然给出唯一编号(1、2、3、4),RANK 并列同号并跳号(1、2、2、4),DENSE_RANK 并列同号但不跳号(1、2、2、3)。需要「恰好 N 行」或去重时用 ROW_NUMBER,求「第 N 高的值」时用 DENSE_RANK。带真实并列数据的例子见 SQL 窗口函数。
9. 什么是主键、外键和复合键?
主键唯一标识一行(flights.flight_id)。外键引用另一张表的键(flights.aircraft_code → aircrafts.aircraft_code)。复合键由多列共同组成:在 seats 里,座位号 12A 会在不同机型中重复出现,所以键是 (aircraft_code, seat_no) 这一对。面试时可以接着说它对 JOIN 的影响:只用复合键的一半去连接,行数就会成倍增加。
10. 为什么 JOIN 有时会让 SUM 变大?怎么修正?
当一行连接到多行时,它会被重复,它的数值也会按重复次数被多次求和。8 月 2 日下的订单,在 bookings 里合计 4,563,873 美元;连接 tickets(每笔订单可能有多张票)之后,同样的 SUM(total_amount) 变成了 7,569,035 美元。修正方法:先把「多」的那一边聚合好再 JOIN,或者在度量值自己的粒度上求和。SUM(DISTINCT ...) 不是解决办法:它会把金额恰好相同的不同订单合并成一个。
现场写查询
11. 不用窗口函数,求航程第二长的机型航程
SELECT MAX(range) AS second_longest
FROM aircrafts
WHERE range < (SELECT MAX(range) FROM aircrafts);
| second_longest |
|---|
| 7900 |
子查询找出最大值(11,100 公里),外层查询在比它小的值里再取最大值。如果面试官追问一般情况下的第 N 个值,就换成 DENSE_RANK(见下一题)。
12. 每个舱位第二高的航段票价
WITH p AS (
SELECT DISTINCT fare_conditions, amount FROM ticket_flights
), r AS (
SELECT fare_conditions, amount,
DENSE_RANK() OVER (PARTITION BY fare_conditions ORDER BY amount DESC) AS dr
FROM p
)
SELECT fare_conditions, amount AS second_highest
FROM r
WHERE dr = 2
ORDER BY fare_conditions;
| fare_conditions | second_highest |
|---|---|
| Business | 2013.00 |
| Comfort | 474.00 |
| Economy | 738.00 |
最高价分别是 2,033、476 和 745 美元。这里用 DENSE_RANK 才对:很多航段的价格都等于最高价,用 ROW_NUMBER 只会把最高价的另一份拷贝当成「第二」。先对价格去重,还能让窗口要处理的数据小很多。
13. 各舱位的票价中位数
SELECT fare_conditions,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS median,
ROUND(AVG(amount), 2) AS mean
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY fare_conditions;
| fare_conditions | median | mean |
|---|---|---|
| Business | 350.00 | 511.43 |
| Comfort | 244.00 | 327.41 |
| Economy | 121.00 | 159.6 |
每个舱位的平均值都明显高于中位数:少数长距离、高票价的航线把平均数拉高了。不等面试官问就主动指出这一点,正确的查询才会变成出色的回答。PERCENTILE_CONT 会在中间两个值之间插值,PERCENTILE_DISC 返回数据中实际存在的一个值。
14. 查找重复数据
「找出同一个乘客姓名在一笔订单里出现在多张机票上的情况。」
SELECT passenger_name, book_ref, COUNT(*) AS tickets
FROM tickets
GROUP BY passenger_name, book_ref
HAVING COUNT(*) > 1
ORDER BY tickets DESC, book_ref
LIMIT 3;
| passenger_name | book_ref | tickets |
|---|---|---|
| ALEKSANDR SOROKIN | 0736ED | 2 |
| TATYANA KUZMINA | 0E7192 | 2 |
| ALEKSANDR VOLKOV | 1697BB | 2 |
这样的组合一共有 34 对。值得主动补充的一点:它们不一定是错误数据。同一笔订单里的两个人完全可能同名,乘客 ID(在这份数据里每张票都不同)才是更可靠的身份判断依据。界定「什么算重复」本身就是答案的一部分。
15. 分组取前 N 名
「莫斯科每个机场航班最多的两个目的地。」
WITH r AS (
SELECT departure_airport, arrival_airport, COUNT(*) AS flights,
ROW_NUMBER() OVER (PARTITION BY departure_airport
ORDER BY COUNT(*) DESC, arrival_airport) AS rn
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO')
GROUP BY departure_airport, arrival_airport
)
SELECT departure_airport, arrival_airport, flights, rn
FROM r
WHERE rn <= 2
ORDER BY departure_airport, rn;
| departure_airport | arrival_airport | flights | rn |
|---|---|---|---|
| DME | LED | 244 | 1 |
| DME | BZK | 183 | 2 |
| SVO | LED | 305 | 1 |
| SVO | BZK | 183 | 2 |
| VKO | BZK | 183 | 1 |
| VKO | LED | 183 | 2 |
记得提一下伏努科沃(VKO)的并列:BZK 和 LED 都是 183 个航班,顺序完全由按字母排序的决胜列决定。如果两个并列的都应该算第一,就用 RANK。
16. 找出没有匹配的行(反连接)
「哪些机型从来没有飞过?」
SELECT a.aircraft_code, a.model
FROM aircrafts a
WHERE NOT EXISTS (
SELECT 1 FROM flights f WHERE f.aircraft_code = a.aircraft_code
);
| aircraft_code | model |
|---|---|
| 320 | Airbus A320-200 |
等价的写法 LEFT JOIN ... WHERE f.flight_id IS NULL 同样可以接受。这两种都比 NOT IN 好(见第 6 题)。
17. 用条件聚合做行转列
「莫斯科每个机场的已到达、已取消和计划中航班数,各占一列。」
SELECT departure_airport,
COUNT(*) FILTER (WHERE status = 'Arrived') AS arrived,
COUNT(*) FILTER (WHERE status = 'Cancelled') AS cancelled,
COUNT(*) FILTER (WHERE status = 'Scheduled') AS scheduled
FROM flights
WHERE departure_airport IN ('SVO', 'DME', 'VKO')
GROUP BY departure_airport
ORDER BY departure_airport;
| departure_airport | arrived | cancelled | scheduled |
|---|---|---|---|
| DME | 1618 | 31 | 1510 |
| SVO | 1503 | 26 | 1396 |
| VKO | 869 | 20 | 801 |
如果面试用的数据库不支持 FILTER,就写 SUM(CASE WHEN status = 'Arrived' THEN 1 ELSE 0 END)。
18. 累计值和日环比变化
WITH daily AS (
SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
FROM bookings
WHERE book_date >= '2026-08-01' AND book_date < '2026-08-08'
GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
SUM(bookings) OVER (ORDER BY day) AS running_total,
LAG(bookings) OVER (ORDER BY day) AS prev_day,
bookings - LAG(bookings) OVER (ORDER BY day) AS change
FROM daily
ORDER BY day
LIMIT 4;
| day | bookings | running_total | prev_day | change |
|---|---|---|---|---|
| 2026-08-01 | 5746 | 5746 | NULL | NULL |
| 2026-08-02 | 5761 | 11507 | 5746 | 15 |
| 2026-08-03 | 5665 | 17172 | 5761 | -96 |
| 2026-08-04 | 5813 | 22985 | 5665 | 148 |
先聚合到天,再在按天的行上使用窗口函数。一个加分的补充:如果某天订单数为 0,这一天在 daily 里根本不存在,LAG 就会拿两天前的数据来比;用一个日期序列补齐可以解决这个问题。
19. 占总数的百分比
「每种航班状态各占全部航班的多少?」
SELECT status, COUNT(*) AS flights,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM flights
GROUP BY status
ORDER BY flights DESC
LIMIT 4;
| status | flights | pct |
|---|---|---|
| Arrived | 16707 | 50.4 |
| Scheduled | 15383 | 46.4 |
| On Time | 518 | 1.6 |
| Cancelled | 414 | 1.2 |
SUM(COUNT(*)) OVER () 把总计放到每个分组旁边。100.0 是为了避免整数除法。
20. 30 天内的复购
「会员中有多少比例在首次订票后 30 天内又订了一次?」
WITH mb AS (
SELECT DISTINCT tm.member_id, b.book_ref, b.book_date
FROM ticket_members tm
JOIN tickets t ON t.ticket_no = tm.ticket_no
JOIN bookings b ON b.book_ref = t.book_ref
), firsts AS (
SELECT member_id, MIN(book_date) AS first_booking
FROM mb
GROUP BY member_id
), flags AS (
SELECT f.member_id,
MAX(CASE WHEN mb.book_date > f.first_booking
AND mb.book_date <= f.first_booking + INTERVAL '30 days'
THEN 1 ELSE 0 END) AS repeat_30d
FROM firsts f
JOIN mb ON mb.member_id = f.member_id
WHERE f.first_booking <= TIMESTAMP '2026-08-11 18:00:00' - INTERVAL '30 days'
GROUP BY f.member_id
)
SELECT COUNT(*) AS members, SUM(repeat_30d) AS repeat_30d,
ROUND(100.0 * SUM(repeat_30d) / COUNT(*), 1) AS pct
FROM flags;
| members | repeat_30d | pct |
|---|---|---|
| 16529 | 9843 | 59.5 |
面试官想听到的三点:
- 粒度。 一位会员在同一笔订单里可能有多张票,所以先用
DISTINCT把数据压到「每位会员每笔订单一行」,然后再计数。 - 观察期是否足够。 只纳入首次订票距快照至少 30 天的会员。8 月 5 日才首次订票的会员还没有满 30 天的机会回来,把他们算作「没有复购」会低估比例。
- 定义。 这里的「首次订票」指的是数据里的第一次,而数据从 2026 年 6 月 17 日开始。要把这一点说出来:这是一个假设,不是关于客户的事实。
更完整的留存写法见 SQL 留存分析。
面试时怎样作答
- 把问题复述成结果表:「每个机场一行,列是 X 和 Y」。这样在写代码之前就能发现理解偏差。
- 说出每张要 JOIN 的表的粒度,并指出哪里可能出现行数膨胀。
- 明确处理并列、NULL 和不完整的周期。 这些边界情况往往是面试官故意埋下的。
- 验证数字是否合理: 和一个已知的总数对比,或者在 JOIN 前后各数一次行数。
- 边写边说。 思路清楚但只写对一部分的查询,通常比一声不吭写出的正确答案得分更高。
想要更多由易到难的动手练习,请看 SQL 练习题及答案。
常见问题
数据分析岗面试常考哪些 SQL 知识点?
最常见的是:GROUP BY 搭配 HAVING、各种 JOIN(包括 LEFT JOIN 和反连接)、NULL 的处理、子查询和 CTE、窗口函数(排名、分组取前 N、累计值、LAG/LEAD)、占比和环比。更高级别的岗位还会考留存、漏斗和数据质量检查。
面试时应该用哪种 SQL 方言?
可以先问清楚;如果没有指定,接近 PostgreSQL 的标准 SQL 是稳妥的默认选择。如果用了某种方言特有的功能,比如 FILTER 或 QUALIFY,顺便说出通用的替代写法。
SQL 怎样求第 N 高的值?
在子查询里用 DENSE_RANK() OVER (ORDER BY value DESC),外层筛选 = N;如果是「每组第 N 高」,再加上 PARTITION BY。如果只求第二高,MAX(value) WHERE value < (SELECT MAX(value) ...) 不用窗口函数也能做到。
现场写 SQL 应该怎么准备?
在接近真实的多表数据库上练习,而不是只做单表小题;给自己计时;并且每次都用一个独立得出的数字来核对结果。上面每道题都可以在 Nomto 的 SQL 练习场里运行和检查。
其他语言: Português (Brasil), Tiếng Việt, English, Español