Cursor-Based Pagination: Bí Kíp Chunk Dữ Liệu Triệu Dòng Mà Không Bị "Nghẹt Thở" Vì Deep Offset
Chào anh em Viblo! 👋
Khi làm việc với các hệ thống có lượng dữ liệu lớn (Big Data), chắc hẳn anh em đã từng phải viết các Background Job để quét qua hàng triệu bản ghi để đồng bộ dữ liệu, gửi email marketing, hoặc làm tính năng Infinite Scroll (cuộn trang vô hạn) kiểu Facebook, TikTok.
Hồi mới vào nghề để chia nhỏ dữ liệu ra xử lý thành từng đợt (chunking), mình cứ bê nguyên công thức kinh điển của SQL vào code: LIMIT 100 OFFSET 0, rồi tăng dần lên OFFSET 100,
OFFSET 200... Code chạy local mượt mà, mình tự tin deploy lên production.
Kết quả là gì? Vài ngày sau, khi bảng dữ liệu phình lên vài triệu dòng, hệ thống bắt đầu "lịm dần". Vòng lặp chunking ở những trang đầu chạy rất nhanh, nhưng càng về sau (khi OFFSET lên đến vài trăm nghìn), mỗi câu query mất tận vài chục giây, CPU nhảy lên 100% rồi sập nguồn. Đó là lúc mình dính vào cái bẫy kinh điển mang tên Deep Offset (Offset sâu).
Hôm nay, mình sẽ chia sẻ với anh em giải pháp tối ưu để thay thế hoàn toàn OFFSET, đó là thuật toán Cursor-based Pagination (Phân trang theo con trỏ) hay còn gọi là Chunk theo Cursor.
1. Tại sao "Deep Offset" lại là kẻ giết chết hiệu năng?
Để hiểu tại sao nó chậm, chúng ta phải biết cách Database Engine thực thi câu lệnh OFFSET.
Giả sử bạn chạy câu lệnh:
SELECT * FROM orders ORDER BY id ASC LIMIT 10 OFFSET 500000;
Nhiều người lầm tưởng Database sẽ dùng Index để nhảy thẳng đến dòng thứ 500.000 rồi lấy 10 dòng tiếp theo. Thực tế không phải vậy!
Database bắt buộc phải quét qua, đọc và sắp xếp tất cả 500.010 dòng đầu tiên, sau đó nó vứt bỏ 500.000 dòng trước đó đi và chỉ giữ lại 10 dòng cuối cùng cho bạn.
- Khi
OFFSETlà 0, DB quét 10 dòng -> Nhanh chớp nhoáng. - Khi
OFFSETlà 1.000.000, DB phải quét 1.000.010 dòng rồi vứt bỏ 1 triệu dòng -> Nghẽn cổ chai I/O và CPU cực nặng. Càng đi sâu, hệ thống càng đuối.
2. Giải pháp cứu rỗi: Chunk theo Cursor (Cursor-based)
Thay vì bảo Database: "Hãy bỏ qua cho tao 500.000 dòng", chúng ta sẽ bảo nó: "Hãy lấy cho tao 10 dòng tiếp theo, tính từ vị trí (con trỏ) cuối cùng mà tao vừa nhìn thấy".
Con trỏ (Cursor) ở đây thường là một cột có tính chất tăng dần (hoặc giảm dần) theo thời gian và bắt buộc phải được đánh Index. Cột id (Auto-increment) hoặc created_at là những ứng cử viên hoàn hảo.
Cách thức hoạt động: Vòng lặp 1 (Khởi đầu): Bạn lấy 10 dòng đầu tiên.
SELECT id, name FROM orders ORDER BY id ASC LIMIT 10;
Giả sử kết quả trả về có id lớn nhất ở dòng cuối cùng là 1052.
Vòng lặp 2 (Dùng Cursor): Thay vì dùng OFFSET 10, bạn tận dụng ngay cái id = 1052 làm con trỏ.
SELECT id, name FROM orders WHERE id > 1052 ORDER BY id ASC LIMIT 10;
Nhờ có Index trên cột id, Database Engine sẽ thực hiện một cú Index Seek nhảy tót một phát đến ngay vị trí id = 1052 rồi lấy 10 dòng kế tiếp. Nó hoàn toàn không cần quét lại bất kỳ một dòng cũ nào trước đó!
Dù bạn có đang ở dòng thứ 10 hay dòng thứ 10 triệu của bảng dữ liệu, tốc độ câu query sử dụng Cursor vẫn nhanh y hệt như nhau (thời gian thực thi gần như bằng hằng số).
3. So sánh trực quan: Offset vs Cursor
| Tiêu chí | Phân trang theo Offset | Phân trang theo Cursor |
|---|---|---|
| Câu lệnh SQL | LIMIT 10 OFFSET X | WHERE id > X LIMIT 10 |
| Tốc độ ở trang đầu | Cực nhanh | Cực nhanh |
| Tốc độ ở trang sâu | Cực chậm (Tỷ lệ thuận với độ sâu) | Luôn luôn nhanh (Hằng số) |
| Sự nhất quán dữ liệu | Dễ bị trùng hoặc sót dữ liệu nếu có dòng mới được thêm/xóa trong lúc đang quét. | Chính xác 100%, không bị ảnh hưởng bởi việc thêm/xóa ở các trang trước. |
| Khả năng nhảy trang | Cho phép nhảy đến trang bất kỳ (Ví dụ: bấm vào Trang 5) | Không thể nhảy trang bất kỳ, bắt buộc phải đi tuần tự từ trước ra sau. |
Vết sẹo thực chiến về "Nhất quán dữ liệu": Tưởng tượng bạn đang chunk dữ liệu bằng OFFSET để gửi mail. Khi bạn đang xử lý đến OFFSET 100, có một User ở OFFSET 50 xóa tài khoản. Toàn bộ dữ liệu phía sau bị đẩy lên 1 dòng. Kết quả là ở vòng lặp OFFSET 101 tiếp theo, bạn sẽ bị sót mất một User do họ đã bị đẩy lên vị trí 100. Nếu dùng Cursor WHERE id > last_id, vị trí của bạn được "neo" cố định, dữ liệu phía trước có thêm hay xóa cũng không làm bạn bị sót.
4. Áp dụng vào code thực tế
Hầu hết các framework hiện đại ngày nay đều đã nhận ra nỗi đau của OFFSET và tích hợp sẵn giải pháp Cursor.
Với Laravel (PHP): Thay vì dùng chunk() (bên dưới dùng Offset), bạn hãy đổi sang dùng chunkById().
// ❌ Không nên dùng với bảng lớn vì nó dùng OFFSET ngầm
DB::table('orders')->chunk(100, function ($orders) { ... });
// ✅ Nên dùng: Laravel tự lấy ID lớn nhất của batch trước làm cursor cho batch sau
DB::table('orders')->chunkById(100, function ($orders) {
foreach ($orders as $order) {
// Xử lý logic
}
});
Với các ngôn ngữ khác (Node.js, Python, Golang): Ý tưởng hoàn toàn tương tự, bạn chỉ cần tạo một biến last_seen_id = 0, sau mỗi vòng lặp while, bạn cập nhật last_seen_id bằng ID của bản ghi cuối cùng trong mảng kết quả và truyền nó vào câu query kế tiếp.
Đúc kết kinh nghiệm Nếu bạn làm tính năng phân trang cho admin, dữ liệu ít (dưới vài chục ngàn dòng) và cần bấm "Trang 1, 2, 3...": Offset vẫn chấp nhận được.
Nếu bạn thiết kế API cho Mobile lướt Newfeed, hoặc viết Background Job, Tool Export dữ liệu nặng đô: Hãy tuyệt đối trung thành với Cursor-based.
Đừng để hệ thống sập chỉ vì một câu lệnh OFFSET quá sâu. Hy vọng bài viết này giúp anh em tối ưu được các tác vụ xử lý dữ liệu lớn của mình một cách mượt mà nhất.
Anh em đã từng phải refactor câu query nào từ Offset sang Cursor chưa? Hiệu năng tăng lên bao nhiêu lần? Cùng chia sẻ ở phần bình luận nhé! Happy Coding! 🚀💻
All rights reserved