Báo cáo cần hiển thị mọi khách hàng, kể cả người chưa có đơn. Bạn dùng LEFT JOIN rồi COUNT(*), nhưng khách chưa mua gì lại có số đơn bằng 1. SQL vẫn chạy thành công; điều sai nằm ở thứ bạn đang đếm sau phép join.
Bài đối chiếu PostgreSQL 17 và SQLite ngày 26/10/2026. Các fixture SQL được chạy trên SQLite CLI 3.54.0. Chưa chạy PostgreSQL trong lượt biên tập, nên các kết luận về PostgreSQL dựa trên tài liệu chính thức; không có execution plan hay benchmark PostgreSQL được tạo từ kết quả SQLite.
1. Quan sát hàng đầu vào của phép đếm
Theo tài liệu LEFT JOIN của PostgreSQL, khi không có hàng bên phải khớp, hàng bên trái vẫn được giữ và các cột bên phải mang NULL. Theo tham chiếu aggregate, COUNT(*) đếm hàng đầu vào, còn COUNT(biểu thức) chỉ đếm giá trị không NULL.
Như vậy, một hàng đại diện khách chưa có đơn vẫn là một hàng của kết quả join. Nếu muốn đếm đơn, bạn cần chọn một cột đại diện chắc chắn có giá trị khi một đơn thực sự khớp, chẳng hạn khóa chính của bảng đơn.
2. Dựng fixture hai khách hàng
Chạy trong database thử, cùng một phiên kết nối để giữ các bảng tạm:
CREATE TEMP TABLE count_customers (
id integer PRIMARY KEY,
name text NOT NULL
);
CREATE TEMP TABLE count_orders (
id integer PRIMARY KEY,
customer_id integer NOT NULL,
status text NOT NULL
);
INSERT INTO count_customers VALUES (1, 'An'), (2, 'Binh');
INSERT INTO count_orders VALUES (101, 1, 'paid'), (102, 1, 'pending');
SELECT c.id, c.name,
COUNT(*) AS joined_rows,
COUNT(o.id) AS order_count
FROM count_customers AS c
LEFT JOIN count_orders AS o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY c.id;
Fixture đã chạy cho An hai joined rows và hai orders; Binh có một joined row nhưng không có order. Hai cột đếm khác nhau vì chúng trả lời hai câu hỏi khác nhau. Không nên đổi alias của COUNT(*) thành order_count rồi xem tên cột là bằng chứng phép tính đã đúng.
Tài liệu aggregate của SQLite cũng mô tả sự khác biệt COUNT(*) với COUNT(X). Hãy chọn cột khóa không NULL của đối tượng cần đếm; nếu đếm một cột tùy chọn như ghi chú đơn, bạn có thể bỏ sót đơn vẫn tồn tại nhưng chưa có ghi chú.
3. Đếm đơn đã thanh toán mà vẫn giữ khách chưa có đơn
Đưa điều kiện xác định đơn cần khớp vào ON:
SELECT c.id, c.name, COUNT(o.id) AS paid_order_count
FROM count_customers AS c
LEFT JOIN count_orders AS o
ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY c.id;
Kết quả đã kiểm tra là An có 1, Binh có 0. Ý định của truy vấn là giữ tập khách hàng, rồi chỉ nối những đơn thỏa điều kiện để đếm. Đây không phải quy tắc “mọi filter đều phải đặt trong ON”; nó phù hợp với báo cáo đang cần hiển thị cả khách không có đơn paid.
So sánh với biến thể sau:
SELECT c.id, c.name, COUNT(o.id) AS paid_order_count
FROM count_customers AS c
LEFT JOIN count_orders AS o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.id, c.name
ORDER BY c.id;
Trong fixture, biến thể này chỉ còn An. Hàng không có order mang status NULL không thỏa điều kiện WHERE. Nếu báo cáo chỉ cần những khách đã có đơn paid, đó có thể là kết quả đúng; nếu cần mọi khách, nó không đáp ứng yêu cầu ban đầu.
4. Rà hiện tượng nhân hàng khi nối thêm bảng
Giả sử sau này một đơn có nhiều dòng sản phẩm và bạn nối cả bảng dòng sản phẩm. Một đơn có thể xuất hiện nhiều lần trong kết quả trung gian. COUNT(o.id) khi đó đếm số lần ID xuất hiện sau join, chưa chắc là số đơn duy nhất.
Bạn cần xác định đơn vị báo cáo trước: khách hàng, đơn hàng hay dòng sản phẩm. Tùy ý định, có thể cần tổng hợp ở cấp đơn trước khi join hoặc dùng COUNT(DISTINCT o.id). Không thêm DISTINCT chỉ để số “trông hợp lý” mà chưa giải thích nguyên nhân nhân hàng.
Kiểm tra trước khi đưa vào báo cáo
Tạo ca khách không có đơn, chỉ có đơn pending, có một đơn paid và có nhiều đơn paid. Ghi số mong đợi theo từng khách. Sau đó thêm ca có nhiều dòng chi tiết cho cùng một đơn nếu truy vấn thật có join cấp đó.
Kiểm tra cả danh sách khách xuất hiện lẫn số đếm. Chỉ đối chiếu tổng cuối cùng có thể bỏ qua việc một khách bị mất trong khi khách khác bị đếm thừa. Với báo cáo lớn, giữ một tập dữ liệu nhỏ có thể đọc bằng mắt để rà ngữ nghĩa trước khi đo hiệu năng.
Lỗi thường gặp
COUNT(1) không tự sửa vấn đề hàng rỗng của LEFT JOIN: số 1 vẫn là một biểu thức không NULL trên hàng đó. Thay COUNT(*) bằng COUNT(c.id) cũng vẫn đếm khách, vì c.id thuộc phía được giữ lại. Hãy nhìn vào đối tượng cần đếm và cột đại diện của phía phù hợp.
Nếu số đếm đúng nhưng thiếu khách, rà WHERE và ON. Nếu số tăng sau khi nối bảng mới, xem kết quả trung gian trước GROUP BY. Bài này không đưa ra kết luận tốc độ giữa các dạng truy vấn; tối ưu cần dữ liệu và plan của database thực tế.
Kết luận
Hãy đếm đối tượng theo ý nghĩa của báo cáo thay vì mặc định đếm mọi hàng sau join. Một fixture có cả trường hợp không khớp sẽ giúp bạn nhận ra sai lệch trước khi con số được đưa lên dashboard.

