Cơ chế offset vs cursor (keyset) đã so sánh ở câu riêng — với bảng hàng triệu dòng, chốt cursor cho API chính (độ sâu trang không giới hạn, dữ liệu ghi liên tục); offset chỉ giữ cho màn admin cần nhảy tới trang số N với dữ liệu vừa phải.
Các quyết định thiết kế:
- Cursor là token mờ (opaque): encode (created_at, id) của dòng cuối thành base64, trả trong next_cursor — client không tự ghép điều kiện SQL, sau này đổi cột sort không phá API.
- Sort phải deterministic: luôn thêm tie-breaker id vào ORDER BY; thiếu nó, các dòng trùng created_at sẽ lặp/sót giữa hai trang.
- Index khớp cả filter lẫn sort: composite index (status, created_at DESC, id DESC) — cột filter đứng trước, cột sort theo sau; sort ngoài index là full scan.
- Bỏ tổng số trang: COUNT(*) mỗi lần gọi sẽ quét cả bảng lớn. Trả has_next bằng cách lấy limit + 1 dòng; UI thật sự cần tổng thì dùng count ước lượng hoặc cache.
- Giới hạn tổ hợp filter + sort được phép: mỗi tổ hợp cần index tương ứng — đừng mở sort tự do trên mọi cột.
GET /posts?status=active&limit=20&cursor=eyJjIjoiMjAy...
→ { "items": [...], "next_cursor": "eyJjIjoiMjAy..." | null }Edge case: dòng nằm ở vị trí cursor bị xóa → keyset vẫn đúng (điều kiện so sánh không cần dòng đó tồn tại); trả kèm quan hệ con thì JOIN/batch một lần cho cả trang, tránh N+1 query.