Spring REST API & Data JPA/Pagination performance — OFFSET vs keyset, Slice bỏ COUNT
34/46
Bài 34 / 46~12 phútRepository & QueriesMiễn phí lượt xem

Pagination performance — OFFSET vs keyset, Slice bỏ COUNT

OFFSET pagination chậm dần tuyến tính theo depth vì DB phải scan và bỏ N row trước khi trả kết quả. Keyset/cursor pagination nhảy thẳng vào vị trí qua B-tree index, tốc độ hằng số. Slice loại COUNT query. Bài này giải thích cơ chế từng chiến lược, trade-off, và khi nào chọn cái nào.

TL;DR: OFFSET N LIMIT 20 yêu cầu DB scan rồi bỏ đúng N row — thời gian tỉ lệ thuận với N, trang 1.000 chậm hơn trang 1 gấp 1.000 lần. Keyset (cursor) pagination dùng WHERE id sau lastId để nhảy thẳng qua B-tree index, tốc độ O(log N) hằng số bất kể depth. Slice<T> bỏ query COUNT(*) thứ hai, tiết kiệm ~50% cho table lớn nhưng không giải quyết OFFSET scan. Ba cơ chế phục vụ ba nhu cầu UX khác nhau: "trang X/Y" cần Page, infinite scroll cần Slice, realtime feed cần keyset.

Bài Pageable và Sort đã giới thiệu cách bind ?page=0&size=20 và API Page<T> / Slice<T>. Bài này đào sâu một câu hỏi: vì sao Page chậm ở deep page, Slice nhanh hơn nhưng vẫn không đủ cho 100M row, và keyset giải quyết thế nào bên dưới?

1. Scenario — deep page trong production

TaskFlow có 2 triệu activity row. Endpoint /api/activities?page=0&size=20 trả kết quả trong 5ms. Nhưng khi crawler của analytics team gửi ?page=50000&size=20, response mất 30 giây rồi timeout.

page=0,     OFFSET=0      → 5ms
page=100,   OFFSET=2000   → 50ms
page=1000,  OFFSET=20000  → 500ms
page=10000, OFFSET=200000 → 5000ms → timeout

Không phải lỗi code. Đây là hành vi xác định của OFFSET — cần hiểu cơ chế để chọn đúng chiến lược.

2. Cơ chế OFFSET — tại sao chậm tuyến tính

SQL LIMIT 20 OFFSET 20000 không có shortcut: PostgreSQL phải đọc tuần tự từ đầu index (hoặc heap), đếm và bỏ qua đúng 20.000 row, rồi mới trả 20 row tiếp theo. Không thể nhảy thẳng vì không biết row thứ 20.001 nằm ở địa chỉ nào cho đến khi đã đọc 20.000 row trước đó.

-- Moi lan deep page, Postgres lap lai toan bo cong viec nay:
SELECT * FROM activities
ORDER BY created_at DESC
LIMIT 20 OFFSET 20000;

-- Postgres thuc hien:
-- 1. Doc index created_at tu dau (hoac full heap scan)
-- 2. Dem va bo qua 20000 row dau
-- 3. Doc 20 row tiep theo
-- 4. Tra ve client
-- Chi phi: doc 20020 row, tra ve 20

Với index trên created_at, bước 1 là index scan thay sequential scan — nhanh hơn ~10 lần nhưng vẫn O(N). Index giúp tìm điểm bắt đầu của toàn bộ tập kết quả, không giúp nhảy đến vị trí OFFSET.

flowchart TB
  subgraph OFF["OFFSET pagination -- chi phi tang tuyen tinh"]
    direction TB
    O1["Index scan tu dau tap ket qua"]
    O2["Bo qua N row dau (OFFSET N)"]
    O3["Doc 20 row tiep theo (LIMIT 20)"]
    O4["Tra ve client -- nhung da doc N+20 row"]
    O1 --> O2 --> O3 --> O4
  end

  subgraph KS["Keyset pagination -- chi phi hang so"]
    direction TB
    K1["WHERE id sau lastId -- dieu kien index"]
    K2["B-tree lookup O(log N) toi vi tri"]
    K3["Doc 20 row tiep theo (LIMIT 20)"]
    K4["Tra ve client -- chi doc 20 row"]
    K1 --> K2 --> K3 --> K4
  end

Đây là lý do keyset nhanh hơn: điều kiện WHERE id sau lastId là một index seek — B-tree tìm ngay vị trí của lastId trong O(log N) rồi đọc 20 row tiếp theo. Không có bước "bỏ qua N row".

3. Keyset pagination — cơ chế seek thay scan

Keyset (cursor-based) pagination dùng giá trị của row cuối đã thấy làm điểm bắt đầu cho trang tiếp theo:

// Trang dau: khong co cursor
List<Activity> findFirst20ByOrderByCreatedAtDescIdDesc();

// Trang tiep theo: cursor = (createdAt, id) cua row cuoi trang truoc
@Query("""
    SELECT a FROM Activity a
    WHERE a.createdAt < :cursorCreatedAt
       OR (a.createdAt = :cursorCreatedAt AND a.id < :cursorId)
    ORDER BY a.createdAt DESC, a.id DESC
    """)
List<Activity> findNextPage(
    @Param("cursorCreatedAt") Instant cursorCreatedAt,
    @Param("cursorId") Long cursorId,
    Pageable limit
);

SQL tương ứng:

-- Trang dau
SELECT * FROM activities
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Postgres: index seek toi dau index, lay 20 row

-- Trang 2 (cursor = row cuoi trang 1: created_at='2026-05-01 10:00:00', id=5000)
SELECT * FROM activities
WHERE created_at < '2026-05-01 10:00:00'
   OR (created_at = '2026-05-01 10:00:00' AND id < 5000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- Postgres: B-tree seek den (created_at, id) = cursor, lay 20 row ke tiep
-- Chi phi: O(log N) bat ke depth

Index cần tạo để hỗ trợ seek:

-- Index composite khop voi ORDER BY va WHERE
CREATE INDEX idx_activities_created_id
ON activities(created_at DESC, id DESC);

Tại sao cần composite cursor (createdAt, id)? Nếu chỉ dùng createdAt, nhiều row cùng timestamp sẽ bị bỏ sót hoặc lặp lại ở ranh giới trang. id là tiebreaker đảm bảo cursor duy nhất cho mỗi row.

Keyset với Spring Data Window API (Spring Data 3.1 trở lên)

Spring Data 3.1 giới thiệu Window<T>ScrollPosition để chuẩn hoá keyset:

public interface ActivityRepository extends JpaRepository<Activity, Long> {
    Window<Activity> findFirst20ByOrderByCreatedAtDescIdDesc(ScrollPosition position);
}

// Service
public ScrollResult<ActivityDto> feed(ActivityCursor cursor, int size) {
    ScrollPosition position = cursor == null
        ? ScrollPosition.offset()
        : ScrollPosition.forward(Map.of(
            "createdAt", cursor.createdAt(),
            "id", cursor.id()
          ));

    Window<Activity> window = repo.findFirst20ByOrderByCreatedAtDescIdDesc(position);

    ActivityCursor nextCursor = window.hasNext()
        ? toCursor(window.getContent().get(window.size() - 1))
        : null;

    return new ScrollResult<>(
        window.getContent().stream().map(ActivityDto::from).toList(),
        nextCursor
    );
}

public record ActivityCursor(Instant createdAt, Long id) {}
public record ScrollResult<T>(List<T> items, ActivityCursor nextCursor) {}

ScrollPosition.forward(Map.of(...)) truyền cursor map xuống Spring Data, framework tự generate điều kiện WHERE keyset phù hợp.

4. Slice — bỏ COUNT query, không bỏ OFFSET scan

Slice<T> giải quyết vấn đề khác với keyset: nó bỏ query COUNT(*) thứ hai mà Page<T> luôn chạy.

// Page: chay 2 SQL
Page<Activity> findAll(Pageable pageable);
// SQL 1: SELECT * FROM activities ... LIMIT 20 OFFSET N
// SQL 2: SELECT COUNT(*) FROM activities ... (co the rat cham)

// Slice: chi 1 SQL
Slice<Activity> findAll(Pageable pageable);
// SQL: SELECT * FROM activities ... LIMIT 21 OFFSET N
// (fetch size+1, neu co row thu 21 thi hasNext=true)

Cơ chế hasNext của Slice: Hibernate fetch size + 1 row. Nếu tồn tại row thứ size + 1 thì hasNext = true và row đó không được trả về client. Không cần COUNT(*) — tiết kiệm ~50% thời gian cho table có COUNT chậm.

flowchart LR
  subgraph PG["Page -- 2 SQL"]
    direction TB
    P1["SQL 1: SELECT ... LIMIT 20 OFFSET N"]
    P2["SQL 2: SELECT COUNT(*) FROM ..."]
    P3["totalElements, totalPages"]
    P1 --> P3
    P2 --> P3
  end

  subgraph SL["Slice -- 1 SQL"]
    direction TB
    S1["SQL: SELECT ... LIMIT 21 OFFSET N"]
    S2["Row thu 21 ton tai?"]
    S3["hasNext = true -- row do khong tra ve"]
    S1 --> S2 --> S3
  end

Điều quan trọng: Slice vẫn dùng OFFSET. Nó chỉ giúp khi COUNT(*) là bottleneck (table có nhiều JOIN, WHERE phức tạp), không giúp khi OFFSET scan là bottleneck. Với table 100M row và OFFSET 2.000.000, Slice vẫn chậm như Page về data query.

5. So sánh ba chiến lược

Page<T>Slice<T>Keyset / Window<T>
SQL chạy2 (data + COUNT)1 (data, fetch size+1)1 (data, seek qua index)
Chi phí deep pageO(N) — scan N rowO(N) — vẫn OFFSETO(log N) — B-tree seek
Biết totaltotalElements, totalPagesKhôngKhông
Stable khi insertKhông — row mới shift trangKhôngCó — cursor anchor row
Nhảy trang randomKhông
UX pattern"Trang X / Y""Load more""Load more" / realtime feed
Phù hợp khiTable nho, admin UIMobile scroll nhanhTable lon, data bien dong

Quy tắc chọn:

  • Cần "Trang X / Y" và table dưới 100k row: Page<T>.
  • Infinite scroll, không cần total, table dưới 1M row: Slice<T>.
  • Table lớn hơn 100k row, data thay đổi thường xuyên, hoặc realtime feed: keyset.

6. Pitfall thường gặp

Pitfall 1 — OFFSET sâu làm timeout endpoint

// SAI: khong gioi han page, OFFSET tang khong kiem soat
@GetMapping("/activities")
public Page<ActivityDto> list(Pageable pageable) {
    return repo.findAll(pageable).map(ActivityDto::from);
}
// Request ?page=50000&size=20 -> timeout 30s
// DUNG: gioi han max page, buoc dung search/filter cho data sau
@GetMapping("/activities")
public Page<ActivityDto> list(
    @RequestParam(defaultValue = "0") @Min(0) @Max(500) int page,
    @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size
) {
    return service.list(PageRequest.of(page, size)).map(ActivityDto::from);
}

Bổ sung cấu hình global trong application.yml:

spring:
  data:
    web:
      pageable:
        max-page-size: 100

Pitfall 2 — Sort không stable gây lặp/bỏ sót row ở ranh giới trang

ORDER BY không unique = kết quả phân trang không xác định

SQL không đảm bảo thứ tự giữa các row có cùng giá trị sort key — hai lần chạy cùng query có thể trả thứ tự khác nhau. Hệ quả ở ranh giới trang: row xuất hiện 2 lần hoặc biến mất, và bug chỉ lộ ra lác đác trên production, gần như không reproduce được trong test. Luôn thêm tiebreaker unique (id) vào cuối mọi Sort.

-- SAI: ORDER BY status khong unique
SELECT * FROM activities ORDER BY status LIMIT 20 OFFSET 20;
-- Neu 2 execution co row moi insert giua 2 lan chay
-- -> row co the xuat hien 2 lan hoac mat hoan toan

-- DUNG: luon co tiebreaker unique
SELECT * FROM activities ORDER BY status, id LIMIT 20 OFFSET 20;

Trong Spring Data:

// DUNG: sort composite co id lam tiebreaker
Pageable pageable = PageRequest.of(page, size,
    Sort.by(Sort.Order.asc("status"), Sort.Order.asc("id")));

Pitfall 3 — Dùng Slice khi vẫn cần COUNT

Slice bỏ COUNT hoàn toàn. Nếu UI cần hiển thị "tìm thấy 1.234 kết quả", Slice không cung cấp thông tin đó. Cần Page hoặc query COUNT riêng với cache.

Pitfall 4 — Keyset cursor không unique

// SAI: cursor chi co createdAt, neu nhieu row cung timestamp
// -> co the bỏ sót hoac lap row
WHERE createdAt < :cursor ORDER BY createdAt DESC LIMIT 20

// DUNG: composite cursor (createdAt, id)
WHERE createdAt < :cursorCreatedAt
   OR (createdAt = :cursorCreatedAt AND id < :cursorId)
ORDER BY createdAt DESC, id DESC LIMIT 20

Liên hệ các bài khác

  • Pageable và Sort: cách bind ?page=&size=&sort= qua PageableHandlerMethodArgumentResolver, cấu hình @PageableDefault, @SortDefault — nền tảng API trước khi chọn chiến lược performance.
  • Cascade & N+1 query: N+1 query khi load relationship trong Page — paginate entity với JOIN FETCH cần countQuery custom để tránh COUNT sai do Cartesian product.
  • Propagation & isolation: đánh dấu service method @Transactional(readOnly = true) khi chỉ đọc data — giảm overhead lock, tối ưu cùng keyset pagination.

Tóm tắt

  • OFFSET N yêu cầu DB đọc và bỏ N row — chi phí O(N), tăng tuyến tính theo depth.
  • Keyset dùng WHERE id sau lastId như điều kiện index seek — chi phí O(log N) hằng số bất kể trang bao nhiêu.
  • Slice<T> bỏ query COUNT(*), tiết kiệm ~50% khi COUNT là bottleneck, nhưng vẫn dùng OFFSET cho data query.
  • Ba chiến lược phục vụ ba UX khác nhau: Page cho "trang X/Y", Slice cho "Load more" trên table vừa, keyset cho table lớn và realtime feed.
  • Luôn có tiebreaker unique (thường là id) trong sort và keyset cursor để tránh bỏ sót hoặc lặp row ở ranh giới trang.

Tự kiểm tra

Tự kiểm tra
Q1
`OFFSET 20000 LIMIT 20` chạy chậm. Giải thích cơ chế DB bên dưới theo từng bước. Tại sao có index trên cột sort vẫn không giúp được nhiều ở deep page?

PostgreSQL thực hiện ba bước: đầu tiên, bắt đầu index scan từ đầu tập kết quả (không nhảy vào giữa được); tiếp theo, duyệt và đếm bỏ qua đúng 20.000 row; cuối cùng, đọc 20 row tiếp theo và trả về. Chi phí thực tế là đọc 20.020 row để trả 20 row.

Index trên cột sort giúp tránh full heap scan — thay vì đọc toàn bộ bảng không theo thứ tự, PostgreSQL đọc index theo thứ tự đã sắp xếp. Nhưng bước "bỏ qua 20.000 row" vẫn phải duyệt đủ 20.000 entry trong index — index không có cách nào nhảy đến vị trí thứ 20.001 mà không đọc 20.000 entry trước đó. Nên index giúp ~10 lần nhưng vẫn O(N).

Khác biệt căn bản: index seek theo giá trị (WHERE id = 12345) là O(log N), còn index scan bỏ qua N entry (OFFSET N) là O(N). Keyset biến bài toán thứ hai thành bài toán thứ nhất.

Q2
Tại sao keyset pagination cần composite cursor (createdAt, id) thay vì chỉ createdAt? Điều gì xảy ra nếu chỉ dùng createdAt khi có 5 row cùng timestamp?

Nếu cursor chỉ là createdAt và có 5 row cùng giá trị timestamp đó, điều kiện WHERE createdAt < cursor sẽ bỏ qua cả 5 row khi lấy trang tiếp theo — trang trước trả về 1 row trong số 5 row cùng timestamp, trang sau dùng timestamp đó làm cursor và bỏ luôn 4 row còn lại.

Composite cursor (createdAt, id) giải quyết: điều kiện là createdAt < cursor_ts OR (createdAt = cursor_ts AND id < cursor_id). Điều này đọc "lấy row nào nhỏ hơn cặp (timestamp, id) theo thứ tự lexicographic" — duy nhất và xác định cho từng row.

Nguyên tắc chung: cursor phải là tập cột tạo thành giá trị duy nhất cho mỗi row. Thực tế đơn giản nhất là dùng (sort_column, id)id luôn unique. Nếu sort theo nhiều cột thì cursor cần tất cả cột đó cộng id.

Q3
Slice<T> nhanh hơn Page<T> ở điểm nào? Với table 10 triệu row và request ?page=500&size=20, Slice có giải quyết được vấn đề không?

Slice<T> nhanh hơn vì bỏ query COUNT(*) thứ hai. Với table có nhiều JOIN hoặc WHERE phức tạp, COUNT có thể mất vài giây — Slice tiết kiệm toàn bộ chi phí đó. Cơ chế: Hibernate fetch size + 1 row, nếu tồn tại row thứ size + 1 thì hasNext = true.

Với ?page=500&size=20 trên 10 triệu row, Slice không giải quyết được vấn đề chính. OFFSET = 10.000, DB vẫn phải đọc và bỏ qua 10.000 row trước khi trả 20 row. Chi phí data query vẫn O(N). Slice chỉ cắt được chi phí COUNT, không cắt được chi phí OFFSET scan.

Kết luận: Slice hữu ích khi COUNT là bottleneck (bảng có WHERE/JOIN phức tạp), không hữu ích khi deep page OFFSET scan là bottleneck. Cho 10 triệu row với deep page, cần keyset.

Q4
Tại sao keyset pagination không hỗ trợ "nhảy thẳng đến trang 50"? Đây có phải hạn chế của Spring Data hay là hạn chế của cơ chế bên dưới?

Đây là hạn chế của cơ chế bên dưới, không phải Spring Data. Keyset hoạt động bằng cách lưu "vị trí đang đứng" dưới dạng giá trị dữ liệu (cursor). Để biết cursor của trang 50, bạn phải đã từng đứng ở cuối trang 49 và lưu lại cursor đó.

Không có cách tính cursor của trang 50 mà không duyệt qua trang 1 đến 49 trước. Đây là đánh đổi căn bản: keyset biến "vị trí" từ số nguyên (OFFSET) sang giá trị dữ liệu (cursor) — tốt cho sequential navigation, không tốt cho random access.

Trong thực tế, "nhảy trang" là UX pattern của admin table — nơi người dùng cần "đi thẳng đến trang 50". Với use case đó, Page<T> và OFFSET là phù hợp, chấp nhận giới hạn số trang tối đa (thường 100-500 trang). Còn với realtime feed, log viewer, hoặc infinite scroll, người dùng không cần nhảy trang — keyset là lựa chọn tự nhiên.

Q5
TaskFlow có endpoint lấy danh sách project cho admin, cần hiển thị "trang X / Y tổng cộng 1.200 project". Sau đó cần thêm endpoint lấy activity log realtime (10 triệu row, insert liên tục). Chọn chiến lược nào cho từng endpoint và tại sao?

Admin project list: dùng Page<T>. Lý do: UX yêu cầu hiển thị "trang X/Y" và total count. Với 1.200 project, COUNT nhanh, OFFSET tối đa ~60 trang (size=20) — không có deep page issue. Chi phí 2 SQL chấp nhận được.

Activity log realtime: dùng keyset với Window<T>. Lý do đầu tiên là scale — 10 triệu row với OFFSET 200.000 sẽ timeout. Lý do thứ hai là data bias — activity insert liên tục, OFFSET sẽ gây lặp/bỏ sót row ở ranh giới trang khi data shift. Keyset dùng cursor anchor vào row cụ thể, stable với insert mới. Lý do thứ ba là UX — activity feed tự nhiên là "load more", không cần "nhảy đến trang 50".

Implementation keyset cần composite cursor (createdAt, id) và index (created_at DESC, id DESC). Encode cursor thành Base64 JSON để client không parse nội bộ và server có thể đổi format sau mà không breaking change.

Bài tiếp theo: Tổng kết module

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên