Phân trang theo con trỏ giúp danh sách ổn định khi dữ liệu thay đổi. Tìm hiểu vì sao phân trang offset vỡ khi có chèn và xóa, và cách triển khai con trỏ gọn và an toàn.

Bạn mở một feed, cuộn một chút, mọi thứ bình thường cho đến khi không còn nữa. Bạn thấy cùng một mục hai lần. Một mục mà bạn chắc chắn đã thấy lại biến mất. Một hàng bạn định chạm dịch xuống, và bạn vào nhầm trang chi tiết.
Đây là lỗi người dùng nhìn thấy được, dù phản hồi API của bạn trông “đúng” khi xét riêng lẻ. Các triệu chứng thường thấy rất dễ nhận ra:
Trên di động tình trạng tệ hơn. Người dùng tạm dừng, chuyển app, mất kết nối rồi tiếp tục sau. Trong thời gian đó, mục mới xuất hiện, mục cũ bị xóa, và một vài mục được chỉnh sửa. Nếu app của bạn cứ yêu cầu “trang 3” bằng offset, ranh giới trang có thể dịch khi người dùng đang cuộn. Kết quả là một feed cảm thấy không ổn định và thiếu đáng tin cậy.
Mục tiêu đơn giản: một khi người dùng bắt đầu cuộn về phía trước, danh sách nên cư xử như một snapshot. Mục mới có thể tồn tại, nhưng chúng không nên xáo trộn những gì người dùng đã xem. Người dùng nên có một chuỗi mượt mà và dự đoán được.
Không có phương pháp phân trang nào hoàn hảo. Hệ thống thực tế có ghi đồng thời, chỉnh sửa, và nhiều lựa chọn sắp xếp. Nhưng phân trang con trỏ thường an toàn hơn offset vì nó phân trang từ một vị trí cụ thể trong thứ tự ổn định, thay vì từ một số lượng hàng động.
Phân trang offset là cách “bỏ qua N, lấy M” để phân trang qua danh sách. Bạn nói với API bao nhiêu mục cần bỏ qua (offset) và bao nhiêu mục trả về (limit). Với limit=20, bạn nhận 20 mục mỗi trang.
Về khái niệm:
GET /items?limit=20&offset=0 (trang đầu)GET /items?limit=20&offset=20 (trang hai)GET /items?limit=20&offset=40 (trang ba)Phản hồi thường bao gồm các mục cộng thêm thông tin đủ để yêu cầu trang tiếp theo.
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
Nó phổ biến vì ánh xạ tốt tới bảng, danh sách admin, kết quả tìm kiếm, và feed đơn giản. Cũng dễ triển khai với SQL bằng LIMIT và OFFSET.
Cái bẫy là giả định ẩn: dataset đứng yên trong khi người dùng phân trang. Trong app thực tế, hàng mới được chèn, hàng bị xóa, và khóa sắp xếp thay đổi. Đó là nơi bắt đầu các “lỗi bí ẩn”.
Phân trang offset giả định danh sách đứng yên giữa các yêu cầu. Nhưng danh sách thực sự dịch. Khi danh sách dịch, một offset như “bỏ qua 20” không còn trỏ tới cùng các mục.
Hãy tưởng tượng một feed sắp xếp theo created_at desc (mới nhất trước), kích thước trang 3.
Bạn tải trang 1 với offset=0, limit=3 và nhận [A, B, C].
Bây giờ mục mới X được tạo và xuất hiện ở đầu. Danh sách trở thành [X, A, B, C, D, E, F, ...]. Bạn tải trang 2 với offset=3, limit=3. Server bỏ qua [X, A, B] và trả về [C, D, E].
Bạn vừa thấy C lần nữa (bị trùng), và sau đó bạn sẽ bỏ lỡ một mục vì mọi thứ dịch xuống.
Xóa gây ra lỗi ngược lại. Bắt đầu với [A, B, C, D, E, F, ...]. Bạn tải trang 1 và thấy [A, B, C]. Trước khi trang 2, B bị xóa, nên danh sách thành [A, C, D, E, F, ...]. Trang 2 với offset=3 bỏ qua [A, C, D] và trả về [E, F, G]. D trở thành một khoảng trống mà bạn không bao giờ fetch.
Trong các feed mới nhất-trước, các chèn xảy ra ở trên cùng, chính xác là thứ làm dịch mọi offset phía sau.
"Danh sách ổn định" là điều người dùng mong đợi: khi họ cuộn về phía trước, các mục không nhảy, không lặp, hoặc biến mất vô lý. Ít liên quan tới đóng băng thời gian hơn là làm cho phân trang có thể dự đoán.
Hai ý tưởng thường bị trộn lẫn:
created_at với tie-breaker như id) để hai yêu cầu với cùng input trả về cùng thứ tự.Làm mới và cuộn-tiến là hành động khác nhau. Làm mới có nghĩa là “cho tôi thấy cái gì mới ngay bây giờ”, nên đầu có thể thay đổi. Cuộn-tiến có nghĩa là “tiếp tục từ chỗ tôi đang đứng”, nên bạn không nên thấy lặp lại hoặc khoảng trống bất ngờ do ranh giới trang dịch.
Một quy tắc đơn giản ngăn hầu hết lỗi phân trang: cuộn về phía trước không bao giờ nên hiển thị trùng lặp.
Phân trang con trỏ di chuyển qua danh sách dùng một dấu thẻ thay vì số trang. Thay vì “cho tôi trang 3”, client nói “tiếp tục từ đây”.
Hợp đồng rất trực quan:
Điều này chịu đựng tốt hơn chèn và xóa vì con trỏ neo vào một vị trí trong danh sách đã được sắp xếp, không phải vào số lượng hàng đang dịch.
Yêu cầu không thể thương lượng là có một thứ tự deterministic. Bạn cần quy tắc sắp xếp ổn định và tie-breaker nhất quán, nếu không con trỏ sẽ không phải là một bookmark đáng tin cậy.
Bắt đầu bằng cách chọn một thứ tự phù hợp với cách người ta đọc danh sách. Feed, tin nhắn và nhật ký hoạt động thường là mới nhất trước. Lịch sử như hóa đơn và audit log thường dễ với thứ tự cũ nhất trước.
Con trỏ phải xác định duy nhất một vị trí trong thứ tự đó. Nếu hai mục có thể chia sẻ cùng giá trị con trỏ, bạn sẽ sớm gặp trùng lặp hoặc khoảng trống.
Các lựa chọn phổ biến và điều cần chú ý:
created_at đơn: đơn giản nhưng không an toàn nếu nhiều hàng cùng timestamp.id đơn: an toàn nếu ID tăng dần, nhưng có thể không khớp thứ tự sản phẩm bạn muốn.created_at + id: thường là sự kết hợp tốt nhất (timestamp cho thứ tự sản phẩm, id làm tie-breaker).updated_at làm khóa chính: rủi ro cho infinite scroll vì chỉnh sửa có thể di chuyển mục giữa các trang.Nếu bạn cung cấp nhiều tuỳ chọn sắp xếp, coi mỗi chế độ sắp xếp là một danh sách khác với quy tắc con trỏ riêng. Một con trỏ chỉ có ý nghĩa cho một thứ tự chính xác.
Bạn có thể giữ mặt phẳng API nhỏ: hai input, hai output.
Gửi limit (bao nhiêu mục bạn muốn) và một cursor tùy chọn (bắt đầu từ đâu). Nếu không có con trỏ, server trả trang đầu.
Ví dụ yêu cầu:
GET /api/messages?limit=30cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
Trả về các mục và một next_cursor. Nếu không còn trang nữa, trả next_cursor: null. Client nên coi con trỏ là một token, không chỉnh sửa.
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
Logic phía server bằng lời đơn giản: sắp xếp theo một thứ tự ổn định, lọc theo con trỏ, rồi áp limit.
Nếu bạn sắp xếp mới nhất trước theo (created_at DESC, id DESC), giải mã con trỏ thành (created_at, id), rồi fetch các hàng nơi (created_at, id) nhỏ hơn cặp con trỏ (strictly less), áp cùng thứ tự, và lấy limit hàng.
Bạn có thể mã hoá con trỏ như JSON base64 (dễ) hoặc token ký/mã hoá (phức tạp hơn). Opaque an toàn hơn vì cho phép bạn thay đổi nội bộ sau này mà không phá client.
Cũng đặt mặc định hợp lý: mặc định mobile (thường 20–30), mặc định web (thường 50), và một max server hard để một client lỗi không thể yêu cầu 10.000 hàng.
Một feed ổn định chủ yếu hứa một điều: khi người dùng bắt đầu cuộn về phía trước, các mục họ chưa thấy không nên nhảy vị trí vì người khác tạo, xóa, hoặc chỉnh sửa bản ghi.
Với phân trang con trỏ, chèn là dễ nhất. Mục mới nên chỉ xuất hiện khi làm mới, không xuất hiện ở giữa các trang đã tải. Nếu bạn sắp xếp theo created_at DESC, id DESC, mục mới sẽ tự nhiên nằm trước trang đầu, nên con trỏ hiện tại tiếp tục vào các mục cũ hơn.
Xóa không nên làm dịch danh sách. Nếu một mục bị xóa, nó đơn giản là không được trả về khi bạn định fetch nó. Nếu bạn cần giữ kích thước trang nhất quán, cứ tiếp tục fetch cho đến khi lấy đủ limit mục hiển thị.
Chỉnh sửa là nơi các đội dễ vô tình tái đưa lỗi vào. Câu hỏi then chốt: chỉnh sửa có thể thay đổi vị trí sắp xếp không?
Hành vi theo snapshot thường là tốt nhất cho danh sách cuộn: phân trang theo khóa bất biến như created_at. Chỉnh sửa có thể thay đổi nội dung, nhưng mục không nhảy vị trí.
Hành vi live sắp xếp theo thứ gì đó như edited_at. Điều đó có thể gây nhảy (một mục cũ bị chỉnh sửa và nhảy lên gần đầu). Nếu bạn chọn này, coi danh sách là thay đổi liên tục và thiết kế UX xoay quanh làm mới.
Đừng làm con trỏ phụ thuộc vào “tìm hàng chính xác này”. Mã hoá vị trí thay vì vậy, ví dụ {created_at, id} của mục cuối cùng trả về. Sau đó truy vấn kế tiếp dựa trên giá trị, không phụ thuộc tồn tại hàng:
WHERE (created_at, id) < (:created_at, :id)id) để tránh trùng lặpPhân trang về phía trước là phần dễ. Các câu hỏi UX khó hơn là phân trang ngược, làm mới, và truy cập ngẫu nhiên.
Với phân trang ngược, hai cách tiếp cận hay được dùng:
next_cursor cho mục cũ hơn và prev_cursor cho mục mới hơn) trong khi giữ một thứ tự trên màn hình.Nhảy ngẫu nhiên khó hơn với con trỏ vì “trang 20” không còn ý nghĩa ổn định khi danh sách thay đổi. Nếu bạn thật sự cần nhảy, hãy nhảy tới một anchor như “xung quanh timestamp này” hoặc “bắt đầu từ message id này”, không phải một chỉ số trang.
Trên di động, caching quan trọng. Lưu con trỏ theo trạng thái danh sách (query + filters + sort), và coi mỗi tab/view như một danh sách riêng. Điều này tránh hiện tượng “chuyển tab và mọi thứ lộn xộn”.
Hầu hết vấn đề phân trang con trỏ không nằm ở database. Chúng đến từ những không nhất quán nhỏ giữa các yêu cầu chỉ hiện ra dưới tải thực tế.
Những nguyên nhân chính:
created_at một mình) khiến tie tạo ra trùng lặp hoặc thiếu mục.next_cursor không khớp với mục cuối thực sự trả về.Nếu bạn xây app trên nền tảng như Koder.ai, các edge case này xuất hiện nhanh vì client web và mobile thường chia sẻ cùng endpoint. Có một hợp đồng con trỏ rõ ràng và một quy tắc sắp xếp deterministic giữ cho cả hai client nhất quán.
Trước khi gọi phân trang là “xong”, xác minh hành vi khi có chèn, xóa, và retry.
next_cursor lấy từ hàng cuối cùng trả vềlimit có max an toàn và mặc định được tài liệu hóaVới làm mới, chọn một quy tắc rõ ràng: hoặc người dùng kéo để làm mới lấy mục mới ở đầu, hoặc bạn định kỳ kiểm tra “có gì mới hơn mục đầu tiên của tôi không?” và hiển thị nút “Mục mới”. Tính nhất quán làm cho danh sách cảm thấy ổn thay vì bị ám.
Hãy tưởng tượng một hộp thư hỗ trợ mà agent dùng trên web, trong khi quản lý kiểm tra cùng hộp thư trên mobile. Danh sách sắp xếp theo mới nhất trước. Mọi người kỳ vọng một điều: khi họ cuộn về phía trước, mục không nhảy, không trùng, hoặc biến mất.
Với phân trang offset, agent tải trang 1 (mục 1–20), rồi cuộn tới trang 2 (offset=20). Trong khi họ đang đọc, hai tin mới tới ở trên cùng. Giờ offset=20 trỏ tới chỗ khác so với trước đó. Người dùng thấy trùng lặp hoặc bỏ lỡ tin.
Với phân trang con trỏ, app yêu cầu “20 mục tiếp theo sau con trỏ này”, nơi con trỏ dựa trên mục cuối cùng user thực sự thấy (thường là (created_at, id)). Tin mới có thể tới suốt ngày, nhưng trang tiếp theo vẫn bắt đầu ngay sau tin cuối cùng user thấy.
Cách đơn giản để kiểm thử trước khi phát hành:
Nếu bạn prototype nhanh, Koder.ai có thể giúp scaffold endpoint và luồng client từ một prompt chat, rồi lặp an toàn bằng Planning Mode cùng snapshots và rollback khi một thay đổi phân trang gây ngạc nhiên trong testing.
Phân trang offset dựa trên việc “bỏ qua N hàng”, nên khi có hàng mới được chèn hoặc hàng cũ bị xóa, số hàng thay đổi. Cùng một offset có thể đột ngột trỏ tới các mục khác so với trước đó, gây ra trùng lặp và khoảng trống khi người dùng đang cuộn.
Phân trang bằng con trỏ dùng một bookmark đại diện cho “vị trí ngay sau mục cuối cùng tôi thấy”. Yêu cầu tiếp theo sẽ tiếp tục từ vị trí đó theo một thứ tự xác định, vì vậy các bản ghi chèn vào đầu hay xóa ở giữa không làm dịch ranh giới trang như offset.
Dùng một thứ tự xác định kèm tie-breaker, thường là (created_at, id) cùng chiều. created_at cho thứ tự thân thiện với sản phẩm, còn id đảm bảo mỗi vị trí duy nhất để tránh lặp hoặc bỏ sót khi timestamp trùng nhau.
Sắp xếp theo updated_at có thể khiến mục nhảy giữa các trang khi chúng bị chỉnh sửa, điều này phá vỡ kỳ vọng “cuộn tiến ổn định”. Nếu bạn cần chế độ live “cập nhật gần đây nhất”, hãy thiết kế UI để làm mới và chấp nhận việc đổi thứ tự thay vì hứa hẹn cuộn vô hạn ổn định.
Trả về một token mờ là next_cursor và để client gửi lại nguyên vẹn. Cách đơn giản là mã hóa (created_at, id) của mục cuối cùng thành một JSON base64, nhưng điều quan trọng là xem nó như một giá trị opaque để bạn có thể thay đổi nội bộ sau này.
Xây truy vấn kế tiếp từ các giá trị con trỏ, không phải từ “tìm hàng này chính xác”. Nếu mục cuối cùng bị xóa, (created_at, id) đã lưu vẫn xác định một vị trí, nên bạn có thể tiếp tục an toàn với điều kiện “strictly less than” (hoặc “greater than”) theo cùng thứ tự.
Dùng so sánh nghiêm ngặt và một tie-breaker duy nhất, và luôn lấy next_cursor từ mục cuối cùng bạn thực sự trả về. Hầu hết lỗi lặp lại đến từ dùng <= thay vì <, bỏ tie-breaker, hoặc sinh next_cursor từ hàng sai.
Chọn một quy tắc rõ ràng: làm mới tải các mục mới hơn ở đầu, trong khi cuộn về phía trước tiếp tục vào các mục cũ hơn từ con trỏ hiện tại. Đừng trộn “nghĩa vụ làm mới” vào cùng một luồng con trỏ, nếu không người dùng sẽ thấy đổi thứ tự và cho rằng danh sách không đáng tin.
Con trỏ chỉ hợp lệ cho một thứ tự và một tập filter chính xác. Nếu client thay đổi chế độ sắp xếp, truy vấn tìm kiếm, hoặc bộ lọc, nó phải bắt đầu một phiên phân trang mới không có con trỏ và lưu con trỏ riêng theo trạng thái danh sách.
Phân trang con trỏ rất tốt cho duyệt tuần tự nhưng không phù hợp cho nhảy “trang 20” ổn định vì dataset có thể thay đổi. Nếu cần nhảy, hãy nhảy tới một anchor như “xung quanh timestamp này” hoặc “bắt đầu sau id này”, rồi phân trang bằng con trỏ từ đó.