Mở đầu — vì sao bài này quan trọng
Ở bài trước, bạn đã làm chủ INNER JOIN — phép nối chỉ giữ lại những dòng có khớp ở cả hai bảng. Nhưng trong công việc thực tế của một Business Analyst (BA), thứ quý giá nhất thường lại nằm ở những dòng không khớp. Khách hàng đăng ký nhưng chưa mua lần nào. Sản phẩm tồn kho nhưng chưa bán được đơn nào. Nhân viên chưa được gán dự án. Mã giảm giá phát ra nhưng chưa ai dùng. Nếu bạn chỉ biết INNER JOIN, toàn bộ nhóm "im lặng" này sẽ biến mất khỏi báo cáo của bạn — và đó chính là nhóm mà ban lãnh đạo muốn nghe nhất.
OUTER JOIN (LEFT, RIGHT, FULL) sinh ra để giữ lại những dòng không khớp đó. Đây cũng là nhóm phép nối khiến BA mới vào nghề sai nhiều nhất: query chạy ra số, trông có vẻ đúng, nhưng âm thầm sai vì một mệnh đề WHERE đặt sai chỗ, hay vì quên rằng phép đếm trên cột NULL cho kết quả khác hẳn. Một con số sai trong báo cáo doanh thu hay tỷ lệ kích hoạt khách hàng có thể dẫn tới quyết định kinh doanh sai. Vì vậy tên bài là "hiểu sâu để không sai" — chúng ta sẽ không chỉ học cú pháp, mà học cách tư duy về bảng nào là "gốc cần giữ trọn vẹn" và bảng nào là "bổ sung nếu có".
Khái niệm cốt lõi
Hãy nhớ một câu thần chú: OUTER JOIN quyết định bảng nào được giữ TRỌN VẸN, kể cả khi bảng kia không có dòng khớp.
LEFT JOIN — giữ trọn bảng bên trái
LEFT JOIN (đầy đủ là LEFT OUTER JOIN, chữ OUTER có thể lược bỏ) giữ lại tất cả các dòng của bảng bên trái. Với mỗi dòng bên trái, SQL đi tìm dòng khớp ở bảng phải theo điều kiện ON. Nếu tìm thấy, ghép vào. Nếu không tìm thấy, dòng bên trái vẫn được giữ, còn các cột lấy từ bảng phải sẽ điền NULL.
-- Mọi customer, kèm đơn hàng nếu có (kể cả customer chưa mua)
SELECT c.customer_id, c.full_name, o.order_id, o.total_amount
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id;
Customer chưa từng mua vẫn xuất hiện, với order_id và total_amount bằng NULL. Đây là cách bạn tìm ra nhóm khách "ngủ đông".
RIGHT JOIN — giữ trọn bảng bên phải
RIGHT JOIN là hình ảnh phản chiếu của LEFT JOIN: nó giữ tất cả dòng của bảng bên phải. Về mặt logic, A RIGHT JOIN B luôn có thể viết lại thành B LEFT JOIN A cho cùng kết quả.
-- Tương đương ví dụ trên, viết bằng RIGHT JOIN
SELECT c.customer_id, c.full_name, o.order_id, o.total_amount
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.customer_id;
Lời khuyên thực chiến: trong đội ngũ phân tích, hãy ưu tiên dùng LEFT JOIN và tránh RIGHT JOIN. Lý do thuần về khả năng đọc: mắt người đọc query từ trên xuống, từ trái sang phải, nên "bảng giữ trọn" nằm ở đầu (FROM) dễ theo dõi hơn là nằm cuối. RIGHT JOIN không sai, nhưng khi query có 4-5 bảng, một RIGHT JOIN chen vào giữa khiến đồng nghiệp rất dễ hiểu nhầm. RIGHT JOIN tồn tại để bạn đọc hiểu code người khác, không phải để bạn viết mới.
FULL OUTER JOIN — giữ trọn cả hai bảng
FULL OUTER JOIN giữ tất cả dòng của cả hai bảng. Dòng nào khớp thì ghép lại; dòng bên trái không khớp thì cột phải là NULL; dòng bên phải không khớp thì cột trái là NULL. Nó hữu ích nhất khi bạn đối soát (reconcile) hai nguồn dữ liệu và muốn thấy cả những thứ "chỉ có ở A" lẫn "chỉ có ở B".
-- Đối soát: mã giao dịch nào có ở hệ thống nội bộ nhưng không có ở cổng thanh toán, và ngược lại
SELECT internal.txn_id AS internal_id, gateway.txn_id AS gateway_id
FROM internal_txn internal
FULL OUTER JOIN gateway_txn gateway ON internal.txn_id = gateway.txn_id
WHERE internal.txn_id IS NULL OR gateway.txn_id IS NULL;
Một lưu ý quan trọng về môi trường: MySQL không hỗ trợ trực tiếp cú pháp FULL OUTER JOIN (tính đến các phiên bản phổ biến). PostgreSQL, SQL Server, Oracle thì có. Trong MySQL bạn phải mô phỏng bằng cách UNION của một LEFT JOIN và một RIGHT JOIN:
SELECT ... FROM a LEFT JOIN b ON a.id = b.id
UNION
SELECT ... FROM a RIGHT JOIN b ON a.id = b.id;
Hình dung bằng tập hợp
Hãy tưởng tượng hai vòng tròn Venn A (trái) và B (phải): INNER JOIN là phần giao ở giữa; LEFT JOIN là toàn bộ vòng A; RIGHT JOIN là toàn bộ vòng B; FULL OUTER JOIN là cả hai vòng gộp lại. Khi muốn lấy riêng phần "chỉ có ở A", bạn dùng LEFT JOIN rồi thêm WHERE B.khoá IS NULL — đây là kỹ thuật anti-join cực kỳ hữu dụng cho BA.
Tình huống thực tế
Ví dụ 1 — Tiki: tìm khách đã đăng ký nhưng chưa từng mua
Đội growth của một sàn thương mại điện tử giả định kiểu Tiki muốn chạy chiến dịch "kích hoạt lần mua đầu tiên". Họ cần danh sách khách đã tạo tài khoản trong quý 1/2026 nhưng đến hết quý vẫn chưa đặt đơn nào.
SELECT c.customer_id, c.full_name, c.email, c.created_at
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE c.created_at >= '2026-01-01' AND c.created_at < '2026-04-01'
AND o.order_id IS NULL;
Bảng customers là bảng cần giữ trọn (mọi khách đăng ký), orders là bảng bổ sung. Sau LEFT JOIN, khách chưa mua sẽ có o.order_id là NULL. Mệnh đề o.order_id IS NULL lọc đúng nhóm này. Kết quả: 4.820 khách trên tổng 18.300 khách đăng ký mới — tức 26% chưa kích hoạt. Một con số rất đáng để đội growth hành động.
Bài học rút ra: điều kiện lọc theo bảng trái (c.created_at) đặt ở WHERE là đúng, vì ta thực sự muốn loại bớt khách. Nhưng điều kiện o.order_id IS NULL là cách "khai thác" tính chất của LEFT JOIN để làm anti-join. Hai loại điều kiện này khác bản chất — ta sẽ thấy chỗ này dễ sai ở mục dưới.
Ví dụ 2 — Grab: doanh thu theo tài xế, kể cả tài xế chưa có cuốc
Một nền tảng gọi xe giả định kiểu Grab cần báo cáo tổng doanh thu cuốc xe theo từng tài xế trong tuần, để tính thưởng. Phòng vận hành nhấn mạnh: báo cáo phải liệt kê đầy đủ tài xế đang hoạt động, kể cả người tuần này chưa chạy cuốc nào (doanh thu = 0), để bộ phận chăm sóc tài xế biết ai cần được nhắc.
SELECT d.driver_id, d.driver_name,
COUNT(t.trip_id) AS so_cuoc,
COALESCE(SUM(t.fare), 0) AS doanh_thu
FROM drivers d
LEFT JOIN trips t
ON t.driver_id = d.driver_id
AND t.trip_date >= '2026-06-15' AND t.trip_date < '2026-06-22'
WHERE d.status = 'active'
GROUP BY d.driver_id, d.driver_name
ORDER BY doanh_thu DESC;
Để ý hai điều then chốt. Thứ nhất, điều kiện thời gian của trips nằm trong ON, không nằm trong WHERE — nhờ vậy tài xế không có cuốc nào trong tuần vẫn được giữ lại với doanh thu 0. Thứ hai, COUNT(t.trip_id) đếm theo cột của bảng phải, nên với tài xế không có cuốc nó trả về 0 chứ không phải 1 — đây là lý do ta không dùng COUNT(*).
Bài học rút ra: đây là cái bẫy kinh điển. Nếu bạn chuyển điều kiện ngày từ ON xuống WHERE, LEFT JOIN sẽ âm thầm biến thành INNER JOIN, và mọi tài xế không có cuốc trong tuần biến mất khỏi báo cáo. Báo cáo vẫn ra số, sếp vẫn đọc, nhưng nó đã sai mục tiêu.
Ví dụ 3 — MoMo: đối soát giao dịch giữa hai hệ thống
Phòng tài chính của một ví điện tử giả định kiểu MoMo cuối ngày cần đối soát: hệ thống ghi sổ nội bộ và log từ ngân hàng đối tác phải khớp nhau. FULL OUTER JOIN giúp lộ ra mọi lệch lạc hai chiều — giao dịch nội bộ ghi nhưng ngân hàng chưa báo, và giao dịch ngân hàng báo nhưng nội bộ chưa ghi.
SELECT i.txn_ref AS noi_bo, b.txn_ref AS ngan_hang,
i.amount AS tien_noi_bo, b.amount AS tien_ngan_hang
FROM internal_ledger i
FULL OUTER JOIN bank_log b ON i.txn_ref = b.txn_ref
WHERE i.txn_ref IS NULL -- chỉ ngân hàng có
OR b.txn_ref IS NULL -- chỉ nội bộ có
OR i.amount <> b.amount; -- cả hai có nhưng lệch tiền
Trong một ngày 1,2 triệu giao dịch, query này lọc ra 37 dòng cần con người xem xét: 12 lệnh ngân hàng đã trừ tiền nhưng nội bộ chưa ghi (rủi ro mất tiền), 9 lệnh nội bộ treo chưa được ngân hàng xác nhận, và 16 lệnh lệch số tiền do phí. Toàn bộ 1,2 triệu giao dịch khớp đúng tự động bị loại khỏi danh sách — đúng như mong muốn.
Bài học rút ra: FULL OUTER JOIN là công cụ đối soát mạnh nhất, nhưng nhớ rằng MoMo giả định này chạy PostgreSQL nên cú pháp dùng được trực tiếp. Nếu hệ thống là MySQL, bạn phải viết lại bằng UNION như đã nói ở trên.
Hướng dẫn từng bước
Khi đứng trước một yêu cầu báo cáo có dính tới "kể cả khi không có...", hãy đi theo quy trình sau:
- Xác định bảng gốc cần giữ trọn. Đọc kỹ yêu cầu nghiệp vụ: "liệt kê mọi khách hàng", "đầy đủ tất cả tài xế", "mỗi sản phẩm trong danh mục". Cụm từ "mọi/tất cả/mỗi" chỉ ra bảng phải được giữ nguyên vẹn. Đặt bảng đó vào
FROM.
- Đặt bảng bổ sung vào LEFT JOIN. Bảng chứa thông tin "nếu có" (đơn hàng, cuốc xe, lượt dùng mã) đi sau
LEFT JOIN.
- Viết điều kiện nối trong ON. Mọi điều kiện thuộc về bảng phải mà bạn vẫn muốn giữ dòng trái khi không khớp — ví dụ lọc khoảng thời gian của đơn hàng — phải nằm trong
ON, không nằm trong WHERE.
- Chỉ đặt vào WHERE những điều kiện thuộc bảng trái, hoặc điều kiện IS NULL có chủ đích. Điều kiện lọc trên bảng trái (status, ngày đăng ký) đặt ở WHERE là an toàn. Còn
cột_phải IS NULLở WHERE chỉ dùng khi bạn cố ý làm anti-join.
- Khi tổng hợp, đếm/cộng theo cột bảng phải. Dùng
COUNT(cột_bảng_phải)thay choCOUNT(*), và bọcSUMbằngCOALESCE(..., 0)để dòng không khớp ra 0 thay vì NULL.
- Kiểm tra lại bằng tổng kiểm soát. So số dòng kết quả với số dòng bảng trái: với LEFT JOIN một-một, số dòng kết quả phải bằng đúng số dòng bảng trái. Nếu nhiều hơn, quan hệ là một-nhiều (một khách nhiều đơn) — bình thường, nhưng phải ý thức được để không nhân đôi khi tính tổng.
Lỗi thường gặp & mẹo
Lỗi 1 — Đặt điều kiện bảng phải vào WHERE, biến LEFT JOIN thành INNER JOIN. Đây là lỗi số một. LEFT JOIN orders o ON o.customer_id = c.id WHERE o.status = 'paid' sẽ loại bỏ mọi khách không có đơn, vì với họ o.status là NULL và NULL = 'paid' cho kết quả không-đúng. Nếu bạn muốn "khách có đơn đã thanh toán, và cả khách chưa có đơn", hãy chuyển o.status = 'paid' vào ON.
Lỗi 2 — Dùng COUNT() trên LEFT JOIN. COUNT() đếm cả dòng có NULL nên tài xế không cuốc nào vẫn ra số 1. Luôn đếm theo cột không-null của bảng phải, ví dụ khoá chính COUNT(t.trip_id).
Lỗi 3 — So sánh NULL bằng = hoặc <>. Trong SQL, NULL = NULL không trả về TRUE, và NULL <> 5 cũng không trả về TRUE. Để lọc dòng không khớp, bắt buộc dùng IS NULL / IS NOT NULL.
Lỗi 4 — Nhân đôi số liệu do quan hệ một-nhiều. Khi LEFT JOIN một bảng có nhiều dòng con (một khách nhiều đơn), rồi lại JOIN tiếp bảng thứ ba, tổng tiền có thể bị nhân lên. Mẹo: tổng hợp bảng con trước trong một subquery/CTE rồi mới JOIN.
Mẹo — Đặt tên alias rõ ràng và chọn cột hiển thị từ đúng bảng. Trong FULL OUTER JOIN, một dòng có thể NULL ở khoá bên trái. Nếu muốn cột khoá luôn có giá trị, dùng COALESCE(a.id, b.id) AS id.
Mẹo — Đọc RIGHT JOIN bằng cách lật ngược. Khi gặp A RIGHT JOIN B trong code cũ, hãy đọc nó như "giữ trọn B" rồi tự dịch trong đầu thành B LEFT JOIN A cho dễ hình dung.
Bài tập thực hành
Giả sử bạn có ba bảng trong một schema thương mại điện tử: customers(customer_id, full_name, city, created_at), orders(order_id, customer_id, order_date, total_amount, status), và coupons(coupon_id, code, customer_id, used_at).
- Anti-join cơ bản: Viết query liệt kê tất cả khách ở "Hà Nội" chưa từng đặt đơn nào. (Gợi ý: LEFT JOIN +
IS NULL.)
- Bẫy ON vs WHERE: Viết query liệt kê mọi khách hàng kèm tổng số tiền đơn hàng đã giao (
status = 'delivered') trong tháng 6/2026, sao cho khách không có đơn giao nào vẫn hiện với tổng = 0. Tự kiểm: nếu bạn đặt điều kiện status hoặc ngày vào WHERE, kết quả sẽ thiếu khách — hãy thử cả hai cách để thấy khác biệt.
- FULL OUTER JOIN / đối soát: Cho thêm bảng
loyalty_members(customer_id, tier). Tìm những khách có trongcustomersnhưng chưa tham gia loyalty, và nhữngcustomer_idxuất hiện trongloyalty_membersnhưng không còn trongcustomers(dữ liệu mồ côi). Nếu dùng MySQL, hãy mô phỏng bằngUNION.
- Suy luận: Một bạn đồng nghiệp than rằng báo cáo "doanh thu theo khách" của họ thiếu mất các khách mới. Bạn nhìn vào query thấy
LEFT JOIN orders o ON ... WHERE o.order_date >= '2026-06-01'. Hãy giải thích trong 2-3 câu vì sao báo cáo thiếu khách và cách sửa.
Tóm tắt
OUTER JOIN giải quyết bài toán "giữ lại cả những dòng không khớp" — nơi BA tìm thấy khách chưa mua, tài xế chưa chạy, giao dịch lệch sổ. LEFT JOIN giữ trọn bảng trái và là lựa chọn mặc định nên ưu tiên; RIGHT JOIN giữ trọn bảng phải, chỉ nên dùng để đọc hiểu code cũ; FULL OUTER JOIN giữ trọn cả hai, lý tưởng cho đối soát (nhớ MySQL phải mô phỏng bằng UNION).
Hai nguyên tắc cần khắc cốt ghi tâm: thứ nhất, điều kiện lọc trên bảng phải phải nằm trong ON, nếu không LEFT JOIN âm thầm biến thành INNER JOIN và báo cáo sai mục tiêu; thứ hai, khi tổng hợp hãy đếm theo cột bảng phải và bọc SUM bằng COALESCE để dòng không khớp ra 0 thay vì NULL. Và luôn nhớ: với NULL, chỉ IS NULL / IS NOT NULL mới đúng, không bao giờ dùng =. Nắm chắc những điều này, bạn sẽ "hiểu sâu để không sai" — đúng như tên bài học.