Skip to content

Phân trang ​

Danh sách đơn hàng, bài viết, log, lịch sử giao dịch đều phải chia trang. Cách chia trang quyết định ba thứ: tốc độ ở trang sâu, tính đúng khi dữ liệu đổi trong lúc người dùng đang lật trang, và hình dạng API. Phần đo tốc độ trên MySQL ở chương 5; trang này nói phần chung cho mọi database.

Hai cách chính ​

OFFSET / LIMITKeyset (seek, cursor)
Câu lệnhORDER BY … LIMIT 20 OFFSET 400WHERE (sort_key) < (giá trị cuối trang trước) ORDER BY … LIMIT 20
Tốc độ trang sâuChậm dần: đọc rồi bỏ toàn bộ phần OFFSETNhư nhau ở mọi trang: seek thẳng qua index
Nhảy tới trang NĐượcKhông, chỉ trang trước / trang sau
Dữ liệu đổi giữa chừngLặp hoặc bỏ sót bản ghiỔn định
Hợp vớiBảng nhỏ, trang quản trị cần số trangFeed, cuộn vô hạn, API công khai, bảng lớn

OFFSET / LIMIT ​

sql
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 400;            -- trang 21

Chậm dần: database phải đọc 420 dòng rồi bỏ 400 dòng đầu. Trang 5.000 là đọc 100.000 dòng cho 20 dòng kết quả.

Sai khi dữ liệu đổi. OFFSET đếm theo vị trí, mà vị trí thay đổi khi có bản ghi mới chèn lên đầu hoặc bản ghi cũ bị xoá:

  • Người dùng đọc trang 1 (bài 1–20). Có 2 bài mới đăng. Trang 2 (OFFSET 20) giờ bắt đầu từ bài thứ 19 cũ: bài 19 và 20 hiện lại lần nữa.
  • Ngược lại, một bài ở trang 1 bị xoá thì mọi bài dồn lên một vị trí: bài đầu tiên của trang 2 cũ bị bỏ sót, không bao giờ hiện ra.
Thử: dữ liệu đổi trong lúc người dùng lật trang
Bảng hiện tại (mới nhất trước) · mỗi trang 4 bài
121110987654321
OFFSET / LIMIT
SELECT … ORDER BY id DESC
LIMIT 4 OFFSET 0;
Keyset (WHERE id < cuối trang trước)
SELECT … ORDER BY id DESC
LIMIT 4;
bài chèn sau khi bắt đầu lật trangbài bị lặp (đã hiện ở trang trước)

Gợi ý: lấy trang 1, bấm “Chèn 2 bài mới”, rồi lấy tiếp tới hết. Làm lại và thử “Xoá 1 bài đã xem” sau trang 1.

ORDER BY phải xác định duy nhất thứ tự. ORDER BY created_at mà nhiều dòng trùng created_at thì database được phép trả các dòng trùng theo thứ tự bất kỳ, và thứ tự đó có thể khác nhau giữa hai câu query. Kết quả: một dòng hiện ở cả trang 2 lẫn trang 3, dòng khác không hiện ở trang nào. Luôn thêm khoá chính làm tie-breaker: ORDER BY created_at DESC, id DESC.

Khi vẫn dùng OFFSET: bảng nhỏ, trang quản trị nội bộ cần nhảy tới trang N. Nên giới hạn số trang tối đa (nhiều hệ thống chỉ cho đi tới trang 50 hoặc 10.000 kết quả đầu, Elasticsearch mặc định cũng vậy), và tối ưu trang sâu bằng deferred join (MySQL chương 5).

Keyset pagination ​

Nhớ giá trị sắp xếp của dòng cuối trang trước, rồi lấy các dòng đứng sau nó:

sql
-- trang đầu
SELECT id, title, created_at FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- trang sau: dòng cuối trang trước có (created_at = :c, id = :i)
SELECT id, title, created_at FROM posts
WHERE created_at < :c
   OR (created_at = :c AND id < :i)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- index: (created_at, id)

PostgreSQL viết gọn được bằng so sánh tuple và vẫn dùng index: WHERE (created_at, id) < (:c, :i). Trên MySQL, dạng tách điều kiện như trên chắc chắn dùng được index; dùng dạng tuple thì kiểm tra lại bằng EXPLAIN.

Điều kiện để keyset đúng và nhanh:

  1. Khoá sắp xếp duy nhất: thêm khoá chính vào cuối ORDER BY và vào điều kiện so sánh.
  2. Index khớp ORDER BY, cùng thứ tự cột và cùng chiều. Có điều kiện lọc thì cột lọc so sánh bằng đứng trước: WHERE user_id = ? … ORDER BY created_at DESC, id DESC cần index (user_id, created_at, id) (quy tắc ESR, MySQL chương 4). Sort trộn chiều (a ASC, b DESC) cần descending index.
  3. Cột sắp xếp không NULL. So sánh với NULL luôn ra UNKNOWN nên các dòng NULL rơi khỏi điều kiện; MySQL xếp NULL lên đầu khi ASC còn PostgreSQL xếp NULL xuống cuối. Đặt NOT NULL, hoặc xử lý nhóm NULL riêng.
  4. Trang trước: đảo chiều so sánh và chiều sắp xếp (> và ASC), lấy 20 dòng, rồi đảo ngược kết quả trước khi trả về.

Giới hạn: không nhảy được tới trang N; sắp xếp theo giá trị tính toán (điểm xếp hạng, khoảng cách) không có index thì vẫn phải tính rồi sort, hoặc lưu sẵn giá trị đó thành cột có index.

Cursor trong API ​

Đừng để lộ chi tiết keyset ra API. Gói các giá trị cần thiết thành một chuỗi mờ (opaque cursor):

js
// cursor = base64url của các giá trị keyset, kèm chữ ký để client không sửa được
const encode = (row) => sign(Buffer.from(JSON.stringify({ c: row.created_at, i: row.id })).toString('base64url'))

// lấy limit + 1 dòng để biết còn trang sau không
const rows = await db.query(sql, [...params, limit + 1])
const hasMore = rows.length > limit
const data = rows.slice(0, limit)
res.json({ data, next_cursor: hasMore ? encode(data[data.length - 1]) : null })
  • Lấy limit + 1 dòng để biết has_more mà không phải đếm.
  • Ràng buộc cursor với bộ lọc và cách sắp xếp: cursor của danh sách “sắp theo giá” không được dùng cho danh sách “sắp theo ngày”. Đưa thông tin sắp xếp vào cursor hoặc kiểm tra khi nhận.
  • Giới hạn kích thước trang (ví dụ tối đa 100) để client không xin 1 triệu dòng một lần.
  • Ví dụ thực tế: Stripe dùng starting_after=<id> (keyset theo id), GitHub và Slack trả cursor trong response.

Đếm tổng số ​

SELECT COUNT(*) trên bảng lớn phải quét cả index (MySQL chương 5), thường còn tốn hơn chính trang dữ liệu. Các cách thay thế:

  • Không đếm: chỉ trả has_more. Đủ cho cuộn vô hạn và hầu hết API.
  • Đếm có giới hạn: SELECT COUNT(*) FROM (SELECT 1 FROM … LIMIT 1001) t rồi hiển thị “hơn 1.000”.
  • Số ước tính: MySQL information_schema.TABLES.TABLE_ROWS, PostgreSQL pg_class.reltuples; chỉ dùng cho cả bảng, không theo bộ lọc.
  • Bảng counter cập nhật cùng transaction hoặc qua CDC, cho các bộ đếm hay hiển thị.

Khi dữ liệu đổi trong lúc lật trang ​

Keyset đứng vững trước bản ghi mới chèn lên đầu và bản ghi bị xoá. Vẫn còn hai trường hợp:

  • Cột sắp xếp của một dòng thay đổi (bài được sửa nên updated_at đổi, sắp theo lượt thích): dòng đó có thể nhảy lên phía trước (không hiện nữa) hoặc ra phía sau (hiện hai lần). Sắp theo cột không đổi (created_at, id) khi cần danh sách ổn định.
  • Muốn danh sách đúng như lúc bắt đầu: lưu thời điểm bắt đầu vào cursor và thêm AND created_at <= :snapshot_time cho mọi trang sau. Bài mới đăng không xen vào; người dùng làm mới thì mới thấy.

Phân trang khi dữ liệu đã chia shard ​

  • OFFSET trên nhiều shard rất đắt: muốn trang có OFFSET 10.000, mỗi shard phải trả 10.020 dòng đầu của nó để gộp lại rồi lấy đúng 20 dòng. 10 shard là 100.200 dòng cho 20 dòng kết quả. Đây là lý do Elasticsearch mặc định giới hạn from + size ở 10.000.
  • Keyset trên nhiều shard: mỗi shard chỉ trả 20 dòng đứng sau cursor, gộp và lấy 20 dòng đầu, cursor giữ khoá của dòng cuối. Elasticsearch có search_after (kèm point-in-time để có snapshot ổn định) cho đúng việc này.

Chọn nhanh ​

Tình huốngChọn
Feed, cuộn vô hạn, nút “xem thêm”Keyset + cursor, không đếm tổng
API công khaiCursor mờ, giới hạn kích thước trang
Trang quản trị cần số trang, bảng nhỏOFFSET, có tie-breaker, giới hạn số trang
Bảng lớn nhưng vẫn cần số trangOFFSET với deferred join, đếm có giới hạn, hoặc thêm bộ lọc để thu hẹp
Xuất toàn bộ dữ liệu (export, job)Keyset theo khoá chính, theo lô

Ghi nhớ

  1. OFFSET chậm dần ở trang sâu, và lặp hoặc bỏ sót bản ghi khi dữ liệu đổi giữa chừng.
  2. Luôn thêm khoá chính làm tie-breaker trong ORDER BY.
  3. Keyset: so sánh với giá trị của dòng cuối trang trước, index khớp ORDER BY, cột sắp xếp NOT NULL.
  4. API: cursor mờ, lấy limit + 1 để biết còn trang sau, giới hạn kích thước trang, tránh COUNT(*) trên bảng lớn.
  5. Dữ liệu chia shard: OFFSET nhân chi phí theo số shard; keyset (search_after) thì không.