SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 13 phút đọc
Bài tập SQL có lời giải: 15 bài từ cơ bản đến nâng cao

Dưới đây là 15 bài tập SQL có lời giải, tất cả trên cùng một cơ sở dữ liệu và xếp từ dễ đến khó: ba bài về câu lệnh SQL cơ bản (SELECT, WHERE, ORDER BY), năm bài về nhóm và hàm tổng hợp (GROUP BY, HAVING), bốn bài về ngày tháng, điều kiện và truy vấn con, và ba bài khó về JOIN và hàm cửa sổ. Mỗi bài có đề bài, truy vấn lời giải, kết quả thật và một hai câu giải thích vì sao lời giải đúng. Hãy tự viết trước rồi mới xem lời giải: giá trị của việc luyện tập nằm ở phút bạn bị bí và tự tìm cách, không phải ở lúc đọc đáp án.
Tất cả bài tập dùng cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto. Cấu trúc bảng theo kiểu PostgreSQL với khối lượng dữ liệu thật: 104 sân bay, 9 mẫu máy bay, 33.121 chuyến bay từ 12/7 đến 10/9/2026, 262.788 đơn đặt chỗ và 366.733 vé. Thời gian tính theo giờ gốc của hãng (Moscow, UTC+3), tiền tính bằng đô la Mỹ. Dữ liệu được chụp lại lúc 18:00 ngày 11/8/2026, nên các chuyến trước thời điểm đó đã bay, còn các chuyến sau mới chỉ có trong lịch. Bạn có thể chạy từng truy vấn trong Sân tập SQL của Nomto và so kết quả với các bảng trong bài.
Các bảng cần dùng
| Bảng | Một dòng là | Cột chính |
|---|---|---|
airports |
một sân bay | airport_code, airport_name, city, timezone, longitude |
aircrafts |
một mẫu máy bay | aircraft_code, model, range (km) |
seats |
một ghế trong khoang của một mẫu máy bay | aircraft_code, seat_no, fare_conditions |
flights |
một chuyến bay cất cánh | flight_id, flight_no, scheduled_departure, scheduled_arrival, departure_airport, arrival_airport, status, aircraft_code, actual_departure, actual_arrival |
bookings |
một đơn đặt chỗ | book_ref, book_date, total_amount |
tickets |
một vé hành khách | ticket_no, book_ref, passenger_name |
ticket_flights |
một vé trên một chặng bay | ticket_no, flight_id, fare_conditions, amount |
boarding_passes |
một thẻ lên máy bay cho một chặng | ticket_no, flight_id, seat_no |
Dễ: SELECT, WHERE, ORDER BY
1. Có bao nhiêu sân bay và bao nhiêu thành phố?
Lời giải
SELECT COUNT(*) AS airports, COUNT(DISTINCT city) AS cities
FROM airports;
| airports | cities |
|---|---|
| 104 | 101 |
COUNT(*) đếm số dòng, COUNT(DISTINCT city) đếm số giá trị khác nhau. Số thành phố ít hơn số sân bay 3 đơn vị, nghĩa là có thành phố có nhiều sân bay (bài 6 sẽ tìm ra chúng).
2. Liệt kê các mẫu máy bay có tầm bay từ 5.000 km trở lên, xa nhất trước
Lời giải
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 |
“Từ … trở lên” nghĩa là >=. Lỗi hay gặp là viết >; ở đây nó cho cùng kết quả chỉ là do may mắn.
3. Năm sân bay xa nhất về phía đông nằm ngoài múi giờ Moscow
Lời giải
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 |
“Xa nhất về phía đông” là kinh độ lớn nhất. 60 trong số 104 sân bay nằm ngoài múi giờ Moscow.
Dễ đến trung bình: GROUP BY và HAVING
4. Mỗi ngày trong tuần có bao nhiêu chuyến bay cất cánh?
Dùng bốn tuần trọn vẹn, từ 13/7 đến 9/8, để mỗi thứ trong tuần xuất hiện số lần bằng nhau.
Lời giải
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 đánh số thứ Hai là 1 và Chủ nhật là 7. Thứ Bảy đông nhất, thứ Sáu vắng nhất. Nếu so sánh các ngày trong tuần trên một khoảng thời gian không chia hết cho 7 ngày, một số thứ sẽ được cộng thêm một tuần chuyến bay.
5. Năm tuyến bay có nhiều chuyến nhất
Lời giải
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 |
Một tuyến là một cặp sân bay, nên cả hai cột đều nằm trong GROUP BY. Các cột sắp xếp phụ giúp thứ tự ổn định khi số chuyến bằng nhau.
6. Thành phố nào có nhiều hơn một sân bay?
Lời giải
SELECT city, COUNT(*) AS airports
FROM airports
GROUP BY city
HAVING COUNT(*) > 1
ORDER BY airports DESC;
| city | airports |
|---|---|
| Moscow | 3 |
| Ulyanovsk | 2 |
Điều kiện đặt trên một phép đếm, nên phải nằm trong HAVING chứ không phải WHERE. Chi tiết xem bài GROUP BY và HAVING trong SQL.
7. Mẫu máy bay nào có hơn 200 ghế?
Lời giải
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 |
Bảng seats có một dòng cho mỗi ghế, nên đếm số dòng theo mẫu máy bay cho ra sức chứa của khoang; phép JOIN chỉ thêm tên mẫu máy bay cho dễ đọc.
8. Doanh thu, số chặng và giá trung bình theo hạng ghế
Lời giải
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 |
Doanh thu theo hạng ghế nằm trong ticket_flights, mỗi dòng là một hành khách trên một chặng. Nếu cộng bookings.total_amount sau khi JOIN với các chặng, mỗi đơn đặt chỗ sẽ bị tính nhiều lần.
Trung bình: ngày tháng, điều kiện, truy vấn con
9. Những chuyến bay dài nhất theo lịch
Lời giải
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 |
Trừ hai mốc thời gian cho ra một khoảng thời gian (interval); EXTRACT(EPOCH FROM ...) đổi nó ra số giây. Nếu không có DISTINCT, cùng một số hiệu chuyến bay sẽ chiếm hết các dòng đầu, mỗi ngày bay một dòng. Có nhiều chuyến cùng dài 8 giờ 50 phút, nên LIMIT 3 chỉ hiện một phần trong số đó.
10. Bao nhiêu phần trăm chuyến bay đã hạ cánh trễ hơn một giờ?
Lời giải
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 |
Tức là 4,76%. FILTER (WHERE ...) chỉ đếm các dòng thỏa điều kiện, trong cùng một lượt quét với tổng số. Ở các hệ quản trị không có FILTER, SUM(CASE WHEN ... THEN 1 ELSE 0 END) làm được việc tương tự. Nhân với 100.0 chứ không phải 100 để tránh phép chia số nguyên.
11. Có bao nhiêu đơn đặt chỗ có giá trị trên mức trung bình?
Lời giải
SELECT COUNT(*) AS above_avg
FROM bookings
WHERE total_amount > (SELECT AVG(total_amount) FROM bookings);
| above_avg |
|---|
| 87224 |
Truy vấn con trả về một con số (trung bình là $790,26), và WHERE bên ngoài so sánh từng dòng với nó. Chỉ khoảng một phần ba trong 262.788 đơn đặt chỗ cao hơn mức trung bình: vài đơn rất lớn đã kéo giá trị trung bình lên.
12. Ba tuyến bay có doanh thu vé cao nhất
Lời giải
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 |
Mỗi chặng thuộc về đúng một chuyến bay, nên phép JOIN này không nhân bản dòng: tổng vẫn ở mức chặng. Tuyến Moscow Domodedovo đi Khabarovsk mang lại nhiều tiền nhất, chứ không phải tuyến nhiều chuyến nhất ở bài 5. Tần suất và doanh thu là hai bảng xếp hạng khác nhau.
Khó: JOIN đúng mức chi tiết và hàm cửa sổ
13. Hệ số lấp đầy ghế theo mẫu máy bay trên các chuyến đã hạ cánh
Hệ số lấp đầy = số hành khách đã lên máy bay / số ghế cung ứng.
Lời giải
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 |
Cả ghế lẫn thẻ lên máy bay đều được tổng hợp về một dòng cho mỗi mẫu máy bay và mỗi chuyến bay trước khi JOIN. Nếu JOIN thẳng bảng seats và boarding_passes gốc vào flights, hai bảng sẽ nhân chéo với nhau. LEFT JOIN cùng COALESCE giữ lại các chuyến không có ai lên máy bay dưới dạng số 0 thay vì loại bỏ chúng. Hiện tượng nhân bản dòng này được giải thích kỹ trong bài JOIN trong SQL.
14. Tuyến bay có doanh thu cao nhất của từng mẫu máy bay
Lời giải
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 |
Hàm cửa sổ (window function) chạy sau GROUP BY, nên có thể xếp hạng theo SUM(tf.amount). PARTITION BY đánh số lại từ đầu cho mỗi mẫu máy bay, còn điều kiện rn = 1 phải đặt ở truy vấn bên ngoài, vì không thể dùng hàm cửa sổ trong WHERE. Khi có giá trị bằng nhau, xem cách dùng RANK và DENSE_RANK trong bài hàm cửa sổ trong SQL.
15. Thay đổi số đơn đặt chỗ theo tuần, và cái bẫy ở tuần cuối
Lời giải
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', ...) đưa mỗi mốc thời gian về thứ Hai của tuần đó; LAG mang số liệu tuần trước sang dòng hiện tại. Truy vấn đúng, nhưng dòng cuối không phải là cú sụt 71,5%: dữ liệu kết thúc lúc 18:00 thứ Ba ngày 11/8, nên tuần đó chưa có đủ hai ngày. Trước khi báo cáo một thay đổi, hãy kiểm tra cả hai kỳ đều trọn vẹn; câu trả lời trung thực ở đây là “+8,5% trong tuần trọn vẹn gần nhất”.
Cách luyện với các bài tập này
- Viết trước, so sau. Truy vấn của bạn có thể khác mà vẫn đúng. Hãy so kết quả, không so cú pháp.
- Kiểm tra số dòng. Trước khi tin một
SUMsau JOIN, soCOUNT(*)với con số bạn dự đoán từ mức chi tiết của từng bảng. - Thêm cột phá hòa vào mọi
ORDER BY ... LIMITcó thể có giá trị bằng nhau, để kết quả lặp lại được. - Hỏi xem kỳ dữ liệu đã trọn vẹn chưa mỗi khi so sánh ngày tháng gần mép dữ liệu.
Nếu bạn muốn phiên bản phỏng vấn của các bài này kèm câu trả lời mẫu, xem câu hỏi phỏng vấn SQL cho data analyst.
Câu hỏi thường gặp
Các bài tập SQL này dùng cơ sở dữ liệu nào?
Chúng được viết theo cú pháp PostgreSQL trên một cơ sở dữ liệu hãng hàng không (chuyến bay, đơn đặt chỗ, vé, thẻ lên máy bay) dùng trong khóa SQL của Nomto. Ngoài FILTER và ISODOW, các truy vấn chạy được trên hầu hết hệ quản trị cơ sở dữ liệu với rất ít hoặc không cần sửa.
Truy vấn của tôi cho cùng kết quả nhưng viết khác. Như vậy có sai không?
Không hẳn. Nhiều bài có vài lời giải đúng, ví dụ FILTER hay CASE, truy vấn con hay CTE. Nếu kết quả của bạn khớp trên toàn bộ dữ liệu, kể cả các trường hợp biên như giá trị bằng nhau hay nhóm rỗng, thì truy vấn ổn.
Nên luyện các chủ đề SQL theo thứ tự nào?
Thứ tự của danh sách này là hợp lý: lọc và sắp xếp, rồi hàm tổng hợp và GROUP BY/HAVING, rồi JOIN, rồi truy vấn con và CTE, cuối cùng là hàm cửa sổ. JOIN kết hợp với tổng hợp gây ra phần lớn lỗi trong công việc thực tế, nên hãy dành thêm thời gian cho các bài 8, 12 và 13.
Các bài này có giống đề phỏng vấn SQL không?
Bài 6 và các bài 9–15 gần với những gì phỏng vấn data analyst thường hỏi: nhóm có điều kiện, JOIN không bị đếm trùng, top N mỗi nhóm và thay đổi so với kỳ trước.
Ngôn ngữ khác: Português (Brasil), English, Español, 简体中文