Một màn hình nhật ký chỉ cần nút “Tải thêm”, nhưng truy vấn lại dùng OFFSET ngày càng lớn. Đây là lúc bạn nên xem xét keyset pagination: lấy dữ liệu tiếp sau một mốc đã biết thay vì bỏ qua một số lượng dòng.
Bài này dùng cú pháp PostgreSQL 18, đối chiếu ngày 21/10/2026. Các ví dụ là truy vấn minh họa cho bảng bài viết; bạn cần chạy trên dữ liệu thử của mình và xem query plan trước khi dùng trong production.
1. Khi nào OFFSET vẫn hợp lý?
LIMIT 20 OFFSET 100 dễ hiểu và phù hợp khi sản phẩm thực sự cần nhảy đến một số trang. Tuy nhiên, PostgreSQL vẫn phải xử lý những dòng bị bỏ qua. Tài liệu cũng yêu cầu thứ tự đủ xác định khi dùng LIMIT, nếu bạn muốn các lần lấy dữ liệu nhất quán. Xem LIMIT và OFFSET.
Với danh sách cuộn liên tục, người dùng thường cần “20 bản ghi tiếp theo”, không cần “trang 742”. Keyset khớp với nhu cầu đó. Đổi lại, bạn không còn tính được vị trí bất kỳ chỉ bằng một số trang.
2. Chuẩn bị thứ tự có khóa phân biệt
Giả sử dữ liệu mới nhất đứng đầu. Chỉ sắp theo created_at là chưa đủ: nhiều bài có thể có cùng thời điểm tạo. Thêm id làm khóa phân biệt và giữ hai cột không null.
CREATE TABLE articles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX articles_created_id_idx
ON articles (created_at DESC, id DESC);
Đây là schema cho ví dụ mới. Với bảng đang phục vụ người dùng, hãy lập kế hoạch migration và cách tạo index phù hợp tải ghi. Không copy lệnh thay đổi cấu trúc vào database thật mà chưa đánh giá ảnh hưởng.
3. Lấy trang đầu và trang tiếp theo
Trang đầu lấy 21 dòng nếu kích thước hiển thị là 20. Dòng thứ 21 giúp xác định có thể còn trang tiếp theo, nhưng không đưa nó vào danh sách trả cho UI.
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 21;
Sau khi hiển thị 20 dòng, lấy created_at và id của dòng thứ 20 làm cursor. Ví dụ mốc là thời điểm dưới đây và ID 120:
SELECT id, title, created_at
FROM articles
WHERE (created_at, id) <
(TIMESTAMPTZ '2026-10-21 10:00:00+07', 120::bigint)
ORDER BY created_at DESC, id DESC
LIMIT 21;
PostgreSQL so sánh cặp giá trị theo thứ tự từ trái sang phải. Khi thời điểm bằng nhau, id quyết định thứ tự. Điều này dựa trên quy tắc so sánh row constructor.
Trong code ứng dụng, truyền cursor dưới dạng tham số của driver database. Chuỗi timestamp và số trong ví dụ chỉ nhằm làm rõ truy vấn, không phải hướng dẫn ghép SQL từ input người dùng.
4. Giữ cursor và bộ lọc đi cùng nhau
Một cursor đề xuất có thể chứa:
{
"createdAt": "2026-10-21T03:00:00.000000Z",
"id": "120",
"sort": "created_desc",
"filterVersion": 1
}
Lưu bigint bằng chuỗi nếu lớp ứng dụng không biểu diễn chính xác mọi số nguyên 64 bit. Giữ đủ độ chính xác của timestamp do database trả về; làm tròn xuống mili giây có thể thay đổi ranh giới khi cột có phần microsecond.
Mã hóa cursor thành Base64 chỉ giúp truyền chuỗi thuận tiện. Nó không chứng minh dữ liệu hợp lệ và cũng không thay thế kiểm tra quyền. Nếu người dùng đổi bộ lọc, chẳng hạn từ tất cả bài sang một tác giả, hãy bắt đầu lại từ trang đầu hoặc dùng cursor được ràng buộc đúng bộ lọc.
5. Kiểm tra trước khi kết luận nhanh hơn
Tài liệu index nhiều cột giải thích vai trò của thứ tự cột đối với B-tree. Nếu truy vấn thực tế có tenant_id, category hoặc trạng thái, index minh họa ở trên chưa chắc là lựa chọn tốt nhất.
Hãy đo bằng EXPLAIN (ANALYZE, BUFFERS) trên môi trường thử. So sánh số dòng đã đọc, thời gian và buffer với cùng tập dữ liệu. Không chỉ nhìn tên “Index Scan”: bảng nhỏ có thể được planner xử lý hiệu quả bằng cách khác.
Một bài kiểm tra chức năng hữu ích là tạo nhiều dòng có cùng created_at, đi hết danh sách rồi kiểm tra không trùng ID và không bỏ sót dữ liệu khi tập dữ liệu giữ nguyên. Sau đó thử chèn bài mới giữa hai lần tải. Bài mới thường nằm trước cursor nên sẽ xuất hiện khi người dùng làm mới đầu danh sách.
Giới hạn cần hiểu
- Nếu sửa cột đang dùng để sắp xếp, một dòng có thể di chuyển qua ranh giới cursor. Keyset không tự cung cấp snapshot của toàn bộ phiên duyệt.
- Khi dữ liệu bị xóa hoặc bộ lọc thay đổi, tập kết quả cũng thay đổi. Hãy mô tả hành vi đó trong yêu cầu sản phẩm.
- Keyset phù hợp cho “tải thêm”; nhu cầu báo cáo, đếm tổng hoặc nhảy trang có thể cần thiết kế khác.
Điểm bắt đầu tốt là một thứ tự ổn định, cursor giữ đúng độ chính xác và phép đo trên truy vấn thật. Đừng đổi toàn bộ màn hình sang keyset chỉ vì tên kỹ thuật nghe có vẻ tối ưu hơn.



