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

Hai người ngồi bàn phỏng vấn, một người chỉ vào laptop có sơ đồ cơ sở dữ liệu, cạnh cốc cà phê và thẻ lên máy bay

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:

  1. 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ì.
  2. Độ “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ế.
  3. Đị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

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