Phân trang trong SQL: Từ OFFSET/LIMIT đến Keyset Pagination và Window Functions

Phân trang là việc tưởng đơn giản nhưng dễ thành một quả "bom hẹn giờ" trong các ứng dụng có dữ liệu tăng theo thời gian. Trang đầu load nhanh thì ai cũng làm được. Đến lúc bảng có vài triệu bản ghi và người dùng nhảy thẳng vào trang thứ 1000, mọi thứ mới bắt đầu lộ ra.
Bài này mình tổng kết các cách phân trang phổ biến trong SQL, kèm phân tích cách database thực sự xử lý chúng và khi nào nên dùng cái nào.
Bạn cho chúng mình xin 1 upvote để chúng mình có động lực ra những bài viết thú vị hơn nữa nhé 😄😄😄
Ở đây còn có nhiều điều bất ngờ về SQL hơn nữa: https://sydexa.com/courses/advanced-sql 🤩🤩🤩
Thêm nữa, dưới đây là các hội nhóm mà chúng mình tổng hợp các kiến thức liên quan, bạn cùng tham gia nhé 😍😍😍
Cộng Đồng Backend và System Design Việt Nam: https://www.facebook.com/groups/sydexa
Kênh TikTok: https://www.tiktok.com/@sydexa.com
Kênh youtube: https://www.youtube.com/@sydexa.official

Cặp đôi quen thuộc OFFSET và LIMIT
Đây gần như đã là một cái phản xạ tự nhiên của anh em dev khi nghĩ đến phân trang. Cú pháp đơn giản và gần như giống nhau giữa các database:
-- PostgreSQL, MySQL, SQLite
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 40;
-- SQL Server, Oracle 12c+
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
OFFSET 40 ROWS
FETCH NEXT 20 ROWS ONLY;
Câu lệnh trên lấy trang 3 (mỗi trang 20 bản ghi) và bỏ qua 40 bản ghi đầu.
Vấn đề bắt đầu khi bạn hiểu OFFSET 40 thực sự làm gì. Database không nhảy thẳng đến vị trí 40 như nhiều người nghĩ. Nó vẫn phải đọc, sau đó sắp xếp toàn bộ bản ghi thỏa mãn điều kiện, đếm đủ 40 cái đầu rồi bỏ đi, để trả về 20 cái tiếp theo. Độ phức tạp là O(N + LIMIT), càng vào trang sâu sẽ càng chậm. Trang 1 mất 10ms thì trang 1000 có thể mất nhiều giây, dù index đã có đầy đủ.
Vấn đề thứ hai là tính nhất quán. Giả sử có 100 bản ghi sắp xếp theo created_at DESC. Người dùng xem trang 1 (20 bản ghi đầu), rồi có 5 bản ghi mới được insert. Khi họ click sang trang 2, 5 bản ghi cuối của trang 1 sẽ bị đẩy xuống đầu trang 2 và người dùng lại nhìn thấy chúng một lần nữa, tổng là hai lần. Ngược lại, nếu có bản ghi bị xóa giữa chừng, sẽ có bản ghi bị bỏ qua ở trang 2, vì bản ghi ở trang 2 bây giờ đã bị đẩy lên trang 1.
Dù vậy, OFFSET/LIMIT vẫn có chỗ đứng. Nó hỗ trợ nhảy đến trang bất kỳ, dễ hiển thị số trang truyền thống kiểu "1 2 3 ... 99", và đơn giản đến mức ai mới học SQL cũng làm được. Với bảng nhỏ (dưới khoảng 100 nghìn bản ghi) hoặc trang admin nội bộ, đây vẫn là lựa chọn hợp lý.
Một điểm mà ít người để ý là ngay cả khi bạn dùng OFFSET/LIMIT, query COUNT(*) đi kèm để hiển thị tổng số trang cũng tốn chi phí không kém. Mình sẽ quay lại vấn đề này ở cuối bài.

Keyset Pagination, cách dùng cho data lớn
Còn gọi là seek method hay cursor-based pagination. Đây là cách mà Facebook, Slack, Stripe và hầu hết các API hiện đại dùng cho kiểu dạng như newfeeds, timeline.
Ý tưởng đơn giản thôi, thay vì bảo database "bỏ qua 1000 bản ghi đầu", thì ta bảo "đưa tôi 20 bản ghi đứng ngay sau bản ghi cuối cùng của trang trước". Nếu cột sắp xếp đã có index, database sẽ đi thẳng đến vị trí đó bằng index B-tree rồi đọc tuần tự, không cần đếm hay sắp xếp gì thêm. Độ phức tạp luôn là O(LIMIT), bất kể trang số bao nhiêu.
-- Trang đầu tiên
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Các trang tiếp theo
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) < ('2026-05-10 08:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Tuple và so sánh tuple
(created_at, id) ở câu lệnh trên là một tuple, một bộ giá trị có thứ tự, giống như một dòng dữ liệu gồm nhiều cột ghép lại. So sánh tuple hoạt động như so sánh từ điển: xét trái trước, nếu bằng nhau thì xét tiếp sang phải.
Cụ thể, (a, b) < (c, d) tương đương với a < c HOẶC (a = c VÀ b < d). Một vài ví dụ:
('2026-05-10', 100) < ('2026-05-11', 50)đúng (vì 10 < 11, không cần xét id).('2026-05-10', 100) < ('2026-05-10', 200)đúng (cùng ngày, so id: 100 < 200).('2026-05-10', 100) < ('2026-05-10', 50)sai (cùng ngày, nhưng 100 không nhỏ hơn 50).
Tại sao phải dùng tuple thay vì viết riêng WHERE created_at < '...'? Vì nếu chỉ so sánh created_at, ta sẽ bỏ sót các bản ghi có cùng created_at nhưng id nhỏ hơn. Giả sử bảng có dữ liệu sau, sắp theo created_at DESC, id DESC, mỗi trang 3 bản ghi:
| id | created_at |
|---|---|
| 20 | 2026-05-10 08:30:00 |
| 18 | 2026-05-10 08:30:00 |
| 15 | 2026-05-10 08:30:00 |
| 12 | 2026-05-10 08:30:00 |
| 10 | 2026-05-10 08:30:00 |
| 8 | 2026-05-10 06:25:00 |
| 5 | 2026-05-10 06:20:00 |
Trang 1 trả về id=20, 18, 15. Con trỏ sau trang 1 là (created_at='2026-05-10 08:30:00', id=15). Nếu lấy trang 2 bằng WHERE created_at < '2026-05-10 08:30:00', kết quả sẽ là id=8 và id=5, còn id=12 và id=10 bị mất tích. Còn dùng tuple WHERE (created_at, id) < ('2026-05-10 08:30:00', 15) thì trang 2 đúng là id=12, 10, 8. --> Từ ví dụ trên, có thể bạn đang đặt câu hỏi trong đầu là tại sao mình lại chọn cột id là cột phụ mà không phải cột nào khác? Vì khi thêm cột id vào sẽ khiến cho mỗi bản ghi có 1 cái định danh riêng, không cột nào có thể bị trùng với cột nào được nữa (đưa nó về thế "unique" và phá cái "thế hòa" hiện tại đi (cùng created_at)).
Lưu ý về hỗ trợ: PostgreSQL, MySQL 8+ và SQLite hỗ trợ cú pháp tuple comparison trực tiếp. SQL Server và Oracle bản cũ thì phải viết tay điều kiện tương đương:
WHERE created_at < '2026-05-10 08:30:00'
OR (created_at = '2026-05-10 08:30:00' AND id < 12345)
Một vài nhược điểm của Keyset
Keyset không phải là miễn phí. Bạn không thể nhảy được đến trang bất kỳ như là combo OFFSET-LIMIT, mà chỉ đi tiếp hoặc lùi tuần tự. Giao diện phải thiết kế lại theo hướng "Tải thêm trang" hoặc "Cuộn vô hạn" thay vì "trang 1, 2, 3...". Và khi sắp xếp theo nhiều cột, logic trỏ tới dòng cuối trang trước cũng phức tạp hơn, đặc biệt khi user có thể đổi chiều sắp xếp hay đổi cột sắp xếp giữa chừng. Ví dụ: cặp (created_at, id) chỉ có nghĩa khi đi cùng ORDER BY created_at DESC, id DESC. Nếu user đang xem danh sách được sắp xếp theo ngày tạo, rồi đột nhiên đổi sang sắp xếp theo giá tăng dần thì mốc đánh dấu cũ trở nên vô nghĩa, nó không cho server biết phải bắt đầu từ đâu trong thứ tự mới. Cách đơn giản nhất là reset về trang đầu mỗi khi user đổi cột sắp xếp, lúc đó họ đang xem một danh sách khác hẳn rồi, quay lại đầu danh sách cũng hợp lý về trải nghiệm người dùng.
Một cái biến thể nhức đầu hơn là sắp xếp nhiều cột với chiều khác nhau, ví dụ ORDER BY dept ASC, salary DESC. Lúc này cú pháp tuple (a, b) < (?, ?) không còn dùng được nữa, vì nó giả định cả hai cột sắp xếp cùng chiều. Phải tự viết tay điều kiện kiểu WHERE dept > ? OR (dept = ? AND salary < ?). Càng nhiều cột với chiều lẫn lộn, điều kiện càng dài và càng dễ sai, đây là chỗ mà nhiều người mới làm về keyset hay bị vướng phải.

Tiếp theo là ROW_NUMBER() Window Function
Phương pháp này hay xuất hiện trong các phiên bản SQL Server cũ (trước 2012, chưa có OFFSET/FETCH) hoặc khi cần phân trang theo nhóm.
Window function là gì
Window function tính toán dựa trên một nhóm các bản ghi liên quan đến bản ghi hiện tại. Khác biệt quan trọng so với GROUP BY là GROUP BY gộp nhiều bản ghi thành một bản ghi tổng hợp, bạn mất chi tiết từng dòng. Còn Window function vẫn giữ nguyên tất cả các bản ghi, chỉ thêm cột tính toán cho mỗi dòng.
Ví dụ cụ thể: bạn có bảng salaries với cột department và salary, muốn xem mỗi nhân viên đứng thứ mấy về lương trong phòng của mình:
SELECT
name,
department,
salary,
ROW_NUMBER() OVER (PARTITION BY department ORDER BY salary DESC) AS rank_in_dept
FROM salaries;
Cú pháp chung là FUNCTION() OVER (PARTITION BY ... ORDER BY ...). PARTITION BY chia bản ghi thành các nhóm, mỗi nhóm là một vùng bản ghi riêng. ORDER BY quyết định thứ tự bản ghi bên trong mỗi vùng bản ghi. Các window function phổ biến gồm ROW_NUMBER(), RANK(), DENSE_RANK(), LAG(), LEAD(), SUM() OVER, AVG() OVER.
Khi dùng để phân trang
WITH numbered AS (
SELECT
id,
title,
created_at,
ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn
FROM articles
)
SELECT id, title, created_at
FROM numbered
WHERE rn BETWEEN 41 AND 60;
Cú pháp tuy dài dòng hơn OFFSET/FETCH nhưng hiệu năng cũng không tốt hơn là mấy, vẫn phải đánh số toàn bộ kết quả trước khi lọc. Trên các database hiện đại gần như không có lý do để dùng nó cho phân trang. Chỉ nên dùng khi maintain code cũ, hoặc khi cần kết hợp với PARTITION BY cho các trường hợp kiểu "top 3 sản phẩm mỗi danh mục":
SELECT category, product_name, sales
FROM (
SELECT
category,
product_name,
sales,
DENSE_RANK() OVER (PARTITION BY category ORDER BY sales DESC) AS rk
FROM products
) t
WHERE rk <= 3;
ROW_NUMBER, RANK, DENSE_RANK khác nhau ở đâu
Ba hàm này cùng đánh số thứ tự bản ghi theo thứ tự sắp xếp, khác nhau ở cách xử lý khi các bản ghi bằng nhau. ROW_NUMBER() luôn trả về số duy nhất kể cả khi giá trị sắp xếp bằng nhau. RANK() cho hai bản ghi bằng nhau cùng thứ hạng, rồi bỏ qua thứ hạng tiếp theo. DENSE_RANK() cũng cho cùng thứ hạng nhưng không bỏ qua thứ hạng tiếp theo.
Ví dụ với bảng điểm thi:
| Tên | Điểm | ROW_NUMBER() | RANK() | DENSE_RANK() |
|---|---|---|---|---|
| An | 95 | 1 | 1 | 1 |
| Bình | 90 | 2 | 2 | 2 |
| Cường | 90 | 3 | 2 | 2 |
| Dũng | 85 | 4 | 4 | 3 |
| Hải | 80 | 5 | 5 | 4 |
Bình và Cường cùng 90 điểm. ROW_NUMBER() vẫn đánh 2 và 3. RANK() đánh cả hai là 2, rồi Dũng nhảy lên 4 (bỏ qua 3). DENSE_RANK() đánh cả hai là 2, rồi Dũng là 3.
Một lưu ý cho phân trang: hầu như luôn dùng ROW_NUMBER(). Dùng RANK() hay DENSE_RANK() có thể khiến một trang có nhiều hơn hoặc ít hơn số bản ghi mong muốn khi có bản ghi bằng nhau.

Vấn đề COUNT(*) mà mình đã nhắc phần đầu
Một điểm mà mình hứa quay lại ở đầu bài. Ngay cả khi bạn dùng keyset pagination siêu nhanh, nếu giao diện vẫn hiển thị "Trang 5 / 200", có nghĩa là bạn vẫn phải chạy SELECT COUNT(*) ở đâu đó. Trên bảng nhiều triệu bản ghi, query này có thể chậm hơn cả phân trang. Các database thường phải quét toàn bảng để đếm.
Có vài cách giải quyết. Đơn giản nhất là bỏ tổng số trang, chỉ hiển thị "Trước" và "Sau", giống cách Google Search chỉ có "Trước 1, 2, 3, 4, 5 Tiếp". Nếu trường hợp bạn vẫn cần phải có, có thể cache số lượng trang trong Redis với thời gian tái đếm lại ngắn. Hoặc một mẹo: query SELECT COUNT(*) FROM (SELECT 1 FROM articles WHERE ... LIMIT 1000) t, đủ để hiển thị "999+" mà không phải đếm toàn bộ.
So sánh nhanh giữa 3 cách
| Tiêu chí | OFFSET/LIMIT | Keyset | ROW_NUMBER() |
|---|---|---|---|
| Độ phức tạp | O(N + LIMIT) | O(LIMIT) | O(N + LIMIT) |
| Nhảy đến trang bất kỳ | Có | Không | Có |
| Nhất quán khi data thay đổi | Không | Có | Không |
| Hiệu năng trang sâu | Kém | Tốt | Kém |
| Phù hợp cuộn vô hạn | Hạn chế | Tốt | Không |
| Phù hợp giao diện có số trang | Tốt | Không | Tốt |
| Độ phức tạp để áp dụng | Thấp | Trung bình | Trung bình |
Với bảng nhỏ, cần nhảy trang, hoặc trang admin nội bộ thì chọn OFFSET/LIMIT, không cần phức tạp hóa. Với bảng lớn, cuộn vô hạn, API public, hoặc data thay đổi liên tục thì chọn keyset. ROW_NUMBER() chỉ nên dùng khi buộc phải maintain SQL cũ hoặc cần phân trang theo từng nhóm.
Vài lưu ý khi tối ưu phân trang
Luôn có ORDER BY rõ ràng. Thiếu nó, thứ tự bản ghi là không xác định và mỗi lần query có thể ra kết quả khác nhau, kể cả khi data không đổi. Cột sắp xếp/lọc trong phân trang phải có index, và với keyset thì cần composite index trên đúng tổ hợp cột (ví dụ (created_at, id) cho cặp tuple ở trên).
Tránh SELECT * và chỉ lấy cột cần thiết. Đọc thêm cột không cần, làm tăng I/O và bộ nhớ không nhỏ một chút nào, đặc biệt với các bảng có cột text hay jsonb lớn.
Test với dữ liệu thật. Hiệu năng phân trang chỉ lộ ra khi bảng đủ lớn, vài triệu bản ghi trở lên. Trên môi trường dev với 1000 bản ghi thì cách nào cũng nhanh, dễ gây ảo tưởng.
Nếu API cho client truyền limit, hãy đặt giới hạn cứng (ví dụ tối đa 100). Không thì sẽ có ngày một vị thần nào đấy gửi limit=1000000 và database lăn ra ngủm.
Tóm cái váy lại
Không có phương pháp phân trang nào "tốt nhất" cho mọi trường hợp. Lựa chọn phụ thuộc vào quy mô data, kiểu giao diện và yêu cầu nghiệp vụ. Nếu đang build app mới mà dữ liệu có khả năng tăng nhiều theo thời gian, mình nghĩ nên bắt đầu với keyset luôn, chuyển đổi sau khi data đã lớn sẽ tốn nhiều công hơn nhiều. Đừng quên COUNT(*) thường mới là thứ giết hiệu năng thực sự, chứ không phải bản thân phân trang.
Phân trang cuối cùng cũng chỉ là một phần nhỏ của bài toán lớn hơn về truy xuất dữ liệu hiệu quả. Index phù hợp, query plan đúng mới là chân ái. Phân trang tốt là điều kiện cần, nhưng chưa đủ.
Lời nhắn
Bạn cho chúng mình xin 1 upvote để chúng mình có động lực ra những bài viết thú vị hơn nữa nhé 😄😄😄
Ở đây còn có nhiều điều bất ngờ về SQL hơn nữa: https://sydexa.com/courses/advanced-sql 🤩🤩🤩
Thêm nữa, dưới đây là các hội nhóm mà chúng mình tổng hợp các kiến thức liên quan, bạn cùng tham gia nhé 😍😍😍
Cộng Đồng Backend và System Design Việt Nam: https://www.facebook.com/groups/sydexa
Kênh TikTok: https://www.tiktok.com/@sydexa.com
Kênh youtube: https://www.youtube.com/@sydexa.official
All rights reserved
