DHDataHọc
Lý thuyếtBài 22/235 phút đọc+10 XP

Phân trang an toàn

Bạn sẽ học được

  • Vì sao OFFSET lớn chậm dần theo số trang.
  • Keyset pagination (con trỏ) hoạt động thế nào và khi nào dùng.
  • Xử lý trùng giá trị sắp xếp bằng khóa phụ.
  • Phân trang lùi và phân trang ổn định khi dữ liệu thay đổi.
  • Đánh đổi giữa hai cách: số trang nhảy tuỳ ý so với tốc độ.

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

Phân trang bằng LIMIT + OFFSET là cách quen thuộc nhất:

code
SELECT id, customer_id, order_date
FROM orders
ORDER BY id
LIMIT 5 OFFSET 10;
code
 id | customer_id | order_date
----+-------------+------------
 11 |          10 | 2025-06-18
 12 |           2 | 2025-07-04
 13 |          11 | 2025-08-09
 14 |          12 | 2025-09-14
 15 |           6 | 2025-10-01

PostgreSQL phải sắp xếp và bỏ qua 10 dòng đầu rồi mới trả 5 dòng. Với bảng nhỏ, không sao. Với 10 triệu dòng, trang thứ 1.000 (OFFSET 19980) nghĩa là đọc và vứt đi gần 20.000 dòng — mỗi lần người dùng bấm "trang sau".

Keyset pagination thay OFFSET bằng điều kiện trên khóa đã sắp xếp:

code
SELECT id, customer_id, order_date
FROM orders
WHERE id > 10
ORDER BY id
LIMIT 5;

Kết quả giống hệt 5 dòng trên. Nhưng kế hoạch khác về bản chất: PostgreSQL dò thẳng đến id = 10 trong index rồi đọc tiếp 5 dòng. Không đọc, không bỏ, không sắp xếp lại toàn bảng. Chi phí gần như không đổi dù đang ở trang 2 hay trang 200.000.

Trên products:

code
SELECT id, name, price
FROM products
WHERE id > 4
ORDER BY id
LIMIT 3;
code
 id | name                  | price
----+-----------------------+--------
  5 | Áo thun cotton        | 250000
  6 | Sách SQL thực chiến   | 180000
  7 | Chuột không dây       | 450000

Muốn trang tiếp theo, client gửi id > 7 — chính là id cuối của trang vừa nhận. Con số đó gọi là con trỏ (cursor).

Vì sao OFFSET chậm

Ba lý do cộng dồn:

  1. Đọc rồi bỏ. OFFSET n yêu cầu sinh ra n dòng đầu để vứt đi. Công việc tỉ lệ với n, không phải với số dòng trả về.
  2. Sắp xếp. Nếu cột ORDER BY không có index, PostgreSQL phải sắp xếp toàn bộ kết quả trước khi cắt. Với ORDER BY order_date DESC, mỗi lần lật trang là một lần sort.
  3. Nhảy trang không giới hạn. Người dùng có thể gọi OFFSET 1000000. Server không có cách nào biết trang đó vô nghĩa trước khi bắt đầu đếm.

Keyset tránh cả ba: điều kiện id > con_tro dùng index, không cần sắp xếp lại (index đã sắp), và con trỏ luôn trỏ đến một vị trí thật trong dữ liệu.

Bảng so sánh:

Tiêu chíLIMIT/OFFSETKeyset
Chi phí trang sâuTăng theo OFFSETGần như cố định
Nhảy đến trang bất kỳCóKhông (chỉ tiến/lùi tuần tự)
Hiển thị tổng số trangCó (kèm COUNT(*))Không tự nhiên
Dữ liệu thay đổi giữa hai lần lậtCó thể lặp hoặc sót dòngỔn định hơn
Cần khóa sắp xếp duy nhấtKhôngCó

Khóa sắp xếp phải duy nhất

Đây là cạm bẫy lớn nhất của keyset. Nếu sắp theo cột không duy nhất:

code
SELECT id, order_date FROM orders
WHERE order_date > DATE '2025-06-01'
ORDER BY order_date
LIMIT 5;

Hai đơn cùng ngày có thể bị bỏ sót hoặc lặp lại, vì điều kiện > loại luôn mọi dòng có cùng order_date với con trỏ. Cách đúng là dùng cặp khóa:

code
SELECT id, order_date FROM orders
WHERE (order_date, id) > (DATE '2025-06-18', 11)
ORDER BY order_date, id
LIMIT 5;

So sánh bộ giá trị (order_date, id) hoạt động đúng thứ tự từ điển: ngày lớn hơn, hoặc cùng ngày và id lớn hơn. Index hỗ trợ là (order_date, id) — đúng thứ tự cột.

Khi sắp giảm dần, đảo cả điều kiện và thứ tự:

code
SELECT id, order_date FROM orders
WHERE (order_date, id) < (DATE '2025-10-01', 15)
ORDER BY order_date DESC, id DESC
LIMIT 5;

Phân trang lùi

Keyset hỗ trợ lùi bằng cách đảo chiều, lấy một trang, rồi đảo lại ở tầng ứng dụng:

code
SELECT * FROM (
  SELECT id, order_date FROM orders
  WHERE (order_date, id) < (DATE '2025-08-09', 13)
  ORDER BY order_date DESC, id DESC
  LIMIT 5
) t
ORDER BY order_date, id;

Nếu giao diện không cần nhảy trang tuỳ ý, "trang trước" và "trang sau" là đủ — và đó chính là mô hình của hầu hết ứng dụng hiện đại: nút "Xem thêm" hoặc cuộn vô hạn.

Tổng số trang

Keyset không tự biết tổng số trang. Nếu giao diện bắt buộc hiển thị "trang 3/40", có ba lựa chọn:

  • Chạy COUNT(*) riêng, chấp nhận chi phí quét.
  • Ước lượng bằng thống kê (reltuples), sai số chấp nhận được.
  • Bỏ số trang, chỉ hiện "Xem thêm" — lựa chọn đơn giản nhất và thường tốt cho người dùng.

Đừng chạy COUNT(*) trên mỗi lần lật trang. Với bảng lớn, đếm toàn bộ đắt ngang một lần quét bảng, trong khi người dùng chỉ cần 20 dòng.

Thực hành

  1. Chạy hai câu OFFSET 10 và id > 10 ở trên, xác nhận cùng 5 dòng.
  2. Viết truy vấn lấy trang tiếp theo của products sau id = 7.
  3. Đổi sang sắp theo order_date và viết điều kiện keyset dùng cặp (order_date, id).
  4. Viết truy vấn "trang trước" của orders khi con trỏ là (2025-08-09, 13).
  5. Giải thích khi nào bạn vẫn chọn OFFSET dù biết nhược điểm.

Lỗi thường gặp

Triệu chứngNguyên nhânCách sửa
Trang càng sau càng chậmOFFSET lớnKeyset
Lật trang bị lặp dòngDữ liệu chèn mới giữa hai lầnDùng khóa duy nhất và con trỏ
Keyset bỏ sót dòng cùng ngàySắp theo cột không duy nhấtDùng cặp (order_date, id)
Không lấy được trang trướcChỉ viết chiều tiếnĐảo ORDER BY và điều kiện
COUNT(*) làm trang chậmĐếm toàn bảng mỗi lầnĐếm riêng hoặc bỏ số trang
Kết quả không ổn địnhThiếu ORDER BYLuôn sắp xếp tường minh

Lỗi "thiếu ORDER BY" đặc biệt nguy hiểm: PostgreSQL không bảo đảm thứ tự nếu không yêu cầu. Kết quả có thể đúng hôm nay và sai ngày mai khi dữ liệu hoặc kế hoạch thay đổi. Phân trang mà không sắp xếp tường minh không phải phân trang.

Tóm tắt

  • OFFSET lớn đọc và bỏ càng nhiều dòng, chậm dần theo số trang.
  • Keyset dùng WHERE khoa > con_tro, chi phí gần như cố định.
  • Khóa sắp xếp phải duy nhất; dùng cặp (cot, id) khi có trùng.
  • Hỗ trợ lùi bằng cách đảo chiều sắp xếp.
  • Đánh đổi: mất khả năng nhảy trang tuỳ ý, đổi lấy tốc độ và ổn định.

Dùng ← → để chuyển bài