Jika endpoint daftar data di aplikasi Spring Boot mulai melambat saat tabel PostgreSQL tumbuh menjadi ratusan ribu atau jutaan baris, penyebab yang paling sering adalah penggunaan OFFSET/LIMIT untuk halaman yang makin dalam. Solusi yang biasanya lebih stabil untuk tabel besar adalah keyset pagination, yaitu mengambil halaman berikutnya berdasarkan nilai kolom terakhir yang sudah diterima client, bukan berdasarkan nomor halaman.

Untuk kasus tabel besar, keyset pagination biasanya lebih cepat karena database tidak perlu melewati banyak baris hanya untuk mencapai offset tertentu. Namun pendekatan ini tidak cocok untuk semua kebutuhan. Artikel ini fokus pada implementasi praktis di Spring Boot dan PostgreSQL: kapan OFFSET/LIMIT mulai bermasalah, bagaimana memilih kolom sort yang stabil, bagaimana membuat composite index, bagaimana menulis query JPA/native, dan apa trade-off-nya saat data berubah di tengah proses paginasi.

Mengapa OFFSET/LIMIT Melambat pada Tabel Besar

Query pagination paling umum terlihat seperti ini:

SELECT id, created_at, amount, status
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 500000 LIMIT 50;

Secara fungsional query ini benar. Masalahnya, PostgreSQL tetap harus mencari, mengurutkan, dan melewati sejumlah besar baris sebelum mengembalikan 50 data terakhir yang diminta. Semakin besar nilai OFFSET, semakin besar kerja yang harus dilakukan.

Pada tabel kecil, dampaknya mungkin belum terasa. Pada tabel dengan jutaan baris, query halaman awal bisa masih cepat, tetapi halaman-halaman dalam akan melambat. Ini sering muncul pada endpoint seperti:

  • riwayat transaksi,
  • audit log,
  • activity feed,
  • order list backoffice,
  • event stream atau tabel time-series sederhana.

Masalah utama OFFSET/LIMIT

  • Biaya bertambah seiring nomor halaman: halaman ke-1 dan halaman ke-10.000 memiliki karakteristik performa yang berbeda jauh.
  • Sorting tetap mahal bila index tidak selaras dengan ORDER BY.
  • Kurang stabil saat data berubah: data baru masuk atau data lama terhapus dapat menyebabkan duplikasi atau data terlewat antar halaman.

Apa Itu Keyset Pagination dan Mengapa Lebih Cocok untuk Jutaan Baris

Keyset pagination menggunakan nilai kolom urut terakhir sebagai penanda halaman berikutnya. Misalnya, alih-alih meminta page=10000, client mengirim cursor seperti createdAt=2024-01-10T12:34:56 dan id=987654.

Query SQL-nya kira-kira seperti ini:

SELECT id, created_at, amount, status
FROM orders
WHERE (created_at, id) < ('2024-01-10 12:34:56', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Mengapa lebih cepat? Karena database bisa langsung melanjutkan pencarian dari posisi tertentu pada index, bukan menghitung dan melewati ratusan ribu baris terlebih dahulu.

Karakteristik keyset pagination

  • Kompleksitas lebih stabil untuk halaman dalam.
  • Sangat cocok untuk infinite scroll atau next/previous navigation.
  • Membutuhkan kolom sort yang stabil dan deterministik.
  • Tidak ideal untuk loncat ke halaman sembarang seperti langsung ke halaman 9999.

Memilih Kolom Sort yang Stabil

Keberhasilan keyset pagination sangat bergantung pada kolom pengurutan. Jangan hanya memakai kolom yang nilainya bisa sama pada banyak baris tanpa tie-breaker. Misalnya, created_at sering tidak unik. Jika hanya mengurutkan berdasarkan created_at DESC, Anda bisa mengalami data ganda atau data terlewat ketika ada beberapa baris dengan timestamp identik.

Pola yang aman adalah menggunakan dua kolom:

  • kolom utama yang merepresentasikan urutan bisnis, misalnya created_at,
  • kolom tie-breaker unik, biasanya id.

Contoh urutan yang baik:

ORDER BY created_at DESC, id DESC

Dengan begitu, setiap posisi halaman punya urutan total yang jelas.

Kriteria kolom sort yang baik

  • Stabil: nilainya jarang atau tidak pernah berubah setelah insert.
  • Relevan dengan kebutuhan bisnis: misalnya daftar terbaru berdasarkan waktu dibuat.
  • Dapat diindeks dengan baik.
  • Deterministik: bila kolom utama tidak unik, tambah tie-breaker unik.

Jika kolom sort sering di-update, keyset pagination bisa menghasilkan perilaku yang membingungkan. Baris yang sudah lewat bisa “muncul lagi” atau berpindah posisi.

Desain Index yang Tepat di PostgreSQL

Untuk mendapatkan manfaat keyset pagination, query dan index harus selaras. Jika query Anda adalah:

ORDER BY created_at DESC, id DESC

maka index yang umum dipakai adalah:

CREATE INDEX idx_orders_created_at_id_desc
ON orders (created_at DESC, id DESC);

PostgreSQL cukup fleksibel soal arah scan index, tetapi secara praktik, menyelaraskan definisi index dengan pola query membantu pembacaan dan memudahkan analisis performa.

Kapan perlu composite index

Jika filter Anda juga tetap, misalnya hanya mengambil order dengan status tertentu:

SELECT id, created_at, amount, status
FROM orders
WHERE status = 'PAID'
  AND (created_at, id) < ('2024-01-10 12:34:56', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 50;

maka index tunggal pada (created_at, id) sering belum cukup optimal. Pertimbangkan composite index yang mengikuti pola filter lalu sort:

CREATE INDEX idx_orders_status_created_at_id
ON orders (status, created_at DESC, id DESC);

Prinsip sederhananya: kolom filter yang selektif dan selalu dipakai biasanya diletakkan di depan, lalu diikuti kolom sort.

Kesalahan indexing yang sering terjadi

  • Mengandalkan index di kolom id saja, padahal query diurutkan berdasarkan created_at, id.
  • Membuat banyak index mirip tanpa melihat query nyata, sehingga write performance memburuk.
  • Query memakai fungsi pada kolom sort, misalnya DATE(created_at), sehingga index sulit dimanfaatkan.
  • Urutan kolom pada index tidak sesuai dengan pola WHERE dan ORDER BY.

Contoh Skenario Nyata: Tabel Orders dengan Jutaan Baris

Bayangkan tabel orders berisi jutaan baris. Endpoint backoffice perlu menampilkan 50 order terbaru, lalu pengguna bisa lanjut ke halaman berikutnya. Data baru juga terus masuk setiap detik.

Jika memakai OFFSET/LIMIT:

SELECT id, created_at, customer_id, amount, status
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 2000000 LIMIT 50;

query akan makin mahal untuk halaman dalam. Jika memakai keyset:

SELECT id, created_at, customer_id, amount, status
FROM orders
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

database cukup melanjutkan dari posisi cursor terakhir.

Implementasi Entitas JPA

Berikut contoh entitas yang realistis untuk kebutuhan ini:

@Entity
@Table(name = "orders")
public class OrderEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @Column(name = "customer_id", nullable = false)
    private Long customerId;

    @Column(name = "amount", nullable = false)
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    private OrderStatus status;

    // getters/setters
}

Penting: createdAt sebaiknya tidak diubah setelah data dibuat. Ini membantu menjaga urutan pagination tetap konsisten.

Repository Spring Data JPA: OFFSET/LIMIT vs Keyset Pagination

Contoh OFFSET/LIMIT dengan Pageable

Untuk tabel kecil atau kebutuhan admin yang benar-benar butuh nomor halaman, pendekatan ini masih valid:

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    Page<OrderEntity> findAllByStatusOrderByCreatedAtDescIdDesc(
            OrderStatus status,
            Pageable pageable
    );
}

Ini mudah dipakai, tetapi pada tabel besar performanya akan terpengaruh oleh offset besar.

Contoh keyset pagination dengan native query

Untuk keyset pagination, native query biasanya lebih jelas dan lebih mudah dikontrol:

public interface OrderRepository extends JpaRepository<OrderEntity, Long> {

    @Query(value = """
        SELECT *
        FROM orders
        WHERE status = :status
          AND (
                :lastCreatedAt IS NULL
                OR (created_at, id) < (:lastCreatedAt, :lastId)
              )
        ORDER BY created_at DESC, id DESC
        LIMIT :limit
        """, nativeQuery = true)
    List<OrderEntity> findNextPage(
            @Param("status") String status,
            @Param("lastCreatedAt") Instant lastCreatedAt,
            @Param("lastId") Long lastId,
            @Param("limit") int limit
    );
}

Catatan praktis:

  • Pada halaman pertama, lastCreatedAt dan lastId bisa null.
  • Cursor harus membawa kedua nilai, bukan hanya timestamp.
  • Gunakan LIMIT sedikit lebih besar hanya jika Anda ingin mendeteksi keberadaan halaman berikutnya dengan pola tertentu, tetapi jangan berlebihan.

Alternatif JPQL

JPQL bisa dipakai, tetapi untuk kondisi tuple seperti (created_at, id) < (...), SQL native di PostgreSQL biasanya lebih langsung dan mudah dipahami saat debugging.

Service dan Cursor API

Untuk API, lebih baik kembalikan cursor daripada nomor halaman. Cursor bisa berupa pasangan createdAt dan id yang di-encode menjadi string agar client tidak perlu memahami detail internal.

public record OrderCursor(Instant createdAt, Long id) {}

public record OrderItemResponse(
        Long id,
        Instant createdAt,
        Long customerId,
        BigDecimal amount,
        String status
) {}

public record OrderPageResponse(
        List<OrderItemResponse> items,
        String nextCursor,
        boolean hasNext
) {}
@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;

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

    public OrderPageResponse getPaidOrders(String cursor, int limit) {
        OrderCursor decoded = decodeCursor(cursor);

        List<OrderEntity> rows = orderRepository.findNextPage(
                "PAID",
                decoded != null ? decoded.createdAt() : null,
                decoded != null ? decoded.id() : null,
                limit + 1
        );

        boolean hasNext = rows.size() > limit;
        List<OrderEntity> pageRows = hasNext ? rows.subList(0, limit) : rows;

        List<OrderItemResponse> items = pageRows.stream()
                .map(o -> new OrderItemResponse(
                        o.getId(),
                        o.getCreatedAt(),
                        o.getCustomerId(),
                        o.getAmount(),
                        o.getStatus().name()
                ))
                .toList();

        String nextCursor = null;
        if (!pageRows.isEmpty() && hasNext) {
            OrderEntity last = pageRows.get(pageRows.size() - 1);
            nextCursor = encodeCursor(new OrderCursor(last.getCreatedAt(), last.getId()));
        }

        return new OrderPageResponse(items, nextCursor, hasNext);
    }

    private String encodeCursor(OrderCursor cursor) {
        String raw = cursor.createdAt().toString() + "|" + cursor.id();
        return Base64.getUrlEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
    }

    private OrderCursor decodeCursor(String cursor) {
        if (cursor == null || cursor.isBlank()) {
            return null;
        }
        String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
        String[] parts = raw.split("\\|");
        return new OrderCursor(Instant.parse(parts[0]), Long.parseLong(parts[1]));
    }
}

Encoding cursor dengan Base64 bukan fitur keamanan. Ini hanya cara praktis untuk membungkus nilai cursor agar mudah dipakai client. Jika cursor memuat informasi sensitif, pertimbangkan signing atau token terpisah.

Endpoint API Pagination

Contoh endpoint yang sederhana dan praktis:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderQueryService orderQueryService;

    public OrderController(OrderQueryService orderQueryService) {
        this.orderQueryService = orderQueryService;
    }

    @GetMapping
    public OrderPageResponse getPaidOrders(
            @RequestParam(required = false) String cursor,
            @RequestParam(defaultValue = "50") int limit
    ) {
        int safeLimit = Math.min(Math.max(limit, 1), 100);
        return orderQueryService.getPaidOrders(cursor, safeLimit);
    }
}

Response-nya bisa seperti ini:

{
  "items": [
    {
      "id": 987654,
      "createdAt": "2024-01-10T12:34:56Z",
      "customerId": 1001,
      "amount": 250000.00,
      "status": "PAID"
    }
  ],
  "nextCursor": "MjAyNC0wMS0xMFQxMjozNDo1Nlp8OTg3NjU0",
  "hasNext": true
}

Masalah Duplikasi dan Skip Data Saat Data Berubah

Ini bagian yang sering diabaikan. Baik OFFSET/LIMIT maupun keyset pagination bisa terdampak ketika data berubah di antara request halaman 1 dan halaman 2.

Pada OFFSET/LIMIT

Jika ada data baru masuk di halaman depan, offset halaman berikutnya bisa bergeser. Akibatnya:

  • baris tertentu bisa muncul dua kali,
  • baris lain bisa terlewat.

Pada keyset pagination

Keyset biasanya lebih stabil untuk navigasi maju. Namun tetap ada hal yang perlu dipahami:

  • Jika ada data baru dengan created_at lebih baru dari cursor saat ini, data itu memang tidak akan muncul di halaman berikutnya. Itu normal karena pengguna sedang melanjutkan dari snapshot urutan sebelumnya.
  • Jika kolom sort berubah setelah data dibaca, posisi baris bisa berubah dan memicu duplikasi atau skip.
  • Jika beberapa baris punya nilai sort sama dan Anda tidak memakai tie-breaker unik, hasil bisa tidak deterministik.

Praktik aman:

  • Gunakan kolom sort yang tidak berubah.
  • Tambahkan tie-breaker unik seperti id.
  • Jangan mengandalkan keyset untuk pengalaman “snapshot konsisten lintas banyak request” kecuali Anda benar-benar merancang level transaksi dan model data untuk itu.

Cara Membaca EXPLAIN ANALYZE Secara Dasar

Jangan menebak performa hanya dari perasaan. Gunakan EXPLAIN ANALYZE untuk membandingkan query OFFSET/LIMIT dan keyset di data nyata.

Contoh:

EXPLAIN ANALYZE
SELECT id, created_at, amount, status
FROM orders
ORDER BY created_at DESC, id DESC
OFFSET 500000 LIMIT 50;

dan:

EXPLAIN ANALYZE
SELECT id, created_at, amount, status
FROM orders
WHERE (created_at, id) < ('2024-01-10 12:34:56', 987654)
ORDER BY created_at DESC, id DESC
LIMIT 50;

Hal yang perlu dilihat

  • Apakah planner memakai Index Scan atau justru Seq Scan.
  • Rows: berapa banyak baris yang harus diproses untuk menghasilkan 50 data.
  • Actual time: waktu aktual pada node penting.
  • Sort: apakah ada operasi sort besar yang mahal.

Interpretasi sederhana

Jika query keyset menggunakan Index Scan dan hanya memproses sedikit baris di dekat posisi cursor, itu pertanda desain query dan index sudah sejalan. Jika query OFFSET/LIMIT menunjukkan banyak baris dibaca atau dibuang sebelum limit terpenuhi, itu menjelaskan mengapa performa memburuk di halaman dalam.

Gunakan data yang mendekati produksi saat menguji. Query yang tampak cepat pada 10.000 baris bisa berperilaku sangat berbeda pada 10 juta baris.

Kapan Keyset Pagination Cocok

  • Daftar data besar dengan urutan jelas, misalnya terbaru ke terlama.
  • Infinite scroll atau tombol “load more”.
  • Endpoint dengan beban baca tinggi.
  • Tabel yang terus tumbuh dan sudah mulai menunjukkan bottleneck pada offset besar.

Kapan Keyset Pagination Tidak Cocok

  • Butuh lompat ke halaman tertentu, misalnya langsung ke halaman 200.
  • Urutan data tidak stabil atau bergantung pada kolom yang sering berubah.
  • Sorting dinamis yang terlalu bebas, misalnya user bisa mengurutkan berdasarkan banyak kolom berbeda tanpa index yang sesuai.
  • Kebutuhan total count yang akurat setiap saat dalam satu flow UI berbasis nomor halaman.

Dalam sistem nyata, sering kali Anda tidak harus memilih satu pendekatan untuk semua endpoint. Misalnya:

  • halaman admin kecil tetap memakai Pageable,
  • API publik untuk feed besar memakai keyset pagination.

Trade-off yang Perlu Dipahami

Kelebihan keyset pagination

  • Performa lebih stabil pada data besar.
  • Lebih cocok untuk tabel yang terus bertambah.
  • Mengurangi biaya membaca halaman dalam.

Kekurangan keyset pagination

  • Implementasi lebih kompleks daripada Pageable biasa.
  • Tidak natural untuk UI berbasis nomor halaman.
  • Cursor management perlu desain API yang rapi.
  • Perlu disiplin pada urutan sort dan index.

Checklist Implementasi di Spring Boot dan PostgreSQL

  1. Tentukan use case yang benar-benar bermasalah pada offset besar.
  2. Pilih kolom sort yang stabil, misalnya created_at.
  3. Tambahkan tie-breaker unik, biasanya id.
  4. Buat query dengan ORDER BY created_at DESC, id DESC.
  5. Buat index yang sesuai, minimal (created_at DESC, id DESC).
  6. Jika ada filter tetap seperti status, evaluasi composite index seperti (status, created_at DESC, id DESC).
  7. Gunakan cursor yang memuat semua kolom urutan yang diperlukan.
  8. Batasi limit di API untuk mencegah request terlalu besar.
  9. Bandingkan EXPLAIN ANALYZE antara query lama dan baru.
  10. Uji kasus data masuk bersamaan untuk melihat perilaku duplikasi/skip.

Kesalahan Umum Saat Migrasi dari OFFSET/LIMIT ke Keyset Pagination

  • Masih memakai sort yang tidak unik tanpa tie-breaker.
  • Menyimpan hanya id di cursor padahal urutan utama berdasarkan created_at.
  • Sudah membuat query keyset, tetapi index belum sesuai.
  • Mencampur arah sort dan operator pembanding secara salah.
  • Menganggap Base64 cursor sebagai mekanisme keamanan.
  • Memaksa semua endpoint pindah ke keyset padahal tidak semua butuh.

Kesimpulan

Untuk aplikasi Spring Boot dengan tabel PostgreSQL berukuran besar, keyset pagination adalah strategi praktis untuk mengatasi bottleneck pagination lambat yang muncul pada OFFSET/LIMIT, terutama di halaman-halaman dalam. Kunci keberhasilannya bukan hanya mengganti query, tetapi juga memilih kolom sort yang stabil, menambahkan tie-breaker unik, dan membuat composite index yang selaras dengan pola filter dan urutan.

Jika kebutuhan Anda adalah navigasi maju pada daftar besar seperti order, log, atau activity feed, keyset pagination hampir selalu layak dipertimbangkan. Jika UI menuntut nomor halaman acak atau sorting sangat dinamis, OFFSET/LIMIT mungkin tetap lebih sederhana. Pilih berdasarkan pola akses nyata, lalu validasi dengan EXPLAIN ANALYZE, bukan asumsi.