Phân trang là tính năng thường được viết trong năm phút rồi không ai nhìn lại, vì ở trang 1 nó luôn chạy tốt. Nó chỉ lộ ra khi một client lật tới trang 5.000, hoặc khi một job xuất dữ liệu quét hết bảng theo từng trang — và lúc đó nó không chậm, nó chậm dần theo đúng số trang đã lật. Bài mở đầu sê-ri này đo hai cách phân trang trên cùng một bảng, cùng một server, để biết chênh lệch thật là bao nhiêu và từ đâu ra.

Hai câu SQL, cùng một endpoint

Ảnh chụp đoạn mã nền tối hai cách phân trang cùng một bảng hai câu SQL với Go net/http và pgx v5 trên bảng reviews 400.000 dòng PostgreSQL 16, cách 1 OFFSET cho GET /reviews?page=10000&size=20 dùng SELECT id product_id stars body FROM reviews ORDER BY id DESC LIMIT $1 OFFSET $2 với tham số size và (page-1)*size kèm chú thích database vẫn phải đi qua 199.980 dòng đầu rồi mới vứt đi và trang càng sâu càng vứt nhiều chi phí tăng tuyến tính theo N, cách 2 KEYSET cho GET /reviews/keyset?after=200021&size=20 dùng SELECT cùng cột FROM reviews WHERE id < $1 ORDER BY id DESC LIMIT $2 với tham số after và size, trả kèm mốc để client lật tiếp không có khái niệm trang số N bằng đoạn if len(out.Items) == size thì c bằng phần tử cuối của out.Items lấy ID và out.NextCursor = &c cho ra JSON items và next_cursor 200001, chú thích index nhảy thẳng tới id < 200021 đọc đúng 20 dòng sâu bao nhiêu cũng vậy, điều kiện để keyset đúng gồm cột sắp xếp phải duy nhất hoặc kèm khoá phụ ORDER BY created_at DESC id DESC, phải có index đúng thứ tự sắp xếp ở đây là reviews_pkey, đổi lại không nhảy thẳng tới trang 57 chỉ có trang kế và trang trước

Hình 1: Hai handler. Cách 1 nhận số trang, cách 2 nhận mốc của trang trước và trả mốc cho trang sau.

Cách 1 là cách ai cũng viết đầu tiên: client gửi ?page=10000&size=20, server tính OFFSET (page-1)*size. Cách 2 — gọi là keyset hay cursor — không có khái niệm "trang số N": client gửi ?after=200021, tức id cuối cùng nó đã thấy, và server trả 20 dòng tiếp theo kèm next_cursor để lật tiếp.

Hai câu SQL khác nhau đúng một mệnh đề: OFFSET $2 so với WHERE id < $1. Toàn bộ chênh lệch ở phần sau nằm trong một mệnh đề đó.

Đo thật: chênh 90 lần ở trang cuối

Server Go dùng net/http và pgx, bảng reviews có 400.000 dòng với khoá chính id. Mình gọi qua HTTP thật (loopback), mỗi điểm đo 30 lần và lấy p50/p99:

Ảnh chụp output thật nền tối trang 19.999 offset 13,62 ms keyset 0,15 ms với Go 1.23 pgx v5 PostgreSQL 16 bảng reviews 400.000 dòng đo qua HTTP 30 lần mỗi điểm, bảng 1 độ trễ một request theo độ sâu trang size 20 gồm trang 1 bỏ qua 0 offset p50 0.12 ms p99 0.17 ms keyset p50 0.12 ms p99 0.19 ms, trang 10 bỏ qua 180 offset 0.13 và 0.19 keyset 0.14 và 0.16, trang 100 bỏ qua 1980 offset 0.17 và 0.21 keyset 0.11 và 0.14, trang 1000 bỏ qua 19980 offset 0.79 và 0.90 keyset 0.12 và 0.15, trang 5000 bỏ qua 99980 offset 3.46 và 3.75 keyset 0.13 và 0.17, trang 10000 bỏ qua 199980 offset 6.85 và 7.21 keyset 0.14 và 0.28, trang 19999 bỏ qua 399960 offset 13.62 và 13.88 keyset 0.15 và 0.17 chênh 90 lần, bảng 2 EXPLAIN ANALYZE BUFFERS trang 10.000 bỏ qua 199.980 dòng cho thấy offset Limit actual rows 20 với Index Scan Backward using reviews_pkey actual rows 200000 Buffers shared hit 2965 Execution Time 9.358 ms còn keyset Limit actual rows 20 với Index Scan Backward actual rows 20 Index Cond id < 200021 Buffers shared hit 4 Execution Time 0.009 ms, bảng 3 duyệt toàn bộ bảng từng trang size 100 4.000 trang offset 400000 dòng 28.54 s keyset 400000 dòng 0.96 s cùng số request cùng số dòng nhanh hơn 30 lần, bảng 4 đang xem trang 1 thì có 5 review mới chèn vào rồi lật sang trang 2 với offset trang 1 id 400000 tới 399981 trang 2 id 399985 tới 399966 có 5 dòng của trang 2 đã thấy ở trang 1 trùng còn keyset trang 1 id 400000 tới 399981 trang 2 id 399980 tới 399961 có 0 dòng trùng, nói thẳng ở trang 1 đến 100 hai cách bằng nhau offset không sai nó chỉ sai khi trang sâu hoặc dữ liệu đang đổi và Index Scan đọc 200.000 dòng để trả 20 dòng là bản chất của OFFSET không phải lỗi của PostgreSQL

Hình 2: Kết quả thật. Bốn phép đo: độ trễ theo độ sâu, kế hoạch thực thi, duyệt cả bảng, và chuyện gì xảy ra khi có dữ liệu mới.

Ở trang 1 tới 100, hai cách không khác nhau — đều quanh 0,12–0,17 ms, và phần lớn con số đó là chi phí HTTP chứ không phải SQL. Đây là lý do lỗi này sống lâu: mọi bài test thủ công đều dừng ở trang 2.

Từ trang 1.000 trở đi, offset tăng tuyến tính theo số dòng bị bỏ qua. Trang 1.000 mất 0,79 ms, trang 5.000 mất 3,46 ms, trang 10.000 mất 6,85 ms, trang 19.999 mất 13,62 ms. Chia cho số dòng bỏ qua, mỗi dòng tốn khoảng 34 nano-giây — ổn định tới mức có thể dự đoán: muốn biết trang N chậm bao nhiêu, nhân N với 20 rồi nhân 34 ns. Keyset thì không có biến N trong công thức: 0,11–0,15 ms ở mọi độ sâu.

Vì sao: OFFSET là "đọc rồi vứt"

Bảng thứ hai trong Hình 2 là EXPLAIN (ANALYZE, BUFFERS) cho trang 10.000. Hai kế hoạch dùng cùng một index reviews_pkey, cùng Index Scan Backward, cùng nút Limit trả 20 dòng. Khác nhau ở dòng bên dưới:

offset keyset
Dòng index phải đọc 200.000 20
Buffer (trang 8 KB) chạm tới 2.965 4
Thời gian thực thi 9,358 ms 0,009 ms

OFFSET 199980 không bảo PostgreSQL "nhảy tới vị trí 199.980". Nó bảo "đọc từ đầu, đếm đủ 199.980 dòng rồi vứt đi, sau đó mới bắt đầu trả". Index B-tree không lưu "dòng thứ bao nhiêu" — nó chỉ biết so sánh giá trị — nên không có cách nào nhảy theo số thứ tự. Trong khi WHERE id < 200021 là một phép so sánh giá trị: index tìm vị trí đó bằng vài bước xuống cây, đọc 4 trang, xong.

Nói cách khác, chi phí của offset không phải lỗi của PostgreSQL, mà là định nghĩa của OFFSET. Thêm index, tăng RAM, bật cache — không cái nào đổi được chuyện phải đếm qua 199.980 dòng.

Duyệt cả bảng: 28,5 giây so với 0,96 giây

Phép đo thứ ba là trường hợp thực tế hay gặp hơn "người dùng lật tới trang 10.000": một job đồng bộ, một script xuất CSV, hoặc một client di động cuộn vô hạn — tất cả đều lật tuần tự qua mọi trang. Với size=100, cả hai cách gửi đúng 4.000 request và nhận đúng 400.000 dòng:

  • offset: 28,54 giây
  • keyset: 0,96 giây

Chênh 30 lần không phải vì request nào của offset cũng chậm, mà vì tổng số dòng bị "đọc rồi vứt" là 0 + 100 + 200 + ... + 399.900 — tức khoảng 800 triệu dòng index được đọc để trả về 400.000 dòng. Đây là lý do một job xuất dữ liệu chạy mượt ở tháng đầu rồi bắt đầu kéo dài hàng giờ khi bảng lớn lên: nó không chậm theo kích thước bảng, nó chậm theo bình phương kích thước bảng.

Chuyện offset sai, không chỉ chậm

Phép đo cuối không liên quan tới hiệu năng. Người dùng mở trang 1 của một feed sắp xếp mới nhất trước (id 400.000 → 399.981). Trong lúc họ đọc, 5 review mới được đăng. Rồi họ bấm "trang 2":

  • offset: trang 2 trả id 399.985 → 399.966 — 5 dòng đầu đã nằm ở trang 1. Người dùng thấy lại 5 bài vừa đọc.
  • keyset: trang 2 trả id 399.980 → 399.961 — nối đúng vào chỗ trang 1 dừng, không trùng dòng nào.

Lý do: "trang 2" của offset nghĩa là "dòng thứ 21 tới 40 tại thời điểm hỏi", mà sau khi có 5 dòng mới chèn lên đầu, dòng thứ 21 giờ là dòng từng đứng thứ 16. Nếu thay vì chèn là xoá 5 dòng, hiệu ứng ngược lại: người dùng bỏ lỡ 5 bài mà không bao giờ biết. Keyset không có vấn đề này vì mốc là một giá trị ("sau id 399.981"), và giá trị đó không đổi dù bảng thêm bớt bao nhiêu.

Với một feed tin, trùng vài bài chỉ gây khó chịu. Với một job đồng bộ dùng offset để kéo "mọi đơn hàng" sang hệ thống khác, bỏ lỡ dòng là mất dữ liệu — và không có lỗi nào được ghi lại.

Cái giá của keyset

Không nhảy thẳng tới trang 57. Keyset chỉ có "trang kế" và "trang trước", vì để tới trang 57 bạn phải biết id cuối của trang 56. Giao diện có dãy số trang 1 2 3 ... 57 không dựng được bằng keyset thuần. Thực tế, phần lớn giao diện hiện đại không cần dãy số đó — cuộn vô hạn, nút "Tải thêm", API cho client đều là lật tuần tự. Nếu thật sự cần nhảy trang, cách hay dùng là kết hợp: offset cho N trang đầu (nơi nó rẻ, như bảng đo cho thấy tới trang 100 vẫn 0,17 ms) và chặn hoặc chuyển sang keyset khi sâu hơn.

Cột sắp xếp phải duy nhất. WHERE id < $1 đúng vì id không trùng. Sắp theo created_at thì hai dòng cùng giây sẽ làm mốc mơ hồ: WHERE created_at < $1 có thể bỏ sót dòng cùng mốc. Cách chữa là thêm khoá phụ — ORDER BY created_at DESC, id DESC với điều kiện WHERE (created_at, id) < ($1, $2) — và index phải khớp đúng thứ tự cột đó, nếu không PostgreSQL quay về quét tuần tự và lợi thế biến mất.

Mốc lộ ra ngoài là một phần hợp đồng API. Trả next_cursor: 200001 nghĩa là client biết id nội bộ, và có người sẽ tự tính after= thay vì dùng mốc server trả. Nhiều API mã hoá mốc (base64 của một JSON chứa cột và giá trị) để giữ quyền đổi cách phân trang sau này mà không phá client. Cái giá là mốc không còn đọc được bằng mắt khi gỡ lỗi.

Số đo ở đây là loopback và bảng nằm trọn trong cache. 2.965 buffer của offset đều là shared hit, tức đọc từ RAM. Khi bảng lớn hơn shared_buffers và phải đọc đĩa, chi phí mỗi dòng của offset tăng lên nhiều lần, trong khi 4 buffer của keyset thì gần như luôn có sẵn. Chênh 90 lần ở đây là sàn, không phải trần.

Ba ý mang về

  1. Offset không sai ở trang 1–100, nó sai ở trang sâu: 0,17 ms ở trang 100 nhưng 13,62 ms ở trang 19.999, tăng tuyến tính ~34 ns mỗi dòng bị bỏ qua — vì OFFSET là "đọc rồi vứt", và index B-tree không nhảy được theo số thứ tự.
  2. Duyệt cả bảng bằng offset là bình phương: 4.000 trang mất 28,5 giây so với 0,96 giây, do tổng số dòng đọc-rồi-vứt là ~800 triệu cho 400.000 dòng thật. Job xuất dữ liệu, đồng bộ, cuộn vô hạn — luôn dùng keyset.
  3. Offset còn trả sai khi dữ liệu đang đổi: 5 dòng chèn vào giữa hai lần lật là 5 dòng trùng (hoặc 5 dòng bỏ lỡ nếu là xoá). Keyset dùng giá trị làm mốc nên không bị, đổi lại mất khả năng nhảy thẳng tới trang N và đòi cột sắp xếp duy nhất kèm index khớp.

Nguồn

Phần sau chuyển sang chuyện ghi: client gọi POST /orders, mạng đứt sau khi server đã tạo đơn, client retry — làm sao để lần thứ hai không tạo thêm một đơn hàng nữa. Sẽ đo thật với khoá idempotency trong Redis và PostgreSQL.