Pagination berbasis OFFSET dan LIMIT sering menjadi sumber masalah latensi saat volume data menembus jutaan baris. Pada halaman-halaman awal, query berjalan cepat. Namun, semakin dalam pengguna melakukan navigasi, waktu respons database meningkat drastis. Artikel ini membedah akar masalah degradasi performa OFFSET di PostgreSQL dan menyajikan alternatif efisien: keyset pagination (cursor-based pagination).

Akar Masalah OFFSET N: Biaya Pemrosesan O(N)

Secara konseptual, pagination dengan OFFSET terlihat sederhana:

SELECT id, created_at, title
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000;

PostgreSQL tidak bisa langsung melompat ke baris ke-500.001. Mesin database harus membaca seluruh 500.000 baris sebelumnya dari indeks atau tabel, mengurutkannya, lalu membuang data tersebut untuk menyisakan 20 baris yang diminta.

Karakteristik masalah OFFSET meliputi:

  • Kompleksitas I/O O(N): Beban CPU dan buffer hit bertambah linier sebanding dengan besaran nilai OFFSET.
  • Anomali Data (Drift): Jika ada baris baru masuk atau baris lama dihapus saat pengguna berpindah halaman, item dapat muncul ganda atau terlewatkan.
  • Beban Cache: Membaca ratusan ribu baris yang tidak dipakai mengotori PostgreSQL buffer cache dan mendesak data aktif lainnya keluar dari RAM.

Solusi: Keyset Pagination dengan Composite Index

Keyset pagination bekerja dengan memanfaatkan posisi baris terakhir yang sudah diambil sebagai filter langsung pada indeks B-Tree. Pendekatan ini mengubah kompleksitas operasi dari O(N) menjadi O(log N).

Untuk menjaga kestabilan urutan, kolom penentu waktu (seperti created_at) tidak cukup karena nilainya bisa identik. Dibutuhkan kombinasi kolom penanda unik (tie-breaker), umumnya id. Skema indeks gabungan mutlak diperlukan:

CREATE INDEX idx_articles_created_at_id ON articles (created_at DESC, id DESC);

Query forward traversal (halaman berikutnya) memanfaatkan row-value comparison (tuples comparison):

-- Halaman pertama
SELECT id, created_at, title
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Halaman berikutnya (menggunakan nilai id dan created_at dari baris terakhir halaman sebelumnya)
SELECT id, created_at, title
FROM articles
WHERE (created_at, id) < ('2023-10-15 14:30:00+00', 4892104)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Benchmark Query Plan: EXPLAIN ANALYZE

Berikut perbandingan rencana eksekusi pada tabel articles dengan 5.000.000 baris data.

1. Query OFFSET (Halaman Dalam)

EXPLAIN ANALYZE
SELECT id, created_at, title
FROM articles
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;

Hasil rencana eksekusi:

Limit  (cost=145320.12..145323.03 rows=20 width=45) (actual time=248.512..248.517 rows=20 loops=1)
  ->  Index Scan using idx_articles_created_at_id on articles  (cost=0.43..726600.60 rows=5000000 width=45) (actual time=0.045..215.340 rows=1000020 loops=1)
Planning Time: 0.124 ms
Execution Time: 248.542 ms

PostgreSQL terpaksa memindai 1.000.020 tuple indeks sebelum mengabaikan 1.000.000 tuple pertama.

2. Query Keyset Pagination

EXPLAIN ANALYZE
SELECT id, created_at, title
FROM articles
WHERE (created_at, id) < ('2023-08-01 10:12:00+00', 3999980)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Hasil rencana eksekusi:

Limit  (cost=0.43..3.36 rows=20 width=45) (actual time=0.038..0.052 rows=20 loops=1)
  ->  Index Scan using idx_articles_created_at_id on articles  (cost=0.43..581280.48 rows=3999980 width=45) (actual time=0.036..0.048 rows=20 loops=1)
        Index Cond: (ROW(created_at, id) < ROW('2023-08-01 10:12:00+00'::timestamp with time zone, 3999980))
Planning Time: 0.118 ms
Execution Time: 0.071 ms

Indeks langsung mencari titik target di B-Tree. Waktu eksekusi turun dari 248 ms menjadi 0,07 ms karena database hanya membaca persis 20 baris yang diminta.

Navigasi Dua Arah: Backward Traversal

Keyset pagination mendukung navigasi mundur (previous page) dengan membalik operator kondisi dan urutan sort, kemudian membalikkan kembali hasilnya di lapisan aplikasi atau via subquery:

-- Mengambil halaman sebelumnya dari baris teratas (created_at: '2023-08-01 10:15:00+00', id: 4000000)
SELECT * FROM (
    SELECT id, created_at, title
    FROM articles
    WHERE (created_at, id) > ('2023-08-01 10:15:00+00', 4000000)
    ORDER BY created_at ASC, id ASC
    LIMIT 20
) sub
ORDER BY created_at DESC, id DESC;

Batasan Keyset Pagination

Meskipun efisien, keyset pagination memiliki sejumlah batasan fungsional:

  • Tidak Ada Loncat Halaman (Random Access): Pengguna tidak bisa langsung melompat dari Halaman 1 ke Halaman 50 tanpa memiliki cursor pembatas halaman sebelumnya.
  • Ketergantungan Indeks Kompleks: Setiap kriteria filter baru (misal: WHERE category_id = 5) membutuhkan indeks majemuk yang mencakup kolom filter dan kolom penanda urutan (category_id, created_at DESC, id DESC).
  • Desain API yang Berbeda: Klien harus mengirimkan kursor (misalnya token base64 yang berisi tuple nilai) alih-alih parameter nomor halaman page=N.

Gunakan keyset pagination untuk fitur infinite scrolling, feed aktivitas, atau export data skala besar. Pertahankan OFFSET terbatas hanya pada kasus admin dashboard dengan batasan halaman maksimum (misal: maksimal 100 halaman).