Phân trang và đánh phiên bản API: offset, cursor và v1/v2

  • author-image

    Nguyễn Đình Cường

  • blog-tag
  • blog-comment 0 comment
  • created-date 09 Nov, 2026
blog-thumbnail

API trả về danh sách mà không phân trang sẽ chậm dần khi dữ liệu lớn, và API không có chiến lược phiên bản sẽ làm hỏng ứng dụng khách mỗi khi bạn thay đổi. Cả hai vấn đề đều dễ lường trước ngay từ đầu và rất đau khi sửa muộn.

Vì sao phải phân trang?

Trả về toàn bộ bảng cho mỗi request lãng phí băng thông, bộ nhớ và thời gian của cả server lẫn client. Phân trang giới hạn số bản ghi mỗi lần, đồng thời đặt trần cho kích thước tối đa để không ai yêu cầu một lúc cả triệu dòng.

Phân trang kiểu offset

GET /articles?page=3&per_page=20-- hoặcGET /articles?limit=20&offset=40
SELECT * FROM articlesORDER BY created_at DESC, id DESCLIMIT 20 OFFSET 40;

Ưu điểm: đơn giản, cho phép nhảy thẳng tới trang bất kỳ, dễ hiển thị số trang. Nhược điểm: khi offset lớn, database vẫn phải quét và bỏ qua nhiều dòng nên chậm dần; và nếu có bản ghi mới chèn vào giữa lúc người dùng đang lật trang, họ có thể thấy trùng hoặc sót bản ghi.

Phân trang kiểu cursor

Thay vì "bỏ qua N dòng", client gửi "con trỏ" là vị trí bản ghi cuối đã nhận, và server trả các bản ghi tiếp theo sau vị trí đó:

GET /articles?limit=20&after=eyJpZCI6MTAwfQ
-- giả sử cursor giải mã ra (created_at, id) của dòng cuối trang trướcSELECT * FROM articlesWHERE (created_at, id) < (:last_created_at, :last_id)ORDER BY created_at DESC, id DESCLIMIT 20;

Ưu điểm: nhanh và ổn định dù bảng lớn nhờ tận dụng chỉ mục, không bị trùng/sót khi có dữ liệu mới. Nhược điểm: không nhảy được tới trang bất kỳ, phù hợp cho cuộn vô hạn (feed, timeline). Cursor thường được mã hóa (ví dụ base64) và client coi nó là chuỗi mờ, không tự chế.

Dù chọn kiểu nào, phải có sắp xếp xác định, và nếu cột sắp xếp có thể trùng giá trị, hãy thêm id làm tiêu chí phụ để thứ tự ổn định.

Định dạng phản hồi

{  "data": [ ... ],  "meta": { "per_page": 20, "next_cursor": "eyJpZCI6ODB9", "has_more": true }}

Bạn cũng có thể dùng header Link với rel="next" như một số API nổi tiếng. Quan trọng là nhất quán và có tài liệu.

Đánh phiên bản API

Khi cần thay đổi làm vỡ tương thích (đổi tên trường, bỏ endpoint, đổi ý nghĩa dữ liệu), bạn cần cho phép ứng dụng cũ tiếp tục hoạt động. Ba cách phổ biến:

  • Trong đường dẫn: /v1/users, /v2/users. Dễ thấy, dễ cache, dễ định tuyến, được dùng rộng rãi nhất.
  • Trong header: như Accept: application/vnd.myapp.v2+json. Gọn URL nhưng khó thử bằng trình duyệt.
  • Trong query: ?version=2. Đơn giản nhưng ít được ưa chuộng.

Với đa số đội nhóm, phiên bản trong đường dẫn là lựa chọn thực dụng.

Thay đổi nào không cần phiên bản mới?

Thêm trường mới vào phản hồi, thêm endpoint mới hoặc thêm tham số tùy chọn thường tương thích ngược, nên không cần tăng phiên bản, miễn là ứng dụng khách được viết để bỏ qua trường lạ. Ngược lại, xóa hay đổi tên trường, đổi kiểu dữ liệu, thắt chặt validation là thay đổi phá vỡ.

Loại bỏ phiên bản cũ có trách nhiệm

  1. Thông báo lộ trình ngừng hỗ trợ từ sớm.
  2. Thêm header cảnh báo như Deprecation hoặc Sunset trong phản hồi.
  3. Theo dõi lưu lượng của phiên bản cũ để biết khi nào an toàn tắt đi.
  4. Hạn chế số phiên bản chạy song song, vì mỗi phiên bản thêm là thêm gánh nặng bảo trì.

Ví dụ cài đặt cursor trong code

Dưới đây là cách mã hóa và giải mã cursor đơn giản bằng JavaScript, đủ để bạn hình dung ý tưởng:

const encode = (row) =>  Buffer.from(JSON.stringify({ t: row.created_at, id: row.id })).toString('base64url');const decode = (cursor) =>  JSON.parse(Buffer.from(cursor, 'base64url').toString());// Lấy limit + 1 bản ghi để biết còn trang sau hay khôngconst rows = await db.query(sql, [...params, limit + 1]);const hasMore = rows.length > limit;const page = rows.slice(0, limit);const nextCursor = hasMore ? encode(page[page.length - 1]) : null;

Mẹo lấy dư một bản ghi giúp biết còn dữ liệu hay không mà không cần chạy thêm câu đếm tổng, vốn tốn kém với bảng lớn. Nhớ kiểm tra và giới hạn giá trị limit do client gửi lên, ví dụ tối đa 100, để tránh bị lạm dụng.

Kết luận

Hãy phân trang ngay từ endpoint danh sách đầu tiên, chọn offset cho dữ liệu nhỏ cần số trang và cursor cho dữ liệu lớn dạng cuộn. Đặt phiên bản /v1 từ ngày đầu để sẵn đường cho tương lai. Hai quyết định nhỏ này sẽ cứu bạn khỏi nhiều đêm khó ngủ về sau.

Khi cần một lộ trình học có hệ thống, bạn có thể tham khảo khóa học trên codebem.com.

author_photo
Nguyễn Đình Cường

0 comment