- Tác giả

- Name
- Nguyễn Đức Xinh
- 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.
- Ư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
- Đặ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; - 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; EXPLAINtrước khi chạy query trên bảng lớn, đặc biệt query cóJOINhoặcNOT EXISTS- Luôn có
LIMITkhi mục đích chỉ là xem mẫu - 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 50và index trênorders.customer_idthì rất nhanh. Với hàng trăm nghìn dòng, hãy chuyển sangLEFT 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ộtok = 1(đạt) hoặc0(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
oklà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ố873LIKE 'INV-2026-%'chỉ xét hóa đơn của năm 2026, và dùng được indexuk_invoice_novì là tiền tố cố định- Giữ cả
countervàmax_usedtrong 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_INCREMENTthì sao? Với InnoDB, khi bạn insert một dòng cóidlớn hơn bộ đếm, MySQL tự đẩyAUTO_INCREMENTlê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ụ,INCRtrong Redis, haySEQUENCEcủ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 INvớ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ịNULLnà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ùngNOT 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 đếmNULL, cònCOUNT(*)đếm mọi dòng. Hiệu của chúng là sốNULLselectivitygầ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 nhaumin_len/max_lenlệ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_CONTvà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ằngUNION 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_WSbỏ qua giá trịNULL, nênCONCAT_WS('|', NULL, 'a')vàCONCAT_WS('|', 'a', NULL)đều ra'a', hai dòng khác nhau sẽ có cùng hash. ThayNULLbằ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ớiINT,DATETIMEvớiDATE) sẽ cho chuỗi khác nhau ('5.00'với'5'). Hãy chuẩn hóa bằngCASThoặcDATE_FORMATtrướ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ộtTEMPORARY TABLEnhiều lần trong một câu query (lỗiCan'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ớDROPsau 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,ROLLBACKvà đ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ừ
paidthànhshipped
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:
- Trả về kết luận, không chỉ dữ liệu. Cột
ok, kết quả "0 dòng lỗi", cộtdiff. Người đọc không phải tự so sánh - Đ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
- 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
- MySQL 8.0 Reference: Window Functions
- MySQL 8.0 Reference: WITH (Common Table Expressions)
- MySQL 8.0 Reference: EXCEPT Clause
- MySQL 8.0 Reference: NULL-safe equal
<=> - MySQL 8.0 Reference: Optimizer Hints (MAX_EXECUTION_TIME)
- Percona Toolkit: pt-table-checksum
- Bài liên quan trong khóa học: Troubleshooting MySQL, Index Nâng Cao, Information Schema, Common Table Expressions
