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%」。
怎样用好这些题
- 先写,再对照。 你的查询写法可能不同,但照样正确。比较的是结果,不是语法。
- 检查行数。 在相信 JOIN 之后的
SUM之前,先用COUNT(*)看看行数是否符合各表的粒度。 - 给
ORDER BY ... LIMIT加上决胜列,只要可能出现并列就加,这样结果才可复现。 - 比较日期时问一句:这个周期完整吗? 尤其是靠近数据边界的时候。
想看这些题的面试版本和参考答案,可以读 数据分析师 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