Site logo
Tác giả
  • avatar Nguyễn Đức Xinh
    Name
    Nguyễn Đức Xinh
    Twitter
Ngày xuất bản
Ngày xuất bản

SQL Điều Tra Dữ Liệu: Bộ Query Kiểm Tra, So Sánh Và Thống Kê

Giới Thiệu

Bài Troubleshooting MySQL tập trung vào sự cố ở mức server: connection, process list, lock, deadlock. Nhưng phần lớn bug mà developer gặp hằng ngày nằm ở dữ liệu:

  • "Xuất hóa đơn mới thì báo lỗi duplicate key"
  • "Màn hình hiện tổng 1,250 đơn nhưng export CSV chỉ có 1,248 dòng"
  • "Sau khi migrate, khách báo thiếu một số đơn hàng"
  • "Sửa query xong, làm sao chắc kết quả vẫn giống như cũ?"
  • "Cần một khách hàng có ít nhất 3 đơn, trong đó có đơn đã hủy, để test, tìm ở đâu?"

Những câu hỏi này không trả lời được bằng SHOW PROCESSLIST. Chúng cần query điều tra: những câu SELECT viết ra để kiểm tra một giả thuyết, đo một con số, hoặc so sánh hai tập dữ liệu.

Bài này tổng hợp các pattern hay dùng nhất, chia theo mục đích:

Nhóm Câu hỏi trả lời
Đặt kết quả cạnh nhau Các con số ở nhiều bảng/điều kiện/môi trường là bao nhiêu, đặt trên một dòng để so
Assertion query Điều kiện X có đúng không? (trả về ok = 1 hoặc 0 dòng lỗi)
Toàn vẹn dữ liệu Có trùng lặp, mồ côi, giá trị sai, tổng lệch không?
Thống kê và profiling Dữ liệu phân bố thế nào? Giá trị nào chiếm đa số?
So sánh Hai bảng, hai môi trường, hai phiên bản query có khớp không?
Hỗ trợ test Tìm data test ở đâu? Verify fix thế nào?

Yêu Cầu

  • MySQL 8.0+ hoặc MariaDB 10.5+. Nhiều query dùng window function (ROW_NUMBER, LAG) và CTE (WITH), chỉ có từ MySQL 8.0 / MariaDB 10.2
  • Đã quen với GROUP BY, JOIN, subquery (xem Subquery, Common Table Expressions)

Các ví dụ dùng schema của một website bán hàng online đơn giản:

customers       : customer_id, email, name, phone, created_at, deleted_at
products        : product_id, product_code, name, price, status, created_at
product_prices  : id, product_id, price, is_current, valid_from
orders          : order_id, order_no, customer_id, status, total_amount,
                  ordered_at, shipped_at, deleted_at
order_items     : item_id, order_id, product_id, qty, unit_price
payments        : payment_id, order_id, amount, paid_at
invoices        : invoice_id, invoice_no, order_id, issued_at
invoice_counters: year, last_no              -- bộ đếm sinh số hóa đơn

Nguyên Tắc An Toàn Khi Điều Tra

Trước khi đi vào các query, hãy nhớ: điều tra thường chạy trên dữ liệu thật, đôi khi trên production. Một câu SELECT viết vội có thể làm chậm cả hệ thống.

  1. Ưu tiên chạy trên replica hoặc bản copy, không chạy trên primary của production nếu không cần thiết
  2. Đặt giới hạn thời gian cho query điều tra:
    -- MySQL: dừng sau 30 giây (chỉ áp dụng cho SELECT)
    SELECT /*+ MAX_EXECUTION_TIME(30000) */ ... ;
    -- MariaDB: giới hạn theo session, tính bằng giây
    SET SESSION max_statement_time = 30;
    
  3. Dùng transaction read-only để chắc chắn không lỡ tay sửa dữ liệu:
    START TRANSACTION READ ONLY;
    -- ... các query điều tra ...
    COMMIT;
    
  4. EXPLAIN trước khi chạy query trên bảng lớn, đặc biệt query có JOIN hoặc NOT EXISTS
  5. Luôn có LIMIT khi mục đích chỉ là xem mẫu
  6. Lưu lại query và kết quả vào ticket. Query điều tra chính là bằng chứng, và thường sẽ cần chạy lại sau khi fix

Phần 1: Đặt Kết Quả Nhiều Bảng Cạnh Nhau Trên Một Dòng

Đây là kỹ thuật cơ bản nhất và được dùng nhiều nhất khi điều tra. Thay vì chạy ba câu query riêng rồi ghi các con số ra giấy để so, hãy gom chúng vào một câu SELECT, mỗi con số là một cột:

SELECT
  (SELECT COUNT(*) FROM orders)       AS orders,
  (SELECT COUNT(*) FROM order_items)  AS order_items,
  (SELECT COUNT(*) FROM payments)     AS payments;
orders   order_items  payments
125430   389112       118207

Giải thích:

  • Câu SELECT ngoài cùng không có FROM. Nó chỉ dùng để "chứa" các cột
  • Mỗi cột là một scalar subquery: một câu SELECT con nằm trong ngoặc, trả về đúng 1 dòng, 1 cột
  • Mỗi subquery độc lập, có thể đọc từ bảng khác, với điều kiện khác
  • Kết quả luôn là một dòng duy nhất: dễ đọc, dễ chụp màn hình gửi vào ticket hoặc chat

Cú pháp tổng quát:

SELECT
  (SELECT <giá trị> FROM <bảng 1> WHERE ...) AS ten_cot_1,
  (SELECT <giá trị> FROM <bảng 2> WHERE ...) AS ten_cot_2,
  ...;

Các tình huống dưới đây đều dùng chung một khuôn này.

Use Case 1: Đếm Cùng Một Bảng Theo Nhiều Điều Kiện

"Hiện có bao nhiêu đơn ở mỗi trạng thái quan trọng?"

SELECT
  (SELECT COUNT(*) FROM orders)                                   AS total,
  (SELECT COUNT(*) FROM orders WHERE status = 'pending')          AS pending,
  (SELECT COUNT(*) FROM orders WHERE status = 'paid')             AS paid,
  (SELECT COUNT(*) FROM orders WHERE status = 'cancelled')        AS cancelled,
  (SELECT COUNT(*) FROM orders WHERE deleted_at IS NOT NULL)      AS soft_deleted;

Với cùng một bảng, có cách viết tốn ít tài nguyên hơn: conditional aggregation. Cách này chỉ quét bảng một lần thay vì năm lần:

SELECT
  COUNT(*)                         AS total,
  SUM(status = 'pending')          AS pending,
  SUM(status = 'paid')             AS paid,
  SUM(status = 'cancelled')        AS cancelled,
  SUM(deleted_at IS NOT NULL)      AS soft_deleted
FROM orders;

Trong MySQL, status = 'paid' trả về 1 hoặc 0, nên SUM(...) chính là số dòng thỏa điều kiện.

Cách viết Khi nào dùng
Scalar subquery Các con số đến từ nhiều bảng khác nhau, hoặc mỗi điều kiện dùng được index riêng
Conditional aggregation Các con số đến từ cùng một bảng, đặc biệt khi bảng lớn và phải quét toàn bộ

Use Case 2: So Sánh Bảng Cha Và Bảng Con

"Có bao nhiêu đơn hàng, và bao nhiêu đơn trong số đó thực sự có sản phẩm?"

SELECT
  (SELECT COUNT(*) FROM orders)                         AS orders,
  (SELECT COUNT(DISTINCT order_id) FROM order_items)    AS orders_with_items,
  (SELECT COUNT(DISTINCT order_id) FROM payments)       AS orders_with_payment;
orders   orders_with_items  orders_with_payment
125430   125428             118207

Nhìn một dòng là thấy ngay: có 2 đơn không có sản phẩm nào. Đó là manh mối để chuyển sang query tìm bản ghi mồ côi (Phần 3).

Use Case 3: Giá Trị Lớn Nhất / Mới Nhất Ở Nhiều Bảng

Kiểm tra bộ đếm, AUTO_INCREMENT, hoặc xem dữ liệu mới nhất ở mỗi bảng để biết batch đồng bộ có bị dừng không:

SELECT
  (SELECT MAX(ordered_at) FROM orders)         AS last_order,
  (SELECT MAX(paid_at)    FROM payments)       AS last_payment,
  (SELECT MAX(created_at) FROM customers)      AS last_customer,
  NOW()                                        AS now_time;
last_order           last_payment         last_customer        now_time
2026-11-04 09:58:12  2026-11-03 23:59:40  2026-11-04 09:41:05  2026-11-04 10:00:03

payments dừng từ nửa đêm trong khi đơn hàng vẫn đều đặn. Rất có thể job đồng bộ thanh toán đã chết. Thêm NOW() vào cùng dòng giúp thấy ngay độ trễ mà không phải tra đồng hồ.

Use Case 4: Thêm Cột Chênh Lệch Và Kết Luận

Muốn có thêm cột diff hoặc ok, bạn sẽ gặp một giới hạn: không dùng được alias của cột khác trong cùng câu SELECT. Viết orders - orders_with_items AS diff sẽ báo lỗi Unknown column.

Cách xử lý: bọc câu query vào một derived table, rồi tính ở câu ngoài:

SELECT t.*,
       t.orders - t.orders_with_items  AS orders_without_items,
       t.orders = t.orders_with_items  AS ok
FROM (
  SELECT
    (SELECT COUNT(*) FROM orders)                       AS orders,
    (SELECT COUNT(DISTINCT order_id) FROM order_items)  AS orders_with_items
) t;
orders   orders_with_items  orders_without_items  ok
125430   125428             2                     0

Đây chính là nền tảng của assertion query ở Phần 2: thêm một cột ok để query tự đưa ra kết luận.

Use Case 5: So Sánh Hai Database Hoặc Hai Môi Trường

Khi hai database nằm trên cùng một server (ví dụ app_dev và app_stg trên cùng RDS, hoặc bảng cũ và bảng mới sau migration), chỉ cần thêm tên database vào trước tên bảng:

SELECT t.*, t.dev_orders - t.stg_orders AS diff
FROM (
  SELECT
    (SELECT COUNT(*) FROM app_dev.orders)  AS dev_orders,
    (SELECT COUNT(*) FROM app_stg.orders)  AS stg_orders
) t;

So sánh nhiều bảng cùng lúc thì chuyển sang dạng dọc, mỗi bảng một dòng (xem Use Case 7):

SELECT 'orders' AS tbl,
       (SELECT COUNT(*) FROM app_dev.orders) AS dev,
       (SELECT COUNT(*) FROM app_stg.orders) AS stg
UNION ALL
SELECT 'order_items',
       (SELECT COUNT(*) FROM app_dev.order_items),
       (SELECT COUNT(*) FROM app_stg.order_items)
UNION ALL
SELECT 'payments',
       (SELECT COUNT(*) FROM app_dev.payments),
       (SELECT COUNT(*) FROM app_stg.payments);
tbl          dev      stg
orders       125430   125430
order_items  389112   389101   ← lệch 11 dòng
payments     118207   118207

Use Case 6: Xem Một Bản Ghi Qua Nhiều Bảng

Khách hàng báo lỗi về đơn ORD-000123. Bạn muốn xem nhanh tình trạng của đơn đó ở tất cả các bảng liên quan:

SET @order_id = (SELECT order_id FROM orders WHERE order_no = 'ORD-000123');

SELECT
  @order_id                                                          AS order_id,
  (SELECT status       FROM orders      WHERE order_id = @order_id)  AS status,
  (SELECT total_amount FROM orders      WHERE order_id = @order_id)  AS total_amount,
  (SELECT COUNT(*)     FROM order_items WHERE order_id = @order_id)  AS item_cnt,
  (SELECT SUM(qty * unit_price) FROM order_items WHERE order_id = @order_id) AS items_total,
  (SELECT COUNT(*)     FROM payments    WHERE order_id = @order_id)  AS payment_cnt,
  (SELECT SUM(amount)  FROM payments    WHERE order_id = @order_id)  AS paid_amount;
order_id  status  total_amount  item_cnt  items_total  payment_cnt  paid_amount
123       paid    450000        3         450000       2            900000

Một dòng cho thấy ngay: đơn bị thanh toán hai lần (payment_cnt = 2, paid_amount gấp đôi). Dùng biến @order_id để chỉ phải sửa một chỗ khi đổi sang đơn khác.

Cùng ý tưởng, áp cho nhiều bản ghi bằng correlated subquery: mỗi dòng của bảng chính, kèm các con số từ bảng khác:

SELECT c.customer_id,
       c.email,
       (SELECT COUNT(*)        FROM orders o WHERE o.customer_id = c.customer_id) AS order_cnt,
       (SELECT MAX(ordered_at) FROM orders o WHERE o.customer_id = c.customer_id) AS last_order,
       (SELECT SUM(total_amount) FROM orders o
         WHERE o.customer_id = c.customer_id AND o.status <> 'cancelled')        AS total_spent
FROM customers c
WHERE c.created_at >= '2026-10-01'
LIMIT 50;

Correlated subquery chạy một lần cho mỗi dòng của bảng ngoài. Với LIMIT 50 và index trên orders.customer_id thì rất nhanh. Với hàng trăm nghìn dòng, hãy chuyển sang LEFT JOIN + GROUP BY.

Use Case 7: Dạng Ngang Hay Dạng Dọc?

Khi có quá nhiều con số, một dòng ngang 15 cột sẽ khó đọc. Có ba cách:

Cách 1: Dạng dọc bằng UNION ALL, mỗi chỉ số một dòng:

SELECT 'orders'           AS metric, COUNT(*) AS value FROM orders
UNION ALL
SELECT 'order_items',                COUNT(*)          FROM order_items
UNION ALL
SELECT 'payments',                   COUNT(*)          FROM payments
UNION ALL
SELECT 'customers active',           COUNT(*)          FROM customers WHERE deleted_at IS NULL;

Cách 2: Giữ dạng ngang nhưng hiển thị dọc trong MySQL CLI bằng \G thay cho ;:

SELECT
  (SELECT COUNT(*) FROM orders)      AS orders,
  (SELECT COUNT(*) FROM order_items) AS order_items,
  (SELECT COUNT(*) FROM payments)    AS payments\G
*************************** 1. row ***************************
     orders: 125430
order_items: 389112
   payments: 118207

Cách 3: CROSS JOIN các derived table, hữu ích khi mỗi bảng cần trả về nhiều con số:

SELECT *
FROM      (SELECT COUNT(*) AS orders, MAX(ordered_at) AS last_order FROM orders)   o
CROSS JOIN (SELECT COUNT(*) AS payments, MAX(paid_at) AS last_paid FROM payments)  p;
Dạng Phù hợp khi
Ngang (scalar subquery) Ít chỉ số (dưới khoảng 8), cần so sánh hoặc tính diff/ok giữa các cột
Dọc (UNION ALL) Nhiều chỉ số, hoặc so sánh nhiều bảng giữa hai môi trường
\G Đang dùng MySQL CLI, kết quả một dòng nhưng nhiều cột
CROSS JOIN derived table Mỗi bảng trả về nhiều con số cùng lúc

Những Lỗi Hay Gặp

1. Subquery returns more than 1 row (lỗi 1242)

Scalar subquery bắt buộc trả về tối đa một dòng:

-- Lỗi nếu khách hàng có nhiều đơn
SELECT (SELECT order_no FROM orders WHERE customer_id = 10) AS order_no;

-- Sửa: dùng hàm tổng hợp, hoặc ORDER BY + LIMIT 1
SELECT (SELECT MAX(order_no) FROM orders WHERE customer_id = 10)                AS latest_no,
       (SELECT order_no FROM orders WHERE customer_id = 10
        ORDER BY ordered_at DESC LIMIT 1)                                      AS last_order_no,
       (SELECT GROUP_CONCAT(order_no) FROM orders WHERE customer_id = 10)       AS all_order_no;

2. Không có dòng nào thì ra NULL, không phải 0

COUNT(*) luôn trả về một con số. Nhưng SUM, MAX, hay SELECT status FROM ... WHERE ... trả về NULL khi không có dòng. Phép tính với NULL ra NULL, và cột ok cũng thành NULL thay vì 0:

SELECT t.*, t.total_amount = t.paid_amount AS ok        -- ok = NULL nếu chưa thanh toán
FROM (SELECT
        (SELECT total_amount FROM orders   WHERE order_id = 123)          AS total_amount,
        (SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = 123) AS paid_amount
     ) t;

Dùng COALESCE(..., 0) cho các phép tổng, và <=> thay cho = khi hai bên có thể cùng là NULL.

3. Lấy số liệu ở các thời điểm khác nhau

Mỗi subquery là một lần đọc riêng. Trên hệ thống đang có dữ liệu ghi vào liên tục, orders và order_items có thể được đếm ở hai thời điểm chênh nhau vài mili giây, dẫn tới lệch giả. Nếu cần các con số nhất quán với nhau, chạy trong một transaction có snapshot:

START TRANSACTION WITH CONSISTENT SNAPSHOT;
SELECT (SELECT COUNT(*) FROM orders) AS orders,
       (SELECT COUNT(*) FROM order_items) AS order_items;
COMMIT;

Với InnoDB ở mức isolation mặc định REPEATABLE READ, mọi câu đọc trong transaction đều nhìn thấy cùng một snapshot.

Phần 2: Assertion Query — Biến Giả Thuyết Thành Một Câu Trả Lời Có/Không

Pattern quan trọng nhất của bài. Thay vì in ra dữ liệu rồi đọc bằng mắt, hãy viết query trả về kết luận.

Có hai dạng:

  • Dạng ok: trả về 1 dòng, có cột ok = 1 (đạt) hoặc 0 (không đạt), kèm các con số liên quan
  • Dạng "0 dòng lỗi": trả về các dòng vi phạm. Đạt khi kết quả rỗng

Ví Dụ: Số Hóa Đơn Mới Có Bị Trùng Không?

Hầu như hệ thống bán hàng nào cũng đánh số hóa đơn theo dạng INV-2026-000873: tiền tố, năm, và một số thứ tự tăng dần, bắt đầu lại từ 1 mỗi năm. Số thứ tự thường được sinh từ một bảng bộ đếm:

invoice_counters : year, last_no          -- mỗi năm một dòng
invoices         : invoice_id, invoice_no, order_id, issued_at
                   UNIQUE KEY uk_invoice_no (invoice_no)

Mỗi lần xuất hóa đơn, ứng dụng tăng last_no của năm hiện tại lên 1, rồi ghép thành invoice_no.

Bug: "Xuất hóa đơn báo lỗi Duplicate entry 'INV-2026-000451' for key 'uk_invoice_no'". Nguyên nhân hay gặp: có người import hóa đơn từ hệ thống cũ hoặc tạo hóa đơn bằng tay qua SQL mà không cập nhật bộ đếm. Bộ đếm "tụt" lại phía sau dữ liệu thật.

Giả thuyết: bộ đếm của năm nay phải lớn hơn hoặc bằng số thứ tự lớn nhất đã dùng trong năm. Nếu nhỏ hơn, hóa đơn tiếp theo sẽ bị trùng số.

SELECT (SELECT last_no FROM invoice_counters WHERE year = 2026)          AS counter,
       (SELECT MAX(CAST(SUBSTRING_INDEX(invoice_no, '-', -1) AS UNSIGNED))
          FROM invoices WHERE invoice_no LIKE 'INV-2026-%')              AS max_used,
       (SELECT last_no FROM invoice_counters WHERE year = 2026)
         >= (SELECT MAX(CAST(SUBSTRING_INDEX(invoice_no, '-', -1) AS UNSIGNED))
               FROM invoices WHERE invoice_no LIKE 'INV-2026-%')         AS ok;
counter  max_used  ok
450      873       0     ← bộ đếm tụt 423 số, đây là nguyên nhân bug

Giải thích:

  • Đây chính là kỹ thuật ở Phần 1: các scalar subquery gom những con số liên quan vào một dòng, thêm cột ok làm kết luận
  • SUBSTRING_INDEX(invoice_no, '-', -1) lấy phần sau dấu - cuối cùng ('000873'), CAST(... AS UNSIGNED) đổi thành số 873
  • LIKE 'INV-2026-%' chỉ xét hóa đơn của năm 2026, và dùng được index uk_invoice_no vì là tiền tố cố định
  • Giữ cả counter và max_used trong kết quả để biết lệch bao nhiêu, từ đó quyết định cách sửa (UPDATE invoice_counters SET last_no = 873 WHERE year = 2026)

Vì sao phải CAST? Nếu so sánh trực tiếp trên chuỗi, MAX() so theo thứ tự chữ cái. Khi số đã được đệm số 0 đủ độ dài (000873), kết quả vẫn đúng. Nhưng chỉ cần một hóa đơn import từ hệ thống cũ không đệm số 0 (INV-2026-99) là MAX() sẽ trả về '99', vì '9' > '0'. Luôn đổi sang số trước khi so sánh.

Kiểm tra thêm: những số nào sẽ bị trùng nếu xuất hóa đơn mới ngay bây giờ?

SELECT invoice_no, issued_at
FROM invoices
WHERE invoice_no LIKE 'INV-2026-%'
  AND CAST(SUBSTRING_INDEX(invoice_no, '-', -1) AS UNSIGNED)
      > (SELECT last_no FROM invoice_counters WHERE year = 2026)
ORDER BY invoice_no
LIMIT 20;

Cột issued_at của các dòng này thường cho biết ngay chúng được tạo cùng lúc (một lần import) hay rải rác (một luồng code tạo hóa đơn mà quên tăng bộ đếm).

Còn AUTO_INCREMENT thì sao? Với InnoDB, khi bạn insert một dòng có id lớn hơn bộ đếm, MySQL tự đẩy AUTO_INCREMENT lên, nên nó gần như không bao giờ bị tụt. Pattern kiểm tra ở trên cần cho các bộ đếm do ứng dụng tự quản lý: bảng counter như ví dụ, INCR trong Redis, hay SEQUENCE của MariaDB 10.3+ và PostgreSQL (insert giá trị tường minh không làm sequence tăng theo).

Gom Nhiều Assertion Thành Một Báo Cáo

Khi cần kiểm tra nhiều điều kiện, ví dụ sau mỗi lần deploy hoặc import, gom chúng bằng UNION ALL:

SELECT 'bộ đếm hóa đơn >= số đã dùng' AS check_name,
       (SELECT last_no FROM invoice_counters WHERE year = 2026)
         >= (SELECT MAX(CAST(SUBSTRING_INDEX(invoice_no, '-', -1) AS UNSIGNED))
               FROM invoices WHERE invoice_no LIKE 'INV-2026-%') AS ok
UNION ALL
SELECT 'không có email khách hàng trùng',
       NOT EXISTS (SELECT 1 FROM customers WHERE deleted_at IS NULL
                   GROUP BY email HAVING COUNT(*) > 1)
UNION ALL
SELECT 'mọi order_item trỏ tới sản phẩm tồn tại',
       NOT EXISTS (SELECT 1 FROM order_items oi
                   WHERE NOT EXISTS (SELECT 1 FROM products p
                                     WHERE p.product_id = oi.product_id))
UNION ALL
SELECT 'qty luôn > 0',
       NOT EXISTS (SELECT 1 FROM order_items WHERE qty <= 0);
check_name                                ok
bộ đếm hóa đơn >= số đã dùng              1
không có email khách hàng trùng           1
mọi order_item trỏ tới sản phẩm tồn tại   0   ← cần điều tra
qty luôn > 0                              1

Lưu query này vào repository (ví dụ docs/sql/health-check.sql). Nó trở thành một bộ test cho dữ liệu, chạy lại được trên mọi môi trường.

Phần 3: Kiểm Tra Toàn Vẹn Dữ Liệu

Trùng Lặp (Duplicate)

Tìm các giá trị bị trùng, kèm số lần và id để xử lý:

SELECT email,
       COUNT(*)                                        AS cnt,
       GROUP_CONCAT(customer_id ORDER BY customer_id)  AS customer_ids
FROM customers
WHERE deleted_at IS NULL
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY cnt DESC
LIMIT 50;

Trùng theo tổ hợp nhiều cột, ví dụ cùng một sản phẩm xuất hiện hai lần trong cùng một đơn (đáng lẽ phải gộp thành một dòng tăng qty):

SELECT order_id, product_id, COUNT(*) AS cnt
FROM order_items
GROUP BY order_id, product_id
HAVING COUNT(*) > 1;

Xem toàn bộ dòng của các bản ghi trùng (không chỉ khóa), dùng window function:

SELECT *
FROM (
  SELECT oi.*,
         COUNT(*)     OVER (PARTITION BY order_id, product_id) AS dup_cnt,
         ROW_NUMBER() OVER (PARTITION BY order_id, product_id ORDER BY item_id) AS rn
  FROM order_items oi
) t
WHERE dup_cnt > 1
ORDER BY order_id, product_id, rn;

Cột rn giúp quyết định giữ bản ghi nào (thường là rn = 1, bản tạo sớm nhất) khi cần dọn dẹp.

Trùng "ẩn" do collation và dữ liệu bẩn: với collation _ci, 'An@Example.com' và 'an@example.com' được coi là bằng nhau, nên GROUP BY email gộp chúng làm một. Nhưng ứng dụng có thể so sánh phân biệt hoa thường, hoặc dữ liệu có khoảng trắng thừa. Tìm các nhóm có nhiều cách viết khác nhau:

SELECT LOWER(TRIM(email))          AS normalized,
       COUNT(DISTINCT BINARY email) AS raw_variants,
       GROUP_CONCAT(DISTINCT email) AS variants
FROM customers
GROUP BY normalized
HAVING raw_variants > 1;

Bản Ghi Mồ Côi (Orphan)

Dòng đơn hàng trỏ tới sản phẩm không tồn tại (thường gặp khi không có foreign key, hoặc sản phẩm bị xóa cứng):

SELECT oi.item_id, oi.order_id, oi.product_id
FROM order_items oi
LEFT JOIN products p ON p.product_id = oi.product_id
WHERE p.product_id IS NULL
LIMIT 100;

Hoặc dùng NOT EXISTS, thường rõ ý hơn và xử lý đúng khi bảng con có nhiều dòng khớp:

SELECT oi.item_id, oi.order_id, oi.product_id
FROM order_items oi
WHERE NOT EXISTS (
  SELECT 1 FROM products p WHERE p.product_id = oi.product_id
)
LIMIT 100;

Tránh NOT IN với subquery có thể trả về NULL. WHERE product_id NOT IN (SELECT product_id FROM ...) sẽ trả về 0 dòng nếu subquery có bất kỳ giá trị NULL nào, vì x NOT IN (..., NULL) luôn là UNKNOWN. Đây là một trong những bug âm thầm nhất trong SQL. Dùng NOT EXISTS để an toàn.

Chiều ngược lại cũng quan trọng: cha không có con, ví dụ đơn hàng đã ở trạng thái paid nhưng không có bản ghi thanh toán nào:

SELECT o.order_no, o.status, o.ordered_at
FROM orders o
WHERE o.status = 'paid'
  AND o.deleted_at IS NULL
  AND NOT EXISTS (SELECT 1 FROM payments pm WHERE pm.order_id = o.order_id)
ORDER BY o.ordered_at
LIMIT 100;

Mỗi Nhóm Phải Có Đúng Một Bản Ghi "Hiện Hành"

Nhiều bảng lưu lịch sử bằng cờ, ví dụ product_prices giữ mọi mức giá đã từng áp dụng, và dòng có is_current = 1 là giá đang dùng. Ràng buộc: mỗi sản phẩm phải có đúng một giá hiện hành.

-- Sản phẩm có nhiều hơn 1 giá hiện hành
SELECT product_id, COUNT(*) AS current_cnt
FROM product_prices
WHERE is_current = 1
GROUP BY product_id
HAVING COUNT(*) > 1;

-- Sản phẩm không có giá hiện hành nào
SELECT product_id
FROM product_prices
GROUP BY product_id
HAVING SUM(is_current = 1) = 0;

Mẹo: trong MySQL, biểu thức so sánh trả về 1/0, nên SUM(is_current = 1) đếm số dòng thỏa điều kiện. Viết gọn hơn SUM(CASE WHEN is_current = 1 THEN 1 ELSE 0 END).

Câu thứ hai chỉ bắt được sản phẩm có lịch sử giá nhưng mất giá hiện hành. Sản phẩm chưa từng có dòng nào trong product_prices cần kiểm tra riêng bằng NOT EXISTS từ bảng products.

Giá Trị Không Hợp Lệ

Gom nhiều quy tắc nghiệp vụ vào một query "0 dòng lỗi", kèm cột lý do:

SELECT order_id, order_no, 'status không hợp lệ' AS reason
FROM orders WHERE status NOT IN ('pending', 'paid', 'shipped', 'cancelled')
UNION ALL
SELECT order_id, order_no, 'ordered_at ở tương lai'
FROM orders WHERE ordered_at > NOW()
UNION ALL
SELECT order_id, order_no, 'shipped_at trước ordered_at'
FROM orders WHERE shipped_at < ordered_at
UNION ALL
SELECT order_id, order_no, 'đã giao nhưng thiếu shipped_at'
FROM orders WHERE status = 'shipped' AND shipped_at IS NULL
UNION ALL
SELECT customer_id, email, 'email có khoảng trắng thừa'
FROM customers WHERE email <> TRIM(email)
LIMIT 200;

status NOT IN (...) không bắt được status IS NULL. Nếu NULL cũng là lỗi, thêm OR status IS NULL.

Tổng Cha Và Con Không Khớp

orders.total_amount được lưu sẵn để hiển thị nhanh, nhưng con số đúng là tổng các dòng trong order_items. Kiểm tra lệch:

SELECT o.order_no,
       o.total_amount                              AS stored_total,
       COALESCE(SUM(oi.qty * oi.unit_price), 0)    AS actual_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.deleted_at IS NULL
GROUP BY o.order_id, o.order_no, o.total_amount
HAVING stored_total <> actual_total
LIMIT 100;

COALESCE(SUM(...), 0) quan trọng: khi đơn không có dòng nào, SUM trả về NULL, và 100 <> NULL không bao giờ đúng, nên đơn đó sẽ bị bỏ sót.

Cùng pattern để so tổng thanh toán với tổng đơn hàng:

SELECT o.order_no, o.total_amount, COALESCE(SUM(pm.amount), 0) AS paid
FROM orders o
LEFT JOIN payments pm ON pm.order_id = o.order_id
WHERE o.status IN ('paid', 'shipped')
GROUP BY o.order_id, o.order_no, o.total_amount
HAVING paid <> o.total_amount;

Phần 4: Thống Kê Và Profiling Dữ Liệu

Trước khi sửa query hoặc thiết kế index, hãy hiểu dữ liệu trông như thế nào.

Profile Một Cột

Một query cho cái nhìn tổng quan về một cột:

SELECT COUNT(*)                                    AS total_rows,
       COUNT(phone)                                AS non_null,
       COUNT(*) - COUNT(phone)                     AS null_cnt,
       SUM(phone = '')                             AS empty_cnt,
       COUNT(DISTINCT phone)                       AS distinct_cnt,
       ROUND(COUNT(DISTINCT phone) / COUNT(*), 4)  AS selectivity,
       MIN(CHAR_LENGTH(phone))                     AS min_len,
       MAX(CHAR_LENGTH(phone))                     AS max_len,
       MIN(phone)                                  AS min_val,
       MAX(phone)                                  AS max_val
FROM customers;

Giải thích:

  • COUNT(col) không đếm NULL, còn COUNT(*) đếm mọi dòng. Hiệu của chúng là số NULL
  • selectivity gần 1 nghĩa là gần như mỗi dòng một giá trị (tốt cho index). Gần 0 nghĩa là ít giá trị khác nhau
  • min_len/max_len lệch nhau bất thường (số điện thoại thường 10 ký tự nhưng có dòng 3 ký tự hoặc 25 ký tự) là dấu hiệu dữ liệu rác
  • Dùng CHAR_LENGTH (đếm ký tự) thay vì LENGTH (đếm byte). Với tiếng Việt/tiếng Nhật utf8mb4, hai hàm cho kết quả rất khác nhau

Phân Bố Giá Trị (Top N)

SELECT status,
       COUNT(*)                                          AS cnt,
       ROUND(100 * COUNT(*) / SUM(COUNT(*)) OVER (), 2)  AS pct
FROM orders
GROUP BY status
ORDER BY cnt DESC;
status     cnt      pct
shipped    1030412  62.08
cancelled   512330  30.87
paid         98220   5.92
pending      19019   1.15

SUM(COUNT(*)) OVER () là window function chạy sau GROUP BY, tính tổng của tất cả nhóm, nên tính được phần trăm trong một query.

Một giá trị chiếm 60% bảng là data skew. Đây là thông tin quan trọng khi query chậm vì optimizer ước lượng sai (xem Index Nâng Cao).

Phân Bố Theo Thời Gian

-- Số đơn và doanh thu theo tháng
SELECT DATE_FORMAT(ordered_at, '%Y-%m') AS month,
       COUNT(*)                         AS orders,
       SUM(total_amount)                AS revenue
FROM orders
WHERE ordered_at >= '2026-01-01'
  AND status <> 'cancelled'
GROUP BY month
ORDER BY month;

Đột biến bất thường (một tháng tăng gấp 10 lần) thường do batch import chạy hai lần. Tháng có 0 dòng sẽ không xuất hiện trong kết quả. Nếu cần thấy cả tháng trống, sinh dãy tháng bằng recursive CTE rồi LEFT JOIN:

WITH RECURSIVE months AS (
  SELECT DATE('2026-01-01') AS m
  UNION ALL
  SELECT m + INTERVAL 1 MONTH FROM months WHERE m < '2026-10-01'
)
SELECT DATE_FORMAT(months.m, '%Y-%m') AS month,
       COUNT(o.order_id)              AS orders
FROM months
LEFT JOIN orders o
       ON o.ordered_at >= months.m
      AND o.ordered_at <  months.m + INTERVAL 1 MONTH
GROUP BY months.m
ORDER BY months.m;

Điều kiện ordered_at >= ... AND ordered_at < ... thay vì DATE_FORMAT(ordered_at, ...) = ... giúp dùng được index trên ordered_at.

Phân Bố Kích Thước Nhóm

"Mỗi đơn hàng có bao nhiêu sản phẩm?" Câu trả lời không phải một con số, mà là một phân bố:

SELECT items_per_order, COUNT(*) AS order_cnt
FROM (
  SELECT order_id, COUNT(*) AS items_per_order
  FROM order_items
  GROUP BY order_id
) t
GROUP BY items_per_order
ORDER BY items_per_order;
items_per_order  order_cnt
1                52310
2                21880
3                 9410
...
350                  2   ← đơn bất thường, cần xem

Những nhóm cực lớn ở cuối danh sách thường là nơi gây ra các bug hiệu năng: trang chi tiết đơn chạy nhanh với đơn bình thường nhưng timeout với đơn có 350 sản phẩm (thường là đơn của khách sỉ, hoặc dữ liệu test quên xóa).

Percentile (P50, P95, P99)

Giá trị trung bình dễ gây hiểu lầm. Với giá trị đơn hàng, thời gian giao hàng, thời gian xử lý... nên xem percentile:

SELECT
  MIN(CASE WHEN pr >= 0.50 THEN total_amount END) AS p50,
  MIN(CASE WHEN pr >= 0.95 THEN total_amount END) AS p95,
  MIN(CASE WHEN pr >= 0.99 THEN total_amount END) AS p99,
  MAX(total_amount)                               AS max_amount,
  ROUND(AVG(total_amount), 2)                     AS avg_amount
FROM (
  SELECT total_amount, CUME_DIST() OVER (ORDER BY total_amount) AS pr
  FROM orders
  WHERE ordered_at >= '2026-01-01' AND status <> 'cancelled'
) t;

CUME_DIST() trả về tỷ lệ dòng có giá trị nhỏ hơn hoặc bằng giá trị hiện tại. Giá trị nhỏ nhất có pr >= 0.95 chính là P95.

Áp dụng tương tự cho thời gian giao hàng: thay total_amount bằng TIMESTAMPDIFF(HOUR, ordered_at, shipped_at).

MariaDB 10.3+ có sẵn PERCENTILE_CONT và MEDIAN() dạng window function. MySQL chưa có, nên dùng cách trên.

Top N Mỗi Nhóm

"3 đơn hàng có giá trị lớn nhất của mỗi khách hàng":

WITH ranked AS (
  SELECT customer_id, order_no, total_amount,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total_amount DESC) AS rn
  FROM orders
  WHERE deleted_at IS NULL
)
SELECT customer_id, order_no, total_amount
FROM ranked
WHERE rn <= 3
ORDER BY customer_id, rn;

Tìm Khoảng Trống Trong Dãy Số

Mã đơn hàng phải liên tục, nhưng nghi có đơn bị xóa nhầm:

SELECT prev_no + 1 AS gap_start,
       cur_no - 1  AS gap_end,
       cur_no - prev_no - 1 AS missing_cnt
FROM (
  SELECT CAST(order_no AS UNSIGNED) AS cur_no,
         LAG(CAST(order_no AS UNSIGNED)) OVER (ORDER BY CAST(order_no AS UNSIGNED)) AS prev_no
  FROM orders
) t
WHERE cur_no - prev_no > 1
ORDER BY gap_start
LIMIT 100;

LAG() lấy giá trị của dòng ngay trước theo thứ tự chỉ định. Hai mã liền nhau cách nhau hơn 1 nghĩa là có khoảng trống.

Phần 5: So Sánh Dữ Liệu

So sánh là việc thường gặp nhất khi migration, refactor query, đồng bộ giữa hệ thống hoặc kiểm tra trước/sau khi fix.

Ví dụ trong phần này: hệ thống cũ (legacy_db) được migrate sang hệ thống mới (app_db), và cần chứng minh dữ liệu đơn hàng không bị mất hay sai lệch.

Bước 1: So Sánh Số Dòng

Luôn bắt đầu bằng con số đơn giản nhất:

SELECT 'legacy' AS src, COUNT(*) AS cnt FROM legacy_db.orders WHERE deleted_at IS NULL
UNION ALL
SELECT 'new',           COUNT(*)        FROM app_db.orders    WHERE deleted_at IS NULL;

Số dòng khớp chưa có nghĩa là dữ liệu khớp. Nhưng số dòng lệch thì chắc chắn có vấn đề, và cho biết cần tìm thiếu hay tìm thừa.

Bước 2: So Sánh Theo Nhóm Để Khoanh Vùng

Khi tổng số lệch, chia nhỏ theo một chiều (tháng, trạng thái, cửa hàng) để tìm chỗ lệch:

SELECT a.month,
       a.cnt AS legacy_cnt,
       b.cnt AS new_cnt,
       COALESCE(b.cnt, 0) - a.cnt AS diff
FROM (SELECT DATE_FORMAT(ordered_at, '%Y-%m') AS month, COUNT(*) AS cnt
      FROM legacy_db.orders GROUP BY month) a
LEFT JOIN (SELECT DATE_FORMAT(ordered_at, '%Y-%m') AS month, COUNT(*) AS cnt
           FROM app_db.orders GROUP BY month) b
       ON b.month = a.month
WHERE NOT (a.cnt <=> b.cnt)
ORDER BY a.month;

Nếu chỉ tháng 2026-03 lệch, phạm vi điều tra đã thu nhỏ từ hàng triệu dòng xuống vài chục nghìn. Query trên lấy legacy làm gốc. Nếu bảng mới có thể có tháng mà bảng cũ không có, chạy thêm chiều ngược lại.

Toán tử <=> là so sánh an toàn với NULL: NULL <=> NULL là 1, 1 <=> NULL là 0. Với = hoặc <>, mọi so sánh có NULL đều ra NULL, khiến dòng bị lọc mất.

Bước 3: So Sánh Từng Dòng Bằng Hash

Tạo "dấu vân tay" cho mỗi dòng từ các cột cần so sánh, rồi so sánh hash:

WITH a AS (
  SELECT order_no,
         MD5(CONCAT_WS('|', IFNULL(customer_id, '\\N'), IFNULL(status, '\\N'),
                            IFNULL(total_amount, '\\N'), IFNULL(ordered_at, '\\N'))) AS h
  FROM legacy_db.orders
),
b AS (
  SELECT order_no,
         MD5(CONCAT_WS('|', IFNULL(customer_id, '\\N'), IFNULL(status, '\\N'),
                            IFNULL(total_amount, '\\N'), IFNULL(ordered_at, '\\N'))) AS h
  FROM app_db.orders
)
SELECT a.order_no, 'khác giá trị' AS diff_type
FROM a JOIN b USING (order_no)
WHERE a.h <> b.h
UNION ALL
SELECT a.order_no, 'chỉ có ở legacy'
FROM a LEFT JOIN b USING (order_no)
WHERE b.order_no IS NULL
UNION ALL
SELECT b.order_no, 'chỉ có ở new'
FROM b LEFT JOIN a USING (order_no)
WHERE a.order_no IS NULL
LIMIT 500;

Giải thích:

  • MySQL không có FULL OUTER JOIN, nên ghép ba phần bằng UNION ALL: khác giá trị, chỉ có bên trái, chỉ có bên phải
  • IFNULL(col, '\\N') là bắt buộc. CONCAT_WS bỏ qua giá trị NULL, nên CONCAT_WS('|', NULL, 'a') và CONCAT_WS('|', 'a', NULL) đều ra 'a', hai dòng khác nhau sẽ có cùng hash. Thay NULL bằng một ký hiệu cố định để tránh va chạm
  • Cùng một giá trị nhưng kiểu dữ liệu khác nhau giữa hai bảng (DECIMAL(10,2) với INT, DATETIME với DATE) sẽ cho chuỗi khác nhau ('5.00' với '5'). Hãy chuẩn hóa bằng CAST hoặc DATE_FORMAT trước khi hash

Sau khi tìm được các dòng khác giá trị, xem chi tiết từng cột:

SELECT a.order_no,
       a.status        AS legacy_status,  b.status        AS new_status,
       a.total_amount  AS legacy_total,   b.total_amount  AS new_total
FROM legacy_db.orders a
JOIN app_db.orders b USING (order_no)
WHERE NOT (a.status <=> b.status)
   OR NOT (a.total_amount <=> b.total_amount)
LIMIT 100;

Dùng EXCEPT (MySQL 8.0.31+, MariaDB 10.3+)

Với version mới, EXCEPT viết gọn hơn nhiều:

-- Dòng có ở legacy nhưng không có (hoặc khác) ở new
SELECT order_no, customer_id, status, total_amount, ordered_at FROM legacy_db.orders
EXCEPT
SELECT order_no, customer_id, status, total_amount, ordered_at FROM app_db.orders;

Chạy thêm chiều ngược lại để thấy dòng thừa ở bảng mới. EXCEPT coi hai NULL là bằng nhau, nên không gặp vấn đề NULL như khi dùng =.

So Sánh Bảng Lớn: Checksum Theo Khoảng

Với bảng hàng chục triệu dòng, so sánh từng dòng giữa hai server rất tốn. Cách làm của các tool như pt-table-checksum: chia bảng thành các khoảng theo khóa chính, tính checksum cho từng khoảng, chỉ đi sâu vào khoảng bị lệch.

-- Chạy trên cả hai DB, rồi so sánh kết quả
SELECT FLOOR(order_id / 100000)                                         AS bucket,
       COUNT(*)                                                         AS cnt,
       SUM(CRC32(CONCAT_WS('|', order_no, IFNULL(status, '\\N'),
                                IFNULL(total_amount, '\\N'))))           AS chk
FROM orders
GROUP BY bucket
ORDER BY bucket;

Bucket nào có cnt hoặc chk khác nhau thì so sánh từng dòng trong khoảng đó. Hai mươi bucket 100k dòng sẽ nhanh hơn rất nhiều so với một phép so sánh 2 triệu dòng.

Nếu chỉ cần biết hai bảng có giống hệt nhau không, CHECKSUM TABLE là cách nhanh nhất:

CHECKSUM TABLE app_dev.orders, app_stg.orders;

Lưu ý: kết quả phụ thuộc vào cấu trúc bảng và row format. Hai bảng có dữ liệu giống nhau nhưng thứ tự cột hoặc kiểu dữ liệu khác nhau sẽ cho checksum khác nhau.

So Sánh Kết Quả Query Cũ Và Mới (Refactor An Toàn)

Khi viết lại một query để tối ưu (ví dụ thêm STRAIGHT_JOIN, đổi JOIN thành EXISTS, thêm điều kiện để optimizer chọn index tốt hơn), phải chứng minh kết quả không đổi:

-- Lưu kết quả của query cũ và query mới vào hai bảng nháp
CREATE TABLE tmp_r_old AS
  SELECT o.order_id FROM /* ... query cũ ... */;

CREATE TABLE tmp_r_new AS
  SELECT o.order_id FROM /* ... query mới ... */;

-- Assertion: số dòng bằng nhau và không có id nào lệch
SELECT (SELECT COUNT(*) FROM tmp_r_old) AS old_cnt,
       (SELECT COUNT(*) FROM tmp_r_new) AS new_cnt,
       (SELECT COUNT(*) FROM tmp_r_old r1
         WHERE NOT EXISTS (SELECT 1 FROM tmp_r_new r2 WHERE r2.order_id = r1.order_id)) AS only_old,
       (SELECT COUNT(*) FROM tmp_r_new r2
         WHERE NOT EXISTS (SELECT 1 FROM tmp_r_old r1 WHERE r1.order_id = r2.order_id)) AS only_new;

DROP TABLE tmp_r_old, tmp_r_new;

Kết quả mong đợi: old_cnt = new_cnt, only_old = 0, only_new = 0.

Ở đây dùng bảng thường (đặt trong schema nháp nếu có), không dùng CREATE TEMPORARY TABLE. Lý do: MySQL không cho mở cùng một TEMPORARY TABLE nhiều lần trong một câu query (lỗi Can't reopen table), mà câu assertion trên đọc mỗi bảng ba lần. MariaDB 10.2+ không có giới hạn này. Nhớ DROP sau khi xong.

Hãy chạy so sánh với nhiều bộ filter khác nhau: không filter, filter phổ biến, filter cho tập rất nhỏ, filter cho tập rất lớn, các trường hợp biên (NULL, chuỗi rỗng). Query tối ưu thường sai ở các trường hợp biên.

Nếu query có ORDER BY ... LIMIT, so sánh cả thứ tự. Hai query có thể trả về cùng tập id nhưng thứ tự khác nhau khi cột sort có giá trị trùng. Đó là lý do nên luôn có cột tie-break (ví dụ ORDER BY ordered_at DESC, order_id DESC).

Phần 6: Query Hỗ Trợ Test

Tìm Data Test Theo Điều Kiện

QA cần "một khách hàng có ít nhất 3 đơn, trong đó có đơn đã hủy và có đơn nhiều hơn 5 sản phẩm" để test màn hình lịch sử mua hàng. Thay vì tự tạo data, hãy tìm trong dữ liệu sẵn có:

SELECT o.customer_id,
       COUNT(DISTINCT o.order_id)                       AS order_cnt,
       COUNT(DISTINCT CASE WHEN o.status = 'cancelled'
                           THEN o.order_id END)         AS cancelled_cnt,
       MAX(ic.item_cnt)                                 AS max_items
FROM orders o
JOIN (SELECT order_id, COUNT(*) AS item_cnt
      FROM order_items GROUP BY order_id) ic
  ON ic.order_id = o.order_id
WHERE o.deleted_at IS NULL
GROUP BY o.customer_id
HAVING order_cnt >= 3
   AND cancelled_cnt > 0
   AND max_items > 5
ORDER BY order_cnt          -- ít đơn nhất trước: dễ kiểm tra bằng mắt
LIMIT 10;

Mỗi điều kiện test là một cột đếm. HAVING lọc các khách hàng thỏa tất cả điều kiện. Sắp xếp theo order_cnt tăng dần để chọn data nhỏ nhất, dễ đối chiếu bằng tay nhất.

Tìm Data Cho Trường Hợp Biên

-- Đơn có nhiều sản phẩm nhất (test hiệu năng, phân trang)
SELECT order_id, COUNT(*) AS item_cnt
FROM order_items
GROUP BY order_id ORDER BY item_cnt DESC LIMIT 5;

-- Tên sản phẩm dài nhất (test layout, cắt chữ)
SELECT product_id, name, CHAR_LENGTH(name) AS len
FROM products
ORDER BY len DESC LIMIT 5;

-- Dữ liệu có ký tự đặc biệt (test escape, CSV, encoding)
SELECT product_id, name
FROM products
WHERE name LIKE '%,%' OR name LIKE '%"%' OR name LIKE '%\n%'
   OR name REGEXP '[①-⑳㈱]'
   OR name <> CONVERT(CONVERT(name USING ascii) USING utf8mb4)   -- có ký tự ngoài ASCII
LIMIT 10;

Nhóm query cuối đặc biệt hữu ích khi hệ thống phải export CSV cho Excel hoặc cho đối tác: dấu phẩy, dấu nháy kép, xuống dòng trong dữ liệu làm vỡ cột, còn emoji hay ký tự như ①, ㈱ thường lỗi khi chuyển sang encoding khác UTF-8 (ví dụ Shift_JIS cho khách Nhật).

Snapshot Trước Và Sau Khi Fix

Bug: một số đơn đã có shipped_at nhưng status vẫn là paid. Trước khi chạy UPDATE sửa dữ liệu, chụp lại trạng thái để có thể so sánh và khôi phục:

-- 1. Xem trước những dòng sẽ bị ảnh hưởng (cùng WHERE với UPDATE)
SELECT COUNT(*) FROM orders WHERE status = 'paid' AND shipped_at IS NOT NULL;

-- 2. Backup đúng những dòng đó
CREATE TABLE bk_orders_20261104 AS
SELECT * FROM orders WHERE status = 'paid' AND shipped_at IS NOT NULL;

-- 3. Chạy UPDATE trong transaction, kiểm tra số dòng trước khi COMMIT
START TRANSACTION;
UPDATE orders SET status = 'shipped'
WHERE status = 'paid' AND shipped_at IS NOT NULL;
SELECT ROW_COUNT();          -- phải bằng con số ở bước 1
-- COMMIT;  hoặc  ROLLBACK;

-- 4. Verify sau khi COMMIT: so với bản backup
SELECT b.order_id, b.status AS before_status, o.status AS after_status
FROM bk_orders_20261104 b
JOIN orders o USING (order_id)
WHERE NOT (b.status <=> 'paid' AND o.status <=> 'shipped');
-- Mong đợi: 0 dòng

Giải thích:

  • Bước 1 và UPDATE dùng cùng điều kiện WHERE. Copy-paste, đừng gõ lại
  • Bảng backup đặt tên kèm ngày để biết khi nào có thể xóa
  • ROW_COUNT() cho biết số dòng thực sự bị thay đổi. Nếu khác với bước 1, ROLLBACK và điều tra
  • Bước 4 là một assertion query "0 dòng lỗi": mọi dòng trong backup phải từ paid thành shipped

Sau đó chạy lại các assertion ở Phần 2 để chắc chắn fix không làm hỏng ràng buộc khác.

Bảng Tra Nhanh

Câu hỏi Kỹ thuật chính
Điều kiện X có đúng không? Scalar subquery + cột ok, hoặc query "0 dòng lỗi"
Có trùng lặp không? GROUP BY ... HAVING COUNT(*) > 1, COUNT(*) OVER (PARTITION BY ...)
Có bản ghi mồ côi không? NOT EXISTS (tránh NOT IN với NULL)
Tổng cha/con có khớp không? LEFT JOIN + COALESCE(SUM(...), 0) + HAVING
Dữ liệu phân bố thế nào? COUNT(*), COUNT(DISTINCT), SUM(COUNT(*)) OVER ()
P95 là bao nhiêu? CUME_DIST()
Top N mỗi nhóm? ROW_NUMBER() OVER (PARTITION BY ...)
Dãy số có bị thủng không? LAG()
Tháng nào không có dữ liệu? Recursive CTE sinh dãy + LEFT JOIN
Hai bảng có khớp không? Đếm → chia nhóm → hash từng dòng, EXCEPT, <=>
Bảng lớn lệch ở đâu? Checksum theo bucket khóa chính
Query mới có cùng kết quả? Lưu hai kết quả vào bảng nháp, assert only_old = only_new = 0
Tìm data test? Mỗi điều kiện một cột đếm + HAVING
Sửa data an toàn? Preview → backup → transaction + ROW_COUNT() → verify

Những Lỗi Hay Gặp Trong Query Điều Tra

Lỗi Hậu quả Cách tránh
NOT IN (subquery) có NULL Trả về 0 dòng, tưởng là không có lỗi Dùng NOT EXISTS
So sánh bằng =/<> với NULL Dòng bị lọc mất âm thầm Dùng <=> hoặc IS NULL
SUM() trên tập rỗng Ra NULL thay vì 0 COALESCE(SUM(x), 0)
MAX() trên cột VARCHAR chứa số '99' > '100' MAX(CAST(col AS UNSIGNED))
CONCAT_WS với NULL khi hash Hai dòng khác nhau cùng hash IFNULL(col, '\\N')
LENGTH() với tiếng Việt/Nhật Đếm byte, không đếm ký tự CHAR_LENGTH()
Quên điều kiện soft delete (deleted_at IS NULL) Đếm cả dữ liệu đã xóa Đưa vào mọi query, hoặc dùng view
Hàm trên cột trong WHERE Không dùng được index, quét toàn bảng Viết điều kiện dạng range
Tin TABLE_ROWS là số chính xác Sai lệch có thể đến 40-50% với InnoDB Dùng COUNT(*) khi cần chính xác

Tổng Kết

Query điều tra tốt có ba đặc điểm:

  1. Trả về kết luận, không chỉ dữ liệu. Cột ok, kết quả "0 dòng lỗi", cột diff. Người đọc không phải tự so sánh
  2. Đi từ thô đến chi tiết. Đếm tổng, chia nhóm để khoanh vùng, rồi mới so sánh từng dòng
  3. Chạy lại được. Lưu vào ticket hoặc repository, dùng lại để verify sau khi fix và trên các môi trường khác

Với ví dụ số hóa đơn ở Phần 2, một câu query đã trả lời được cả nguyên nhân (bộ đếm tụt 423 số), mức độ ảnh hưởng (số nào sẽ bị trùng) và tiêu chí nghiệm thu sau khi sửa (ok = 1). Đó là sức mạnh của việc biến một giả thuyết thành một câu SQL.

Tham Khảo