Mở đầu — vì sao bài này quan trọng
Nếu bạn từng nhận một bảng kết quả SQL gồm hàng nghìn dòng và phải tự dò mắt tìm "khách chi tiêu nhiều nhất" hay "đơn hàng gần nhất", bạn đã chạm đúng vào lý do của bài học này. ORDER BY không chỉ là chuyện sắp xếp cho đẹp — nó là cách bạn quyết định điều gì xuất hiện trước mắt người đọc báo cáo. Và khi kết hợp với LIMIT/OFFSET, nó trở thành công cụ để bạn trả lời những câu hỏi rất nghiệp vụ: "Top 10 sản phẩm bán chạy?", "5 khách hàng VIP cần chăm sóc?", "Trang 3 của danh sách giao dịch để hiển thị trên giao diện app?".
Với một BA (Business Analyst), đây là bộ kỹ năng dùng hằng ngày. Mọi dashboard có bảng "Top N", mọi danh sách phân trang trên web/app, mọi báo cáo "mới nhất trước" đều đứng trên hai mệnh đề này. Quan trọng hơn, đây cũng là nơi rất dễ sai một cách âm thầm — query vẫn chạy, vẫn ra số, nhưng số sai. Bài này sẽ giúp bạn dùng ORDER BY, LIMIT, OFFSET một cách chắc tay, hiểu rõ cạm bẫy về thứ tự không ổn định (non-deterministic), về NULL, và về hiệu năng khi phân trang trên bảng lớn.
Khái niệm cốt lõi
ORDER BY: sắp xếp theo một hay nhiều cột
ORDER BY chạy sau khi SELECT đã chọn xong dữ liệu, và nó định nghĩa thứ tự các dòng trong kết quả trả về.
-- Mới nhất trước
SELECT order_id, customer_id, created_at
FROM orders
ORDER BY created_at DESC;-- Sắp xếp nhiều cột: giá tăng dần, cùng giá thì theo tên A→Z
SELECT product_name, price
FROM products
ORDER BY price ASC, name ASC;
ASC (ascending) là tăng dần — và cũng là mặc định nếu bạn không ghi gì. DESC (descending) là giảm dần. Khi viết nhiều cột, SQL sắp theo cột đầu trước; chỉ khi cột đầu bằng nhau mới xét tới cột thứ hai làm "tiêu chí phá hòa" (tie-breaker). Hãy nhớ hình ảnh này: như xếp danh bạ theo họ, trùng họ thì xếp tiếp theo tên.
Sắp xếp theo biểu thức và alias
Bạn không bị giới hạn ở tên cột. Có thể sắp theo một biểu thức tính toán, hoặc theo alias bạn đặt trong SELECT:
SELECT
order_id,
quantity * unit_price AS revenue
FROM order_items
ORDER BY revenue DESC; -- sắp theo alias
Trong nhiều hệ quản trị (MySQL, PostgreSQL), ORDER BY cho phép tham chiếu alias đã đặt ở SELECT vì nó chạy sau bước chọn cột. Bạn cũng có thể viết ORDER BY quantity * unit_price DESC trực tiếp — cả hai đều đúng.
Xử lý NULL khi sắp xếp
Đây là chỗ khác biệt giữa các hệ quản trị mà BA hay vấp. NULL nghĩa là "không có giá trị", và mỗi hệ đặt nó ở vị trí khác nhau:
- PostgreSQL: mặc định
NULLnằm cuối khiASC, và nằm đầu khiDESC. PostgreSQL cho phép điều khiển rõ ràng bằngNULLS FIRST/NULLS LAST. - MySQL: coi
NULLlà nhỏ nhất, nênNULLnằm đầu khiASC, cuối khiDESC. MySQL không cóNULLS FIRST/LAST, nhưng bạn mô phỏng được.
-- PostgreSQL: đẩy khách chưa có ngày mua cuối xuống dưới cùng
SELECT customer_id, last_purchase_date
FROM customers
ORDER BY last_purchase_date DESC NULLS LAST;-- MySQL: mô phỏng NULLS LAST bằng mẹo cột phụ
SELECT customer_id, last_purchase_date
FROM customers
ORDER BY (last_purchase_date IS NULL), last_purchase_date DESC;
Trong mẹo MySQL trên, (last_purchase_date IS NULL) trả về 0 cho dòng có giá trị và 1 cho dòng NULL; sắp theo nó trước sẽ đẩy mọi NULL xuống cuối.
LIMIT và OFFSET: lấy bao nhiêu, bỏ qua bao nhiêu
LIMIT n giới hạn kết quả ở n dòng đầu tiên sau khi đã sắp xếp. OFFSET m bảo SQL bỏ qua m dòng đầu rồi mới lấy.
-- Top 10 sản phẩm doanh thu cao nhất
SELECT product_name, SUM(quantity * unit_price) AS revenue
FROM order_items oi
JOIN products p ON p.product_id = oi.product_id
GROUP BY product_name
ORDER BY revenue DESC
LIMIT 10;
Phân trang (pagination) là sự kết hợp của hai mệnh đề. Trang có kích thước page_size, muốn lấy trang số page (đếm từ 1):
-- Trang 3, mỗi trang 20 dòng → bỏ qua 40, lấy 20 tiếp theo
SELECT order_id, created_at
FROM orders
ORDER BY created_at DESC, order_id DESC
LIMIT 20 OFFSET 40;
-- OFFSET = (page - 1) page_size = (3-1) 20 = 40
Lưu ý cú pháp khác nhau: PostgreSQL dùng LIMIT 20 OFFSET 40 (rõ ràng nhất). MySQL chấp nhận cả LIMIT 40, 20 (offset trước, count sau — dễ nhầm thứ tự!) lẫn LIMIT 20 OFFSET 40. SQL Server dùng cú pháp khác hẳn: ORDER BY ... OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY. Khi viết cho team đa hệ, hãy ghi chú rõ.
Quy tắc vàng: ORDER BY phải xác định (deterministic)
Đây là khái niệm quan trọng nhất của bài. Nếu bạn LIMIT mà không ORDER BY, hoặc ORDER BY theo một cột có nhiều giá trị trùng nhau, thì thứ tự các dòng trùng là không đảm bảo. Database có quyền trả về theo bất kỳ thứ tự nào nó thấy nhanh — và thứ tự đó có thể đổi giữa hai lần chạy. Hậu quả: phân trang bị lặp dòng hoặc thiếu dòng. Cách chữa: luôn thêm một cột định danh duy nhất (thường là khóa chính) làm tie-breaker cuối cùng.
Tình huống thực tế
Tình huống 1 — Tiki: bảng "Top 5 nhà bán" bị nhảy số mỗi lần refresh
Một BA tại sàn thương mại điện tử (kiểu Tiki) dựng dashboard "Top 5 nhà bán theo doanh thu tháng". Query ban đầu:
SELECT seller_name, SUM(gmv) AS total_gmv
FROM monthly_seller_stats
WHERE month = '2026-05'
GROUP BY seller_name
ORDER BY total_gmv DESC
LIMIT 5;
Vấn đề phát sinh khi có ba nhà bán cùng đạt đúng 1.2 tỷ đồng GMV. Vì total_gmv của họ bằng nhau và không có tie-breaker, mỗi lần dashboard refresh, thứ tự ba nhà bán này lại đảo, và đôi khi nhà bán đứng thứ 5 vs thứ 6 bị hoán đổi — tức danh sách Top 5 đổi thành viên dù số liệu y hệt. Phía vận hành nghi ngờ dữ liệu sai.
Cách sửa: thêm tie-breaker ổn định.
ORDER BY total_gmv DESC, seller_id ASC
LIMIT 5;
Bài học: trong mọi báo cáo Top N có khả năng trùng giá trị, hãy thêm một cột duy nhất (ID) vào cuối ORDER BY. Nó không làm thay đổi logic nghiệp vụ nhưng khiến kết quả lặp lại được — điều tối quan trọng để xây niềm tin vào báo cáo.
Tình huống 2 — Ứng dụng giao đồ ăn: phân trang lịch sử đơn bị trùng/sót dòng
Một startup giao đồ ăn (kiểu ShopeeFood/GrabFood) hiển thị "Lịch sử đơn hàng" trên app, mỗi trang 15 đơn, sắp theo ngày tạo mới nhất. Đội backend dùng:
SELECT order_id, created_at, total_amount
FROM orders
WHERE user_id = 88123
ORDER BY created_at DESC
LIMIT 15 OFFSET 15; -- trang 2
Khách phàn nàn: có đơn xuất hiện ở cả trang 1 lẫn trang 2, và một đơn khác thì biến mất. Nguyên nhân: nhiều đơn được tạo trong cùng một giây nên created_at của chúng giống hệt; thứ tự nội bộ giữa các đơn trùng giờ không ổn định, nên ranh giới giữa trang 1 và trang 2 "trượt".
Sửa bằng tie-breaker duy nhất:
ORDER BY created_at DESC, order_id DESC
LIMIT 15 OFFSET 15;
Khi bảng orders lớn (hàng triệu dòng) và người dùng lướt tới trang 500, OFFSET 7485 buộc database phải duyệt và bỏ qua 7.485 dòng trước khi lấy 15 dòng — rất chậm. Giải pháp tốt hơn cho cuộn vô hạn là keyset pagination (còn gọi seek method): thay vì đếm offset, ta nhớ "mốc" của dòng cuối trang trước và lấy tiếp từ đó.
-- Trang trước kết thúc ở (created_at = '2026-05-10 09:00:00', order_id = 50321)
SELECT order_id, created_at, total_amount
FROM orders
WHERE user_id = 88123
AND (created_at, order_id) < ('2026-05-10 09:00:00', 50321)
ORDER BY created_at DESC, order_id DESC
LIMIT 15;
Bài học: OFFSET tiện cho phân trang số trang nhỏ, nhưng với bảng lớn và cuộn sâu, keyset pagination nhanh hơn nhiều và không bao giờ trùng/sót khi dữ liệu mới chèn vào giữa lúc người dùng đang cuộn.
Tình huống 3 — Fintech ví điện tử: "10 giao dịch gần nhất" và bẫy NULL
Một BA tại ví điện tử (kiểu MoMo) cần báo cáo "10 giao dịch giá trị lớn nhất trong tuần, ưu tiên đã đối soát". Một số giao dịch chưa có settled_at (giá trị NULL vì chưa đối soát xong). BA viết trên PostgreSQL:
SELECT txn_id, amount, settled_at
FROM transactions
WHERE created_at >= '2026-05-19'
ORDER BY amount DESC, settled_at ASC
LIMIT 10;
Kết quả bất ngờ: các giao dịch chưa đối soát (settled_at = NULL) lại nhảy lên đầu danh sách khi amount bằng nhau, vì trong PostgreSQL NULL đứng đầu khi ASC. Ý định của BA là ưu tiên giao dịch đã đối soát (có ngày) lên trước. Sửa lại:
ORDER BY amount DESC, settled_at ASC NULLS LAST, txn_id ASC
LIMIT 10;
Bài học: luôn tự hỏi "cột này có NULL không, và mình muốn NULL ở đâu?". Đừng để hành vi mặc định của hệ quản trị quyết định thay nghiệp vụ. Và đừng quên tie-breaker txn_id ở cuối.
Hướng dẫn từng bước
Khi cần viết một query có sắp xếp và phân trang, hãy theo trình tự sau:
- Xác định tiêu chí nghiệp vụ chính. "Mới nhất trước" →
created_at DESC. "Doanh thu cao nhất" →revenue DESC. Viết ra bằng lời trước khi gõ SQL.
- Liệt kê tie-breaker. Tự hỏi: cột chính có thể trùng giá trị không? Gần như luôn luôn là có. Thêm một cột định danh duy nhất (khóa chính) vào cuối
ORDER BY.
- Kiểm tra NULL. Cột sắp xếp có khả năng
NULLkhông? Nếu có, quyết địnhNULLS FIRSThayNULLS LAST(PostgreSQL), hoặc dùng mẹoIS NULL(MySQL).
- Quyết định cách giới hạn. Chỉ cần Top N? Dùng
LIMIT n. Cần phân trang theo số trang? TínhOFFSET = (page - 1) * page_size. Cần cuộn vô hạn trên bảng lớn? Cân nhắc keyset pagination.
- Chạy thử với dữ liệu trùng. Cố tình test trường hợp giá trị bằng nhau để xác nhận thứ tự ổn định giữa hai lần chạy.
-- Mẫu hoàn chỉnh cho phân trang an toàn
SELECT customer_id, customer_name, total_spent
FROM customer_summary
ORDER BY total_spent DESC, customer_id ASC -- (1) chính + (2) tie-breaker
LIMIT 20 OFFSET 40; -- (4) trang 3, mỗi trang 20
Lỗi thường gặp & mẹo
LIMITmà quênORDER BY. "Lấy 10 dòng" mà không sắp xếp thì 10 dòng đó là 10 dòng bất kỳ, có thể đổi mỗi lần chạy.LIMITluôn phải đi cùngORDER BY.
- Thiếu tie-breaker. Lỗi phổ biến nhất và khó phát hiện nhất. Báo cáo vẫn ra số, nhưng thứ tự dòng trùng giá trị không ổn định, gây nhảy số khi refresh hoặc trùng/sót khi phân trang. Luôn thêm khóa chính vào cuối.
- Nhầm thứ tự trong
LIMIT a, bcủa MySQL.LIMIT 40, 20nghĩa là bỏ qua 40, lấy 20, không phải "lấy 40 rồi 20". Nếu không chắc, dùng cú pháp tường minhLIMIT 20 OFFSET 40.
- Quên rằng
DESC/ASCchỉ áp cho cột ngay trước nó.ORDER BY price, name DESCnghĩa làpricetăng dần (mặc định) vànamegiảm dần — không phải cả hai cùng giảm. Muốn cả hai giảm phải viếtORDER BY price DESC, name DESC.
OFFSETlớn làm chậm query. Bỏ qua hàng chục nghìn dòng tốn công duyệt. Với bảng lớn, ưu tiên keyset pagination. Đồng thời đảm bảo cột trongORDER BYcó index phù hợp.
- Sắp xếp số lưu dưới dạng chuỗi. Nếu cột mã đơn là kiểu text
'10', '2', '1',ORDER BYsẽ cho'1', '10', '2'(so theo từng ký tự) chứ không phải1, 2, 10. Ép kiểu hoặc lưu đúng kiểu số nếu cần sắp theo giá trị số.
- Mẹo dùng số thứ tự cột.
ORDER BY 2 DESCsắp theo cột thứ hai trongSELECT. Tiện khi gõ nhanh, nhưng dễ vỡ khi bạn thêm/bớt cột — hạn chế dùng trong query lưu lại lâu dài.
Bài tập thực hành
Giả sử bạn có bảng orders(order_id, customer_id, created_at, status, total_amount) của một sàn TMĐT Việt Nam.
- Viết query lấy 20 đơn hàng mới nhất, mới nhất lên đầu, đảm bảo thứ tự ổn định khi có nhiều đơn cùng thời điểm tạo.
- Lấy Top 10 khách hàng chi tiêu nhiều nhất (tổng
total_amount), cùng mức chi tiêu thì khách cócustomer_idnhỏ hơn xếp trước.
- Hiển thị trang 4 của danh sách đơn, mỗi trang 25 dòng, sắp theo
created_atgiảm dần. Tính đúngOFFSET.
- Trên PostgreSQL: sắp đơn theo
total_amountgiảm dần, nhưng những đơn cótotal_amountlàNULLphải xuống cuối danh sách.
- Nâng cao: viết lại bài 1 dùng keyset pagination cho trang tiếp theo, biết dòng cuối trang trước có
created_at = '2026-05-01 12:30:00'vàorder_id = 99001.
OFFSET = (4 - 1) * 25 = 75, dùng ORDER BY created_at DESC, order_id DESC LIMIT 25 OFFSET 75.Tóm tắt
ORDER BY định nghĩa thứ tự hiển thị; LIMIT/OFFSET quyết định lấy bao nhiêu và bỏ qua bao nhiêu. Ba điều cần khắc cốt ghi tâm: (1) LIMIT luôn đi cùng ORDER BY; (2) luôn thêm tie-breaker duy nhất (thường là khóa chính) để thứ tự xác định và lặp lại được — đây là gốc rễ của hầu hết lỗi "số nhảy" và "phân trang trùng/sót"; (3) chủ động quyết định vị trí của NULL thay vì phó mặc cho mặc định của hệ quản trị. Khi làm việc với bảng lớn và cuộn sâu, hãy nhớ rằng OFFSET lớn rất chậm — keyset pagination là người bạn của bạn. Nắm chắc bài này, bạn đã có nền tảng vững để dựng mọi bảng Top N, mọi danh sách phân trang, và mọi báo cáo "mới nhất trước" một cách đáng tin cậy.