Menu
ESC

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

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

Đang tải...

Data quality queries: duplicates, NULLs, outliers

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

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

Trong sự nghiệp của một Business Analyst, bạn sẽ sớm nhận ra một sự thật khó chịu: phần lớn thời gian phân tích không nằm ở việc viết những câu query "đẹp", mà nằm ở việc xử lý dữ liệu bẩn. Theo nhiều khảo sát của giới làm data, BA và analyst dành tới 60–80% thời gian chỉ để làm sạch và kiểm tra dữ liệu trước khi thực sự phân tích được. Và điều quan trọng hơn: trong tổ chức, BA thường là người ĐẦU TIÊN phát hiện ra dữ liệu có vấn đề — trước cả khi data engineer hay sếp nhận ra.

Tại sao lại như vậy? Vì BA là người ngồi giữa: bạn nhận yêu cầu từ business, rồi đào vào dữ liệu để trả lời. Khi con số bạn chạy ra "không hợp lý" — doanh thu tháng này gấp đôi bình thường, số khách hàng nhiều hơn dân số một tỉnh, hay tỷ lệ chuyển đổi vượt 100% — bạn chính là người chuông báo động đầu tiên. Nếu bạn không kiểm tra và cứ thế đưa con số lên slide cho sếp, hậu quả có thể rất nghiêm trọng: quyết định kinh doanh sai, ngân sách marketing đổ nhầm chỗ, hoặc tệ nhất là mất uy tín cá nhân.

Bài học này trang bị cho bạn một bộ query kiểm tra chất lượng dữ liệu (data quality queries) mà bạn nên có sẵn như bộ đồ nghề, chạy chúng mỗi khi tiếp cận một bảng dữ liệu mới. Chúng ta tập trung vào ba "căn bệnh" phổ biến nhất: bản ghi trùng lặp (duplicates), giá trị thiếu (NULLs), và giá trị bất thường (outliers). Đây không phải lý thuyết suông — đây là kỹ năng phòng thủ giúp bạn không bao giờ trình bày một báo cáo sai mà không hề hay biết.

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

Ba nhóm vấn đề chất lượng dữ liệu

Trước khi viết query, hãy hiểu rõ bạn đang săn lùng cái gì:

  • Duplicates (trùng lặp): Cùng một thực thể bị ghi nhiều lần. Ví dụ: một khách hàng đăng ký hai lần với cùng số điện thoại, một đơn hàng bị ghi đôi do lỗi retry của hệ thống thanh toán. Trùng lặp làm phồng số liệu — bạn đếm khách hàng nhiều hơn thực tế, tính doanh thu cao hơn thực tế.
  • NULLs (giá trị thiếu): Ô dữ liệu trống. Một khách không điền email, một đơn hàng thiếu mã tỉnh thành. NULL nguy hiểm ở chỗ nó âm thầm bị loại khỏi các phép tính tổng hợp (AVG, SUM bỏ qua NULL) và làm sai logic ở các phép so sánh.
  • Outliers (giá trị bất thường): Giá trị nằm ngoài vùng hợp lý. Một đơn hàng 50 tỷ đồng trong khi trung bình là 500 nghìn, một khách hàng có ngày sinh năm 1899. Outlier có thể là lỗi nhập liệu, lỗi đơn vị (nhầm đồng với nghìn đồng), hoặc đôi khi là dữ liệu thật cần chú ý.

1. Đếm NULL theo từng cột

Query đầu tiên trong bộ đồ nghề: đếm xem mỗi cột thiếu bao nhiêu giá trị. Mẹo kinh điển là dùng SUM kết hợp với CASE WHEN (hoặc tận dụng việc COUNT(cột) bỏ qua NULL):

SELECT
  COUNT(*) AS tong_so_dong,
  SUM(CASE WHEN email IS NULL THEN 1 ELSE 0 END) AS email_null,
  SUM(CASE WHEN phone IS NULL THEN 1 ELSE 0 END) AS phone_null,
  SUM(CASE WHEN city IS NULL THEN 1 ELSE 0 END) AS city_null,
  ROUND(100.0  SUM(CASE WHEN email IS NULL THEN 1 ELSE 0 END) / COUNT(), 2) AS ty_le_email_null
FROM customers;

Việc tính thêm tỷ lệ phần trăm rất quan trọng: 50 dòng NULL trên 100 dòng là thảm họa, nhưng 50 dòng trên 5 triệu dòng thì gần như vô hại. Luôn nhìn con số trong bối cảnh tổng thể.

Một cách viết gọn hơn tận dụng đặc tính của COUNT:

SELECT
  COUNT(*) - COUNT(email) AS email_null,
  COUNT(*) - COUNT(phone) AS phone_null
FROM customers;

COUNT(email) chỉ đếm các dòng email khác NULL, nên hiệu số chính là số dòng bị thiếu.

2. Tìm bản ghi trùng lặp

Vũ khí chính ở đây là GROUP BY cộng với HAVING COUNT(*) > 1. Bạn nhóm theo các cột mà về mặt nghiệp vụ phải là duy nhất, rồi lọc ra những nhóm có nhiều hơn một dòng:

SELECT
  phone,
  COUNT(*) AS so_lan_xuat_hien
FROM customers
GROUP BY phone
HAVING COUNT(*) > 1
ORDER BY so_lan_xuat_hien DESC;

Khi cần xác định trùng lặp theo tổ hợp nhiều cột (ví dụ trùng cả họ tên lẫn ngày sinh), chỉ cần thêm cột vào GROUP BY:

SELECT full_name, birth_date, COUNT(*) AS so_ban_ghi
FROM customers
GROUP BY full_name, birth_date
HAVING COUNT(*) > 1;

Nếu muốn xem chi tiết từng dòng trùng (chứ không chỉ đếm), ROW_NUMBER() là công cụ mạnh hơn:

SELECT *
FROM (
  SELECT *,
    ROW_NUMBER() OVER (
      PARTITION BY phone ORDER BY created_at
    ) AS rn
  FROM customers
) t
WHERE rn > 1;

Những dòng có rn > 1 chính là các bản trùng (dòng đầu tiên theo created_at được giữ làm bản gốc). Đây cũng là nền tảng để sau này bạn viết câu dọn dữ liệu trùng.

3. Phát hiện outliers

Cách đơn giản nhất là dùng các ngưỡng nghiệp vụ rõ ràng — giá trị âm ở nơi không thể âm, ngày tháng phi lý:

SELECT *
FROM orders
WHERE total_amount < 0
   OR total_amount > 1000000000   -- đơn trên 1 tỷ: đáng nghi
   OR order_date > CURRENT_DATE   -- đơn ở tương lai: chắc chắn sai
   OR order_date < '2015-01-01';  -- trước khi công ty thành lập

Cách thống kê hơn là dựa trên phân phối dữ liệu. Một quy tắc phổ biến là IQR (Interquartile Range): tính phân vị 25% (Q1) và 75% (Q3), bất kỳ giá trị nào nằm ngoài khoảng [Q1 - 1.5IQR, Q3 + 1.5IQR] được coi là outlier:

WITH stats AS (
  SELECT
    PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY total_amount) AS q1,
    PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY total_amount) AS q3
  FROM orders
)
SELECT o.*
FROM orders o, stats s
WHERE o.total_amount < s.q1 - 1.5 * (s.q3 - s.q1)
   OR o.total_amount > s.q3 + 1.5 * (s.q3 - s.q1);

(Lưu ý: PERCENTILE_CONT có sẵn trên PostgreSQL; MySQL cần cách tiếp cận khác mà ta sẽ nói ở phần Lỗi thường gặp.)

Tình huống thực tế

Ví dụ 1 — Tiki: doanh thu tháng "tăng đột biến" nhờ đơn hàng trùng

Một BA tại sàn thương mại điện tử (giả định theo mô hình Tiki) chuẩn bị báo cáo doanh thu tháng 11 — mùa sale 11/11. Con số ban đầu cho thấy GMV (tổng giá trị giao dịch) tăng 35% so với tháng trước, một mức tăng đẹp đến mức đáng ngờ. Trước khi đưa lên slide, cô chạy query kiểm tra trùng lặp:

SELECT order_id, COUNT(*) AS so_lan
FROM order_transactions
WHERE DATE(created_at) = '2025-11-11'
GROUP BY order_id
HAVING COUNT(*) > 1;

Kết quả: hơn 8.000 order_id xuất hiện 2–3 lần. Nguyên nhân là cổng thanh toán bị nghẽn trong giờ cao điểm, hệ thống retry và ghi nhận giao dịch nhiều lần. Khi loại bỏ trùng lặp, mức tăng thực tế chỉ là 12%.

Bài học: Nếu cô không kiểm tra, công ty đã ăn mừng một thành tích ảo và có thể đặt KPI tháng sau dựa trên con số sai. Query trùng lặp đã cứu cô khỏi một báo cáo sai và bảo vệ uy tín của cả team data.

Ví dụ 2 — Be/Grab: tỷ lệ "khách thiếu vùng" làm hỏng báo cáo địa lý

Một BA tại nền tảng gọi xe (mô hình Be) được giao phân tích nhu cầu theo quận để tối ưu phân bổ tài xế. Khi nhóm chuyến đi theo district, cô thấy quận "Không xác định" chiếm tới 18% số chuyến. Cô chạy query đếm NULL:

SELECT
  COUNT(*) AS tong_chuyen,
  SUM(CASE WHEN pickup_district IS NULL THEN 1 ELSE 0 END) AS thieu_quan,
  ROUND(100.0  SUM(CASE WHEN pickup_district IS NULL THEN 1 ELSE 0 END) / COUNT(), 1) AS ty_le
FROM trips
WHERE DATE(start_time) BETWEEN '2025-06-01' AND '2025-06-30';

Hóa ra pickup_district bị NULL ở các chuyến đặt qua app phiên bản cũ — phiên bản chưa gửi tọa độ GPS đầy đủ. 18% dữ liệu thiếu vùng khiến mọi phân tích theo quận đều lệch.

Bài học: Nếu cô vô tư lọc bỏ các dòng NULL, cô sẽ vô tình loại bỏ toàn bộ nhóm người dùng app cũ — thường là người dùng ở khu vực ngoại thành, ít cập nhật. Phát hiện này không chỉ sửa báo cáo mà còn tạo ra một insight: cần thúc đẩy người dùng cập nhật app. Một vấn đề chất lượng dữ liệu lại mở ra cơ hội nghiệp vụ.

Ví dụ 3 — MoMo: giao dịch outlier do lỗi đơn vị tiền tệ

Một BA tại ví điện tử (mô hình MoMo) phân tích giá trị giao dịch trung bình. Con số AVG(amount) bỗng nhảy lên 4,2 triệu đồng/giao dịch, trong khi cảm nhận thực tế chỉ khoảng vài trăm nghìn. Cô nghi có outlier kéo trung bình lên, nên kiểm tra phân phối:

SELECT
  MIN(amount) AS nho_nhat,
  MAX(amount) AS lon_nhat,
  AVG(amount) AS trung_binh,
  PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY amount) AS trung_vi
FROM wallet_transactions
WHERE DATE(created_at) >= '2025-06-01';

Trung vị (median) chỉ là 180.000 đồng, nhưng MAX lên tới 9 tỷ đồng. Đào sâu, cô phát hiện vài trăm giao dịch bị nhân nhầm 1.000 lần do một service mới ghi số tiền bằng đơn vị "đồng" trong khi service cũ dùng "nghìn đồng".

Bài học: Trung vị trung thực hơn trung bình khi có outlier. Chênh lệch lớn giữa AVG và median là tín hiệu kinh điển báo dữ liệu bị lệch. Cô báo lỗi cho team kỹ thuật, đồng thời dùng median cho báo cáo tạm thời để con số phản ánh đúng hành vi đa số người dùng.

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

Khi nhận một bảng dữ liệu mới, hãy chạy quy trình kiểm tra này theo thứ tự:

  • Đếm tổng số dòng và khoảng thời gian. Chạy SELECT COUNT(*), MIN(created_at), MAX(created_at) FROM bang; để biết quy mô và phạm vi dữ liệu. Đây là điểm tham chiếu cho mọi con số sau.
  • Kiểm tra NULL theo từng cột quan trọng. Tập trung vào các cột bạn sẽ dùng để lọc, nhóm hoặc tính toán. Dùng pattern SUM(CASE WHEN ... IS NULL) kèm tỷ lệ phần trăm. Cột khóa chính, cột tiền, cột ngày là ưu tiên hàng đầu.
  • Kiểm tra tính duy nhất của khóa. Cột lẽ ra là duy nhất (id, email, phone, order_id) có thực sự duy nhất không? Dùng GROUP BY ... HAVING COUNT(*) > 1. Nếu khóa chính cũng trùng, bạn đang gặp vấn đề nghiêm trọng ở tầng dữ liệu nguồn.
  • Xem nhanh phân phối các cột số. Chạy MIN, MAX, AVG, và median cùng lúc. So sánh AVG với median để phát hiện độ lệch. Nhìn MIN/MAX để bắt giá trị âm hoặc giá trị khổng lồ phi lý.
  • Kiểm tra giá trị phân loại (categorical). Với các cột như status, city, payment_method, chạy SELECT cot, COUNT(*) FROM bang GROUP BY cot ORDER BY 2 DESC; để xem có giá trị lạ không — như "Hà Nội", "ha noi", "HN" cùng tồn tại (vấn đề chuẩn hóa).
  • Ghi lại phát hiện. Đừng giữ trong đầu. Note lại: cột nào thiếu bao nhiêu %, có trùng lặp không, outlier ở đâu. Đây là cơ sở để bạn quyết định xử lý thế nào và để báo cho team kỹ thuật.

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

  • Nhầm = NULL với IS NULL. Trong SQL, WHERE email = NULL luôn trả về rỗng vì NULL không bằng bất cứ thứ gì, kể cả chính nó. Luôn dùng IS NULL / IS NOT NULL.
  • Quên rằng hàm tổng hợp bỏ qua NULL. AVG(amount) tính trung bình chỉ trên các dòng không NULL. Nếu 30% dòng thiếu amount, trung bình của bạn chỉ phản ánh 70% dữ liệu — và bạn có thể không hề biết. Luôn đếm NULL trước khi tin vào AVG/SUM.
  • COUNT() vs COUNT(cot). COUNT() đếm mọi dòng; COUNT(cot) chỉ đếm dòng có cot khác NULL. Đây vừa là bẫy vừa là mẹo: hiệu của chúng chính là số NULL.
  • MySQL không có PERCENTILE_CONT. Để tính median trên MySQL (phiên bản cũ), bạn cần dùng window function ROW_NUMBER/COUNT hoặc các kỹ thuật biến. Trên MySQL 8.0+ có thể dùng PERCENT_RANK(). Hãy kiểm tra hệ quản trị bạn đang dùng trước khi copy query.
  • Trùng lặp "mềm" do định dạng. Hai email An@Gmail.com an@gmail.com về mặt nghiệp vụ là một, nhưng GROUP BY coi là khác nhau. Mẹo: chuẩn hóa trước khi nhóm — GROUP BY LOWER(TRIM(email)).
  • Đừng vội xóa dữ liệu bẩn. Vai trò của BA là phát hiện và báo cáo, không phải tự ý xóa. Hãy hiểu nguyên nhân gốc (lỗi hệ thống? lỗi nhập liệu?) rồi phối hợp với team kỹ thuật. Việc xóa/sửa thuộc về DML, sẽ học ở bài sau.
  • Phân biệt outlier "lỗi" và outlier "thật". Một đơn hàng 500 triệu có thể là khách doanh nghiệp mua sỉ thật, không phải lỗi. Đừng loại bỏ outlier một cách máy móc — hãy điều tra trước khi kết luận.

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

Giả sử bạn có bảng orders(order_id, customer_id, total_amount, status, order_date, shipping_city) của một sàn TMĐT Việt Nam.

  • Viết một query đếm số NULL và tỷ lệ NULL cho ba cột: customer_id, total_amount, shipping_city. Cột nào thiếu nhiều nhất?
  • Tìm tất cả các order_id xuất hiện nhiều hơn một lần. Sau đó nâng cấp: dùng ROW_NUMBER() để chỉ ra dòng nào là bản trùng cần xem xét (giữ lại bản có order_date sớm nhất).
  • Liệt kê các đơn hàng "đáng nghi": total_amount âm, hoặc order_date ở tương lai, hoặc total_amount lớn hơn 100 triệu đồng.
  • So sánh AVG(total_amount) với median của total_amount. Nếu chênh lệch lớn, viết một câu giải thích điều đó gợi ý gì về dữ liệu.
  • Chạy GROUP BY shipping_city và tìm các giá trị có thể là cùng một thành phố nhưng viết khác nhau (ví dụ "TP.HCM", "Ho Chi Minh", "HCM"). Đề xuất cách chuẩn hóa.

Tóm tắt

  • BA thường là người đầu tiên phát hiện dữ liệu bẩn, nên cần một bộ query kiểm tra chất lượng chạy thường xuyên trên mọi bảng mới.
  • NULL: dùng SUM(CASE WHEN ... IS NULL) kèm tỷ lệ %; nhớ rằng hàm tổng hợp âm thầm bỏ qua NULL.
  • Duplicates: dùng GROUP BY ... HAVING COUNT(*) > 1, hoặc ROW_NUMBER() để soi từng dòng trùng; chuẩn hóa bằng LOWER(TRIM()) để bắt trùng "mềm".
  • Outliers: kiểm tra MIN/MAX, so sánh AVG với median, dùng ngưỡng nghiệp vụ hoặc quy tắc IQR. Chênh lệch lớn giữa trung bình và trung vị là tín hiệu cảnh báo kinh điển.
  • Ba câu chuyện Tiki, Be và MoMo cho thấy: một query kiểm tra đơn giản có thể cứu bạn khỏi báo cáo sai, bảo vệ uy tín, và đôi khi còn mở ra insight nghiệp vụ mới.
  • Nguyên tắc vàng: phát hiện và báo cáo, hiểu nguyên nhân gốc, đừng vội xóa. Kiểm tra dữ liệu trước khi tin vào con số — đó là bản năng của một BA giỏi.