Một màn hình quản trị cần hiện đơn gần nhất của từng khách, gồm mã đơn, số tiền và trạng thái. MAX(created_at) tìm được thời điểm, nhưng chưa chọn cho bạn toàn bộ một bản ghi. Thêm MAX(amount) còn có thể lấy tiền từ một đơn khác.
ROW_NUMBER() giúp diễn đạt việc chọn hàng rõ ràng hơn: chia theo khách, sắp xếp các đơn trong từng nhóm, rồi lấy hàng đứng đầu. Bài đối chiếu cú pháp PostgreSQL 17 ngày 27/10/2026. Các truy vấn minh họa được chạy trên SQLite có hỗ trợ window function; chưa chạy trực tiếp trên PostgreSQL và không có benchmark hiệu năng.
1. Tạo dữ liệu có trường hợp dễ nhầm
Dùng database thử nghiệm riêng. Với SQLite CLI, mở sqlite3 :memory: rồi chạy các đoạn SQL theo thứ tự trong cùng phiên. Chuỗi thời gian có cùng định dạng UTC để thứ tự từ điển cũng đúng thứ tự thời gian của fixture.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
amount INTEGER NOT NULL,
status TEXT NOT NULL
);
INSERT INTO orders VALUES
(10, 1, '2026-10-25T08:00:00Z', 900, 'paid'),
(11, 1, '2026-10-26T08:00:00Z', 100, 'paid'),
(12, 1, '2026-10-26T08:00:00Z', 200, 'pending'),
(20, 2, '2026-10-24T08:00:00Z', 500, 'paid');
Khách 1 có đơn cũ đắt nhất và hai đơn mới cùng thời điểm. Đây là hai điều kiện quan trọng để kiểm tra truy vấn, thay vì chỉ dùng dữ liệu mà mỗi khách có đúng một đơn.
Trong PostgreSQL thật, nên dùng kiểu thời gian phù hợp như timestamptz và số tiền theo đơn vị nhỏ nhất hoặc kiểu số phù hợp nghiệp vụ. Khai báo TEXT ở đây để ví dụ chạy thống nhất trên SQLite, không phải đề xuất schema production cho mọi hệ quản trị.
2. Xếp hạng rồi chọn hàng đầu tiên
WITH ranked AS (
SELECT orders.*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS position
FROM orders
)
SELECT id, customer_id, created_at, amount, status
FROM ranked
WHERE position = 1
ORDER BY customer_id;
Kết quả gồm đơn 12 của khách 1, số tiền 200, trạng thái pending; và đơn 20 của khách 2, số tiền 500. Tất cả cột của mỗi dòng đều đến từ cùng một đơn.
PARTITION BY bắt đầu lại việc đánh số cho từng khách. ORDER BY trong OVER xác định hàng nào được ưu tiên trong nhóm. ORDER BY customer_id cuối truy vấn chỉ sắp thứ tự các dòng kết quả; nó không thay thế thứ tự trong window.
Điều kiện id DESC là quy tắc phân xử khi thời gian bằng nhau. Trong fixture, ID là duy nhất nên thứ tự đã xác định. ID lớn hơn không tự chứng minh thời gian tạo muộn hơn trong mọi hệ thống phân tán; hãy chọn quy tắc phù hợp và ghi rõ nó trong yêu cầu sản phẩm.
3. Đặt điều kiện trạng thái ở đâu?
Hai yêu cầu dưới đây khác nhau:
- Đơn paid gần nhất: chỉ cho đơn paid tham gia xếp hạng.
- Đơn gần nhất nếu nó đã paid: xếp hạng tất cả, rồi xem trạng thái của hàng đứng đầu.
Với yêu cầu đầu tiên, đặt WHERE status = 'paid' bên trong CTE, sau FROM orders. Khách 1 sẽ nhận đơn 11. Với yêu cầu thứ hai, giữ nguyên CTE và đổi điều kiện ngoài thành WHERE position = 1 AND status = 'paid'; khách 1 sẽ không có mặt vì đơn mới nhất là pending.
Đừng chuyển điều kiện giữa hai vị trí chỉ để làm câu SQL trông ngắn hơn. Hãy dùng dữ liệu có đơn pending mới hơn một đơn paid để xác nhận ý nghĩa.
4. Vì sao không dùng nhiều MAX?
SELECT customer_id, MAX(created_at), MAX(amount)
FROM orders
GROUP BY customer_id
ORDER BY customer_id;
Khách 1 có thời điểm 2026-10-26T08:00:00Z và số tiền 900. Hai giá trị này tồn tại trong dữ liệu, nhưng không nằm trên cùng một đơn. Truy vấn aggregate đúng với ý nghĩa lấy cực đại độc lập; nó sai với yêu cầu lấy nguyên bản ghi mới nhất.
Một cách join trở lại theo MAX(created_at) cũng cần xử lý thời điểm trùng nhau, nếu không có thể trả cả đơn 11 và 12. Nếu nghiệp vụ muốn giữ tất cả hàng đồng hạng, hãy chọn quy tắc khác thay vì ép chúng thành một bằng ROW_NUMBER().
Kiểm tra và lỗi thường gặp
Window function được tính sau bước lọc thông thường của cùng tầng truy vấn, nên không đặt trực tiếp ROW_NUMBER() vào WHERE cùng tầng. CTE hoặc subquery tạo tầng lọc bên ngoài.
Thử thêm khách không có đơn, thời gian bằng nhau và khách chỉ có pending. Truy vấn này bắt đầu từ bảng đơn nên không hiện khách chưa có đơn. Muốn hiện họ cần thêm bảng khách và thiết kế phép join riêng.
Khi dữ liệu lớn, xem execution plan trên database thật và bộ lọc thật. Bài chưa kết luận một index hay cách viết luôn nhanh hơn. Trước hết, hãy bảo đảm các cột trả về cùng thuộc hàng mà nghiệp vụ đã chọn.

