Họ đang đo gì
Bạn có hiểu database thực sự làm gì với OFFSET không, và có nhận ra bug hiển thị trùng khi dữ liệu thay đổi giữa hai trang.
Trả lời ngắn~30 giây
OFFSET không nhảy cóc được: engine vẫn phải đọc và loại bỏ 100.000 hàng trước khi trả về 20 hàng, nên trang càng sâu càng chậm tuyến tính. Thay bằng keyset pagination — nhớ giá trị sắp xếp của hàng cuối rồi hỏi WHERE (created_at, id) < ($1, $2) ORDER BY … LIMIT 20. Chi phí không đổi ở mọi trang, và cũng không còn cảnh một hàng xuất hiện hai lần khi có bản ghi mới chèn vào giữa.
Giải thích sâu
Vấn đề thứ hai ít người nhắc mà lại phiền hơn: OFFSET không ổn định. Người dùng đọc trang 3, trong lúc đó có bản ghi mới chèn lên đầu, họ bấm sang trang 4 và thấy lại hàng cuối của trang 3. Với cuộn vô hạn thì lỗi này lộ rõ tới mức người dùng báo lại. Keyset không có vấn đề đó vì nó neo vào GIÁ TRỊ của hàng cuối chứ không phải vị trí thứ tự.
Chi tiết dễ sai khi cài đặt: cột sắp xếp phải là duy nhất, hoặc bạn phải ghép thêm khoá chính để phá hoà. Sắp xếp theo created_at một mình mà có hai hàng cùng mili giây thì hàng sẽ bị nhảy cóc hoặc lặp. Dùng tuple (created_at, id) và so sánh dạng row-value (created_at, id) < ($1, $2) là cách gọn nhất; Postgres so sánh tuple đúng theo từ điển và dùng được index ghép (created_at DESC, id DESC).
Đánh đổi thật của keyset là bạn mất khả năng nhảy tới trang bất kỳ: không có “trang 47”, chỉ có tiếp và lùi. Với API và cuộn vô hạn thì đó không phải mất mát. Với bảng quản trị mà người dùng thật sự bấm số trang thì hoặc giữ OFFSET và chấp nhận giới hạn độ sâu, hoặc đổi thiết kế sang lọc thay vì phân trang sâu.
-- OFFSET: đọc rồi vứt 100.000 hàng
SELECT * FROM posts ORDER BY created_at DESC, id DESC
OFFSET 100000 LIMIT 20; -- ~420 ms, càng sâu càng chậm
-- Keyset: đi thẳng vào vị trí trên index
SELECT * FROM posts
WHERE (created_at, id) < ($1, $2) -- giá trị của hàng cuối trang trước
ORDER BY created_at DESC, id DESC
LIMIT 20; -- ~1 ms, ở trang 1 hay trang 5.000 đều vậy
CREATE INDEX posts_feed_idx ON posts (created_at DESC, id DESC);Câu hỏi tiếp theo họ sẽ hỏi
?Cursor trong API nên chứa gì?
Đúng những giá trị sắp xếp của hàng cuối, mã hoá base64 để client coi nó là chuỗi mờ. Đừng nhét offset vào rồi gọi là cursor — bạn giữ nguyên vấn đề và mất luôn khả năng đổi cách phân trang sau này.
?Cần tổng số trang thì làm sao?
Đếm chính xác trên bảng lớn là truy vấn đắt riêng. Thường thì đếm xấp xỉ từ thống kê (reltuples) là đủ cho giao diện, hoặc chỉ hiện “có thêm dữ liệu” thay vì một con số mà không ai bấm tới.
Trả lời thế này là mất điểm
- Nói OFFSET chậm “vì thiếu index”. Có index vẫn chậm — engine vẫn phải đi qua từng mục để đếm cho đủ offset.
- Bỏ qua việc phá hoà. Keyset không có tie-break là bug mất hàng, và nó chỉ xuất hiện khi dữ liệu đủ dày.