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

Cuốn sổ với danh sách đã đánh dấu, bên cạnh mô hình máy bay nhỏ và laptop hiển thị bảng dữ liệu

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

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, 简体中文