SQL 数据分析 · 指南 · 2026年10月29日 · 12 分钟阅读

SQL 练习题及答案:15 道由浅入深的题目,附真实运行结果

笔记本上一排已打勾的清单,旁边是一架小飞机模型和一台显示数据表的笔记本电脑

下面是 15 道 SQL 练习题,全部基于同一个数据库,按难度从易到难排列:三道入门(SELECT、WHERE、ORDER BY),五道分组与聚合(GROUP BY、HAVING),四道日期、条件和子查询,最后三道是 JOIN 粒度和窗口函数。每题都有题目、答案查询、真实结果,以及一两句说明为什么这样写。建议先自己动手写,再看答案:练习真正有用的部分,是你卡住想办法的那一分钟,而不是读答案的那几秒。

所有题目都使用 Nomto SQL 课程里的航空公司数据库。它采用 PostgreSQL 风格的表结构,数据量是真实规模:104 个机场、9 种机型、2026 年 7 月 12 日至 9 月 10 日的 33,121 个航班、262,788 笔订单和 366,733 张机票。时间是航空公司所在地时间(莫斯科,UTC+3),金额单位是美元。数据快照截取于 2026 年 8 月 11 日 18:00,所以在此之前的航班已经飞完,之后的只是计划。每个查询都可以在 Nomto 的 SQL 练习场里运行,并和本文的表格对照;课程的整体介绍见 Nomto SQL 课程。

会用到的表

表 一行代表 主要列
airports 一个机场 airport_code、airport_name、city、timezone、longitude
aircrafts 一种机型 aircraft_code、model、range(公里)
seats 某机型客舱里的一个座位 aircraft_code、seat_no、fare_conditions
flights 一次起飞 flight_id、flight_no、scheduled_departure、scheduled_arrival、departure_airport、arrival_airport、status、aircraft_code、actual_departure、actual_arrival
bookings 一笔订单 book_ref、book_date、total_amount
tickets 一位乘客的一张机票 ticket_no、book_ref、passenger_name
ticket_flights 机票上的一个航段 ticket_no、flight_id、fare_conditions、amount
boarding_passes 一个航段的登机牌 ticket_no、flight_id、seat_no

文本值是英文的('Moscow'、'Economy'、'Arrived'),写条件时要照原样写。

入门:SELECT、WHERE、ORDER BY

1. 一共有多少个机场、多少个城市?

答案

SELECT COUNT(*) AS airports, COUNT(DISTINCT city) AS cities
FROM airports;
airports cities
104 101

COUNT(*) 数的是行数,COUNT(DISTINCT city) 数的是不同取值的个数。城市比机场少三个,说明有的城市不止一个机场(第 6 题会把它们找出来)。

2. 列出航程至少 5,000 公里的机型,按航程从长到短排序

答案

SELECT model, range
FROM aircrafts
WHERE range >= 5000
ORDER BY range DESC;
model range
Boeing 777-300 11100
Boeing 767-300 7900
Airbus A319-100 6700
Airbus A320-200 5700
Airbus A321-200 5600

「至少」对应的是 >=。常见的失误是写成 >:在这份数据里结果碰巧一样,但换一份数据就不一定了。

3. 不在莫斯科时区的机场中,最东边的五个

答案

SELECT airport_code, airport_name, city, timezone
FROM airports
WHERE timezone <> 'Europe/Moscow'
ORDER BY longitude DESC
LIMIT 5;
airport_code airport_name city timezone
DYR Anadyr Anadyr Asia/Anadyr
PKC Yelizovo Petropavlovsk-Kamchatsky Asia/Kamchatka
GDX Magadan Magadan Asia/Magadan
UUS Khomutovo Yuzhno-Sakhalinsk Asia/Sakhalin
KXK Khurba Komsomolsk-on-Amur Asia/Vladivostok

「最东边」就是经度最大。104 个机场里有 60 个不在莫斯科时区。

入门到中级:GROUP BY 和 HAVING

4. 一周中每天各有多少航班起飞?

取 7 月 13 日到 8 月 9 日这四个完整的星期,让每个星期几出现的次数相同。

答案

SELECT EXTRACT(ISODOW FROM scheduled_departure) AS weekday, COUNT(*) AS flights
FROM flights
WHERE scheduled_departure >= '2026-07-13'
  AND scheduled_departure <  '2026-08-10'
GROUP BY EXTRACT(ISODOW FROM scheduled_departure)
ORDER BY weekday;
weekday flights
1 2144
2 2180
3 2192
4 2200
5 1972
6 2272
7 2232

ISODOW 把星期一记为 1,星期日记为 7。星期六最忙,星期五最空。如果统计区间不是整数个星期,有些星期几会多算一周的航班,比较就不公平了。

5. 航班数最多的五条航线

答案

SELECT departure_airport, arrival_airport, COUNT(*) AS flights
FROM flights
GROUP BY departure_airport, arrival_airport
ORDER BY flights DESC, departure_airport, arrival_airport
LIMIT 5;
departure_airport arrival_airport flights
LED SVO 305
SVO LED 305
DME LED 244
LED DME 244
BZK DME 183

一条航线由两个机场组成,所以两列都要放进 GROUP BY。多加的排序列让数量相同时的顺序固定下来,每次运行结果都一样。

6. 哪些城市有不止一个机场?

答案

SELECT city, COUNT(*) AS airports
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY airports DESC;
city airports
Moscow 3
Ulyanovsk 2

条件是针对计数结果的,所以要写在 HAVING 里,而不是 WHERE。两者的区别详见 GROUP BY 和 HAVING 的区别。

7. 哪些机型的座位超过 200 个?

答案

SELECT a.model, COUNT(*) AS seats
FROM aircrafts a
JOIN seats s ON s.aircraft_code = a.aircraft_code
GROUP BY a.model
HAVING COUNT(*) > 200
ORDER BY seats DESC;
model seats
Boeing 777-300 402
Boeing 767-300 222

seats 每个座位一行,所以按机型数行数就是客舱座位数;JOIN 只是为了带上可读的机型名称。

8. 各舱位的收入、航段数和平均票价

答案

SELECT fare_conditions,
       COUNT(*) AS segments,
       SUM(amount) AS revenue,
       ROUND(AVG(amount), 2) AS avg_price
FROM ticket_flights
GROUP BY fare_conditions
ORDER BY revenue DESC;
fare_conditions segments revenue avg_price
Economy 920793 146956844.00 159.6
Business 107642 55051796.00 511.43
Comfort 17291 5661169.00 327.41

按舱位的收入要从 ticket_flights 算,它是每位乘客每个航段一行。如果把 bookings 连接到航段上再对 total_amount 求和,同一笔订单会被重复计算很多次。

中级:日期、条件、子查询

9. 计划飞行时间最长的航班

答案

SELECT DISTINCT flight_no, departure_airport, arrival_airport,
       EXTRACT(EPOCH FROM scheduled_arrival - scheduled_departure) / 60 AS minutes
FROM flights
ORDER BY minutes DESC, flight_no
LIMIT 3;
flight_no departure_airport arrival_airport minutes
PG0144 DME PKC 530
PG0145 PKC DME 530
PG0168 DME UUS 530

两个时间戳相减得到一个时间间隔,EXTRACT(EPOCH FROM ...) 把它换算成秒。如果不加 DISTINCT,同一个航班号每飞一天就占一行,前几名会全是它。飞行时间同为 8 小时 50 分钟的航班不止三个,所以 LIMIT 3 只显示了其中一部分。

10. 已到达的航班中,晚点超过一小时的占多少?

答案

SELECT COUNT(*) AS arrived,
       COUNT(*) FILTER (WHERE actual_arrival - scheduled_arrival > INTERVAL '1 hour') AS late_1h,
       ROUND(100.0 * COUNT(*) FILTER (WHERE actual_arrival - scheduled_arrival > INTERVAL '1 hour')
             / COUNT(*), 2) AS pct
FROM flights
WHERE status = 'Arrived';
arrived late_1h pct
16707 796 4.76

FILTER (WHERE ...) 只统计满足条件的行,而且和总数在同一次扫描里完成。不支持 FILTER 的数据库可以用 SUM(CASE WHEN ... THEN 1 ELSE 0 END) 达到同样效果。乘以 100.0 而不是 100,是为了避免整数除法把小数截掉。

11. 有多少笔订单的金额高于平均订单金额?

答案

SELECT COUNT(*) AS above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
above_avg
87224

子查询返回一个数(平均值是 790.26 美元),外层 WHERE 拿它和每一行比较。262,788 笔订单里只有大约三分之一高于平均值:少数大额订单把平均数拉高了。

12. 机票收入最高的三条航线

答案

SELECT f.departure_airport, f.arrival_airport, SUM(tf.amount) AS revenue
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
GROUP BY f.departure_airport, f.arrival_airport
ORDER BY revenue DESC
LIMIT 3;
departure_airport arrival_airport revenue
DME KHV 7534783.00
KHV DME 7337978.00
DME OVB 5482189.00

每个航段只属于一个航班,所以这个 JOIN 不会让行数翻倍,求和仍然停留在航段粒度。收入最高的是莫斯科多莫杰多沃到哈巴罗夫斯克,而不是第 5 题里航班最多的航线:航班频次和收入是两种不同的排名。

进阶:在正确粒度上 JOIN,以及窗口函数

13. 已到达航班中,各机型的客座率

客座率 = 登机乘客数 / 提供的座位数。

答案

WITH cap AS (
  SELECT aircraft_code, COUNT(*) AS seats
  FROM seats
  GROUP BY aircraft_code
), bp AS (
  SELECT flight_id, COUNT(*) AS boarded
  FROM boarding_passes
  GROUP BY flight_id
)
SELECT f.aircraft_code,
       COUNT(*) AS flights,
       ROUND(100.0 * SUM(COALESCE(bp.boarded, 0)) / SUM(cap.seats), 1) AS load_pct
FROM flights f
JOIN cap ON cap.aircraft_code = f.aircraft_code
LEFT JOIN bp ON bp.flight_id = f.flight_id
WHERE f.status = 'Arrived'
GROUP BY f.aircraft_code
ORDER BY load_pct DESC;
aircraft_code flights load_pct
773 307 65.8
733 646 56.4
763 612 50.2
SU9 4292 48.1
319 624 40.3
CR2 4570 36.5
321 982 34.2
CN1 4674 14.4

关键在于:座位和登机牌都先聚合成「每个机型一行」和「每个航班一行」,然后才去 JOIN。如果把原始的 seats 和原始的 boarding_passes 直接连到航班上,两者会互相相乘。LEFT JOIN 加 COALESCE 让没有乘客登机的航班算作 0,而不是被丢掉。这种行数膨胀的问题在 SQL JOIN 用法详解 里有详细说明。

14. 每种机型收入最高的航线

答案

WITH r AS (
  SELECT f.aircraft_code, f.departure_airport, f.arrival_airport,
         SUM(tf.amount) AS revenue,
         ROW_NUMBER() OVER (PARTITION BY f.aircraft_code ORDER BY SUM(tf.amount) DESC) AS rn
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  GROUP BY f.aircraft_code, f.departure_airport, f.arrival_airport
)
SELECT aircraft_code, departure_airport, arrival_airport, revenue
FROM r
WHERE rn = 1
ORDER BY revenue DESC;
aircraft_code departure_airport arrival_airport revenue
763 DME KHV 7534783.00
773 DME OVB 5482189.00
321 LED IKT 4116030.00
319 SVO UUS 2365732.00
733 SVO MJZ 1494478.00
SU9 DME NSK 1347231.00
CR2 DME NJC 449695.00
CN1 PEE ARH 50600.00

窗口函数在 GROUP BY 之后执行,所以可以按 SUM(tf.amount) 排名。PARTITION BY 让每个机型重新从 1 开始编号;筛选 rn = 1 必须放在外层,因为窗口函数不能出现在 WHERE 里。如果需要处理并列,可以看 SQL 窗口函数 里 RANK 和 DENSE_RANK 的用法。

15. 订单量的周环比,以及最后一周的陷阱

答案

WITH w AS (
  SELECT DATE_TRUNC('week', book_date) AS week, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-07-13'
  GROUP BY DATE_TRUNC('week', book_date)
)
SELECT CAST(week AS date) AS week,
       bookings,
       LAG(bookings) OVER (ORDER BY week) AS prev_week,
       ROUND(100.0 * (bookings - LAG(bookings) OVER (ORDER BY week))
             / LAG(bookings) OVER (ORDER BY week), 1) AS change_pct
FROM w
ORDER BY week;
week bookings prev_week change_pct
2026-07-13 38490 NULL NULL
2026-07-20 38728 38490 0.6
2026-07-27 39216 38728 1.3
2026-08-03 42561 39216 8.5
2026-08-10 12122 42561 -71.5

DATE_TRUNC('week', ...) 把每个时间戳归到所在周的星期一;LAG 把上一周的值带到当前行。查询本身没有错,但最后一行并不是「暴跌 71.5%」:数据截止于 8 月 11 日(星期二)18:00,这一周还不到两天。报告环比之前,先确认两个周期都是完整的;这里诚实的结论是「最近一个完整周增长 8.5%」。

怎样用好这些题

想看这些题的面试版本和参考答案,可以读 数据分析师 SQL 面试题。

常见问题

这些 SQL 练习题用的是什么数据库?

题目用 PostgreSQL 语法编写,数据是 Nomto SQL 课程里的航空公司数据库(航班、订单、机票、登机牌)。除了 FILTER 和 ISODOW,大多数查询在其他主流数据库上不改或稍作修改就能运行。在 MySQL 里,EXTRACT(EPOCH FROM ...) 和 DATE_TRUNC 要换成 TIMESTAMPDIFF 和 DATE_FORMAT 之类的函数。

我的查询结果一样,但写法不同,算错吗?

不一定。很多题都有不止一种正确写法,例如 FILTER 和 CASE、子查询和 CTE。只要在完整数据上结果一致,包括并列、空分组这类边界情况,你的查询就没问题。

练习 SQL 应该按什么顺序?

按本文的顺序就可以:筛选和排序,然后是聚合函数和 GROUP BY/HAVING,再到 JOIN,接着是子查询和 CTE,最后是窗口函数。实际工作中大部分错误都出在 JOIN 和聚合一起用的时候,所以第 8、12、13 题值得多花点时间。

这些题和 SQL 面试题像吗?

第 6 题和第 9 到 15 题都很接近数据分析岗面试常考的内容:带条件的分组、不重复计算的 JOIN、分组取前 N 名,以及环比变化。

结果和答案对不上,该怎么排查?

分步骤比较:先只运行 FROM 和 JOIN 部分并数一下行数,再加上 WHERE,然后加 GROUP BY。数字从哪一步开始偏离预期,错误就在哪一步。

其他语言: Português (Brasil), Tiếng Việt, English, Español