Paginasi standar di Spring Data JPA umumnya memanfaatkan implementasi Pageable dan PageRequest.of(page, size). Pendekatan ini menghasilkan klausul SQL LIMIT dan OFFSET. Pada tabel berukuran jutaan baris, strategi offset menyebabkan latensi tinggi dan lonjakan utilisasi disk I/O. Solusi deterministik untuk masalah ini adalah beralih ke keyset pagination (dikenal juga sebagai cursor-based pagination atau seek method).

Akar Masalah: Mengapa OFFSET Gagal pada Skala Besar

Saat mengeksekusi kueri dengan OFFSET 1000000 LIMIT 20, database engine tidak melompat langsung ke baris ke-1.000.001. Database membaca 1.000.020 baris dari disk atau memori buffer cache, mengurutkannya, membuang 1.000.000 baris pertama, dan hanya mengembalikan 20 baris terakhir ke aplikasi.

Masalah ini diperparah oleh dua faktor dalam Spring Data JPA:

  • Pemindaian redundan (O(N) Complexity): Semakin dalam halaman yang diakses pengguna, semakin banyak baris yang harus dipindai dan dibuang. Ini menyebabkan I/O trashing pada database buffer pool.
  • Query COUNT(*) bawaan Page<T>: Untuk menghitung total halaman, Spring Data JPA otomatis mengeksekusi kueri agregasi count() yang memicu full index scan atau full table scan pada tabel besar.
-- Logika internal database pada offset pagination
SELECT * FROM orders 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 1000000; -- Database membaca 1.000.020 baris lalu membuang 1.000.000 baris.

Konsep Dasar Keyset Pagination

Keyset pagination mengeliminasi pemindaian baris yang tidak dibutuhkan dengan memanfaatkan predikat filter (WHERE) berbasis nilai kolom pengurutan dari baris data terakhir yang diambil sebelumnya. Database melakukan pencarian langsung ke node daun B-Tree (index seek) dalam kompleksitas waktu $O(\log N)$.

Agar pengurutan bersifat deterministik, kolom pengurutan harus bersifat unik. Jika kolom utama (misal: created_at) memiliki nilai duplikat, wajib ditambahkan kolom pembeda (tie-breaker), umumnya berupa primary key (misal: id).

Konfigurasi Composite Index

Keyset pagination bergantung penuh pada indeks B-Tree multi-kolom yang sesuai dengan urutan klausul filter dan pengurutan.

-- Skema indeks PostgreSQL / MySQL
CREATE INDEX idx_orders_created_at_id 
ON orders (created_at DESC, id DESC);

Tanpa indeks komposit yang searah dengan polaritas pengurutan (keduanya DESC atau keduanya ASC), database terpaksa melakukan in-memory sort yang membatalkan efisiensi keyset.

Implementasi di Spring Data JPA (Versi 3.x+)

Mulai Spring Data JPA 3.x (Spring Boot 3.x), antarmuka ScrollPosition dan return type Window<T> disediakan secara native untuk mendukung keyset pagination tanpa penulisan kueri manual yang rentan salah.

1. Entity Definition

@Entity
@Table(name = "orders", indexes = {
    @Index(name = "idx_orders_created_at_id", columnList = "createdAt DESC, id DESC")
})
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private Instant createdAt;

    @Column(nullable = false)
    private String customerId;

    @Column(nullable = false)
    private BigDecimal amount;

    // Getter dan Setter
}

2. Repository Interface

Gunakan return type Window<T> dan parameter ScrollPosition:

import org.springframework.data.domain.KeysetScrollPosition;
import org.springframework.data.domain.ScrollPosition;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Window;
import org.springframework.data.repository.Repository;

public interface OrderRepository extends Repository<Order, Long> {

    // Menggunakan Fluent Query API via By
    Window<Order> findFirst20ByOrderByCreatedAtDescIdDesc(ScrollPosition position);
}

3. Service Layer Execution

Permintaan halaman pertama menggunakan ScrollPosition.keyset(). Permintaan berikutnya menggunakan posisi kursor dari data terakhir pada window sebelumnya.

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Window<Order> getFirstPage() {
        // Halaman pertama: tanpa cursor
        return orderRepository.findFirst20ByOrderByCreatedAtDescIdDesc(ScrollPosition.keyset());
    }

    public Window<Order> getNextPage(KeysetScrollPosition lastPosition) {
        // Halaman berikutnya: mulai tepat setelah keyset terakhir
        return orderRepository.findFirst20ByOrderByCreatedAtDescIdDesc(lastPosition);
    }
}

Implementasi Alternatif: JPQL Tuple Comparison

Jika masih menggunakan Spring Data JPA versi lama atau membutuhkan custom criteria yang kompleks, gunakan perbandingan tuple manual melalui JPQL/HQL.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
        SELECT o FROM Order o 
        WHERE (:lastCreatedAt IS NULL AND :lastId IS NULL) 
           OR (o.createdAt < :lastCreatedAt) 
           OR (o.createdAt = :lastCreatedAt AND o.id < :lastId)
        ORDER BY o.createdAt DESC, o.id DESC
        """
    )
    List<Order> findNextPage(
        @Param("lastCreatedAt") Instant lastCreatedAt,
        @Param("lastId") Long lastId,
        Pageable pageable // Gunakan PageRequest.of(0, size)
    );
}
Catatan: Hindari penggunaan sintaks tuple row constructor seperti (o.createdAt, o.id) < (:lastCreatedAt, :lastId) pada beberapa versi Hibernate dan database dialect yang belum mendukung ekspresi tersebut secara optimal menjadi index scan.

Evaluasi Kinerja: EXPLAIN ANALYZE

Pengujian pada tabel PostgreSQL berisi 5.000.000 baris data memperlihatkan perbedaan pola eksekusi antara offset dan keyset.

Offset Query (Halaman Dalam)

EXPLAIN ANALYZE
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 2000000;

-- Hasil:
-- Limit  (cost=285123.45..285126.30 rows=20 width=45) (actual time=684.312..684.318 rows=20 loops=1)
--   ->  Index Scan using idx_orders_created_at_id on orders  (cost=0.43..712808.62 rows=5000000 width=45) (actual time=0.045..592.120 rows=2000020 loops=1)
-- Planning Time: 0.124 ms
-- Execution Time: 684.345 ms

Database membaca 2.000.020 baris via index scan sebelum akhirnya membuang 2.000.000 baris tersebut ke buffer discard.

Keyset Query

EXPLAIN ANALYZE
SELECT * FROM orders
WHERE (created_at < '2023-10-15 08:30:00') 
   OR (created_at = '2023-10-15 08:30:00' AND id < 3491234)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Hasil:
-- Limit  (cost=0.43..3.28 rows=20 width=45) (actual time=0.038..0.046 rows=20 loops=1)
--   ->  Index Scan using idx_orders_created_at_id on orders  (cost=0.43..427810.12 rows=3000000 width=45) (actual time=0.037..0.043 rows=20 loops=1)
-- Planning Time: 0.110 ms
-- Execution Time: 0.068 ms

Waktu eksekusi terpangkas dari 684 ms menjadi 0.068 ms. Database melakukan index seek langsung ke posisi data tanpa memproses baris riwayat.

Trade-offs dan Mitigasi Desain API

Keyset pagination mengharuskan perubahan arsitektur data flow antara frontend dan backend:

  • Tidak mendukung navigasi acak: Pengguna tidak bisa langsung melompat dari halaman 1 ke halaman 50. Paginasi ini dirancang untuk pola infinite scrolling atau tombol "Load More".
  • Stateful Token: API respons harus menyediakan token kursor untuk pemanggilan berikutnya. Token ini dapat berupa string Base64 yang mengenkapsulasi nilai keyset terakhir (misal: JSON berisi id dan createdAt).
  • Data Mutasi: Berbeda dengan offset yang rentan menghasilkan duplikasi data saat baris baru disisipkan di awal halaman, keyset pagination stabil terhadap mutasi data real-time karena titik acuan penelusuran terkunci pada nilai kursor terakhir.