Menu
ESC

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

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

Đang tải...

Conditional logic: CASE WHEN, COALESCE, NULLIF, IFNULL

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

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

Hãy tưởng tượng bạn là BA tại một sàn thương mại điện tử như Tiki. Sếp gửi tin nhắn lúc 9 giờ tối: "Em phân loại giúp anh đơn hàng tháng này thành Premium, Standard, Economy theo giá trị, đồng thời chỉ ra những đơn nào bị thiếu thông tin giao hàng." Bạn mở dữ liệu lên và nhận ra cuộc đời không màu hồng: cột total có vài dòng là NULL, cột discount lúc có lúc không, và bạn cần biến một con số thành một nhãn phân loại có ý nghĩa.

Đây chính là lúc bốn công cụ của bài học hôm nay tỏa sáng: CASE WHEN, COALESCE, NULLIF, và IFNULL. Nếu SELECT, WHERE, JOIN là bộ xương của SQL thì nhóm hàm điều kiện (conditional logic) chính là phần "trí thông minh" giúp query biết suy nghĩ — biết phân nhánh "nếu thế này thì thế kia", biết xử lý dữ liệu thiếu một cách duyên dáng thay vì để báo cáo vỡ trận.

Với một BA, đây không phải kiến thức "có cũng được". Đây là kỹ năng dùng hằng ngày: phân khúc khách hàng, gắn nhãn trạng thái, làm sạch giá trị NULL trước khi đưa lên dashboard, tránh lỗi chia cho 0 khi tính tỷ lệ. Nắm vững bốn công cụ này, bạn sẽ tự tin biến dữ liệu thô lộn xộn thành báo cáo gọn gàng mà không cần làm phiền team Data Engineer.

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

CASE WHEN — con dao đa năng

CASE WHEN là cách SQL diễn đạt logic "nếu... thì... ngược lại thì...". Có hai dạng viết.

Dạng searched (linh hoạt nhất, nên dùng):

SELECT
  id,
  total,
  CASE
    WHEN total >= 10000000 THEN 'Premium'
    WHEN total >= 3000000  THEN 'Standard'
    WHEN total >= 500000   THEN 'Economy'
    ELSE 'Micro'
  END AS order_tier
FROM orders;

Cách đọc: SQL kiểm tra từng WHEN theo thứ tự từ trên xuống. Điều kiện nào đúng đầu tiên sẽ thắng, các điều kiện sau bị bỏ qua. Nếu không WHEN nào đúng, kết quả là phần ELSE. Nếu không có ELSE và không khớp điều kiện nào, kết quả là NULL.

Dạng simple (so sánh bằng với một giá trị cố định):

SELECT
  status,
  CASE status
    WHEN 'paid'     THEN 'Đã thanh toán'
    WHEN 'pending'  THEN 'Chờ xử lý'
    WHEN 'cancelled' THEN 'Đã huỷ'
    ELSE 'Không xác định'
  END AS status_vn
FROM orders;

Dạng simple ngắn gọn nhưng chỉ so sánh bằng (=). Khi cần khoảng giá trị, so sánh phức tạp, hay nhiều điều kiện AND/OR, hãy luôn dùng dạng searched.

Điểm quan trọng cần khắc cốt: thứ tự các WHEN rất quan trọng. Nếu ở ví dụ trên bạn đặt WHEN total >= 500000 lên đầu, thì mọi đơn đều rơi vào 'Economy' trước khi kịp xét tới 'Premium'. Luôn xếp điều kiện chặt nhất (giá trị lớn nhất) lên trên.

COALESCE — lấy giá trị đầu tiên không NULL

COALESCE nhận một danh sách các giá trị và trả về giá trị đầu tiên khác NULL. Cực kỳ hữu ích để thay thế dữ liệu trống bằng giá trị mặc định.

SELECT
  customer_id,
  COALESCE(phone, email, 'Chưa có liên hệ') AS contact_info
FROM customers;

Ở đây SQL sẽ lấy phone nếu có; nếu phoneNULL thì lấy email; nếu cả hai đều NULL thì trả về chuỗi 'Chưa có liên hệ'. COALESCE nhận bao nhiêu tham số cũng được và là chuẩn ANSI SQL — chạy được trên MySQL, PostgreSQL, SQL Server, Oracle.

IFNULL (MySQL) và ISNULL/NVL — phiên bản hai tham số

IFNULL(a, b) là dạng rút gọn: trả về a nếu a khác NULL, ngược lại trả về b. Bản chất nó chính là COALESCE(a, b) chỉ với đúng hai tham số.

-- MySQL
SELECT IFNULL(discount, 0) AS discount_safe FROM orders;

Lưu ý quan trọng về tính tương thích: IFNULL là hàm riêng của MySQL. PostgreSQL không có IFNULL — bên đó bạn dùng COALESCE. Oracle dùng NVL, SQL Server dùng ISNULL. Lời khuyên của tôi: cứ dùng COALESCE cho mọi nơi, bạn sẽ không bao giờ phải sửa code khi đổi database.

NULLIF — biến một giá trị thành NULL

NULLIF(a, b) làm điều ngược lại với trực giác: nó trả về NULL nếu a = b, ngược lại trả về a. Nghe có vẻ kỳ lạ, nhưng nó có một công dụng "thần thánh": chống lỗi chia cho 0.

SELECT
  campaign_id,
  clicks,
  conversions,
  conversions * 1.0 / NULLIF(clicks, 0) AS conversion_rate
FROM marketing_campaigns;

Nếu clicks = 0, NULLIF(clicks, 0) biến nó thành NULL, và phép chia cho NULL ra NULL (không báo lỗi). Thay vì query bị crash với "division by zero", bạn nhận về NULL — một kết quả "không có dữ liệu" hợp lý mà bạn có thể bọc thêm COALESCE để hiển thị thành 0 hay '—'.

Bộ ba kết hợp

Trong thực tế, ba công cụ này thường đi cùng nhau:

SELECT
  COALESCE(revenue * 1.0 / NULLIF(orders_count, 0), 0) AS avg_order_value
FROM daily_stats;

NULLIF chặn chia cho 0, phép chia ra NULL nếu mẫu số bằng 0, rồi COALESCE biến NULL đó thành 0 để báo cáo nhìn sạch sẽ.

Tình huống thực tế

Tình huống 1 — Phân khúc khách hàng tại Tiki cho chiến dịch CRM

Bối cảnh: Team Marketing của một sàn TMĐT muốn chia khách hàng thành các nhóm để gửi ưu đãi khác nhau, dựa trên tổng chi tiêu trong 12 tháng. Khách VIP nhận voucher 500k, khách thường nhận voucher 50k, khách "ngủ đông" nhận email kích hoạt lại.

SELECT
  c.customer_id,
  c.full_name,
  COALESCE(SUM(o.total), 0) AS total_spent,
  CASE
    WHEN COALESCE(SUM(o.total), 0) >= 50000000 THEN 'VIP'
    WHEN COALESCE(SUM(o.total), 0) >= 10000000 THEN 'Loyal'
    WHEN COALESCE(SUM(o.total), 0) >= 1000000  THEN 'Active'
    WHEN COALESCE(SUM(o.total), 0) > 0          THEN 'Casual'
    ELSE 'Inactive'
  END AS segment
FROM customers c
LEFT JOIN orders o
  ON c.customer_id = o.customer_id
  AND o.created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY c.customer_id, c.full_name;

Diễn giải: LEFT JOIN giữ lại cả những khách chưa từng mua (nên SUM của họ là NULL). COALESCE(SUM(o.total), 0) biến NULL thành 0 để những khách này rơi vào nhóm 'Inactive' thay vì bị lọt khỏi CASE. Để ý mình lặp lại COALESCE(SUM(o.total), 0) ở mỗi WHEN — đúng, hơi dài dòng, và bài về CTE sau này sẽ dạy bạn cách tránh lặp. Nhưng ở mức này, nó hoạt động chính xác.

Bài học rút ra: khi phân khúc dựa trên giá trị tổng hợp, luôn nghĩ tới trường hợp "không có giao dịch nào" — đó là nhóm dễ bị bỏ quên nhất nhưng lại quan trọng cho chiến dịch reactivation.

Tình huống 2 — Bảng điều khiển vận hành của Grab và lỗi chia cho 0

Bối cảnh: BA của một hãng gọi xe cần tính tỷ lệ hoàn thành chuyến (completion rate) cho từng tài xế trong tuần. Vấn đề: có tài xế mới đăng ký, được phân chuyến nhưng tuần đó số chuyến nhận (assigned) bằng 0. Query đầu tiên của một bạn junior bị crash toàn bộ dashboard chỉ vì vài tài xế này.

SELECT
  driver_id,
  assigned_trips,
  completed_trips,
  ROUND(
    completed_trips * 100.0 / NULLIF(assigned_trips, 0),
    1
  ) AS completion_rate_pct,
  CASE
    WHEN assigned_trips = 0 THEN 'Chưa hoạt động'
    WHEN completed_trips * 100.0 / assigned_trips >= 90 THEN 'Xuất sắc'
    WHEN completed_trips * 100.0 / assigned_trips >= 70 THEN 'Đạt'
    ELSE 'Cần theo dõi'
  END AS performance_label
FROM driver_weekly_stats;

Diễn giải: NULLIF(assigned_trips, 0) cứu cả query khỏi crash — tài xế chưa nhận chuyến nào sẽ có completion_rate_pct = NULL. Đồng thời trong CASE, mình đặt điều kiện assigned_trips = 0 lên đầu tiên để gắn nhãn 'Chưa hoạt động' cho nhóm này, tránh việc phép tính bên dưới gặp mẫu số 0. Đây là ví dụ kinh điển cho thấy NULLIFCASE bổ trợ cho nhau: một cái bảo vệ phép tính, một cái bảo vệ logic phân loại.

Bài học rút ra: bất kỳ khi nào bạn viết phép chia trong SQL báo cáo, hãy phản xạ tự hỏi "mẫu số có thể bằng 0 không?". Nếu câu trả lời là "có thể", bọc nó trong NULLIF ngay lập tức. Một dashboard không bao giờ được phép sập chỉ vì một dòng dữ liệu biên.

Tình huống 3 — Làm sạch dữ liệu liên hệ tại MoMo cho đội chăm sóc khách hàng

Bối cảnh: Đội CSKH của một ví điện tử cần một danh sách khách hàng kèm "kênh liên hệ ưu tiên" để gọi điện nhắc nhở giao dịch treo. Dữ liệu thực tế lộn xộn: có người chỉ có số điện thoại, có người chỉ có Zalo, nhiều ô bị nhập chuỗi rỗng '' thay vì NULL thật.

SELECT
  user_id,
  COALESCE(
    NULLIF(TRIM(phone), ''),
    NULLIF(TRIM(zalo_id), ''),
    NULLIF(TRIM(email), ''),
    'Không có kênh liên hệ'
  ) AS preferred_contact,
  CASE
    WHEN NULLIF(TRIM(phone), '') IS NOT NULL THEN 'Gọi điện'
    WHEN NULLIF(TRIM(zalo_id), '') IS NOT NULL THEN 'Nhắn Zalo'
    WHEN NULLIF(TRIM(email), '') IS NOT NULL THEN 'Gửi email'
    ELSE 'Cần bổ sung thông tin'
  END AS contact_channel
FROM users;

Diễn giải: đây là chỗ NULLIF lóe sáng theo cách ít người biết. Dữ liệu thật rất hay có chuỗi rỗng '' — mà COALESCE coi '' là một giá trị "hợp lệ" (khác NULL), nên nếu không xử lý, COALESCE sẽ trả về chuỗi rỗng vô nghĩa. Mẹo NULLIF(TRIM(phone), '') biến cả khoảng trắng lẫn chuỗi rỗng thành NULL thật, rồi COALESCE mới bỏ qua được chúng để nhảy sang kênh tiếp theo.

Bài học rút ra: trong dữ liệu Việt Nam (và mọi nơi), "trống" có nhiều mặt nạ: NULL, '', hay chuỗi toàn dấu cách. Kết hợp NULLIF với TRIM là cách chuẩn để quy mọi loại "trống" về một mối trước khi xử lý.

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

Giả sử bạn cần làm bài toán của Tình huống 1 từ con số không. Đây là quy trình tư duy mình khuyên bạn theo:

Bước 1 — Xác định cột nguồn và các giá trị biên. Cột phân loại là total. Hỏi ngay: nó có thể NULL không? (Có, với khách chưa mua.) Có giá trị âm không? (Có thể, với đơn hoàn tiền.) Ghi chú lại để xử lý.

Bước 2 — Vẽ ranh giới các nhóm trước khi viết SQL. Liệt kê ra giấy: VIP ≥ 50tr, Loyal ≥ 10tr, Active ≥ 1tr, Casual > 0, còn lại Inactive. Việc này quan trọng hơn cú pháp — sai ranh giới là sai cả báo cáo.

Bước 3 — Viết CASE từ điều kiện chặt nhất xuống. Luôn từ giá trị lớn nhất tới nhỏ nhất, để WHEN đúng đầu tiên là WHEN chính xác.

Bước 4 — Bọc giá trị nguồn bằng COALESCE nếu nó có thể NULL. Ở đây là COALESCE(SUM(o.total), 0) để khách chưa mua không biến mất.

Bước 5 — Luôn có ELSE. Đừng để query trả về NULL ngầm. Một nhãn 'Khác' rõ ràng giúp bạn phát hiện ngay khi có dữ liệu rơi ngoài dự kiến.

Bước 6 — Kiểm tra bằng GROUP BY đếm số lượng mỗi nhóm. Chạy SELECT segment, COUNT(*) FROM (...) GROUP BY segment để xem phân bố có hợp lý không. Nếu 95% khách rơi vào một nhóm, có thể ranh giới của bạn sai.

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

Lỗi 1 — Quên ELSE rồi ngạc nhiên vì cột có NULL. Khi không WHEN nào khớp và không có ELSE, CASE trả về NULL. Nếu sau đó bạn COUNT hay SUM trên cột này, kết quả sẽ lệch mà không báo lỗi. Mẹo: tập thói quen luôn viết ELSE ngay cả khi nghĩ "chắc không bao giờ rơi vào đây".

Lỗi 2 — Đặt sai thứ tự WHEN. Như đã nói, CASE dừng ở điều kiện đúng đầu tiên. WHEN total >= 0 đặt trước WHEN total >= 10000000 sẽ nuốt hết mọi đơn. Luôn xếp từ chặt nhất tới lỏng nhất.

Lỗi 3 — Nhầm IFNULL chạy trên PostgreSQL. Bạn viết query trên MySQL ở máy, deploy lên PostgreSQL của công ty, và nhận lỗi "function ifnull does not exist". Mẹo: dùng COALESCE cho mọi trường hợp, nó là chuẩn chung và đa năng hơn (nhận nhiều tham số).

Lỗi 4 — Quên chuỗi rỗng '' không phải NULL. COALESCE(name, 'N/A') không thay được ''. Mẹo: bọc NULLIF(col, '') khi nghi ngờ dữ liệu có chuỗi rỗng.

Lỗi 5 — So sánh với NULL bằng dấu = trong CASE. WHEN total = NULL THEN ... luôn cho kết quả "không đúng" (vì NULL không bằng bất cứ thứ gì, kể cả chính nó). Phải viết WHEN total IS NULL THEN .... Đây là lỗi cực kỳ phổ biến với người mới.

Mẹo vàng — CASE chạy được cả trong WHERE, ORDER BY, và bên trong hàm tổng hợp. Một kỹ thuật rất mạnh là "đếm có điều kiện":

SELECT
  COUNT(*) AS total_orders,
  SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders,
  ROUND(
    SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END)  100.0 / COUNT(),
    1
  ) AS cancel_rate_pct
FROM orders;

Mẫu SUM(CASE WHEN ... THEN 1 ELSE 0 END) này là một trong những công thức bạn sẽ dùng nhiều nhất đời BA — đếm số dòng thỏa một điều kiện ngay trong cùng một query tổng hợp.

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

Dùng bảng orders(order_id, customer_id, total, status, payment_method, shipping_address, created_at) của một sàn TMĐT giả định.

Bài 1 — Cơ bản. Viết query gắn nhãn mỗi đơn thành 'Free ship' nếu total >= 300000, ngược lại 'Tính phí ship'. Dùng CASE WHEN.

Bài 2 — Xử lý NULL. Cột payment_method có nhiều giá trị NULL và một số ô là chuỗi rỗng ''. Viết query trả về payment_method đã làm sạch: nếu trống (NULL hoặc rỗng) thì hiển thị 'Chưa rõ'. Gợi ý: kết hợp COALESCENULLIF.

Bài 3 — Chống chia cho 0. Bảng daily_stats(day, revenue, order_count) có những ngày order_count = 0. Tính giá trị đơn trung bình (revenue / order_count) sao cho không bị lỗi, và hiển thị 0 cho những ngày không có đơn.

Bài 4 — Đếm có điều kiện. Từ bảng orders, tính trong một query duy nhất: tổng số đơn, số đơn 'paid', số đơn 'cancelled', và tỷ lệ phần trăm đơn bị huỷ. Gợi ý: dùng SUM(CASE WHEN ... THEN 1 ELSE 0 END).

Bài 5 — Tổng hợp. Phân khúc khách hàng thành 'VIP', 'Thường', 'Mới' dựa trên tổng total 6 tháng gần nhất, đảm bảo khách chưa mua vẫn xuất hiện trong nhóm 'Mới'. Dùng LEFT JOIN, COALESCE, và CASE.

Gợi ý đáp án Bài 2:

SELECT
  order_id,
  COALESCE(NULLIF(TRIM(payment_method), ''), 'Chưa rõ') AS payment_clean
FROM orders;

Tóm tắt

Bốn công cụ điều kiện trong bài này là bộ não logic của SQL với một BA:

  • CASE WHEN — phân nhánh "nếu... thì...", dùng để phân loại, gắn nhãn, và đếm có điều kiện. Nhớ: xét từ trên xuống, dừng ở WHEN đúng đầu tiên; xếp điều kiện chặt nhất lên đầu; luôn có ELSE.
  • COALESCE — trả về giá trị đầu tiên khác NULL. Chuẩn ANSI, chạy mọi database, nhận nhiều tham số. Ưu tiên dùng nó thay cho IFNULL.
  • IFNULL (MySQL) / NVL / ISNULL — dạng rút gọn hai tham số, nhưng phụ thuộc hệ quản trị; biết để đọc code người khác, còn khi viết cứ chọn COALESCE.
  • NULLIF — biến a thành NULL khi a = b; ứng dụng số một là chống chia cho 0 với NULLIF(mẫu_số, 0), và quy chuỗi rỗng về NULL với NULLIF(TRIM(col), '').
Ghi nhớ ba phản xạ nghề nghiệp: thấy phép chia thì nghĩ tới NULLIF; thấy cột có thể trống thì nghĩ tới COALESCE; thấy cần phân loại thì nghĩ tới CASE. Nắm chắc bộ ba này, bạn đã có đủ vũ khí để biến dữ liệu thô lộn xộn thành báo cáo sạch sẽ, đáng tin cậy — đúng phẩm chất của một BA giỏi.