Bạn đã bao giờ thắc mắc tại sao Google có thể đếm hàng tỷ kết quả tìm kiếm chỉ trong 0.1 giây, nhưng lại âm thầm chặn bạn bấm đến trang cuối cùng? Hay quen thuộc hơn trong thực tế công việc: đã bao giờ hệ thống của bạn đột ngột báo lỗi "Timeout" chỉ vì một người dùng cố gắng lướt sang trang thứ 100 của danh sách sản phẩm?
Khi dự án mới bắt đầu với vài nghìn bản ghi, LIMIT và OFFSET trông giống như những công cụ hoàn hảo. Nhưng khi cơ sở dữ liệu phình to lên con số 50 triệu dòng, những câu lệnh SQL quen thuộc đó lại trở thành "nút thắt cổ chai" nguy hiểm. Chúng ép máy chủ phải bốc vác hàng triệu dữ liệu thừa thãi từ ổ cứng lên RAM rồi... ném đi, biến một thao tác chuyển trang đơn giản thành nguyên nhân đánh sập cả hệ thống.
Đằng sau những dải phân trang mượt mà của các ông lớn công nghệ không đơn thuần là việc vung tiền nâng cấp máy chủ. Chìa khóa của họ nằm ở nghệ thuật thao túng dữ liệu và sự thấu hiểu sâu sắc về kiến trúc bên dưới.
Trong bài viết này, chúng ta sẽ lật mở những bí mật ẩn sau nút "Next" tưởng chừng đơn giản đó. Hãy cùng khám phá xem tại sao một thủ thuật nhỏ như Deferred Join lại cứu sống được cả một cơ sở dữ liệu, Điểm neo (Cursor) đã tạo ra phép màu "cuộn vô hạn" trên mạng xã hội như thế nào, và làm sao để đưa tất cả những tư duy hệ thống này vào thực chiến RESTful API với Spring Boot. Đã đến lúc chúng ta đi xa hơn những hàm Pageable có sẵn, để thực sự làm chủ cách hệ thống vận hành!

I. Sự lừa dối ngọt ngào của LIMIT-OFFSET
Đây là cách phân trang truyền thống mà bạn thường thấy trên các trang web có dải số trang ở dưới cùng (1, 2, 3... Next). Về mặt kỹ thuật, nó hoạt động dựa trên hai tham số chính:
- LIMIT (hoặc Page Size): Số lượng bản ghi tối đa bạn muốn hiển thị trên một trang.
- OFFSET: Số lượng bản ghi bạn muốn hệ thống "bỏ qua" tính từ bản ghi đầu tiên.
Phân trang truyền thống (Offset-based Pagination) gần như là bài học vỡ lòng của mọi kỹ sư Backend. Cú pháp SQL của nó vô cùng trực quan và thân thiện:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
Khi đọc câu lệnh trên, chúng ta thường có một ảo giác rằng: "Hệ thống sẽ đi thẳng đến vị trí thứ 100.000, bỏ qua tất cả những phần trước đó, và chỉ nhặt ra đúng 20 sản phẩm tiếp theo."
Nhưng sự thật phũ phàng là: Các cơ sở dữ liệu quan hệ (như MySQL hay PostgreSQL) không có khả năng nhảy cóc (skip) trực tiếp như vậy. Để trả về cho bạn 20 bản ghi ở một trang rất sâu, cơ sở dữ liệu phải thực hiện một quá trình "bốc vác" cực kỳ cồng kềnh dưới nền:
-
Tải toàn bộ: Nó phải tìm kiếm và tải toàn bộ dữ liệu chi tiết của 100.020 bản ghi từ ổ cứng (Disk) lên bộ nhớ (RAM). Dữ liệu này bao gồm cả những cột rất nặng như văn bản mô tả chi tiết, đường dẫn hình ảnh, hay file đính kèm.
-
Ném đi: Sau khi đã chất đầy dữ liệu lên RAM, nó mới bắt đầu đếm và... thẳng tay vứt bỏ 100.000 bản ghi đầu tiên đi.
-
Trả kết quả: Cuối cùng, nó chỉ giữ lại đúng 20 bản ghi cuối cùng để trả về cho người dùng.
Hiện tượng này được gọi là Tìm. Khi dữ liệu chỉ có vài nghìn dòng, sức mạnh của máy chủ hoàn toàn đủ để che giấu sự lãng phí này, khiến bạn lầm tưởng hệ thống vẫn đang chạy rất nhanh. Nhưng khi bảng dữ liệu cán mốc hàng triệu dòng, việc bắt máy chủ nhấc bổng hàng trăm ngàn bản ghi nặng nề rồi lại ném đi sẽ vắt kiệt băng thông ổ cứng và dung lượng RAM.
Hậu quả tất yếu: Máy chủ quá tải, CPU chạm mức 100%, các truy vấn bị treo (Timeout) và toàn bộ hệ thống sụp đổ chỉ vì một người dùng đang cố bấm lướt xem các trang cũ.
Hãy thử suy luận tình huống này:
Giả sử bạn đang xem Trang 1 của một diễn đàn (hiển thị 10 bài viết mới nhất, từ bài số 1 đến bài số 10). Ngay lúc bạn đang đọc, có một người dùng khác vừa đăng thêm 1 bài viết mới toanh lên diễn đàn.
Theo bạn, khi bạn bấm sang Trang 2 (hệ thống sẽ dùng lệnh bỏ qua 10 bài viết trên cùng), điều gì sẽ xảy ra với các bài viết mà bạn nhìn thấy ở Trang 2? Liệu có bài nào bị lặp lại hay biến mất không?
- ➡️ Câu trả lời là CÓ.
Khi có 1 bài viết mới chen vào đầu danh sách, toàn bộ các bài viết cũ sẽ bị đẩy lùi xuống 1 vị trí. Lệnh OFFSET 10 khi tải Trang 2 sẽ đếm bỏ qua bài mới cộng với 9 bài cũ. Kết quả là bài thứ 10 (của Trang 1 cũ) bị đẩy sang làm bài đầu tiên của Trang 2, khiến người dùng thấy dữ liệu bị trùng lặp (duplicated).
Tương tự, hiệu ứng ngược lại sẽ xảy ra nếu một bài viết ở Trang 1 bị xóa đi trong lúc bạn đang đọc. Danh sách bị kéo lên, và lệnh OFFSET 10 sẽ vô tình nhảy cóc, khiến bạn bị bỏ sót (missing) mất một bài viết khi sang Trang 2.
Hai điểm yếu cốt lõi này (hiệu suất giảm dần và sai lệch dữ liệu) chính là lý do các nền tảng có dữ liệu thay đổi liên tục như Facebook hay Twitter không bao giờ dùng dải số trang (1, 2, 3...) cho bảng tin (News Feed).
Để giải quyết triệt để vấn đề này, chúng ta bước sang kỹ thuật thứ hai trong lộ trình: Cursor-based Pagination (Phân trang theo con trỏ).
Thay vì bảo cơ sở dữ liệu làm một việc rập khuôn là "hãy đếm và bỏ qua X bài viết đầu tiên" (như cách làm của OFFSET), chúng ta sẽ dùng một "Con trỏ" (Cursor) làm mốc.
Dựa trên nguyên lý đánh dấu mốc này, theo bạn, khi tải xong Trang 1 (gồm 10 bài viết), hệ thống nên gửi dữ liệu gì về cho máy chủ để yêu cầu lấy tiếp Trang 2 một cách chính xác tuyệt đối, bất chấp việc có ai đó vừa thêm hay xóa bài viết ở phía trên? Hãy cùng mình đi vào phương pháp phân trang tiếp theo nhé
II. Kiến trúc Điểm neo (Cursor-based): Nghệ thuật của "Cuộn vô hạn"
Với Offset, bạn bảo: "Hãy đếm bỏ qua 10 người đầu tiên". Nhưng với Cursor (Con trỏ), cách làm sẽ giống như bạn kẹp một chiếc thẻ đánh dấu (bookmark) 🔖 vào người thứ 10 (giả sử tên anh ấy là ID: 96). Khi cần gọi nhóm người tiếp theo, bạn chỉ cần yêu cầu: "Hãy gọi 10 người đứng ngay sau anh ID: 96".
Lúc này, dù có 5 người mới chen vào đầu hàng, hay 2 người bỏ đi ở phía trên, thì những người đứng sau anh ID: 96 vẫn là mục tiêu chính xác tuyệt đối. Hệ thống không cần quan tâm phần đầu hàng có bao nhiêu người nữa.
Về mặt kỹ thuật, dữ liệu hệ thống cần gửi về cho máy chủ chính là một điểm neo (Cursor) duy nhất của bài viết cuối cùng mà người dùng vừa thấy. Điểm neo này thường là một giá trị độc nhất và có tính thứ tự, phổ biến nhất là ID bài viết hoặc mốc thời gian created_at.
Giả sử Trang 1 của bạn có 10 bài viết, sắp xếp từ mới nhất đến cũ nhất:
| ID bài viết | Nội dung | Trạng thái hiển thị |
|---|---|---|
| 105 | Bài viết mới nhất | Trang 1 |
| ... | ... | Trang 1 |
| 96 | Bài viết thứ 10 | Trang 1 (Trở thành Cursor 📍) |
Thay vì gửi tham số Trang = 2, giao diện sẽ gửi yêu cầu: Lấy 10 bài viết, bắt đầu từ sau bài có ID là 96. Vì danh sách bài viết đang được sắp xếp từ mới nhất đến cũ nhất (giảm dần), nên các bài viết cũ hơn chắc chắn sẽ có ID nhỏ hơn. Câu lệnh SQL lúc này sẽ trông rất gọn gàng:
SELECT * FROM posts WHERE id < 96 ORDER BY id DESC LIMIT 10;
Tại sao cách này lại giải quyết được cả 2 bài toán của Offset?
-
Về hiệu suất: Cơ sở dữ liệu không cần quét lại từ đầu nữa. Nhờ có "chỉ mục" (Index) trên cột ID, hệ thống sẽ nhảy thẳng đến vị trí của ID = 96 trong chớp mắt và lấy đúng 10 bản ghi tiếp theo. Dù bạn cuộn đến bài viết thứ 1 triệu, tốc độ tải vẫn nhanh y như trang đầu tiên.
-
Về tính chính xác: Vì mốc ID = 96 là một điểm neo cố định, nên dù có 100 bài viết mới chèn vào phía trên hay bị xóa đi, hệ thống vẫn chỉ lấy các bài viết nằm dưới cái neo đó. Không còn tình trạng trùng lặp hay lọt bài.
Đây chính là nguyên lý đằng sau tính năng Infinite Scroll (Cuộn vô hạn) mà bạn lướt hàng ngày trên Facebook, TikTok hay Instagram.
Tuy nhiên, kỹ thuật này cũng có một "gót chân Achilles" (điểm yếu) liên quan đến cách sắp xếp. Chúng ta vừa dùng ID làm con trỏ vì nó có một đặc tính hoàn hảo: mỗi ID là duy nhất (unique).
Hãy thử đặt một tình huống phức tạp hơn. Giả sử người dùng muốn sắp xếp danh sách bài viết trên diễn đàn theo "Số lượt Like" từ cao xuống thấp (thay vì theo thời gian). Nếu chúng ta dùng số Like làm con trỏ (ví dụ: tải trang tiếp theo bằng lệnh Lấy các bài viết có Like < 50), bạn nghĩ hệ thống sẽ gặp rắc rối gì nếu có rất nhiều bài viết có cùng chính xác 50 lượt Like?
Vấn đề nằm ở chỗ cốt lõi này: Con trỏ (Cursor) bắt buộc phải là một giá trị duy nhất (unique) để hệ thống định vị chính xác vị trí đang đứng. Lượt Like thì không như vậy.
| Column 1 | Column 2 | Column 3 |
|---|---|---|
| 101 | 60 | Trang1 |
| 102 | 50 | Trang 1 (Trở thành Cursor 📌) |
| 103 | 50 | (Đang chờ tải ở Trang 2) |
| 104 | 50 | (Đang chờ tải ở Trang 2) |
| 105 | 40 | (Đang chờ tải ở Trang 2) |
Nếu bạn gửi yêu cầu lấy Trang 2 với câu lệnh: Lấy các bài viết có Lượt Like < 50 (tức là nhỏ hơn bài số 102).
Cơ sở dữ liệu sẽ quét và nhảy thẳng đến bài có 49, 48 hoặc 40 Like. Kết quả là bài viết ID 103 và 104 sẽ bị bỏ sót hoàn toàn vì chúng có Like bằng 50 chứ không nhỏ hơn 50.
Ngược lại, nếu bạn dùng dấu <= (nhỏ hơn hoặc bằng 50) để cố vớt bài 103 và 104, thì hệ thống sẽ lại tải luôn cả bài 102, gây ra lỗi trùng lặp dữ liệu cho người xem.
Đây là giới hạn lớn nhất của Cursor-based Pagination: Nó rất khó xử lý khi bạn muốn sắp xếp dữ liệu theo các tiêu chí dễ bị trùng nhau (như số Like, số sao đánh giá, hay giá tiền sản phẩm).
Để giải quyết bài toán này, các kỹ sư thường tạo ra một "Điểm neo kép" (Composite Cursor). Theo bạn, chúng ta có thể ghép cặp số lượt Like với một thông tin nào khác của bài viết (một thông tin mà chúng ta đã chắc chắn là luôn luôn độc nhất) để làm bài toán phân định (tie-breaker) khi các bài viết có cùng số Like không?
Chắc chắn là các bạn cũng đã đoán ra rồi đúng không. Đó chính là ID,
Vì ID luôn là duy nhất đối với mỗi bài viết trong cơ sở dữ liệu, nên khi ghép "Số Like" và "ID" lại với nhau, chúng ta sẽ tạo ra một Điểm neo kép (Composite Cursor) hoàn hảo, không bao giờ bị trùng lặp.
Cách hệ thống hoạt động lúc này sẽ vô cùng thông minh. Giả sử bài viết cuối cùng ở Trang 1 có 50 Like và ID = 104. Yêu cầu lấy Trang 2 sẽ được dịch ra thành câu lệnh SQL tìm các bài viết thỏa mãn 1 trong 2 điều kiện sau:
-
Lượt Like nhỏ hơn 50.
-
Hoặc: Lượt Like BẰNG 50, NHƯNG ID phải nhỏ hơn 104.
SELECT * FROM posts
WHERE likes < 50 OR (likes = 50 AND id < 104)
ORDER BY likes DESC, id DESC LIMIT 10;
Nhờ "kẻ phân xử" là ID, các bài viết có ID 103, 102 (dù có cùng 50 Like) vẫn sẽ được tải lên ở Trang 2 một cách trơn tru. Không có bài nào bị bỏ sót, cũng không có bài nào bị lặp lại!
Như vậy bạn có thể thấy, kiến trúc Điểm neo (Cursor-based) thật sự là một "phép màu" tối thượng cho trải nghiệm cuộn vô hạn trên mạng xã hội. Tốc độ siêu tốc, không bị xô lệch dữ liệu — nó giải quyết hoàn hảo mọi nhược điểm của Offset.
Nhưng khoan đã, có một "tử huyệt" chí mạng của Cursor mà chúng ta chưa nhắc đến: Bạn không thể nhảy cóc đến một trang bất kỳ.
Hãy tưởng tượng bạn đang xây dựng một bảng quản trị đơn hàng (Admin Dashboard) cho E-commerce, hoặc một danh sách sản phẩm nơi khách hàng muốn ghi nhớ rằng "chiếc áo mình thích nằm ở trang số 15". Ở những bài toán nghiệp vụ kinh điển này, người dùng bắt buộc phải nhìn thấy dải số trang (1, 2, 3... 15) để điều hướng. Cuộn vô hạn lúc này trở thành một thảm họa về mặt trải nghiệm người dùng (UX).
Thực tế phũ phàng ép buộc chúng ta phải quay trở lại với phương pháp truyền thống: Bắt buộc phải dùng LIMIT và OFFSET.
Vậy câu hỏi sống còn đặt ra là: Nếu không thể dùng Cursor, làm thế nào để hệ thống của chúng ta sống sót qua thảm họa "bốc vác dữ liệu thừa" và báo lỗi Timeout đã phân tích ở Phần II? Chẳng lẽ chịu bó tay và vung tiền nâng cấp RAM?
Đừng vội tuyệt vọng. Cơ sở dữ liệu quan hệ vẫn còn giấu một con bài tẩy mang tên: Deferred Join (JOIN trì hoãn).
III. Giải cứu Server bằng "Deferred Join" (JOIN trì hoãn)
Chúng ta cùng mổ xẻ Deferred Join (JOIN trì hoãn). Đây là một thủ thuật vô cùng thanh lịch và tối ưu trực tiếp trên câu lệnh SQL mà không cần phải cài đặt thêm một hệ thống (như Elasticsearch) phức tạp nào.
Nguyên nhân cốt lõi khiến câu lệnh phân trang LIMIT ... OFFSET ... truyền thống bị chậm là do hiện tượng đọc dữ liệu thừa. Khi bạn dùng SELECT*, cơ sở dữ liệu phải tải toàn bộ thông tin của hàng triệu bản ghi (bao gồm các cột dữ liệu rất nặng như mô tả chi tiết, đường dẫn hình ảnh, đánh giá...) từ ổ cứng lên bộ nhớ (RAM), sau đó mới "ném" phần OFFSET đi. Quá trình bốc vác này làm hệ thống kiệt sức.Nguyên nhân cốt lõi khiến câu lệnh phân trang LIMIT ... OFFSET ... truyền thống bị chậm là do hiện tượng đọc dữ liệu thừa. Khi bạn dùng SELECT *, cơ sở dữ liệu phải tải toàn bộ thông tin của hàng triệu bản ghi (bao gồm các cột dữ liệu rất nặng như mô tả chi tiết, đường dẫn hình ảnh, đánh giá...) từ ổ cứng lên bộ nhớ (RAM), sau đó mới "ném" phần OFFSET đi. Quá trình bốc vác này làm hệ thống kiệt sức.
Ý tưởng của Deferred Join là: Thay vì bắt cơ sở dữ liệu "bưng bê" toàn bộ dữ liệu nặng nề của hàng triệu sản phẩm, chúng ta chỉ yêu cầu nó đi tìm "địa chỉ nhà" (ID) trước.
Bí mật sức mạnh ở đây nằm ở Chỉ mục (Index). Khi bạn dùng truy vấn con để SELECT id, cơ sở dữ liệu không thèm đụng đến khối dữ liệu thật khổng lồ trên ổ cứng. Nó chỉ mở cấu trúc Index ra (giống như lướt qua phần mục lục ở đầu sách). Index vô cùng nhỏ gọn, thường được lưu trữ sẵn trên bộ nhớ RAM và đã được sắp xếp từ trước.
Nhờ vậy, hệ thống có thể lướt qua hàng triệu dòng mục lục trong nháy mắt, nhặt ra chính xác 20 cái ID cần thiết. Sau khi có 20 "địa chỉ" này trong tay, nó mới thực hiện phép JOIN để vòng lại ổ cứng và bốc đúng 20 bản ghi chi tiết lên.
Sự khác biệt trong thực chiến mã nguồn sẽ trông như thế này:
Cách cũ (Chậm & Dễ Timeout do Full Scan):
SELECT * FROM products ORDER BY price DESC LIMIT 20 OFFSET 1000000;
Cách mới với Deferred Join (Nhanh & Tối ưu nhờ Index Scan):
SELECT p.* FROM products p
INNER JOIN (
-- Truy vấn con: Chỉ quét Index để nhặt 20 ID cực nhanh
SELECT id FROM products ORDER BY price DESC LIMIT 20 OFFSET 1000000
) AS deferred_ids ON p.id = deferred_ids.id;
Bằng tuyệt chiêu Deferred Join, chúng ta đã thành công ép cơ sở dữ liệu phải lướt trên bề mặt Chỉ mục (Index) thay vì bốc vác dữ liệu thô. Máy chủ đã được cứu, dải số trang (1, 2, 3...) đã chạy mượt mà dù người dùng có bấm đến trang thứ 10.000 đi chăng nữa.
IV. Khi SQL "hụt hơi": Search Engine lên ngôi
Deferred Join thực sự rất hay đúng không, nhưng nó chỉ phát huy tác dụng khi bạn tìm kiếm hoặc sắp xếp những cột dữ liệu đơn giản (như ID, giá tiền). Chính vì thế niềm vui của dev thường không kéo dài lâu. Hệ thống của bạn sẽ nhanh chóng bộc lộ tử huyệt tiếp theo ngay khi hành vi của người dùng thay đổi: Họ lờ đi dải phân trang, nhấp chuột thẳng vào thanh tìm kiếm (Search Box) và gõ cụm từ như "áo thun nam màu đen cỡ L", Lúc này, điểm neo (Cursor) của bạn vỡ vụn, và phép màu Deferred Join cũng hoàn toàn vô dụng, cơ sở dữ liệu truyền thống (như MySQL) sẽ phải đọc text của từng sản phẩm để tìm từ khóa. Dù có dùng Index thông thường, nó vẫn sẽ "hụt hơi" với 50 triệu bản ghi.
Để vượt qua giới hạn vật lý này, các kỹ sư hệ thống bắt buộc phải tách phần tìm kiếm ra khỏi Database SQL, và chuyển giao nó cho các hệ sinh thái Search Engine chuyên dụng (như Elasticsearch) với một vũ khí tối thượng: Chỉ mục đảo ngược (Inverted Index) 🗂️.
Nó không lưu dữ liệu theo dạng bảng (dòng và cột) thông thường, mà sử dụng một cấu trúc dữ liệu đặc biệt gọi là Chỉ mục đảo ngược (Inverted Index) 🗂️.
Hãy hình dung thế này:
-
Cơ sở dữ liệu truyền thống (Giống mục lục đầu sách): Sản phẩm ID 1 chứa các từ "áo", "thun", "nam". Sản phẩm ID 2 chứa "quần", "jean", "nam".
-
Chỉ mục đảo ngược (Giống bảng tra cứu từ vựng ở cuối sách): Nó nhóm các từ lại.
-
Từ "áo" -> Nằm ở ID 1, 5, 9.
-
Từ "nam" -> Nằm ở ID 1, 2, 9.
-
Từ "thun" -> Nằm ở ID 1, 7.
Nhờ cấu trúc này, việc phân trang và tìm kiếm trở nên siêu tốc vì hệ thống không cần quét qua hàng triệu sản phẩm nữa, nó chỉ cần nhìn vào danh sách ID có sẵn của từng từ.
Dựa trên nguyên lý của Chỉ mục đảo ngược này, tôi có một câu hỏi cho bạn:
Giả sử người dùng tìm kiếm từ khóa ghép là "áo thun". Hệ thống nhìn vào bảng tra cứu và thấy:
- Từ "áo" có ở các ID: [1, 3, 5, 8]
- Từ "thun" có ở các ID: [2, 3, 5, 9]
Theo bạn, hệ thống sẽ dùng phép toán logic nào (AND, OR, hay NOT) để trộn hai danh sách ID này lại và tìm ra được sản phẩm là "áo thun", sau đó nhặt ra 10 kết quả đầu tiên để phân trang?
⏩️ Đó chính là phép toán AND (Giao).
Vì chúng ta muốn tìm những sản phẩm có chứa cả chữ "áo" VÀ chữ "thun", hệ thống sẽ đi tìm phần giao nhau của hai tập hợp này đó chính là ID 3 và 5.
Thay vì phải quét qua nội dung của 50 triệu sản phẩm, hệ thống (như Elasticsearch) chỉ cần làm một phép toán giao (AND) siêu tốc giữa các mảng ID có sẵn trong Inverted Index. Nếu kết quả trả về là một mảng gồm 2 triệu ID khớp với từ khóa "áo thun", hệ thống chỉ việc "cắt" lấy 20 ID tương ứng với số trang bạn đang xem (ví dụ: lấy 20 ID đầu tiên cho Trang 1) và tải thông tin chi tiết của 20 ID đó lên. Chúng ta đã giải quyết xong bài toán tối ưu bằng cấu trúc Index đặc biệt.
Với sức mạnh như vậy, ElasticSearch thực sự là một lựa chọn hàng đầu, nhưng thực ra thì cũng có vài công cụ khác cũng khá tốt mà bạn có thể cân nhắc
-
Meilisearch: Gọn nhẹ, siêu dễ cài đặt, tích hợp sẵn tính năng tự sửa lỗi chính tả (Typo-tolerance) cực nhạy.
-
Typesense: Tốc độ phản hồi siêu nhanh (tính bằng micro-giây) do chạy hoàn toàn trên RAM (In-memory).
-
Algolia: Dịch vụ đám mây (SaaS) cao cấp. Trải nghiệm tìm kiếm hoàn hảo, không cần quản lý hạ tầng nhưng chi phí khá cao.
-
Apache Solr: "Người anh em" cùng lõi Lucene với ES, độ ổn định cực cao cho các hệ thống doanh nghiệp (Enterprise) lớn.
-
PostgreSQL (GIN Index): Giải pháp "cây nhà lá vườn", tận dụng luôn DB SQL có sẵn để xử lý Full-text search cho vài triệu bản ghi mà không cần cài đặt thêm server.
V. Thực chiến Spring Boot: Ghép nối mọi thứ
Kiến trúc dù có hay đến đâu thì cuối cùng cũng phải biến thành những dòng code chạy được. Trong Spring Boot, việc triển khai phân trang sẽ chia làm hai trường phái rõ rệt dựa trên kiến trúc bạn chọn.
1. Sức mạnh "ăn liền" của Offset-based với Pageable
Với Spring Boot (cụ thể là khi sử dụng Spring Data JPA), việc triển khai phân trang truyền thống (Offset-based) được hỗ trợ sẵn thông qua một vài Interface cốt lõi, giúp chúng ta không phải tự viết các câu lệnh SQL LIMIT và OFFSET thủ công.
Hai thành phần quan trọng nhất chúng ta cần làm quen là:
-
Pageable: Interface dùng để đóng gói thông tin yêu cầu phân trang từ phía người dùng (ví dụ: muốn xem trang số mấy, kích thước mỗi trang là bao nhiêu bản ghi, và sắp xếp theo tiêu chí nào).
-
Page<T>: Interface đóng gói kết quả trả về từ cơ sở dữ liệu (bao gồm danh sách dữ liệu của trang hiện tại, tổng số trang, tổng số bản ghi...).
Giả sử chúng ta đang có một ProductRepository kế thừa từ JpaRepository. Để báo cho repository biết chúng ta muốn phân trang, chúng ta chỉ cần truyền một đối tượng Pageable vào hàm truy vấn.
Lúc này công việc của bạn là yêu cầu Trang số mấy và lấy bao nhiêu bản ghi. Ví dụ để yêu cầu lấy Trang 2 với 10 sản phẩm/trang, chúng ta sẽ truyền các con số vào PageRequest.of như sau:
Pageable pageable = PageRequest.of(1, 10);
-
1: là index của Trang 2.
-
10: là số lượng bản ghi trên một trang (Page Size).
Sau khi có đối tượng pageable này, chúng ta chỉ cần truyền nó vào một hàm trong Repository. Spring Data JPA sẽ tự động thao tác ngầm và dịch nó thành câu lệnh LIMIT 10 OFFSET 10 dưới cơ sở dữ liệu.
Page<Product> productPage = productRepository.findAll(pageable);
Chú ý là kết quả trả về không phải là một danh sách List< Product > thông thường, mà nó được bọc trong một đối tượng đặc biệt là Page<T> như trong ví dụ chính là Page< Product >
Để vẽ được dải phân trang (1, 2, 3... Trang cuối) và các nút điều hướng một cách hoàn hảo, Front-end không chỉ cần dữ liệu, mà còn cần các siêu dữ liệu (metadata) về tình trạng hiện tại của danh sách.
Phép màu nằm ở đối tượng Page<T> mà Spring trả về. Nó tự động đóng gói thành một file JSON hoàn hảo chứa đầy đủ siêu dữ liệu (metadata) cho Front-end:
{
"content": [
{ "id": 1, "name": "Áo thun nam", "price": 150000 }
],
"pageable": {
"pageNumber": 1,
"pageSize": 10
},
"totalElements": 50000000,
"totalPages": 5000000,
"last": false
}
Đối tượng này của Spring Boot từ khi nọt lòng đã tự động tính toán và gom tất cả những "mảnh ghép" đó lại cho chúng ta. Cụ thể, ngoài danh sách 10 sản phẩm thực tế, nó còn cung cấp sẵn các hàm cực kỳ tiện lợi:
-
getTotalElements(): Tổng số lượng bản ghi khớp với điều kiện tìm kiếm trong cơ sở dữ liệu (ví dụ: 2 triệu sản phẩm).
-
getTotalPages(): Tổng số trang có thể tạo ra. Front-end dùng số này để biết con số cuối cùng trên dải phân trang là bao nhiêu.
-
getNumber(): Số thứ tự trang hiện tại (nhớ là nó bắt đầu từ 0 nhé).
-
hasNext() / hasPrevious(): Trả về true hoặc false để biết phía trước hoặc phía sau có còn trang nào không.
-
isFirst() / isLast(): Dấu hiệu nhận biết bạn đang ở trang đầu hay trang cuối. Điều này cực kỳ hữu ích để Front-end biết khi nào cần làm mờ (disable) nút "⬅️ Trước" hoặc "Tiếp ➡️".
Nhờ có đối tượng Page này, Backend chỉ cần một dòng code là đã trả về một cấu trúc dữ liệu hoàn hảo, Front-end chỉ việc đọc và vẽ lên giao diện 🎨.
2. Tự xây dựng API Điểm neo (Cursor-based) cho Cuộn vô hạn
Khác với Offset, Spring Boot hiện tại chưa có sẵn một class kiểu "ăn liền" cho Cursor. Chúng ta phải tự tay thiết kế luồng dữ liệu.
Tại tầng Controller, API sẽ tiếp nhận các "điểm neo" thay vì số trang:
@GetMapping("/posts")
public ResponseEntity<CursorResponse<Post>> getPosts(
@RequestParam(required = false) Integer lastLikes,
@RequestParam(required = false) Long lastId,
@RequestParam(defaultValue = "10") int size) {
// Logic gọi xuống Repository để chạy câu lệnh WHERE likes < lastLikes...
List<Post> posts = postService.getPostsByCursor(lastLikes, lastId, size);
// Tự động trích xuất điểm neo từ bài viết cuối cùng trong danh sách
return ResponseEntity.ok(CursorResponse.of(posts));
}
Và đây là cấu trúc JSON tùy chỉnh (Custom JSON Response) mà Backend sẽ trả về để Front-end có thể tiếp tục gọi API khi người dùng cuộn chuột:
{
"data": [
{ "id": 105, "likes": 55, "title": "Bài viết mới" },
{ "id": 104, "likes": 50, "title": "Bài viết cũ hơn" }
],
"meta": {
"has_next": true,
"next_cursor": {
"last_likes": 50,
"last_id": 104
}
}
}
Lần tới, khi người dùng cuộn đến đáy màn hình, Front-end chỉ cần bốc nguyên cục next_cursor này và nhét lên URL: ?last_likes=50&last_id=104&size=10. Vòng lặp cuộn vô hạn cứ thế tiếp diễn cực kỳ mượt mà và không hề gây áp lực lên Database!
VI. Kim chỉ nam: Chọn phương pháp nào cho dự án của bạn?
Để trả lời câu hỏi này, chúng ta hãy cùng giải quyết một bài toán thực tế: Giả sử bạn đang xây dựng một Bảng quản trị (Admin Dashboard) cho bộ phận Kế toán. Họ cần lọc hóa đơn, sắp xếp theo ngày, và thỉnh thoảng muốn gõ số "50" để nhảy thẳng đến trang 50 đối chiếu sổ sách.
Nếu áp dụng một cách máy móc tư duy "Cursor là tối thượng vì nó nhanh", bạn sẽ tạo ra một thảm họa về trải nghiệm người dùng (UX). Kế toán viên không lướt ứng dụng để giải trí như TikTok; họ cần sự chính xác. Họ cần biết chính xác có tổng cộng bao nhiêu hóa đơn (Total Elements), và họ bắt buộc phải có khả năng nhảy cóc (Skip) đến một vị trí bất kỳ. Cursor hoàn toàn bất lực trong kịch bản này.
Do đó, lựa chọn duy nhất và chính xác nhất cho bài toán này là Offset-based kết hợp với Deferred Join. Nó vừa thỏa mãn được nghiệp vụ khắt khe của kế toán (giữ nguyên dải số trang và khả năng nhảy cóc), lại vừa bảo vệ được máy chủ khỏi thảm họa "bốc vác dữ liệu thừa" khi họ truy cập vào những trang rất sâu.
Trong thiết kế hệ thống (System Design), không có công nghệ nào là "viên đạn bạc" giải quyết được mọi thứ. Mọi quyết định đều là sự đánh đổi (Trade-off). Dưới đây là "Kim chỉ nam" giúp bạn định vị nhanh phương pháp phù hợp nhất:
1. Offset-based truyền thống (LIMIT / OFFSET cơ bản)
-
Khi nào dùng: Dự án startup giai đoạn đầu, trang web cá nhân, blog nhỏ, bảng quản trị nội bộ có lượng dữ liệu ít (dưới 100.000 bản ghi).
-
Đánh đổi: Chấp nhận rủi ro chậm dần theo thời gian để đổi lấy tốc độ phát triển (Code cực nhanh với Pageable mặc định của Spring Boot).
2. Offset-based Tối ưu (Kết hợp Deferred Join)
-
Khi nào dùng: Các trang thương mại điện tử (Shopee, Tiki), Bảng quản trị hệ thống lớn (ERP, CRM). Dữ liệu lên tới hàng chục triệu bản ghi nhưng giao diện bắt buộc phải có dải số trang (1, 2, 3... 100).
-
Đánh đổi: Tốn thêm công sức chia nhỏ truy vấn (chỉ quét ID trước rồi mới lấy dữ liệu sau) ở tầng logic code, nhưng cứu sống được toàn bộ Database.
3. Kiến trúc Điểm neo (Cursor-based)Khi nào dùng:
-
Ứng dụng di động (Mobile App), Bảng tin mạng xã hội (News Feed), Lịch sử tin nhắn Chat. Nơi người dùng có thói quen "Cuộn vô hạn" (Infinite Scroll) và dữ liệu mới được đẩy vào liên tục theo thời gian thực (Real-time).
-
Đánh đổi: Tốc độ truy vấn luôn là siêu tốc ($O(1)$) dù dữ liệu có là hàng tỷ dòng. Nhưng bù lại, API khó thiết kế hơn, bắt buộc phải quản lý các "điểm neo" phức tạp và người dùng tuyệt đối không thể nhảy cóc trang.
4. Search Engine (Inverted Index với Elasticsearch, Meilisearch...)
-
Khi nào dùng: Khi người dùng bắt đầu dùng thanh tìm kiếm để gõ văn bản tự do (Full-text search), hoặc khi giao diện có quá nhiều bộ lọc đa chiều (Lọc theo giá + màu sắc + thương hiệu + đánh giá sao).
-
Đánh đổi: Chi phí vận hành máy chủ tăng vọt, kiến trúc hệ thống phức tạp lên gấp đôi vì phải giải quyết bài toán đồng bộ dữ liệu giữa Database gốc và Search Engine. Nhưng bù lại, trải nghiệm tìm kiếm của người dùng sẽ đạt mức hoàn hảo.
VII. Lời kết
Phân trang chưa bao giờ là một bài toán nhàm chán nếu chúng ta thực sự đào sâu vào nó. Từ một câu lệnh SQL đơn giản, nó mở ra cả một chân trời về tư duy tối ưu Chỉ mục (Index), thao tác bộ nhớ, và nghệ thuật thiết kế giao diện diện (UI/UX).
Lần tới, khi bạn gõ PageRequest.of(page, size) trong Spring Boot, hy vọng bạn sẽ nhìn thấy cả một hệ thống bánh răng đang vận hành bên dưới nền tảng. Đừng chỉ là một "thợ gõ code" lắp ráp các thư viện có sẵn. Hãy hiểu rõ kiến trúc, và bạn sẽ làm chủ được hệ thống của mình!