Masalah Performa: Mengapa OFFSET Lambat pada Skala Besar
Pola pagination tradisional berbasis LIMIT ... OFFSET ... menjadi bottleneck performa saat volume data bertumbuh. Ketika server menjalankan kueri seperti SELECT * FROM items ORDER BY created_at DESC LIMIT 20 OFFSET 100000;, database tidak langsung melompat ke baris ke-100.001.
Database engine (seperti PostgreSQL atau MySQL) harus membaca B-Tree index, memindai 100.020 baris dari disk atau memori, lalu membuang 100.000 baris pertama hanya untuk mengambil 20 baris terakhir. Kompleksitas kueri ini bernilai O(N) terhadap nilai offset. Akibatnya terjadi lonjakan I/O, konsumsi buffer cache berlebih, dan peningkatan latensi respons server Nitro.
Keyset pagination (cursor-based pagination) menyelesaikan kendala ini dengan mengubah penelusuran data menjadi operasi pencarian berindeks konstan bernilai O(1) relatif terhadap nomor halaman. Server mencatat nilai penanda (cursor) dari baris data terakhir, kemudian mencari baris berikutnya menggunakan klausa WHERE terindeks.
Struktur Skema dan Composite Index
Kunci efisiensi keyset pagination terletak pada pendefinisian composite index deterministik. Jika sorting dilakukan berdasarkan kolom yang memiliki duplikasi nilai (seperti created_at), tambahkan kolom identitas unik seperti id sebagai tie-breaker.
CREATE TABLE items (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Composite index wajib mencakup kolom sorting dan kolom tie-breaker
CREATE INDEX idx_items_created_at_id ON items (created_at DESC, id DESC);
Penggunaan index (created_at DESC, id DESC) memungkinkan database engine melakukan Index Seek langsung ke lokasi data yang relevan tanpa membaca baris sebelumnya.
Perbandingan Eksekusi: EXPLAIN ANALYZE
Evaluasi perbedaan rencana eksekusi antara pendekatan OFFSET dan Keyset pada tabel berisi 1.000.000 baris.
1. Query Berbasis OFFSET
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 500000;
Hasil rencana eksekusi:
Limit (cost=42512.30..42514.00 rows=20 width=48) (actual time=138.412..138.418 rows=20 loops=1)
-> Index Scan using idx_items_created_at_id on items (cost=0.42..85024.18 rows=1000000 width=48) (actual time=0.038..109.215 rows=500020 loops=1)
Planning Time: 0.125 ms
Execution Time: 138.455 ms
Database membaca 500.020 baris indeks secara fisik sebelum membuangnya. Ini menghabiskan waktu eksekusi sebesar ~138 ms.
2. Query Berbasis Keyset
EXPLAIN ANALYZE
SELECT id, title, created_at
FROM items
WHERE (created_at, id) < ('2026-03-01 10:00:00+00', 500000)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Hasil rencana eksekusi:
Limit (cost=0.42..2.12 rows=20 width=48) (actual time=0.045..0.052 rows=20 loops=1)
-> Index Scan using idx_items_created_at_id on items (cost=0.42..42512.30 rows=500000 width=48) (actual time=0.044..0.050 rows=20 loops=1)
Index Cond: (ROW(created_at, id) < ROW('2026-03-01 10:00:00+00'::timestamptz, 500000))
Planning Time: 0.089 ms
Execution Time: 0.068 ms
Database langsung melompat ke record tujuan menggunakan row comparison. Waktu eksekusi terpangkas hingga 0.068 ms, stabil terlepas dari seberapa jauh posisi data di dalam tabel.
Implementasi Server Route Nitro (/server/api/items.get.ts)
Di Nuxt 3 Nitro, buat endpoint yang memvalidasi parameter cursor dan mengeksekusi kueri terindeks. Query mengambil LIMIT + 1 untuk memeriksa ketersediaan halaman berikutnya tanpa melakukan kueri COUNT(*) tambahan.
// server/api/items.get.ts
import { defineEventHandler, getQuery } from 'h3'
import { db } from '~/server/utils/db' // Instans koneksi database (pg/Kysely/Drizzle)
interface ItemRow {
id: number
title: string
created_at: Date
}
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const limit = Math.min(Math.max(Number(query.limit) || 20, 1), 100)
const cursorCreatedAt = query.cursor_created_at ? String(query.cursor_created_at) : null
const cursorId = query.cursor_id ? Number(query.cursor_id) : null
let sql: string
let params: (string | number)[]
// Validasi: Kedua komponen cursor harus ada jika pagination sedang berjalan
if (cursorCreatedAt && cursorId && !isNaN(cursorId)) {
sql = `
SELECT id, title, created_at
FROM items
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3
`
params = [cursorCreatedAt, cursorId, limit + 1]
} else {
sql = `
SELECT id, title, created_at
FROM items
ORDER BY created_at DESC, id DESC
LIMIT $1
`
params = [limit + 1]
}
const { rows } = await db.query<ItemRow>(sql, params)
const hasMore = rows.length > limit
const items = hasMore ? rows.slice(0, limit) : rows
const lastItem = items[items.length - 1]
const nextCursor = hasMore && lastItem
? {
cursor_created_at: lastItem.created_at.toISOString(),
cursor_id: lastItem.id,
}
: null
return {
items,
nextCursor,
}
})
Integrasi Frontend Nuxt 3: Infinite Scrolling
Di sisi klien Nuxt 3, data diakumulasikan ke dalam array reaktif setiap kali cursor berikutnya dipanggil. Implementasi ini mencegah pergeseran data (data drift) yang sering terjadi pada OFFSET ketika ada record baru masuk saat pagination berjalan.
<script setup lang="ts">
interface Item {
id: number
title: string
created_at: string
}
interface Cursor {
cursor_created_at: string
cursor_id: number
}
interface ApiResponse {
items: Item[]
nextCursor: Cursor | null
}
const items = ref<Item[]>([])
const nextCursor = ref<Cursor | null>(null)
const hasMore = ref(true)
const pending = ref(false)
async function fetchNextPage() {
if (pending.value || !hasMore.value) return
pending.value = true
try {
const params: Record<string, string | number> = { limit: 20 }
if (nextCursor.value) {
params.cursor_created_at = nextCursor.value.cursor_created_at
params.cursor_id = nextCursor.value.cursor_id
}
const data = await $fetch<ApiResponse>('/api/items', { params })
items.value.push(...data.items)
nextCursor.value = data.nextCursor
hasMore.value = Boolean(data.nextCursor)
} catch (error) {
console.error('Gagal mengambil data items:', error)
} finally {
pending.value = false
}
}
// Fetch initial page pada SSR / hydration
await fetchNextPage()
</script>
<template>
<div class="container">
<ul>
<li v-for="item in items" :key="item.id">
<strong>{{ item.title }}</strong> - {{ item.created_at }}
</li>
</ul>
<div v-if="hasMore" class="pagination-control">
<button :disabled="pending" @click="fetchNextPage">
{{ pending ? 'Memuat...' : 'Muat Lebih Banyak' }}
</button>
</div>
<p v-else>Semua data telah ditampilkan.</p>
</div>
</template>
Batasan Teknis dan Trade-Off
Meskipun keyset pagination mengeliminasi kueri lambat, ada beberapa kompromi arsitektural yang perlu diperhatikan:
- Tidak mendukung random-access page: Pengguna tidak dapat melompat langsung ke halaman acak tertentu (misal: Halaman 42). Pola ini dirancang khusus untuk alur linier seperti infinite scroll atau tombol Next / Previous.
- Kompleksitas navigasi dua arah: Navigasi balik (Previous) memerlukan pembalikan arah sorting (
ASC) dan penataan ulang hasil array di level kode backend atau database sebelum dikembalikan ke klien. - Ketergantungan terhadap index: Jika endpoint membutuhkan filter dinamis pada banyak kolom tanpa composite index yang cocok, performa keyset pagination akan terdegradasi menjadi sequential scan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!