Cursor phải mã hoá đúng vị trí của bản ghi cuối trang theo khoá sắp xếp, không phải offset.
-- sort: created_at desc, id desc (id là tiebreaker duy nhất)
select * from orders
where (created_at, id) < ($last_created_at, $last_id)
order by created_at desc, id desc
limit 20;So sánh tuple (created_at, id) là mấu chốt: nếu chỉ where created_at < $x thì các bản ghi trùng created_at ở biên trang sẽ bị mất hoặc lặp. Cần index khớp đúng thứ tự (created_at desc, id desc) để truy vấn chạy bằng index seek, đó cũng là lý do cursor nhanh hơn OFFSET lớn (offset vẫn phải quét bỏ N dòng đầu).
Cursor nên là chuỗi opaque với client: base64 của {"created_at":"...","id":"...","sort":"-created_at","filter_hash":"ab12"}. Lý do:
- Client không parse được nên bạn đổi cấu trúc bên trong lúc nào cũng được.
- Nhúng sort và filter_hash để từ chối cursor bị dùng lẫn với bộ filter khác — nếu không, kết quả sẽ sai một cách khó lần.
- Nếu dữ liệu nhạy cảm thì ký (HMAC) để client không tự chế cursor.
Khi dữ liệu thay đổi lúc đang duyệt:
- Bản ghi chèn vào phía sau con trỏ (mới hơn, đã trôi qua) → không xuất hiện; đây là hành vi mong đợi của feed và cũng là lý do trang 1 nên tải lại từ đầu.
- Bản ghi bị xoá → cursor vẫn hợp lệ vì so sánh theo giá trị khoá, không theo vị trí; trang tiếp theo chỉ ngắn đi. Đây là điểm hơn hẳn OFFSET, nơi xoá một dòng làm cả trang sau dịch lên và client bỏ sót bản ghi.
- Bản ghi bị sửa created_at → nó nhảy chỗ trong thứ tự và có thể bị lặp hoặc mất. Nếu không chấp nhận được thì sắp xếp theo khoá bất biến (id/snowflake), hoặc dùng snapshot theo thời điểm (as_of timestamp, hoặc MVCC snapshot với cursor phía DB).
Hạn chế của cursor: không nhảy tới trang bất kỳ và khó cho tổng số trang — nên API dạng feed dùng cursor, còn bảng quản trị cần nhảy trang thì vẫn dùng offset trên tập dữ liệu nhỏ.