Phân Trang Trong Hệ Thống Lớn
Phân trang là chức năng quen thuộc trong các hệ thống web và API. Nhưng khi dữ liệu ngày càng phình to và số lượng truy cập tăng mạnh, thì cách bạn thiết kế phân trang sẽ quyết định xem hệ thống của bạn có còn chịu tải nổi hay không. Đằng sau thao tác tưởng chừng đơn giản là một loạt quyết định kiến trúc liên quan đến truy vấn, chỉ mục, chiến lược cache và trải nghiệm người dùng.
Offset pagination: đơn giản nhưng không dành cho hệ thống lớn#
Phân trang theo offset là lựa chọn mặc định trong hầu hết framework và ORM. Tuy nhiên, sự tiện lợi này đi kèm với một cái giá đắt về hiệu năng khi quy mô dữ liệu tăng lên. Về bản chất, truy vấn dạng LIMIT 20 OFFSET 10000
buộc cơ sở dữ liệu phải quét qua 10.000 bản ghi đầu tiên trước khi trả về kết quả. Tức là dù bạn không dùng, hệ thống vẫn phải duyệt qua toàn bộ các bản ghi đó.
Khi dữ liệu thay đổi trong lúc người dùng đang phân trang, kết quả trả về dễ bị trùng hoặc thiếu, vì truy vấn dựa trên thứ tự tuyệt đối chứ không phải dữ liệu cụ thể. Điều này dẫn đến trải nghiệm không nhất quán, đặc biệt với dữ liệu real-time như bảng log, feed mạng xã hội hay danh sách giao dịch.
Một số tối ưu nhỏ như đảm bảo ORDER BY
có index hỗ trợ, tránh SELECT *
, hoặc giới hạn offset tối đa có thể giúp cải thiện phần nào. Nhưng nhìn chung, offset pagination chỉ phù hợp cho bảng nhỏ hoặc giao diện admin ít thay đổi.
Benchmark (PostgreSQL 15, 5 triệu bản ghi):#
Offset | Thời gian truy vấn |
---|---|
OFFSET 0 | 3 ms |
OFFSET 10,000 | 14 ms |
OFFSET 100,000 | 180 ms |
OFFSET 1,000,000 | >1500 ms |
Hiệu năng giảm rõ rệt khi offset tăng – đó là giới hạn vật lý không thể tránh khỏi nếu tiếp tục dùng phương pháp này.
Cursor pagination: lựa chọn hiệu quả hơn cho dữ liệu thay đổi#
Trái ngược với offset, cursor pagination sử dụng giá trị dữ liệu làm điểm mốc để phân trang. Thay vì nhảy tới “vị trí” thứ N, truy vấn sẽ tìm các bản ghi có giá trị nhỏ hơn bản ghi cuối cùng của trang trước. Cách tiếp cận này cho phép cơ sở dữ liệu tận dụng index hiệu quả hơn và loại bỏ hoàn toàn việc quét dữ liệu dư thừa.
Không chỉ nhanh hơn, cursor pagination còn ổn định hơn trong môi trường dữ liệu động. Vì truy vấn phụ thuộc vào giá trị cụ thể, bạn không còn phải lo lắng việc dữ liệu chèn/xoá làm lệch vị trí offset.
Tuy nhiên, bạn phải từ bỏ khái niệm “trang số” cố định. Cursor pagination phù hợp cho trải nghiệm cuộn liên tục, API feed, hoặc bất kỳ nơi nào bạn ưu tiên hiệu suất hơn là điều hướng trang cụ thể.
Keyset pagination: tối ưu sâu cho hệ thống lớn và phức tạp#
Khi bạn cần cả hiệu năng lẫn tính ổn định cao, keyset pagination là lựa chọn phù hợp nhất. Đây là một dạng mở rộng của cursor pagination, trong đó truy vấn sử dụng cặp khóa để phân biệt rõ ràng giữa các bản ghi, đặc biệt trong trường hợp có nhiều giá trị created_at
trùng nhau.
SELECT * FROM posts
WHERE (created_at < $1) OR (created_at = $1 AND id < $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Cách viết này không chỉ chính xác mà còn ổn định khi dữ liệu thay đổi nhanh. Tuy nhiên, để đạt hiệu quả, bạn phải tạo đúng composite index (ví dụ: INDEX(created_at DESC, id DESC)
), nếu không, truy vấn vẫn sẽ chậm.
Keyset pagination loại bỏ hoàn toàn vấn đề trùng lặp và thiếu sót dữ liệu khi phân trang, là lựa chọn chuẩn cho hệ thống lớn, nhiều người dùng và dữ liệu biến động liên tục.
Thực hành với Rust và PostgreSQL#
Khi triển khai backend với Rust và sqlx
, bạn có thể dễ dàng áp dụng keyset pagination vào các API trả về danh sách bản ghi. Dưới đây là ví dụ:
#[derive(sqlx::FromRow)]
struct Post {
id: i64,
title: String,
created_at: chrono::NaiveDateTime,
}
async fn get_posts_after(
pool: &PgPool,
cursor: Option<(chrono::NaiveDateTime, i64)>,
limit: i64,
) -> Result<Vec<Post>, sqlx::Error> {
let posts = if let Some((timestamp, last_id)) = cursor {
sqlx::query_as!(
Post,
r#"
SELECT id, title, created_at
FROM posts
WHERE (created_at < $1)
OR (created_at = $1 AND id < $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
"#,
timestamp,
last_id,
limit
)
.fetch_all(pool)
.await?
} else {
sqlx::query_as!(
Post,
r#"
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT $1
"#,
limit
)
.fetch_all(pool)
.await?
};
Ok(posts)
}
Cache và preload: tối ưu thêm một bước cho UX#
Ngay cả khi phân trang đã tối ưu về truy vấn, bạn vẫn có thể giảm tải hệ thống bằng cách cache trang đầu tiên – thường là nơi có lưu lượng truy cập lớn nhất. Ngoài ra, với ứng dụng cuộn vô hạn, bạn có thể preload trang kế tiếp song song khi người dùng đang xem trang hiện tại.
Với các API công cộng, việc đặt giới hạn phân trang hợp lý (ví dụ: không phân trang quá 1000 bản ghi) sẽ giúp giảm nguy cơ bị lạm dụng, đồng thời bảo vệ hệ thống khỏi các truy vấn tốn kém tài nguyên.
Kết luận: phân trang là vấn đề hiệu năng, không phải chỉ là UI#
Offset pagination phù hợp với bảng nhỏ và dữ liệu tĩnh. Cursor pagination giải quyết tốt dữ liệu động, còn keyset pagination là chuẩn mực khi bạn cần hiệu suất cao và dữ liệu ổn định. Dù bạn dùng ngôn ngữ hay hệ quản trị cơ sở dữ liệu nào, logic phân trang đúng – cùng thiết kế chỉ mục và truy vấn hợp lý – là yếu tố sống còn để đảm bảo hệ thống vận hành mượt mà và mở rộng bền vững.