Phân trang an toàn
Bạn sẽ học được
- Vì sao
OFFSETlớ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:
SELECT id, customer_id, order_date
FROM orders
ORDER BY id
LIMIT 5 OFFSET 10; 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-01PostgreSQL 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:
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:
SELECT id, name, price
FROM products
WHERE id > 4
ORDER BY id
LIMIT 3; id | name | price
----+-----------------------+--------
5 | Áo thun cotton | 250000
6 | Sách SQL thực chiến | 180000
7 | Chuột không dây | 450000Muố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:
- Đọc rồi bỏ.
OFFSET nyêu cầu sinh randòng đầu để vứt đi. Công việc tỉ lệ vớin, không phải với số dòng trả về. - Sắp xếp. Nếu cột
ORDER BYkhông có index, PostgreSQL phải sắp xếp toàn bộ kết quả trước khi cắt. VớiORDER BY order_date DESC, mỗi lần lật trang là một lần sort. - 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/OFFSET | Keyset |
|---|---|---|
| Chi phí trang sâu | Tăng theo OFFSET | Gầ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ố trang | Có (kèm COUNT(*)) | Không tự nhiên |
| Dữ liệu thay đổi giữa hai lần lật | Có thể lặp hoặc sót dòng | Ổn định hơn |
| Cần khóa sắp xếp duy nhất | Không | Có |
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:
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:
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ự:
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:
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
- Chạy hai câu
OFFSET 10vàid > 10ở trên, xác nhận cùng 5 dòng. - Viết truy vấn lấy trang tiếp theo của
productssauid = 7. - Đổi sang sắp theo
order_datevà viết điều kiện keyset dùng cặp(order_date, id). - Viết truy vấn "trang trước" của
orderskhi con trỏ là(2025-08-09, 13). - Giải thích khi nào bạn vẫn chọn
OFFSETdù biết nhược điểm.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách sửa |
|---|---|---|
| Trang càng sau càng chậm | OFFSET lớn | Keyset |
| Lật trang bị lặp dòng | Dữ liệu chèn mới giữa hai lần | Dùng khóa duy nhất và con trỏ |
| Keyset bỏ sót dòng cùng ngày | Sắp theo cột không duy nhất | Dùng cặp (order_date, id) |
| Không lấy được trang trước | Chỉ 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 định | Thiếu ORDER BY | Luô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
OFFSETlớ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