99 Ngày Spring — Ngày 31: Pagination nâng cao
Giai đoạn 4 — Data nâng cao — mở màn bằng việc quay lại một người quen: phân trang. Ngày 18 ta dùng Pageable/Page và mọi thứ "cứ thế chạy"; Ngày 30 checkpoint cũng trả Page. Hôm nay nhìn vào hóa đơn: câu count(*) ẩn sau mỗi Page, Slice — phiên bản bỏ count, vấn đề kép của offset sâu (chậm dần + trôi dữ liệu), và keyset pagination — kỹ thuật con trỏ mà mọi feed lớn ngoài đời đang chạy. Cuối bài là bảng chọn chiến lược cho từng loại màn hình.

Page — và hóa đơn count(*)
// Ngày 18 ta trả Page — hôm nay nhìn HÓA ĐƠN của nó
Page<Task> page = repo.findByStatus(TaskStatus.TODO, PageRequest.of(0, 20));
// Bật log SQL (Ngày 27) — MỖI lần gọi là HAI câu query:
// 1. SELECT ... FROM tasks WHERE status = ? ORDER BY created_at DESC LIMIT 20
// 2. SELECT count(*) FROM tasks WHERE status = ? ← trả tiền cho câu này!
page.getTotalElements(); // 1_284_503 — nhờ count(*)
page.getTotalPages(); // 64_226 — nhờ count(*)
page.hasNext(); // cũng suy từ count
// count(*) trên bảng lớn KHÔNG rẻ: PostgreSQL phải quét index/heap thật —
// bảng chục triệu dòng, filter phức tạp → câu count chậm NGANG câu lấy dữ liệu.
// Trớ trêu: càng phân trang để "nhẹ", càng trả thêm một câu quét nặng.
// Khi nào ĐÁNG trả? Khi UI thật sự cần "Trang 3/64.226" hoặc "1.2 triệu kết quả":
// bảng quản trị, trang tìm kiếm có nhảy trang. Còn lại — mua thừa.Mỗi
Pagelà hai câu SQL: câu lấy dữ liệu và câucount(*)nuôigetTotalElements/getTotalPages— bật log SQL (Ngày 27) một lần để tự thấy; rất nhiều API trả tổng số trang mà UI không bao giờ hiển thị.count(*)không phải phép đếm miễn phí: DB phải quét thật qua index hoặc heap theo đúng bộ filter — bảng lớn, điều kiện phức tạp thì câu count nặng ngang câu dữ liệu, và nó chạy mỗi lần lật trang.Câu hỏi quyết định: UI có thật sự cần "Trang 3/64.226" hay "1,2 triệu kết quả" không? Có →
Pagexứng đáng (bảng quản trị, tìm kiếm nhảy trang). Không → đang mua thừa — sang mục kế.
Slice — bỏ count bằng mẹo size + 1
// Slice — phân trang không count: chỉ cần biết "còn nữa không?"
Slice<Task> slice = repo.findByStatus(TaskStatus.TODO, PageRequest.of(0, 20));
// Mẹo cơ chế: Spring lấy size + 1 = 21 dòng —
// về được 21 → hasNext = true, cắt còn 20 trả bạn. KHÔNG câu count nào cả.
slice.hasNext(); // true — nhờ dòng thứ 21
slice.getContent(); // 20 phần tử
slice.getTotalElements(); // ❌ không tồn tại — Slice không biết tổng
// Đổi Page → Slice: chỉ đổi KIỂU TRẢ VỀ của method repository
Slice<Task> findByStatus(TaskStatus status, Pageable pageable);
// Hợp với UI nào? Mọi nơi không hiển thị tổng số trang:
// - feed cuộn vô hạn (mobile, mạng xã hội)
// - nút "Tải thêm"
// - API nội bộ chỉ cần duyệt hết dần
// Còn muốn "trang cuối"? Không có tổng thì không có trang cuối —
// đó là dấu hiệu bạn cần Page, hoặc cần nghĩ lại UI.Cơ chế gọn mà thông minh: xin
size + 1dòng — dòng dư trả lờihasNextrồi bị cắt bỏ; không câu count nào, và đổi từPagechỉ là đổi kiểu trả về của method repository.Slicekhớp mọi UI không cần tổng: cuộn vô hạn, nút "Tải thêm", job duyệt dần — chiếm đa số màn hình danh sách hiện đại; món duy nhất mất làgetTotalElements/getTotalPages.Mẹo thực dụng khi cần tổng thi thoảng: endpoint danh sách trả
Slice, thêm endpoint/countriêng gọi thưa (cache 1 phút) — tách "đọc trang" khỏi "đếm tổng" thay vì ép mỗi lần lật trang trả cả hai.
Offset sâu — chậm dần và trôi dữ liệu
// Vấn đề thật sự nằm ở OFFSET — cả Page lẫn Slice đều dùng nó
// PageRequest.of(5000, 20) sinh ra:
// SELECT ... ORDER BY created_at DESC OFFSET 100000 LIMIT 20
// 1. Hiệu năng: OFFSET không "nhảy cóc" — DB phải ĐỌC RỒI VỨT 100.000 dòng
// trước khi trả 20 dòng bạn cần. Trang càng sâu càng chậm, tuyến tính.
// (Đo thật trên Postgres: trang 1 mất 2ms — trang 5000 mất 700ms cùng query.)
// 2. Trôi dữ liệu: giữa hai lần bấm "trang sau", có 3 task mới được tạo →
// mọi dòng bị đẩy lùi 3 vị trí:
// Trang 1 lúc 10:00: [T100 .. T81]
// Trang 2 lúc 10:01: [T83 .. T64] ← T83, T82, T81 xuất hiện LẠI (trùng)
// ... hoặc ngược lại: xóa dữ liệu → có dòng bị NHẢY QUA không bao giờ thấy.
// Feed cuộn vô hạn bằng offset = tin trùng lặp mỗi lần kéo — bug quen mặt.
// 3. Không atomic: totalPages tính lúc 10:00 đã sai vào 10:01.
// → Offset ổn cho bảng quản trị ít trang, dữ liệu tĩnh.
// Feed lớn, ghi liên tục? Cần cách khác — keyset.OFFSETkhông biết nhảy cóc: muốn bỏ 100.000 dòng thì DB phải đọc rồi vứt đủ 100.000 — trang càng sâu càng chậm tuyến tính, và cảPagelẫnSliceđều chung số phận vì cùng cưỡi trên offset.Trôi dữ liệu là bug âm thầm hơn: bản ghi mới chèn giữa hai lần lật đẩy mọi dòng lùi lại → phần tử xuất hiện lặp ở trang sau (hoặc bị nhảy qua khi có xóa) — đây chính là "feed hiện tin trùng" kinh điển, và là lý do offset không hợp dữ liệu ghi liên tục.
Kết luận trung thực: offset không sai — nó hợp bảng quản trị vài trăm trang, dữ liệu tĩnh, người dùng nội bộ; nó sai chỗ khi đem làm feed công khai trên bảng lớn ghi liên tục.
Keyset pagination — con trỏ thay số trang
// Keyset (cursor) pagination — "đưa tôi 20 dòng CŨ HƠN dòng cuối tôi đã thấy"
// Thay số trang bằng CON TRỎ = giá trị khóa sắp xếp của dòng cuối trang trước.
// Khóa sắp xếp phải ỔN ĐỊNH + DUY NHẤT → thêm id phá hòa khi createdAt trùng:
// ORDER BY created_at DESC, id DESC
public interface TaskRepository extends JpaRepository<Task, Long> {
// Trang đầu — chưa có con trỏ:
List<Task> findByOrderByCreatedAtDescIdDesc(Limit limit);
// Trang sau — mọi dòng "cũ hơn" con trỏ (so sánh bộ đôi):
@Query("""
select t from Task t
where t.createdAt < :creAt
or (t.createdAt = :creAt and t.id < :id)
order by t.createdAt desc, t.id desc
""")
List<Task> tiepTheo(Instant creAt, Long id, Limit limit);
}
// Service: trả 20 dòng + con trỏ cho lần gọi kế
List<Task> trang = repo.tiepTheo(conTro.createdAt(), conTro.id(), Limit.of(20));
Task cuoi = trang.getLast();
// → client giữ {createdAt, id} của "cuoi" (mã hóa base64 làm cursor token)
// Vì sao nhanh: WHERE + ORDER BY khớp index (created_at, id) →
// DB NHẢY THẲNG tới vị trí, đọc đúng 20 dòng — trang 1 hay trang 1 triệu đều ~2ms.
// Dữ liệu mới chèn vào cũng không làm trùng/sót: con trỏ neo theo GIÁ TRỊ, không theo vị trí.
// Cái giá: không nhảy tới "trang 37", không totalPages —
// đúng bản chất feed; và cần index ĐÚNG BỘ (created_at, id).Đổi câu hỏi là đổi tất cả: thay "lấy trang 5001" bằng "lấy 20 dòng cũ hơn con trỏ" —
WHEREso bộ đôi(createdAt, id)khớp index nên DB nhảy thẳng tới vị trí: trang 1 hay trang 1 triệu đều vài mili giây, và không còn trùng/sót vì con trỏ neo theo giá trị.Ba điều kiện sống còn: khóa sắp xếp ổn định + duy nhất (thêm
idphá hòa —createdAttừ auditing Ngày 29 trùng nhau là chuyện thường), index đúng bộ(created_at, id), và cursor gửi client nên mã hóa opaque (base64) để tự do đổi cấu trúc sau này.Cái giá phải khai thật: không nhảy tới trang bất kỳ, không tổng số — đúng bản chất feed nên thường chẳng ai đòi; cần cả hai thế giới thì kết hợp: keyset cho cuộn, endpoint count riêng cho con số hiển thị.
Chọn chiến lược — bảng tra nhanh
Bảng quản trị nội bộ, vài nghìn dòng, cần nhảy trang →
Page(Ngày 18) — đơn giản là đúng.Danh sách lớn, nút Tải thêm, không hiện tổng →
Slice— bỏ count miễn phí, đổi mỗi kiểu trả về.Feed công khai, ghi liên tục, cuộn sâu → keyset với
(createdAt, id)+ index — nhanh phẳng và không trôi.Cần con số tổng? Endpoint
/countriêng + cache — đừng bắt mỗi lần lật trang trả tiền đếm.Mọi chiến lược: sort phải có tie-breaker (
id) — sort mơ hồ là thứ tự đổi giữa hai lần gọi, phân trang nào cũng vỡ.
Bài tập nhỏ
Bật log SQL, gọi endpoint
Pagecủa checkpoint Ngày 30 — đếm số câu query; đổi sangSlicevà đếm lại.Seed 1 triệu task (batch insert), so thời gian
page=1vớipage=40000— vẽ nhanh đồ thị thời gian theo độ sâu.Tái hiện trôi dữ liệu: mở trang 1, chèn 3 task mới, mở trang 2 — chỉ ra phần tử lặp; lặp lại với keyset để thấy hết trùng.
Cài
tiepTheotheo bài + recordCursor(Instant, Long)mã hóa base64; viết endpointGET /api/tasks/feed?cursor=hoàn chỉnh.Tạo index
(created_at, id)rồi chạyEXPLAIN ANALYZEcâu keyset — xác nhận Index Scan, không Seq Scan.
Kết luận
Bốn ý gói bài: Page = dữ liệu + count(*) — chỉ trả tiền khi UI thật sự hiển thị tổng; Slice bỏ count bằng mẹo size + 1, đổi mỗi kiểu trả về; offset sâu vừa chậm tuyến tính vừa trôi dữ liệu trên bảng ghi liên tục; keyset neo theo giá trị (createdAt, id) + index đúng bộ — nhanh phẳng ở mọi độ sâu, đổi lại mất nhảy trang. Ngày 32 giải bài toán màn hình lọc đa điều kiện: Specification & dynamic query — ghép điều kiện như ghép LEGO thay vì bùng nổ derived query. Hẹn gặp lại!
Site Admin
Engineer and writer. Building things with TypeScript and distributed systems.
Bình luận (0)
Bạn cần đăng nhập bằng Google để bình luận.
Hãy là người bình luận đầu tiên.


