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

面试官想听到的三点:

  1. 粒度。 一位会员在同一笔订单里可能有多张票,所以先用 DISTINCT 把数据压到「每位会员每笔订单一行」,然后再计数。
  2. 观察期是否足够。 只纳入首次订票距快照至少 30 天的会员。8 月 5 日才首次订票的会员还没有满 30 天的机会回来,把他们算作「没有复购」会低估比例。
  3. 定义。 这里的「首次订票」指的是数据里的第一次,而数据从 2026 年 6 月 17 日开始。要把这一点说出来:这是一个假设,不是关于客户的事实。

更完整的留存写法见 SQL 留存分析。

面试时怎样作答

想要更多由易到难的动手练习,请看 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