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 msPostgreSQL 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 msIndeks 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).
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!