Mở đầu — vì sao bài này quan trọng
Khi bạn là BA (Business Analyst) và bắt đầu tự viết query để làm report, có một sự thật phũ phàng: một query "chạy ra số" hoàn toàn khác với một query "ra đúng số". Bạn có thể bấm Run, thấy kết quả trả về, copy vào Excel hay dashboard, gửi cho sếp — và mọi thứ trông rất ổn. Cho đến khi ai đó phát hiện con số doanh thu bị thổi phồng gấp đôi, hoặc report chạy 4 phút mỗi lần refresh làm cả phòng phải chờ.
Antipattern là những cách viết SQL "trông hợp lý nhưng sai" hoặc "chạy được nhưng tệ". Chúng đặc biệt nguy hiểm với BA vì BA thường không có background kỹ thuật sâu, không ai review query, và quan trọng nhất: kết quả query của bạn trực tiếp ảnh hưởng đến quyết định kinh doanh. Một developer viết sai code thì app bị bug và thường có người phát hiện; một BA viết sai query thì sếp ra quyết định sai mà không ai biết.
Bài này tổng hợp những antipattern phổ biến nhất mà tôi đã chứng kiến hàng trăm lần khi mentor các bạn BA — không phải để bạn nhớ vẹt, mà để khi viết report bạn có một "radar cảnh báo" tự bật lên: "Khoan, chỗ này có khi nào tôi đang mắc lỗi kinh điển không?". Đây không phải bài dạy cú pháp mới — bạn đã học JOIN, GROUP BY, subquery ở các bài trước. Đây là bài dạy bạn cách không phá hỏng những thứ đó.
Khái niệm cốt lõi
Antipattern trong SQL cho BA chia thành ba nhóm chính: sai về tính đúng đắn (số ra sai), tệ về hiệu năng (query chậm, tốn tài nguyên), và dở về khả năng bảo trì (query không ai đọc nổi, sửa một chỗ vỡ chỗ khác). Tôi sẽ đi qua những antipattern nguy hiểm nhất theo từng nhóm.
1. SELECT * trong report production
Đây là antipattern kinh điển nhất.
-- Antipattern
SELECT * FROM orders WHERE created_at >= '2026-01-01';-- Tốt hơn
SELECT id, customer_id, total_amount, status, created_at
FROM orders
WHERE created_at >= '2026-01-01';
SELECT * có vẻ tiện, nhưng trong report nó gây ba vấn đề: (1) kéo về cả những cột nặng không cần (ví dụ cột raw_payload dạng JSON, hay description text dài), làm query chậm và tốn băng thông; (2) khi ai đó thêm/đổi cột trong bảng, report của bạn âm thầm thay đổi cấu trúc, dễ vỡ dashboard; (3) người đọc query không biết bạn thực sự cần gì. Trong report, hãy luôn liệt kê cột rõ ràng.
2. Nhân đôi dữ liệu vì JOIN sai cardinality
Đây là antipattern nguy hiểm nhất về tính đúng đắn và là thủ phạm số một làm sai con số.
-- Antipattern: SUM bị thổi phồng
SELECT o.customer_id, SUM(o.total_amount) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.customer_id;
Vấn đề: một đơn hàng có nhiều dòng order_items. Khi JOIN, mỗi đơn bị nhân lên theo số dòng sản phẩm. SUM(o.total_amount) vì thế bị nhân lên gấp nhiều lần. Đơn 500.000đ có 5 sản phẩm sẽ được tính thành 2.500.000đ. Đây là lỗi "fan-out": JOIN với bảng có quan hệ một-nhiều sẽ làm nhân bản các hàng ở bảng "một".
Cách đúng: aggregate ở đúng cấp độ. Nếu chỉ cần doanh thu theo khách, đừng JOIN order_items làm gì. Nếu cần cả hai, hãy aggregate order_items riêng trong subquery/CTE trước khi JOIN.
3. Filter cột bảng phải trong WHERE của LEFT JOIN
-- Antipattern: LEFT JOIN bị biến thành INNER JOIN
SELECT c.id, c.name, o.total_amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'completed';
Bạn muốn lấy tất cả khách hàng kèm đơn completed (nếu có). Nhưng điều kiện WHERE o.status = 'completed' loại bỏ luôn những khách chưa có đơn nào — vì với họ o.status là NULL và NULL không thỏa = 'completed'. LEFT JOIN của bạn vô tình biến thành INNER JOIN. Cách đúng: đưa điều kiện vào mệnh đề ON (LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'completed').
4. COUNT(*) khi có JOIN một-nhiều
Tương tự lỗi fan-out: COUNT() sau JOIN đếm số hàng sau khi nhân bản*, không phải số thực thể bạn nghĩ. Muốn đếm số khách hàng duy nhất, dùng COUNT(DISTINCT c.id); muốn đếm số đơn, đừng đếm trên bảng đã JOIN với order_items.
5. Hàm bọc quanh cột trong WHERE (giết index)
-- Antipattern: không dùng được index trên created_at
WHERE DATE(created_at) = '2026-06-01'-- Tốt hơn: giữ cột "trần", dịch điều kiện sang hằng số
WHERE created_at >= '2026-06-01' AND created_at < '2026-06-02'
Khi bạn bọc hàm quanh cột (DATE(), YEAR(), UPPER()...), database phải tính hàm đó cho từng hàng và không thể dùng index — full table scan. Quy tắc vàng: để cột ở dạng nguyên bản một bên dấu so sánh, đẩy mọi phép biến đổi sang phía hằng số.
6. Lạm dụng DISTINCT để "che" lỗi nhân bản
Khi thấy kết quả bị lặp, phản xạ sai là thêm DISTINCT cho hết lặp. Nhưng DISTINCT chỉ giấu triệu chứng — nó không sửa nguyên nhân (JOIN sai). Tệ hơn, nếu bạn đang SUM, DISTINCT không cứu được số bị thổi phồng. Khi thấy cần DISTINCT, hãy dừng lại hỏi: "Tại sao lại có bản sao? JOIN của tôi có sai cardinality không?".
7. So sánh với NULL bằng dấu =
WHERE status = NULL luôn trả về rỗng. NULL không bằng gì cả, kể cả chính nó. Phải dùng IS NULL / IS NOT NULL. Tương tự, WHERE status != 'cancelled' sẽ loại bỏ cả những hàng có status là NULL — một cái bẫy âm thầm khi bạn nghĩ "mọi đơn không bị hủy".
Tình huống thực tế
Ví dụ 1: Doanh thu Tiki bị thổi phồng 3 lần
Một bạn BA ở một sàn TMĐT (giả định kiểu Tiki) được giao làm report doanh thu theo danh mục sản phẩm cho Q2/2026. Bạn ấy viết:
SELECT cat.name, SUM(o.total_amount) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
WHERE o.created_at >= '2026-04-01'
GROUP BY cat.name;
Report ra tổng doanh thu 47 tỷ. Phòng tài chính đối chiếu sổ sách thì chỉ có 16 tỷ. Chênh gần 3 lần. Sếp gần như mất niềm tin vào cả team data.
Diễn giải: total_amount nằm ở cấp đơn hàng, nhưng query JOIN xuống tận order_items. Mỗi đơn trung bình có ~3 dòng sản phẩm, nên mỗi total_amount bị cộng 3 lần. Cách sửa là aggregate đúng cấp: phân bổ doanh thu theo dòng sản phẩm bằng oi.quantity * oi.unit_price thay vì o.total_amount, vì khi đã xuống cấp item thì phải dùng số liệu của item.
SELECT cat.name, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
JOIN categories cat ON cat.id = p.category_id
WHERE o.created_at >= '2026-04-01'
GROUP BY cat.name;
Bài học: Trước khi SUM một cột, hãy luôn hỏi: "Cột này sống ở cấp độ nào (đơn hàng? dòng sản phẩm? khách hàng?), và sau JOIN nó có bị nhân bản không?". Đây là kỹ năng quan trọng nhất khi làm report tiền bạc.
Ví dụ 2: Báo cáo khách hàng "biến mất" tại một fintech
Một bạn BA ở ví điện tử (giả định kiểu MoMo) làm report đếm số người dùng đã đăng ký, kèm tổng giá trị giao dịch tháng 6. Mục tiêu là tất cả user, kể cả người chưa giao dịch (để tính tỷ lệ user active). Bạn viết:
SELECT u.id, u.full_name, SUM(t.amount) AS total_txn
FROM users u
LEFT JOIN transactions t ON t.user_id = u.id
WHERE t.created_at >= '2026-06-01'
GROUP BY u.id, u.full_name;
Report ra 120.000 user. Nhưng marketing biết rõ hệ thống có 450.000 user. Tỷ lệ active bị tính sai hoàn toàn, dẫn đến quyết định đổ thêm ngân sách re-engagement nhầm chỗ.
Diễn giải: Hai lỗi chồng nhau. Thứ nhất, WHERE t.created_at >= '2026-06-01' áp lên bảng phải của LEFT JOIN, biến nó thành INNER JOIN — mọi user chưa có giao dịch tháng 6 bị loại. Thứ hai, ngay cả user có giao dịch tháng khác cũng bị loại. Sửa lại bằng cách đẩy điều kiện vào ON:
SELECT u.id, u.full_name, SUM(t.amount) AS total_txn
FROM users u
LEFT JOIN transactions t
ON t.user_id = u.id
AND t.created_at >= '2026-06-01'
AND t.created_at < '2026-07-01'
GROUP BY u.id, u.full_name;
Bây giờ user không giao dịch vẫn xuất hiện với total_txn là NULL — đúng như mong muốn để tính tỷ lệ active.
Bài học: Với LEFT JOIN, điều kiện lọc bảng bên phải gần như luôn phải nằm trong ON, không phải WHERE. Chỉ đặt vào WHERE khi bạn cố ý muốn lọc bỏ cả hàng không khớp (lúc đó dùng INNER JOIN cho rõ ràng hơn).
Ví dụ 3: Dashboard Be bị chậm vì hàm trong WHERE
Một bạn BA ở nền tảng gọi xe (giả định kiểu Be) làm dashboard đếm số chuyến theo ngày trong tháng. Bảng trips có 80 triệu hàng và có index trên created_at. Bạn viết:
SELECT DATE(created_at) AS d, COUNT(*) AS trips
FROM trips
WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 6
GROUP BY DATE(created_at);
Query chạy 95 giây mỗi lần refresh. Cả team than dashboard "treo".
Diễn giải: YEAR(created_at) và MONTH(created_at) bọc hàm quanh cột có index, nên database phải quét toàn bộ 80 triệu hàng để tính hàm cho từng hàng. Index trên created_at trở nên vô dụng. Sửa lại bằng range so sánh với hằng số:
SELECT CAST(created_at AS DATE) AS d, COUNT(*) AS trips
FROM trips
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01'
GROUP BY CAST(created_at AS DATE);
Với điều kiện này, database dùng được index để nhảy thẳng đến khoảng ngày cần thiết. Thời gian chạy giảm từ 95 giây xuống dưới 2 giây.
Bài học: Phần GROUP BY DATE(...) thì không sao (vì lúc đó đã lọc xong, chỉ làm trên tập nhỏ), nhưng phần WHERE thì tuyệt đối tránh bọc hàm quanh cột được index. Đây là khác biệt giữa một dashboard mượt và một dashboard "treo".
Hướng dẫn từng bước
Quy trình kiểm tra một query report trước khi đem dùng:
- Soi cardinality của mọi JOIN. Với mỗi JOIN, tự hỏi: "Một hàng bảng trái khớp với bao nhiêu hàng bảng phải?". Nếu là một-nhiều và bạn đang
SUM/COUNTcột của bảng trái — bạn đang có nguy cơ fan-out. Hãy aggregate bảng "nhiều" trong CTE trước.
- Đếm thử để phát hiện nhân bản. Chạy
SELECT COUNT(*)trước và sau khi thêm một JOIN. Nếu số hàng tăng bất thường, JOIN đang nhân bản dữ liệu.
- Kiểm tra LEFT JOIN có bị "INNER hóa" không. Quét mọi điều kiện trong
WHEREtham chiếu bảng bên phải của LEFT JOIN. Nếu có và nó không xử lý NULL, khả năng cao bạn đã vô tình lọc mất hàng. Cân nhắc đưa vàoON.
- Liệt kê cột thay cho
SELECT *. Chỉ lấy đúng cột report cần.
- Soi mọi hàm trong WHERE. Nếu có hàm bọc quanh cột (đặc biệt cột ngày/giờ được index), viết lại thành range so sánh hằng số.
- Kiểm tra logic NULL. Mọi
= NULLđổi thànhIS NULL. Với!=/<>, tự hỏi: "Tôi có muốn giữ lại hàng NULL không?". Nếu có, thêmOR cột IS NULL.
- Đối chiếu với một con số đã biết. Luôn có ít nhất một "neo" — ví dụ tổng doanh thu tháng trước mà tài chính đã chốt — để kiểm tra query mới có ra số hợp lý không.
Lỗi thường gặp & mẹo
- Tin vào "query chạy được nghĩa là đúng". Chạy được chỉ nghĩa là cú pháp hợp lệ. Tính đúng đắn phải được kiểm chứng bằng đối chiếu, không phải bằng việc nó trả về kết quả.
- GROUP BY thiếu cột không phải hàm tổng hợp. Trên PostgreSQL bạn sẽ bị báo lỗi, nhưng MySQL (chế độ mặc định cũ) sẽ âm thầm trả về một giá trị bất kỳ trong nhóm cho cột thiếu — ra số rác mà không cảnh báo. Luôn đưa mọi cột không tổng hợp vào
GROUP BY.
- Dùng
HAVINGthay choWHERE(và ngược lại).WHERElọc trước khi gom nhóm,HAVINGlọc sau. Đặt điều kiện lọc hàng thường (ví dụstatus = 'completed') vàoHAVINGkhiến query chậm hơn vì gom nhóm cả dữ liệu thừa.
- Quên rằng NULL "ăn" mọi phép tính.
revenue + bonusmàbonuslà NULL thì kết quả là NULL, không phảirevenue. DùngCOALESCE(bonus, 0).
- OR trong WHERE giết index.
WHERE a = 1 OR b = 2thường không dùng được index hiệu quả. Cân nhắc viết lại bằngUNIONnếu cần hiệu năng.
- Mẹo "aggregate trước, JOIN sau". Đây là vũ khí mạnh nhất chống fan-out: gom nhóm bảng chi tiết trong một CTE để đưa nó về đúng một hàng mỗi thực thể, rồi mới JOIN. Khi đó cardinality luôn là một-một, không bao giờ nhân bản.
- Mẹo comment ý định. Thêm một dòng
-- mỗi hàng = 1 đơn hàngtrên CTE giúp bạn (và người sau) biết "hạt" dữ liệu (grain) là gì, tránh JOIN sai cấp.
Bài tập thực hành
Giả sử bạn có ba bảng: customers(id, name, city), orders(id, customer_id, total_amount, status, created_at), order_items(id, order_id, product_id, quantity, unit_price).
- Tìm lỗi: Query sau định tính tổng doanh thu mỗi khách nhưng ra số quá lớn. Chỉ ra antipattern và viết lại:
SELECT c.name, SUM(o.total_amount) AS rev
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY c.name;
- Sửa LEFT JOIN: Viết query liệt kê tất cả khách hàng kèm tổng giá trị đơn
completedtrong năm 2026 (khách chưa có đơn nào vẫn phải xuất hiện với tổng = 0).
- Tối ưu WHERE: Bảng
orderscó index trêncreated_at. Viết lại điều kiệnWHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 3thành dạng dùng được index.
- Bẫy NULL: Viết query đếm số đơn không ở trạng thái
'cancelled', đảm bảo bao gồm cả những đơn cóstatuslà NULL.
- Tự kiểm: Lấy một query report bạn đang dùng thật ở công việc, chạy qua 7 bước trong phần "Hướng dẫn từng bước" và ghi lại bạn phát hiện được bao nhiêu antipattern.
order_items. Vì chỉ cần doanh thu cấp đơn, hãy bỏ JOIN order_items đi, hoặc nếu bắt buộc giữ thì dùng SUM trên một CTE đã gom order_items về một hàng mỗi đơn.Tóm tắt
Antipattern SQL không phải lỗi cú pháp — chúng là những cái bẫy "chạy được nhưng sai" hoặc "đúng nhưng chậm" mà BA dễ mắc nhất vì ít ai review query của bạn. Ba nhóm cần khắc cốt ghi tâm: fan-out do JOIN một-nhiều làm thổi phồng SUM/COUNT (nguy hiểm nhất về số liệu), LEFT JOIN bị INNER hóa do filter sai chỗ, và hàm bọc quanh cột trong WHERE giết index (nguy hiểm nhất về hiệu năng). Thêm vào đó là những lỗi nhỏ nhưng âm thầm: SELECT *, lạm dụng DISTINCT để che lỗi, và so sánh sai với NULL.
Vũ khí phòng thủ mạnh nhất của bạn là hai thói quen: (1) luôn xác định "grain" — mỗi hàng đại diện cho thực thể gì — trước khi aggregate, và (2) luôn đối chiếu kết quả với một con số đã biết. Một BA giỏi không phải người viết query phức tạp nhất, mà là người có "radar" tự bật cảnh báo mỗi khi sắp mắc một antipattern kinh điển. Hãy biến 7 bước kiểm tra ở trên thành checklist mặc định trước khi bất kỳ report nào của bạn rời khỏi máy.