Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Pagination: offset, cursor, page-based

API and Technical Fundamentals cho BA Bài 22/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn là BA cho một sàn thương mại điện tử như Tiki hay Shopee. Một ngày đẹp trời, đội mobile báo lỗi: màn hình "Lịch sử đơn hàng" của khách VIP load mãi không xong, app treo, rồi crash. Bạn mở log lên xem thì phát hiện API GET /orders đang cố trả về 47.000 đơn hàng của một khách hàng doanh nghiệp trong một response duy nhất. Server tốn 8 giây để query, response nặng 22 MB, và chiếc điện thoại Android tầm trung của khách hàng không đủ RAM để parse khối JSON khổng lồ đó.

Đây chính xác là vấn đề mà pagination (phân trang) sinh ra để giải quyết. Khi một list API có khả năng trả về hàng nghìn, hàng triệu bản ghi, việc nhồi tất cả vào một response là tự sát: chậm, tốn bộ nhớ phía client, dễ timeout, và làm sập trải nghiệm người dùng.

Là một BA, bạn không phải là người code thuật toán phân trang, nhưng bạn là người viết specreview thiết kế API. Nếu bạn không hiểu sự khác nhau giữa các kiểu phân trang, bạn sẽ viết ra những yêu cầu mơ hồ kiểu "API trả về danh sách đơn hàng có phân trang" — và rồi dev tự quyết, đến lúc giao hàng mới phát hiện không đáp ứng được nhu cầu thực tế (ví dụ: không cuộn vô tận được, hoặc số liệu nhảy lung tung khi load trang). Bài này trang bị cho bạn đủ chiều sâu để chọn đúng kiểu phân trang và viết spec rõ ràng.

Khái niệm cốt lõi

Pagination là kỹ thuật chia một tập kết quả lớn thành nhiều "trang" nhỏ, mỗi lần API chỉ trả về một trang. Client muốn xem tiếp thì gọi lại API để lấy trang kế. Có ba kiểu phân trang chính mà BA cần phân biệt rõ.

Pattern 1: Offset / Limit

Đây là kiểu cổ điển và trực quan nhất. Bạn nói với server: "Bỏ qua N bản ghi đầu, rồi cho tôi M bản ghi tiếp theo."

GET /orders?offset=0&limit=20    → lấy bản ghi 1–20
GET /orders?offset=20&limit=20   → lấy bản ghi 21–40
GET /orders?offset=40&limit=20   → lấy bản ghi 41–60
  • limit: số bản ghi tối đa trả về (kích thước trang).
  • offset: số bản ghi cần bỏ qua từ đầu.
Bản chất nó dịch thẳng sang câu SQL: SELECT * FROM orders LIMIT 20 OFFSET 40.

Ưu điểm: Dễ hiểu, dễ implement, cho phép nhảy đến bất kỳ trang nào (muốn xem trang 50 thì chỉ cần offset = 49 × limit).

Nhược điểm chí mạng: Khi offset lớn, database vẫn phải đếm và bỏ qua tất cả bản ghi trước đó. OFFSET 1000000 nghĩa là DB phải quét qua 1 triệu dòng rồi vứt đi, chỉ để lấy 20 dòng cuối — cực chậm. Ngoài ra nó dễ bị lỗi "nhảy bản ghi" khi dữ liệu thay đổi giữa các lần gọi (sẽ nói kỹ ở phần lỗi thường gặp).

Pattern 2: Page-based

Đây thực ra là một "lớp áo" thân thiện hơn của offset/limit. Thay vì để client tự tính offset, ta cho client truyền thẳng số trang.

GET /orders?page=1&size=20   → trang 1
GET /orders?page=2&size=20   → trang 2
GET /orders?page=3&size=20   → trang 3

Server tự quy đổi: offset = (page - 1) × size. Về mặt kỹ thuật, page-based và offset-based giống hệt nhau dưới gầm; chúng chia sẻ cùng ưu/nhược điểm. Khác biệt chỉ là cách thể hiện. Page-based hợp với giao diện có thanh phân trang dạng "1 2 3 ... 50" như trang admin, kết quả tìm kiếm.

Response của kiểu này thường kèm metadata để client dựng được thanh phân trang:

{
  "data": [ ... 20 đơn hàng ... ],
  "pagination": {
    "page": 2,
    "size": 20,
    "total_items": 1043,
    "total_pages": 53
  }
}

Pattern 3: Cursor-based (keyset)

Đây là kiểu hiện đại, được Facebook, Twitter/X, Slack, Stripe ưa dùng cho các feed cuộn vô tận. Thay vì "bỏ qua N dòng", cursor nói: "cho tôi các bản ghi sau con trỏ này."

Cursor là một chuỗi mã hóa, thường trỏ đến vị trí của bản ghi cuối cùng ở trang trước (ví dụ mã hóa created_at + id của nó).

GET /orders?limit=20
→ trả về 20 đơn + "next_cursor": "eyJpZCI6MTIzNDV9"

GET /orders?limit=20&cursor=eyJpZCI6MTIzNDV9 → trả về 20 đơn tiếp theo + next_cursor mới

Dưới gầm, server không dùng OFFSET mà dùng điều kiện WHERE trên cột đã đánh index: SELECT * FROM orders WHERE id < 12345 ORDER BY id DESC LIMIT 20. Nhờ đó database nhảy thẳng đến vị trí cần lấy mà không phải quét bỏ hàng triệu dòng — hiệu năng ổn định bất kể bạn cuộn sâu đến đâu.

Ưu điểm: Hiệu năng cao và ổn định ở mọi độ sâu; không bị lỗi nhảy/trùng bản ghi khi dữ liệu thay đổi.

Nhược điểm: Không nhảy được đến trang bất kỳ (chỉ tiến/lùi tuần tự); khó hiển thị "tổng số trang"; cursor là chuỗi mờ (opaque) nên khó debug bằng mắt.

So sánh nhanh

  • Offset/Limit & Page-based: dễ làm, nhảy trang tự do, nhưng chậm khi sâu và dễ lỗi khi dữ liệu động. Hợp với bảng admin, kết quả tìm kiếm có số trang.
  • Cursor-based: nhanh, ổn định, chống lỗi nhảy bản ghi, nhưng chỉ đi tuần tự. Hợp với feed, timeline, infinite scroll, dữ liệu thay đổi liên tục.

Tình huống thực tế

Tình huống 1: Foody và thảm họa offset sâu

Một startup giao đồ ăn (gọi tên là "FoodNow" cho dễ hình dung) xây màn hình admin để xem toàn bộ đánh giá nhà hàng. Họ dùng page-based pagination, mặc định 50 đánh giá/trang. Ban đầu mọi thứ êm đẹp. Nhưng sau 18 tháng, bảng reviews có 4,2 triệu dòng.

Một nhân viên vận hành muốn xem các đánh giá cũ nhất, click vào trang cuối: page=84000. Server dịch thành OFFSET 4199950 LIMIT 50. Database phải quét và loại bỏ gần 4,2 triệu dòng chỉ để lấy 50 dòng cuối. Query mất 11 giây, dashboard timeout ở mốc 10 giây, trang trắng xóa.

Diễn giải: Đây là điểm yếu kinh điển của offset. Chi phí truy vấn tỉ lệ thuận với độ sâu trang. Đội kỹ thuật xử lý bằng cách: (1) giới hạn không cho nhảy quá trang 1000, (2) bổ sung bộ lọc theo khoảng ngày để thu hẹp tập dữ liệu trước khi phân trang.

Bài học cho BA: Khi viết spec cho bảng dữ liệu lớn dùng page-based, luôn hỏi: "Tập dữ liệu này tối đa bao nhiêu dòng? Người dùng có thực sự cần nhảy đến trang cuối không?" Nếu dữ liệu sẽ phình to và người dùng hay tìm dữ liệu cũ, hãy cân nhắc cursor hoặc bắt buộc kèm bộ lọc.

Tình huống 2: Timeline của một mạng xã hội Việt và lỗi "đọc trùng"

Một mạng xã hội nội địa (giả định "Lotus-style") dùng offset cho feed bài viết, 10 bài/lần. Người dùng đang ở trang 1 (bài 1–10), trong lúc họ cuộn, có 3 bài mới được đăng lên đầu feed. Khi họ gọi trang 2 (offset=10), 3 bài đầu giờ đã đẩy 3 bài cũ xuống — kết quả là 3 bài người dùng vừa đọc ở trang 1 xuất hiện lại ở trang 2. Người dùng bực mình tưởng app lỗi.

Diễn giải: Vấn đề là offset trỏ theo vị trí, mà vị trí thay đổi khi có bản ghi mới chèn vào. Đội kỹ thuật chuyển sang cursor-based: cursor trỏ đến id của bài cuối cùng đã thấy. Dù có bao nhiêu bài mới đăng lên, lệnh "lấy các bài có id nhỏ hơn cursor" vẫn trả về đúng các bài tiếp theo, không trùng, không sót.

Bài học cho BA: Với dữ liệu thay đổi liên tục theo thời gian thực (feed, thông báo, chat), cursor-based gần như là lựa chọn bắt buộc. Trong spec, hãy ghi rõ yêu cầu phi chức năng: "Khi có bản ghi mới chèn vào trong lúc người dùng đang phân trang, kết quả không được trùng lặp hoặc bỏ sót."

Tình huống 3: Đọc tài liệu Stripe khi tích hợp thanh toán

Đội của bạn tích hợp cổng thanh toán Stripe cho một SaaS Việt Nam. Bạn cần đối soát giao dịch, gọi API GET /v1/charges để lấy danh sách thanh toán. Bạn mở tài liệu và thấy Stripe chỉ cung cấp cursor-based: các tham số limit (tối đa 100), starting_afterending_before (nhận id của object làm con trỏ).

GET /v1/charges?limit=100
GET /v1/charges?limit=100&starting_after=ch_3Nk9...

Stripe cố tình không cho offset hay "nhảy đến trang 500", vì với dữ liệu tài chính khổng lồ và liên tục phát sinh, cursor là cách duy nhất đảm bảo hiệu năng và tính nhất quán.

Diễn giải: Khi viết spec cho job đối soát chạy nền, bạn không thể giả định "lấy trang 1 đến trang N". Bạn phải mô tả vòng lặp: gọi API, lấy next_cursor (ở đây là id cuối), truyền vào starting_after, lặp đến khi has_more = false.

Bài học cho BA: Trước khi thiết kế luồng tích hợp, hãy đọc kỹ tài liệu phân trang của API bên thứ ba. Kiểu phân trang của họ quyết định cách bạn thiết kế logic xử lý dữ liệu phía mình.

Hướng dẫn từng bước

Khi bạn cần đặc tả pagination cho một list API, hãy đi theo các bước sau.

Bước 1 — Ước lượng quy mô dữ liệu. Hỏi: tập kết quả tối đa khoảng bao nhiêu dòng? Vài trăm hay vài triệu? Dữ liệu tĩnh hay thay đổi liên tục? Đây là yếu tố quyết định kiểu phân trang.

Bước 2 — Hiểu nhu cầu UI/UX. Giao diện là thanh phân trang có số trang (1, 2, 3...) hay là cuộn vô tận (infinite scroll)? Người dùng có cần nhảy đến trang bất kỳ không? Có cần hiển thị "tổng số kết quả" không?

Bước 3 — Chọn kiểu phân trang.

  • UI có số trang + cần nhảy trang + dữ liệu vừa phải, tĩnh → page-based.
  • Cuộn vô tận / feed / dữ liệu thay đổi liên tục / dữ liệu rất lớn → cursor-based.
  • API nội bộ kỹ thuật, đơn giản → offset/limit cũng được.
Bước 4 — Đặc tả tham số request. Ghi rõ tên tham số, kiểu dữ liệu, giá trị mặc định và giới hạn. Ví dụ: size mặc định 20, tối đa 100; nếu vượt 100 thì cap về 100 (không trả lỗi).

Bước 5 — Đặc tả cấu trúc response. Phải có: mảng data, và metadata phân trang. Với page-based: page, size, total_items, total_pages. Với cursor-based: next_cursor, has_more. Cân nhắc thêm link tiện dụng kiểu next, prev.

Bước 6 — Định nghĩa hành vi biên (edge cases). Điều gì xảy ra khi: trang vượt quá tổng số trang (trả mảng rỗng hay lỗi 404?); cursor không hợp lệ (lỗi 400); size âm hoặc bằng 0; tập kết quả rỗng. Ghi hết vào spec.

Bước 7 — Thống nhất với dev và QA. Viết acceptance criteria rõ ràng để QA test được, ví dụ: "Gọi với size=20 trên tập 45 bản ghi: trang 1 và 2 trả 20 bản ghi, trang 3 trả 5 bản ghi, total_pages = 3."

Lỗi thường gặp & mẹo

Lỗi 1 — Quên giới hạn limit tối đa. Nếu không cap, client tinh nghịch (hoặc bug) gửi limit=1000000 và làm sập server. Luôn đặt trần, thường 100 hoặc 200.

Lỗi 2 — Dùng offset sâu cho bảng triệu dòng. Như tình huống FoodNow. Mẹo: với dữ liệu lớn, ưu tiên cursor hoặc bắt buộc kèm bộ lọc thu hẹp.

Lỗi 3 — Lỗi nhảy/trùng bản ghi với offset trên dữ liệu động. Như tình huống feed. Mẹo: dữ liệu thay đổi liên tục thì dùng cursor.

Lỗi 4 — Trả lỗi 404 khi trang vượt quá phạm vi. Phần lớn trường hợp nên trả 200 OK với mảng data rỗng — đó không phải lỗi, chỉ là hết dữ liệu. Để client phân biệt rõ "không tìm thấy resource" với "hết trang".

Lỗi 5 — Đếm total_count trên bảng cực lớn mỗi request. Câu COUNT(*) trên bảng hàng chục triệu dòng có thể chậm ngang query chính. Mẹo: với cursor-based, thường ta bỏ luôn tổng số, chỉ dùng has_more.

Lỗi 6 — Quên định nghĩa thứ tự sắp xếp ổn định. Pagination chỉ đúng khi ORDER BY ổn định và xác định. Nếu sắp xếp theo cột có giá trị trùng (ví dụ chỉ theo created_at) mà không có "tie-breaker" như id, các bản ghi cùng timestamp có thể nhảy lung tung giữa các trang. Mẹo: luôn sắp xếp kèm một cột duy nhất, ví dụ ORDER BY created_at DESC, id DESC.

Mẹo BA vàng: Khi review spec API của dev, hãy luôn mở phần list endpoint và hỏi đúng ba câu: "Phân trang kiểu gì? limit tối đa bao nhiêu? Sắp xếp theo cột nào và có ổn định không?" Ba câu này lọc ra phần lớn vấn đề.

Bài tập thực hành

  • Phân loại tình huống. Với mỗi màn hình sau, chọn kiểu phân trang phù hợp và giải thích trong 1–2 câu: (a) trang admin xem danh sách 200 nhân viên có thanh số trang; (b) feed bài viết cuộn vô tận của một mạng xã hội; (c) màn hình lịch sử giao dịch ngân hàng với 5 triệu bản ghi, cho phép lọc theo khoảng ngày.
  • Viết mini-spec. Bạn phụ trách API GET /products cho một sàn TMĐT. Hãy viết đặc tả gồm: tham số request (tên, mặc định, giới hạn), cấu trúc JSON response kèm metadata phân trang, và ít nhất 3 edge case kèm hành vi mong đợi.
  • Tìm lỗi. Một dev đề xuất: "API GET /notifications dùng offset/limit, sắp xếp ORDER BY created_at DESC, không giới hạn limit." Đây là feed thông báo real-time, mỗi giây có thông báo mới. Hãy chỉ ra ít nhất 2 vấn đề và đề xuất sửa.
  • Đọc tài liệu thật. Mở tài liệu pagination của GitHub API hoặc Stripe API. Xác định: họ dùng kiểu phân trang nào, tên các tham số, và client biết "còn dữ liệu nữa hay không" bằng cách nào.

Tóm tắt

  • Pagination chia tập kết quả lớn thành các trang nhỏ để tránh response chậm, nặng, dễ timeout và làm sập client.
  • Offset/limitpage-based về bản chất giống nhau: dễ làm, nhảy trang tự do, nhưng chậm khi offset sâu và dễ lỗi nhảy/trùng bản ghi trên dữ liệu động.
  • Cursor-based trỏ đến vị trí bản ghi cuối, dùng index thay vì quét bỏ; hiệu năng ổn định, chống lỗi trùng/sót, nhưng chỉ đi tuần tự và khó hiển thị tổng số trang.
  • Chọn kiểu phân trang dựa trên: quy mô dữ liệu, tính động của dữ liệu, và nhu cầu UI (số trang hay cuộn vô tận).
  • Là BA, hãy luôn đặc tả rõ: tham số và giới hạn, cấu trúc metadata response, các edge case, và thứ tự sắp xếp ổn định. Khi review spec, hỏi đúng ba câu: kiểu phân trang, limit tối đa, và cột sắp xếp.