SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 14 phút đọc
Câu hỏi phỏng vấn SQL cho data analyst: 20 câu có đáp án

Phỏng vấn SQL cho vị trí data analyst thường kiểm tra hai điều: bạn có hiểu SQL thực thi một truy vấn như thế nào không (câu hỏi lý thuyết), và bạn có biến được một câu hỏi kinh doanh thành truy vấn đúng trong thời gian ngắn không (bài viết truy vấn trực tiếp). Dưới đây là 20 câu hỏi phỏng vấn SQL thuộc cả hai loại, đúng kiểu mà data analyst hay được hỏi, kèm đáp án mẫu ngắn gọn. Đáp án lý thuyết là điều bạn sẽ nói thành lời; đáp án bài tập là truy vấn bạn sẽ viết.
Các bài viết truy vấn dùng cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto: airports, aircrafts, seats, flights (33.121 chuyến bay trong tháng 7–9/2026), bookings, tickets, ticket_flights (mỗi dòng là một hành khách trên một chặng bay, amount tính bằng đô la Mỹ), boarding_passes, cùng chương trình khách hàng thân thiết (ticket_members). Dữ liệu được chụp lại lúc 18:00 ngày 11/8/2026. Mọi truy vấn viết theo cú pháp PostgreSQL và mọi kết quả trong bài là kết quả chạy thật; bạn có thể tự chạy lại trong Sân tập SQL của Nomto.
Một mẹo dùng được cho mọi bài: trước khi viết, hãy nói to một dòng của kết quả nghĩa là gì và một dòng của mỗi bảng nghĩa là gì. Phần lớn đáp án sai trong phỏng vấn đến từ chỗ hai điều này không khớp nhau.
Câu hỏi lý thuyết
1. SQL thực thi một câu SELECT theo thứ tự nào?
FROM và các JOIN, WHERE, GROUP BY, hàm tổng hợp, HAVING, hàm cửa sổ, SELECT (kể cả bí danh), DISTINCT, ORDER BY, LIMIT. Thứ tự này giải thích vì sao không dùng được hàm tổng hợp trong WHERE, vì sao không lọc được hàm cửa sổ trong WHERE, và vì sao ORDER BY dùng được bí danh cột còn WHERE thì không.
2. WHERE và HAVING khác nhau thế nào?
WHERE lọc dòng trước khi nhóm và không dùng được hàm tổng hợp. HAVING lọc nhóm sau khi tổng hợp. WHERE status = 'Arrived' quyết định chuyến bay nào được đếm; HAVING COUNT(*) > 1000 quyết định sân bay nào xuất hiện trong kết quả. Giải thích chi tiết có trong bài GROUP BY và HAVING trong SQL.
3. Giải thích INNER, LEFT và FULL OUTER JOIN
INNER JOIN chỉ giữ các dòng khớp ở cả hai bên. LEFT JOIN giữ mọi dòng bên trái và điền NULL cho bên phải khi không có dòng khớp. FULL JOIN giữ các dòng không khớp ở cả hai bên. Một câu trả lời tốt sẽ nói thêm hệ quả: trong dữ liệu của chúng ta, aircrafts JOIN flights trả về 8 mẫu máy bay vì Airbus A320-200 không có chuyến bay nào; aircrafts LEFT JOIN flights trả về đủ 9. Xem thêm bài JOIN trong SQL.
4. COUNT(*), COUNT(column) và COUNT(DISTINCT column) khác nhau ra sao?
COUNT(*) đếm số dòng, COUNT(column) đếm số giá trị khác NULL, COUNT(DISTINCT column) đếm số giá trị khác NULL và khác nhau.
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 chuyến bay, trong đó 16.773 chuyến có giờ cất cánh thực tế, bay dưới 710 số hiệu chuyến.
5. NULL hoạt động thế nào trong phép so sánh?
Mọi phép so sánh với NULL đều trả về “không xác định” chứ không phải true, nên WHERE x = NULL không khớp dòng nào:
SELECT COUNT(*) AS flights FROM flights WHERE actual_departure = NULL; -- 0
SELECT COUNT(*) AS flights FROM flights WHERE actual_departure IS NULL; -- 16348
Hãy dùng IS NULL / IS NOT NULL. Các hàm tổng hợp ngoài COUNT(*) đều bỏ qua NULL, và COALESCE(x, 0) thay NULL bằng 0 ở những chỗ ý bạn là số 0.
6. Vì sao NOT IN với truy vấn con lại nguy hiểm?
Nếu truy vấn con trả về dù chỉ một giá trị NULL, x NOT IN (...) sẽ không bao giờ đúng, và truy vấn không trả về dòng nào. Trong dữ liệu của chúng ta, aircrafts WHERE aircraft_code NOT IN (SELECT aircraft_code FROM flights) trả về đúng 1 mẫu máy bay. Chỉ cần thêm một NULL vào kết quả của truy vấn con, cùng truy vấn đó sẽ trả về 0 dòng. Hãy dùng NOT EXISTS, không có cái bẫy này.
7. UNION và UNION ALL
UNION loại bỏ dòng trùng, UNION ALL giữ mọi dòng và nhanh hơn vì bỏ qua bước khử trùng.
SELECT COUNT(*) FROM (
SELECT departure_airport AS airport FROM flights
UNION
SELECT arrival_airport FROM flights
) u;
Truy vấn này trả về 104 (số sân bay khác nhau). Với UNION ALL nó trả về 66.242, mỗi chuyến bay hai dòng. Hãy dùng UNION ALL trừ khi bạn thật sự cần khử trùng.
8. ROW_NUMBER, RANK và DENSE_RANK
Cả ba đều đánh số dòng theo một thứ tự sắp xếp. Khi có giá trị bằng nhau, ROW_NUMBER vẫn cho số duy nhất (1, 2, 3, 4), RANK cho số trùng rồi nhảy cóc (1, 2, 2, 4), DENSE_RANK cho số trùng nhưng không nhảy cóc (1, 2, 2, 3). Dùng ROW_NUMBER khi cần “đúng N dòng” hoặc khử trùng, DENSE_RANK khi cần “giá trị lớn thứ N”. Ví dụ với dữ liệu bằng nhau thật có trong bài hàm cửa sổ trong SQL.
9. Khóa chính, khóa ngoại và khóa phức hợp là gì?
Khóa chính xác định duy nhất một dòng (flights.flight_id). Khóa ngoại tham chiếu tới khóa của bảng khác (flights.aircraft_code → aircrafts.aircraft_code). Khóa phức hợp gồm nhiều cột cùng lúc: trong seats, số ghế 12A lặp lại ở nhiều mẫu máy bay, nên khóa là cặp (aircraft_code, seat_no). Trong phỏng vấn, hãy nói tiếp ý nghĩa của nó với JOIN: JOIN chỉ trên một nửa khóa phức hợp sẽ nhân bản dòng.
10. Vì sao JOIN đôi khi làm SUM bị phình to, và sửa thế nào?
Khi một dòng JOIN với nhiều dòng, nó bị lặp lại, và giá trị của nó được cộng một lần cho mỗi lần lặp. Các đơn đặt chỗ tạo ngày 2/8 có tổng $4.563.873 trong bảng bookings; sau khi JOIN với tickets (mỗi đơn có thể có nhiều vé), cùng phép SUM(total_amount) cho ra $7.569.035. Cách sửa: tổng hợp phía “nhiều” trước khi JOIN, hoặc cộng chỉ số ở đúng mức chi tiết của nó. SUM(DISTINCT ...) không phải cách sửa: nó gộp các đơn khác nhau có cùng số tiền thành một.
Bài viết truy vấn
11. Tìm tầm bay lớn thứ hai mà không dùng hàm cửa sổ
SELECT MAX(range) AS second_longest
FROM aircrafts
WHERE range < (SELECT MAX(range) FROM aircrafts);
| second_longest |
|---|
| 7900 |
Truy vấn con tìm giá trị lớn nhất (11.100 km); truy vấn ngoài lấy giá trị lớn nhất trong số các giá trị nhỏ hơn nó. Nếu người phỏng vấn hỏi giá trị thứ N nói chung, hãy chuyển sang DENSE_RANK (câu tiếp theo).
12. Giá chặng bay cao thứ hai trong mỗi hạng ghế
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 |
Giá cao nhất lần lượt là $2.033, $476 và $745. DENSE_RANK là lựa chọn đúng: nhiều chặng có cùng mức giá cao nhất, và ROW_NUMBER sẽ chỉ trả về thêm một bản sao của giá đó làm “thứ hai”. Khử trùng giá trước giúp cửa sổ nhỏ hơn.
13. Giá trung vị theo hạng ghế
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 |
Ở hạng nào trung bình cũng cao hơn hẳn trung vị: một số ít tuyến dài, đắt tiền kéo trung bình lên. Tự nói ra điều này mà không cần ai hỏi là thứ biến một truy vấn đúng thành một câu trả lời tốt. PERCENTILE_CONT nội suy giữa hai giá trị ở giữa; PERCENTILE_DISC trả về một giá trị có thật trong dữ liệu.
14. Tìm bản ghi trùng
“Tìm các đơn đặt chỗ mà cùng một tên hành khách xuất hiện trên nhiều hơn một vé.”
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 |
Có 34 cặp như vậy. Ý nên nêu thêm: đây chưa chắc là lỗi. Hai người trong cùng một đơn có thể trùng một cái tên phổ biến; mã định danh hành khách (ở đây là duy nhất trên mỗi vé) mới là cách kiểm tra danh tính tốt hơn. Xác định thế nào là “trùng” cũng là một phần của câu trả lời.
15. Top N mỗi nhóm
“Hai điểm đến nhiều chuyến nhất từ mỗi sân bay ở Moscow.”
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 |
Hãy nhắc đến trường hợp bằng nhau ở Vnukovo: BZK và LED đều có 183 chuyến, và chỉ cột phá hòa theo thứ tự chữ cái quyết định thứ tự. Nếu cả hai dòng bằng nhau đều phải tính là hạng nhất, hãy dùng RANK.
16. Dòng không có dòng khớp (anti join)
“Mẫu máy bay nào chưa từng bay?”
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 |
Cách viết tương đương LEFT JOIN ... WHERE f.flight_id IS NULL cũng được chấp nhận. Cả hai đều tốt hơn NOT IN (câu 6).
17. Xoay bảng (pivot) bằng tổng hợp có điều kiện
“Với mỗi sân bay ở Moscow, hiển thị số chuyến đã hạ cánh, bị hủy và theo lịch thành các cột.”
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 |
Nếu hệ quản trị bên phía người phỏng vấn không có FILTER, hãy dùng SUM(CASE WHEN status = 'Arrived' THEN 1 ELSE 0 END).
18. Tổng lũy kế và thay đổi so với ngày trước
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 |
Tổng hợp theo ngày trước, rồi mới áp dụng hàm cửa sổ trên các dòng theo ngày. Một nhận xét đáng nói thêm: nếu có ngày không có đơn nào, ngày đó sẽ vắng mặt trong daily, và LAG sẽ so với hai ngày trước; một chuỗi ngày đầy đủ (date series) sẽ khắc phục điều này.
19. Tỷ trọng trên tổng
“Mỗi trạng thái chiếm bao nhiêu phần trăm tổng số chuyến bay?”
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 () là tổng chung được đặt cạnh mỗi nhóm. 100.0 giúp tránh phép chia số nguyên.
20. Hành vi quay lại trong vòng 30 ngày
“Bao nhiêu phần trăm thành viên chương trình khách hàng thân thiết đặt thêm một đơn trong vòng 30 ngày kể từ đơn đầu tiên?”
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 |
Ba điểm người phỏng vấn muốn thấy:
- Mức chi tiết. Một thành viên có thể có nhiều vé trong cùng một đơn, nên
DISTINCTđưa dữ liệu về một dòng cho mỗi thành viên mỗi đơn trước khi đếm bất cứ thứ gì. - Độ “chín” của dữ liệu. Chỉ tính những thành viên có đơn đầu tiên cách thời điểm chụp dữ liệu ít nhất 30 ngày. Một thành viên đặt đơn đầu tiên ngày 5/8 chưa có đủ 30 ngày để quay lại; tính họ là “không quay lại” sẽ làm tỷ lệ thấp hơn thực tế.
- Định nghĩa. “Đơn đầu tiên” ở đây là đơn đầu tiên trong dữ liệu, mà dữ liệu bắt đầu từ ngày 17/6/2026. Hãy nói rõ điều đó; đây là một giả định, không phải sự thật về khách hàng.
Cách trả lời trong buổi phỏng vấn
- Diễn đạt lại câu hỏi thành bảng kết quả: “mỗi sân bay một dòng, có cột X và Y”. Cách này bắt được hiểu lầm trước khi bạn viết code.
- Nêu mức chi tiết của từng bảng bạn JOIN, và chỉ ra chỗ nào có thể bị nhân bản dòng.
- Xử lý rõ ràng giá trị bằng nhau, NULL và kỳ dữ liệu chưa trọn vẹn. Đó là những trường hợp biên người phỏng vấn cố tình cài vào.
- Kiểm tra lại con số: so với một tổng bạn đã biết, hoặc đếm số dòng trước và sau JOIN.
- Vừa viết vừa nói. Một truy vấn đúng một phần kèm lập luận rõ ràng thường được đánh giá cao hơn một truy vấn đúng nhưng im lặng.
Muốn luyện thêm các bài từ dễ đến khó, xem bài tập SQL có lời giải.
Câu hỏi thường gặp
Phỏng vấn data analyst thường hỏi những chủ đề SQL nào?
Phổ biến nhất: GROUP BY với HAVING, JOIN (kể cả LEFT JOIN và anti join), xử lý NULL, truy vấn con và CTE, hàm cửa sổ (xếp hạng, top N mỗi nhóm, tổng lũy kế, LAG/LEAD), tỷ trọng trên tổng và thay đổi so với kỳ trước. Vị trí senior còn hỏi thêm tỷ lệ giữ chân (retention), phễu chuyển đổi và kiểm tra chất lượng dữ liệu.
Nên dùng phương ngữ SQL nào khi phỏng vấn?
Hãy hỏi trước, nhưng SQL chuẩn gần với PostgreSQL là lựa chọn an toàn. Nếu bạn dùng tính năng riêng của một hệ quản trị như FILTER hay QUALIFY, hãy nhắc đến cách viết thay thế dùng được ở mọi nơi.
Làm sao tìm giá trị lớn thứ N trong SQL?
Dùng DENSE_RANK() OVER (ORDER BY value DESC) trong truy vấn con rồi lọc = N; thêm PARTITION BY nếu cần “theo từng nhóm”. Nếu chỉ cần giá trị lớn thứ hai, MAX(value) WHERE value < (SELECT MAX(value) ...) chạy được mà không cần hàm cửa sổ.
Nên chuẩn bị cho bài viết SQL trực tiếp thế nào?
Hãy luyện trên một cơ sở dữ liệu nhiều bảng gần với thực tế thay vì các câu đố một bảng, tự bấm giờ, và luôn đối chiếu kết quả với một con số độc lập. Mọi bài ở trên đều có thể chạy và kiểm tra trong Sân tập SQL của Nomto.
Ngôn ngữ khác: Português (Brasil), English, Español, 简体中文