Lập trình · 19/09/2026

Cursor Pagination cho API: Phân trang ổn định khi dữ liệu liên tục thay đổi

OFFSET 100000 trông vô hại, nhưng database vẫn phải tìm và bỏ qua một lượng lớn record trước khi trả vài chục dòng. Trong lúc người dùng chuyển trang, dữ liệu mới còn có thể được chèn vào đầu danh sách, khiến một item xuất hiện hai lần hoặc biến mất khỏi hành trình đọc.

Cursor Pagination cho API: Phân trang ổn định khi dữ liệu liên tục thay đổi

OFFSET 100000 trông vô hại, nhưng database vẫn phải tìm và bỏ qua một lượng lớn record trước khi trả vài chục dòng. Trong lúc người dùng chuyển trang, dữ liệu mới còn có thể được chèn vào đầu danh sách, khiến một item xuất hiện hai lần hoặc biến mất khỏi hành trình đọc.

Cursor pagination, còn gọi là keyset pagination, không hỏi “bỏ qua bao nhiêu dòng”. Nó hỏi “tiếp tục sau vị trí ổn định nào trong thứ tự đã chọn”. Cách tiếp cận này phù hợp với feed, lịch sử giao dịch, audit log, danh sách đơn hàng và các API có dữ liệu lớn hoặc cập nhật thường xuyên.

Offset và cursor khác nhau ở đâu?

Tiêu chíOffset paginationCursor pagination
Request?page=200&limit=50?after=opaque-token&limit=50
Truy vấnBỏ qua N dòngĐi tiếp từ khóa sắp xếp
Trang sâuCó thể chậm dầnThường giữ chi phí ổn định với index phù hợp
Dữ liệu thay đổiDễ trùng hoặc bỏ sótỔn định hơn theo thứ tự khóa
Nhảy đến trang 73DễKhông phải mục tiêu chính

Offset vẫn hợp lý cho tập dữ liệu nhỏ, trang quản trị cần nhảy theo số trang hoặc báo cáo tĩnh. Cursor không phải lựa chọn mặc định cho mọi màn hình; nó đổi khả năng nhảy ngẫu nhiên lấy hiệu năng và tính liên tục.

1. Bắt đầu từ một thứ tự toàn phần

Cursor chỉ đúng khi mọi record có vị trí xác định duy nhất. Sắp xếp theo created_at DESC chưa đủ vì nhiều row có thể cùng timestamp. Cần thêm tie-breaker duy nhất, thường là primary key:

ORDER BY created_at DESC, id DESC

Cặp (created_at, id) tạo thứ tự toàn phần nếu id duy nhất và cả hai giá trị không đổi trong vòng đời record. Không dùng một field có thể bị sửa như updated_at nếu việc record đổi vị trí giữa lúc phân trang là không mong muốn.

SQL không bảo đảm thứ tự khi thiếu ORDER BY. Ngay cả khi kết quả “có vẻ” đi theo primary key trong môi trường phát triển, query plan hoặc dữ liệu production có thể tạo thứ tự khác.

2. Viết keyset query đúng với chiều sắp xếp

Trang đầu lấy các order mới nhất:

SELECT id, created_at, status, total
FROM orders
WHERE tenant_id = :tenant_id
ORDER BY created_at DESC, id DESC
LIMIT :limit_plus_one;

Nếu record cuối trang có created_at = 2026-09-19T08:30:00Zid = 9102, trang kế tiếp dùng phép so sánh cùng bộ khóa:

SELECT id, created_at, status, total
FROM orders
WHERE tenant_id = :tenant_id
  AND (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit_plus_one;

Trong PostgreSQL, row constructor được so sánh từ trái sang phải. Dạng tương đương là:

created_at < :cursor_created_at
OR (created_at = :cursor_created_at AND id < :cursor_id)

Toán tử phải đổi theo chiều sort. Trộn ASC/DESC, xử lý NULL hoặc sort theo biểu thức cần được thiết kế và test riêng; không áp dụng máy móc một dấu <.

3. Tạo index khớp filter và order

Query nhanh khi database có thể đi theo B-tree index thay vì sort hoặc quét phần lớn bảng:

CREATE INDEX orders_tenant_created_id_idx
ON orders (tenant_id, created_at DESC, id DESC);

tenant_id đứng trước vì mọi query đều lọc theo tenant; hai cột còn lại khớp thứ tự cursor. Nếu API luôn lọc thêm status, cân nhắc index khác dựa trên selectivity và workload thực tế, không ghép mọi filter vào một index khổng lồ.

Dùng EXPLAIN (ANALYZE, BUFFERS) với dữ liệu gần production. Một index đúng trên giấy vẫn có thể không được chọn nếu query trả phần lớn bảng, thống kê cũ hoặc kiểu tham số không khớp.

4. Cursor phải opaque nhưng vẫn kiểm chứng được

Client không cần biết cursor chứa timestamp và ID. Server có thể serialize payload versioned rồi base64url:

{
  "v": 1,
  "created_at": "2026-09-19T08:30:00.000000Z",
  "id": 9102,
  "filter": "sha256:..."
}

Base64 chỉ là encoding, không phải bảo mật. Nếu việc sửa cursor có thể vượt ranh giới quyền truy cập hoặc tạo query bất thường, ký payload bằng HMAC và xác minh signature bằng so sánh constant-time. Dữ liệu nhạy cảm không nên nằm trong cursor; nếu cần bí mật, dùng authenticated encryption hoặc lưu state phía server.

Field v cho phép đổi format sau này. Decoder phải giới hạn kích thước token, kiểm tra kiểu, ngày hợp lệ và từ chối version không hỗ trợ bằng lỗi 400 rõ ràng, thay vì để parser hoặc database phát sinh 500.

5. Ràng buộc cursor với filter và scope

Một cursor sinh cho status=paid không nên được dùng lại cho status=pending. Tương tự, cursor của tenant A tuyệt đối không được mở dữ liệu tenant B.

Có hai hướng:

  • Đưa filter đã chuẩn hóa và scope vào payload có chữ ký.
  • Lưu hash của filter trong cursor rồi so với request hiện tại.

Tenant/user scope vẫn phải lấy từ principal đã xác thực và luôn xuất hiện trong WHERE. Chữ ký cursor bảo vệ tính toàn vẹn, không thay thế authorization.

6. Thiết kế response cho client dễ dùng

{
  "data": [ ... ],
  "page": {
    "next_cursor": "eyJ2IjoxLC4uLn0.signature",
    "previous_cursor": null,
    "has_more": true
  }
}

Lấy limit + 1 record để biết còn trang tiếp theo hay không, sau đó chỉ trả limit. Cách này tránh chạy COUNT(*) cho mỗi request. Total count có thể đắt và nhanh lỗi thời; chỉ cung cấp nếu trải nghiệm sản phẩm thật sự cần, có thể bằng endpoint hoặc số liệu xấp xỉ riêng.

Giới hạn limit ở server, ví dụ mặc định 25 và tối đa 100. Không tin giá trị client vì page size cực lớn có thể gây tải database, memory và serialization.

7. Hỗ trợ trang trước mà không đảo lộn dữ liệu

Để đi ngược, cursor thường chứa khóa của record đầu trang hiện tại. Với danh sách hiển thị giảm dần, server tìm các record lớn hơn cursor theo chiều tăng dần, giới hạn N, rồi đảo mảng trước khi trả để giao diện vẫn giữ thứ tự giảm dần:

SELECT id, created_at, status, total
FROM orders
WHERE tenant_id = :tenant_id
  AND (created_at, id) > (:cursor_created_at, :cursor_id)
ORDER BY created_at ASC, id ASC
LIMIT :limit_plus_one;

Không dùng chung một token mơ hồ cho cả hai chiều. Có thể phát hành next_cursorprevious_cursor riêng, hoặc encode direction rồi validate chặt.

8. Điều gì xảy ra khi dữ liệu thay đổi?

Cursor pagination ổn định hơn offset nhưng không tự tạo snapshot:

  • Record mới ở đầu feed: người đang đi về phía cũ thường không bị đẩy lệch; họ chỉ chưa thấy record mới.
  • Record bị xóa: hành trình tiếp tục từ khóa, không cần bù vị trí offset.
  • Sort key bị sửa: record có thể xuất hiện lại hoặc bị bỏ qua vì nó chuyển vị trí.
  • Filter-relevant field đổi: record có thể vào hoặc rời tập kết quả giữa các request.

Với feed, semantics “dữ liệu đang sống” thường chấp nhận được. Với export hoặc quy trình phải đọc đúng một tập bất biến, thêm snapshot boundary như created_at <= :as_of, dùng transaction snapshot phù hợp hoặc materialize một export job. Cursor không thay thế isolation.

9. Timestamp, ID và NULL là các bẫy phổ biến

  • Giữ độ chính xác timestamp nhất quán khi encode/decode; làm tròn microsecond có thể bỏ sót record.
  • Không giả định UUID ngẫu nhiên phản ánh thời gian; dùng nó làm tie-breaker, không dùng thay sort time.
  • Nếu sort column có NULL, định nghĩa rõ NULLS FIRST/LAST và predicate tương ứng, hoặc dùng cột non-null đã chuẩn hóa.
  • Collation của text có thể thay đổi thứ tự theo locale/version; cursor sort theo text cần contract và migration thận trọng.
  • Không dùng floating-point làm khóa ổn định nếu giá trị được tính lại.

10. Error contract và khả năng quan sát

Cursor hết hạn, sai signature, không đúng filter hoặc không hỗ trợ version nên trả 400 với mã lỗi máy đọc được. Không trả stack trace hay chi tiết chữ ký. Nếu cursor hợp lệ nhưng record neo đã bị xóa, keyset query vẫn có thể tiếp tục vì nó dùng giá trị khóa, không cần row còn tồn tại.

Theo dõi latency theo page depth ước tính, số row đọc/trả, tỷ lệ cursor invalid, page size, query plan và số request chạm giới hạn. Log payload cursor đã giải mã có thể chứa định danh; ưu tiên log version, direction và hash rút gọn thay vì toàn bộ token.

11. Kịch bản kiểm thử bắt buộc

  1. Nhiều record cùng timestamp vẫn xuất hiện đúng một lần nhờ tie-breaker.
  2. Chèn record mới giữa hai request không làm lặp item ở trang kế.
  3. Xóa record cuối trang trước không làm query tiếp theo lỗi.
  4. Cursor bị sửa một byte bị từ chối.
  5. Cursor của filter hoặc tenant khác không được chấp nhận.
  6. Đi next rồi previous trả lại cùng cửa sổ và thứ tự.
  7. Limit bằng 0, âm hoặc vượt max được normalize/từ chối đúng contract.
  8. Timestamp giữ nguyên precision qua vòng encode/decode.
  9. Query trang sâu vẫn dùng index mong đợi trên dữ liệu lớn.

Checklist production

  • Order có tie-breaker duy nhất và sort key ổn định.
  • Keyset predicate khớp chính xác với order và hướng.
  • Composite index bắt đầu bằng scope/filter quan trọng rồi đến sort keys.
  • Cursor opaque, versioned, giới hạn kích thước và được ký khi cần.
  • Authorization luôn áp dụng độc lập với cursor.
  • Response dùng limit + 1, page size có giới hạn.
  • Semantics khi insert, update, delete và snapshot được ghi rõ.
  • Next/previous, filter mismatch và cursor lỗi đều có test.

Kết luận

Cursor pagination không chỉ là base64 hóa ID cuối trang. Thiết kế production bắt đầu từ thứ tự toàn phần, predicate đúng chiều và index phù hợp; sau đó mới đến token, chữ ký, filter, điều hướng hai chiều và semantics khi dữ liệu thay đổi. Làm đúng, API có thể đi qua hàng triệu record với chi phí ổn định hơn và trải nghiệm ít trùng/bỏ sót hơn so với offset.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.