Endpoint cần liệt kê khách đã có ít nhất một đơn thanh toán. Bạn JOIN bảng đơn vào khách rồi thấy cùng khách xuất hiện hai lần. Vấn đề bắt đầu từ đơn vị kết quả: bạn muốn một dòng mỗi khách, trong khi JOIN tạo một dòng mỗi cặp phù hợp.
Bài này đối chiếu PostgreSQL 17 và tài liệu SQLite ngày 02/11/2026. Fixture được chạy bằng Python 3.9.6 với SQLite 3.54.0 trong database bộ nhớ. Chưa chạy PostgreSQL server và chưa đo hiệu năng; mục tiêu là kiểm chứng số dòng và điều kiện truy vấn.
Tạo dữ liệu có chủ ý
Chạy SQL sau trên database thử trống, không dùng bảng production. Hai khách cùng tên giúp kiểm tra cả trường hợp gộp nhầm người:
CREATE TABLE demo_customers (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE demo_orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
status TEXT NOT NULL
);
INSERT INTO demo_customers VALUES (1, 'An'), (2, 'Binh'), (3, 'An');
INSERT INTO demo_orders VALUES
(11, 1, 'paid'),
(12, 1, 'paid'),
(13, 2, 'pending'),
(14, 3, 'paid');
Fixture cố ý không định nghĩa toàn bộ constraint của một hệ thống đơn hàng. Các ID liên kết đều có khách hợp lệ, nhưng đó là dữ liệu thử, không phải bằng chứng rằng ứng dụng đã bật kiểm tra khóa ngoại. Giá trị trạng thái là chuỗi demo, chưa mô hình hóa hoàn tiền hay thanh toán từng phần.
JOIN giữ từng cặp phù hợp
SELECT c.id, c.name
FROM demo_customers AS c
JOIN demo_orders AS o ON o.customer_id = c.id
WHERE o.status = 'paid'
ORDER BY c.id, o.id;
Kết quả đã kiểm tra là (1, An), (1, An), (3, An). Khách 1 có hai đơn paid nên góp hai dòng. Bản thân JOIN hoạt động đúng; truy vấn đang diễn đạt quan hệ nhiều kết quả hơn yêu cầu của endpoint.
Nếu cần mỗi đơn kèm tên khách, bạn nên giữ JOIN và chọn cả o.id. Nhưng nếu chỉ hỏi khách có thỏa điều kiện tồn tại hay không, không cần mang từng đơn vào kết quả ngoài.
Dùng EXISTS cho điều kiện có ít nhất một dòng
SELECT c.id, c.name
FROM demo_customers AS c
WHERE EXISTS (
SELECT 1
FROM demo_orders AS o
WHERE o.customer_id = c.id
AND o.status = 'paid'
)
ORDER BY c.id;
Kết quả là (1, An), (3, An). Subquery liên hệ với dòng khách ngoài qua o.customer_id = c.id. Khi điều kiện tồn tại đúng, dòng khách được giữ một lần, bất kể khách có một hay nhiều đơn phù hợp. SELECT 1 chỉ thể hiện mục đích kiểm tra tồn tại; nó không có nghĩa subquery trả duy nhất một dòng.
Đừng bỏ điều kiện liên hệ rồi chỉ giữ status = 'paid'. Khi đó, chỉ cần một đơn paid ở bất kỳ khách nào là mọi khách đều vượt qua bộ lọc. Với fixture này, truy vấn sai ấy trả cả khách 2. Đây là case nhỏ nhưng hữu ích trong review và kiểm thử.
DISTINCT có thay thế được không?
Nếu JOIN rồi SELECT DISTINCT c.id, c.name, fixture cũng cho hai khách mong muốn. Tuy nhiên, dùng DISTINCT c.name chỉ còn một giá trị An, làm mất một khách thật. Cần chọn cột theo định danh nghiệp vụ, không dùng việc kết quả trông gọn hơn làm bằng chứng đúng.
EXISTS làm ý định lọc dễ đọc hơn trong trường hợp này. Điều đó không chứng minh nó luôn nhanh hơn JOIN kèm DISTINCT. Kế hoạch thực thi phụ thuộc database, index, thống kê và dữ liệu thực. Có thể xem xét index bắt đầu từ customer_id và điều kiện trạng thái theo workload, rồi dùng công cụ phân tích của database đang triển khai.
Kiểm tra trước khi đưa vào API
Thử khách không có đơn, chỉ pending, một paid, nhiều paid và hai khách trùng tên. Với fixture, thêm một đơn paid nữa cho khách 1 phải giữ nguyên tập ID [1, 3] của truy vấn EXISTS. Kiểm tra cả tổng số dòng nếu tầng API tính pagination, vì đếm cặp JOIN và đếm khách là hai yêu cầu khác nhau.
Nếu endpoint cần số đơn hoặc tổng tiền, EXISTS chỉ giải quyết bộ lọc tồn tại; hãy thiết kế phần tổng hợp riêng. Chốt “mỗi dòng đại diện cho gì” trước khi thêm JOIN là cách đơn giản để tránh sửa kết quả bằng DISTINCT một cách máy móc.


