Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Common interview questions: SQL cho BA

SQL and Data Analysis cho BA Bài 58/60

Mở đầu — vì sao bài này quan trọng

Bạn đã đi gần hết hành trình khóa học. Bạn biết JOIN, biết window functions, biết viết cohort, funnel, RFM. Nhưng có một thực tế phũ phàng: phần lớn cơ hội nghề nghiệp của một BA không quyết định bởi việc bạn biết SQL, mà bởi việc bạn chứng minh được mình biết SQL trong vỏn vẹn 30–45 phút phỏng vấn, dưới áp lực, trên một cái whiteboard hoặc một editor trống trơn không có autocomplete.

Phỏng vấn SQL cho vị trí BA (Business Analyst, Data Analyst) ở Việt Nam — từ Tiki, Shopee, MoMo, VNG cho tới các công ty product nhỏ — gần như luôn có một vòng "live SQL". Người phỏng vấn không cần bạn viết query hoàn hảo. Họ muốn xem cách bạn tư duy: bạn có hỏi lại về schema không, bạn có nghĩ đến NULL không, bạn có biết khi nào INNER JOIN làm mất dòng không.

Bài này không dạy bạn kiến thức SQL mới — tất cả đã có ở 57 bài trước. Bài này dạy bạn cách đóng gói kiến thức đó để vượt qua vòng phỏng vấn: những câu hỏi kinh điển luôn xuất hiện, nhiều cách giải cho cùng một bài, cách trình bày tư duy thành tiếng, và những cái bẫy khiến ứng viên giỏi vẫn trượt. Hãy coi đây là buổi luyện tập trước trận đấu thật.

Khái niệm cốt lõi

Người phỏng vấn thực sự đánh giá điều gì

Một câu hỏi SQL phỏng vấn luôn được chấm trên bốn trục, không chỉ trục "chạy đúng":

  • Tư duy làm rõ yêu cầu (clarification): Bạn có hỏi "second highest salary thì có tính trùng lương không?", "có cần xử lý trường hợp chỉ có 1 nhân viên không?" — đây là phản xạ vàng của một BA.
  • Đúng logic, đặc biệt với edge case: NULL, dòng trùng, bảng rỗng, ties (giá trị bằng nhau).
  • Khả năng diễn giải: Bạn nói ra suy nghĩ của mình trong lúc gõ. Im lặng gõ 5 phút rồi đưa ra đáp án đúng vẫn bị đánh giá thấp hơn người vừa gõ vừa giải thích.
  • Hiểu trade-off: Biết nhiều cách giải và biết cách nào nhanh hơn, dễ đọc hơn.

Nhóm câu hỏi kinh điển bạn gần như chắc chắn gặp

Nhóm 1 — Ranking / Nth highest. Câu "second highest salary" là huyền thoại. Có ít nhất ba cách giải, và việc bạn biết cả ba cho thấy độ sâu.

Cách 1 — subquery với MAX (chỉ đúng cho "thứ hai"):

SELECT MAX(salary) AS second_highest
FROM employees
WHERE salary < (SELECT MAX(salary) FROM employees);
Điểm cộng: ngắn, an toàn khi bảng rỗng (trả về NULL thay vì lỗi). Điểm trừ: không tổng quát hóa cho "thứ N".

Cách 2 — tổng quát với window function (dùng cho "thứ N" bất kỳ):

SELECT DISTINCT salary
FROM (
    SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS rnk
    FROM employees
) t
WHERE rnk = 2;
Đây là lúc bạn phải hỏi lại: dùng DENSE_RANK hay RANK? Nếu hai người cùng lương cao nhất, RANK sẽ nhảy từ 1 sang 3 (bỏ qua 2), còn DENSE_RANK cho lương cao thứ hai thực sự. BA giỏi nêu rõ giả định này.

Cách 3 — LIMIT/OFFSET (dễ nhưng có bẫy):

SELECT DISTINCT salary FROM employees ORDER BY salary DESC LIMIT 1 OFFSET 1;
Bẫy: nếu không DISTINCT, hai người cùng lương cao nhất sẽ làm OFFSET 1 trả về vẫn là lương cao nhất.

Nhóm 2 — Tìm bản ghi trùng (duplicates).

SELECT email, COUNT(*) AS cnt
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
Câu hỏi nối tiếp thường gặp: "Xóa trùng, giữ lại bản ghi có id nhỏ nhất thì làm sao?" — lúc này bạn cần window ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) rồi xóa các dòng rn > 1.

Nhóm 3 — Phân biệt INNER vs LEFT JOIN qua bài toán. Câu hỏi kinh điển: "Tìm khách hàng chưa từng đặt đơn nào." Đây là bài kiểm tra anti-join:

SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.customer_id IS NULL;
Người phỏng vấn đang xem bạn có hiểu LEFT JOIN giữ lại dòng bên trái và điền NULL, từ đó lọc IS NULL để tìm "không có match".

Nhóm 4 — Aggregate có điều kiện. "Đếm số đơn 'completed' và 'cancelled' trên cùng một dòng cho mỗi tháng." Đây là pivot bằng CASE WHEN:

SELECT DATE_FORMAT(created_at, '%Y-%m') AS thang,
       SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS so_hoan_thanh,
       SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS so_huy
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m');

Nhóm 5 — WHERE vs HAVING. Một câu hỏi lý thuyết kinh điển: "Khác nhau giữa WHERE và HAVING?" Trả lời chuẩn: WHERE lọc từng dòng trước khi gom nhóm, HAVING lọc sau khi GROUP BY trên kết quả tổng hợp. Không thể dùng WHERE COUNT(*) > 5 vì lúc WHERE chạy thì COUNT chưa tồn tại.

Tình huống thực tế

Tình huống 1 — Vòng SQL tại một sàn TMĐT kiểu Tiki

Lan ứng tuyển vị trí Data Analyst tại một sàn thương mại điện tử. Người phỏng vấn đưa schema gồm orders(order_id, customer_id, order_value, created_at, status) và yêu cầu: "Tìm top 3 khách hàng có tổng giá trị đơn hoàn thành cao nhất trong tháng 5/2026, kèm số đơn của họ."

Lan làm đúng bài bản. Trước khi gõ, cô hỏi: "Đơn cancelled có tính vào tổng không ạ?" — Người phỏng vấn gật đầu hài lòng, trả lời "chỉ tính completed". Sau đó cô viết:

SELECT customer_id,
       COUNT(*) AS so_don,
       SUM(order_value) AS tong_gia_tri
FROM orders
WHERE status = 'completed'
  AND created_at >= '2026-05-01'
  AND created_at <  '2026-06-01'
GROUP BY customer_id
ORDER BY tong_gia_tri DESC
LIMIT 3;

Điểm cô ghi điểm cao nhất không phải là query, mà là khi cô giải thích vì sao dùng created_at < '2026-06-01' thay vì BETWEEN ... AND '2026-05-31': "Nếu cột là datetime, BETWEEN đến '2026-05-31' sẽ bỏ sót các đơn lúc 23:59 ngày 31 vì nó mặc định là 2026-05-31 00:00:00." Đây chính xác là tư duy phòng-lỗi mà BA cần.

Bài học: Câu hỏi tưởng đơn giản nhưng cài bẫy ở filter ngày tháng và ở status. Hỏi lại giả định trước khi gõ, và xử lý ranh giới ngày bằng >=< thay vì BETWEEN với datetime.

Tình huống 2 — Bài toán "second highest" tại một fintech kiểu MoMo

Minh phỏng vấn vị trí BA cho team Risk của một ví điện tử. Câu hỏi: "Tìm giao dịch có giá trị cao thứ hai. Lưu ý nhiều giao dịch có thể cùng giá trị."

Câu chốt "nhiều giao dịch có thể cùng giá trị" là cái bẫy. Minh đầu tiên định viết LIMIT 1 OFFSET 1, nhưng nhận ra nếu hai giao dịch cùng đứng đầu thì OFFSET 1 vẫn trả về giá trị cao nhất. Anh chuyển sang:

SELECT DISTINCT amount
FROM transactions
WHERE amount < (SELECT MAX(amount) FROM transactions);
Rồi anh dừng lại và nói thêm: "Query này trả về tất cả giá trị nhỏ hơn max. Để lấy đúng giá trị cao thứ hai, em bọc thêm ORDER BY amount DESC LIMIT 1, hoặc dùng MAX() ngoài cùng cho gọn." Phiên bản cuối:
SELECT MAX(amount) AS gia_tri_cao_thu_hai
FROM transactions
WHERE amount < (SELECT MAX(amount) FROM transactions);
Người phỏng vấn hỏi tiếp: "Nếu yêu cầu đổi thành cao thứ 5 thì sao?" Minh lập tức chuyển sang DENSE_RANK — cho thấy anh không học vẹt một đáp án mà hiểu nguyên lý.

Bài học: Luôn dự phòng cho ties. Khi câu hỏi nhấn mạnh "có thể trùng", đó là tín hiệu trực tiếp rằng họ muốn xem bạn xử lý DISTINCT/DENSE_RANK. Và hãy chuẩn bị sẵn cho câu hỏi "tổng quát hóa thành thứ N".

Tình huống 3 — Câu hỏi mở "đo retention" tại một startup ride-hailing

Hà phỏng vấn BA tại một hãng gọi xe kiểu Be. Đề bài không phải dạng có đáp án cố định: "Bạn sẽ viết SQL thế nào để biết bao nhiêu phần trăm tài xế tháng này còn hoạt động ở tháng sau?"

Đây là câu hỏi "open-ended" — họ kiểm tra cách bạn phân rã bài toán, không phải cú pháp. Hà không vội gõ. Cô vẽ ra: "Em cần định nghĩa 'hoạt động' là gì — có chuyến đi hoàn thành chứ không phải chỉ mở app, đúng không ạ?" Sau khi thống nhất, cô phác:

WITH driver_month AS (
    SELECT DISTINCT driver_id,
           DATE_FORMAT(trip_date, '%Y-%m-01') AS thang
    FROM trips
    WHERE status = 'completed'
)
SELECT a.thang,
       COUNT(DISTINCT a.driver_id) AS active_thang_nay,
       COUNT(DISTINCT b.driver_id) AS quay_lai_thang_sau,
       ROUND(COUNT(DISTINCT b.driver_id) * 100.0
             / COUNT(DISTINCT a.driver_id), 1) AS retention_pct
FROM driver_month a
LEFT JOIN driver_month b
       ON b.driver_id = a.driver_id
      AND b.thang = DATE_ADD(a.thang, INTERVAL 1 MONTH)
GROUP BY a.thang;

Hà không nhớ chính xác cú pháp DATE_ADD của từng hệ quản trị, nên cô nói thẳng: "Cú pháp hàm ngày khác nhau giữa MySQL và PostgreSQL, em quen MySQL; trên Postgres em sẽ dùng a.thang + INTERVAL '1 month'." Sự thành thật này được đánh giá cao hơn nhiều so với gõ bừa rồi sai.

Bài học: Với câu hỏi mở, giá trị nằm ở việc bạn làm rõ định nghĩa nghiệp vụ (thế nào là "active") và chia nhỏ bằng CTE cho dễ đọc. Không ai trừ điểm vì bạn quên cú pháp hàm ngày của một dialect cụ thể — nhưng họ sẽ trừ điểm nặng nếu bạn không hỏi "active nghĩa là gì".

Hướng dẫn từng bước

Đây là quy trình 6 bước để xử lý bất kỳ câu hỏi SQL phỏng vấn nào, áp dụng được ngay cả khi bạn chưa từng gặp dạng bài đó:

  • Đọc to và diễn giải lại đề. "Để em chắc chắn: anh muốn em tìm khách hàng chưa có đơn nào, output gồm tên và email, đúng không ạ?" Việc này mua thời gian suy nghĩ và lộ ra hiểu lầm sớm.
  • Hỏi về schema và dữ liệu. Cột nào là khóa? Kiểu dữ liệu của cột ngày là date hay datetime? Cột nào có thể NULL? Một quan hệ là 1-1 hay 1-nhiều (ảnh hưởng tới việc JOIN có nhân đôi dòng không)?
  • Nêu các edge case bạn sẽ xử lý. "Em sẽ lưu ý trường hợp trùng giá trị, và trường hợp bảng có thể rỗng." Chỉ cần nói ra là đã ghi điểm.
  • Phác cách giải bằng lời trước khi gõ. "Em sẽ gom nhóm theo customer, lọc bằng HAVING, rồi sort giảm dần." Người phỏng vấn có thể chỉnh hướng cho bạn trước khi bạn lãng phí thời gian gõ sai.
  • Gõ và vừa gõ vừa nói. Đặt alias rõ ràng, dùng CTE thay vì subquery lồng nhiều tầng để code dễ đọc — đây cũng là tín hiệu "người này viết query cho người khác đọc được".
  • Tự kiểm tra lại với một ví dụ nhỏ. "Giả sử có 2 khách cùng tổng 5 triệu, query của em sẽ trả về cả hai vì em không LIMIT — đúng ý anh chứ?" Bước self-review này phân biệt ứng viên trung bình với ứng viên xuất sắc.

Lỗi thường gặp & mẹo

Lỗi 1 — Quên rằng INNER JOIN làm mất dòng. Khi đề yêu cầu "tất cả khách hàng kèm tổng đơn", nếu bạn INNER JOIN sang orders, khách chưa có đơn sẽ biến mất. Phải dùng LEFT JOIN và COALESCE(SUM(...), 0).

Lỗi 2 — COUNT(cột) khác COUNT(). COUNT() đếm mọi dòng; COUNT(column) bỏ qua dòng NULL ở cột đó. Người phỏng vấn rất hay hỏi sự khác biệt này. COUNT(DISTINCT column) lại đếm số giá trị duy nhất.

Lỗi 3 — So sánh với NULL bằng =. WHERE status = NULL luôn trả về rỗng. Phải dùng IS NULL / IS NOT NULL. Đây là bẫy gần như chắc chắn xuất hiện ở vòng phỏng vấn.

Lỗi 4 — Dùng HAVING khi đáng lẽ là WHERE. Lọc trên cột thường (không tổng hợp) thì dùng WHERE để hiệu năng tốt hơn; HAVING chỉ dành cho điều kiện trên hàm tổng hợp.

Lỗi 5 — Gõ trong im lặng. Sai lầm lớn nhất không phải về kỹ thuật. Người phỏng vấn không đọc được suy nghĩ trong đầu bạn. Im lặng = họ không có gì để chấm.

Mẹo: Học thuộc nguyên lý của 5 nhóm câu hỏi ở trên thay vì học vẹt từng đáp án. Khi biết "anti-join = LEFT JOIN + IS NULL", bạn giải được vô số biến thể. Mẹo nữa: luôn ưu tiên CTE (WITH) thay vì subquery lồng — code dễ đọc luôn ghi điểm cộng về "code quality". Cuối cùng, khi không nhớ cú pháp một dialect, hãy nói thẳng giả định bạn đang dùng MySQL hay PostgreSQL; sự minh bạch luôn tốt hơn gõ bừa.

Bài tập thực hành

Giả sử có bảng employees(emp_id, name, department, salary, manager_id)orders(order_id, customer_id, amount, status, created_at). Hãy tự luyện trước, rồi đối chiếu hướng tiếp cận:

  • Tìm lương cao thứ ba trong toàn công ty, có xử lý trường hợp trùng lương. (Gợi ý: DENSE_RANK.)
  • Với mỗi phòng ban, tìm nhân viên có lương cao nhất phòng đó. (Gợi ý: ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC), lấy rn = 1.)
  • Tìm những nhân viên có lương cao hơn lương trung bình của phòng họ. (Gợi ý: correlated subquery hoặc window AVG() OVER (PARTITION BY department).)
  • Tìm khách hàng có ít nhất 3 đơn completed trong năm 2026. (Gợi ý: WHERE lọc status và năm, GROUP BY customer, HAVING COUNT > 2.)
  • Tìm các nhân viên không quản lý ai (không xuất hiện ở cột manager_id của bất kỳ ai). (Gợi ý: anti-join hoặc NOT IN — nhưng cẩn thận NULL với NOT IN!)
  • Câu mở: "Bạn sẽ đo tỷ lệ đơn bị hủy theo từng tháng như thế nào?" — hãy diễn giải cách phân rã trước khi viết query.
Với mỗi bài, hãy luyện nói ra quy trình 6 bước, không chỉ gõ đáp án. Tốt nhất là nhờ một người bạn đóng vai người phỏng vấn, hỏi vặn các edge case.

Tóm tắt

Phỏng vấn SQL cho BA không phải bài kiểm tra trí nhớ cú pháp — nó là bài kiểm tra tư duy phân tích dưới áp lực. Người phỏng vấn chấm bạn ở bốn trục: làm rõ yêu cầu, xử lý đúng edge case, diễn giải thành lời, và hiểu trade-off giữa các cách giải.

Hãy thuộc nguyên lý của các nhóm câu kinh điển: Nth highest (subquery vs DENSE_RANK vs LIMIT/OFFSET), tìm trùng, anti-join để tìm "không có match", pivot bằng CASE WHEN, và phân biệt WHERE/HAVING. Nhớ ba cái bẫy NULL: IS NULL thay vì =, COUNT(*) khác COUNT(col), và NOT IN gặp NULL.

Quan trọng nhất, áp dụng quy trình 6 bước cho mọi câu hỏi: diễn giải đề, hỏi schema, nêu edge case, phác bằng lời, vừa gõ vừa nói, tự kiểm tra. Một ứng viên xử lý câu hỏi trung bình với quy trình rõ ràng thường vượt qua ứng viên gõ đúng đáp án trong im lặng. Bạn đã có đủ kiến thức SQL từ 57 bài trước — giờ là lúc luyện cách trình bày nó cho thật thuyết phục.