Masalah umum pada query feed adalah performa yang tampak baik saat data masih kecil, lalu menurun perlahan ketika tabel terus bertambah. Pola ini sering muncul pada workload yang mirip sistem AI atau metadata pipeline seperti konteks repo Ornith-1: data masuk terus-menerus, feed harus diurutkan berdasarkan waktu atau skor, dan pengguna ingin scroll halaman berikutnya tanpa jeda yang terasa.
Dalam kondisi seperti ini, OFFSET/LIMIT sering menjadi sumber biaya yang tidak terlihat di awal. Solusi yang biasanya lebih stabil adalah menggabungkan desain index yang tepat dengan keyset pagination untuk halaman lanjutan. Fokus artikel ini adalah teknik database yang dapat diterapkan secara umum, terlepas dari framework atau jenis aplikasinya.
Mengapa query feed melambat saat data terus tumbuh
Query feed biasanya punya pola yang mirip:
- ada filter, misalnya
tenant_id,status, atauvisibility - ada sorting, umumnya berdasarkan
created_at DESC,published_at DESC, atau skor tertentu - ada paginasi, awalnya dengan
LIMIT ... OFFSET ...
Masalahnya bukan hanya jumlah hasil yang dikembalikan, tetapi jumlah baris yang harus dilewati database untuk mencapai offset tertentu. Semakin jauh halaman yang diminta, semakin banyak pekerjaan yang perlu dilakukan, meskipun jumlah baris yang dikembalikan tetap kecil.
Contoh query yang sering terlihat:
SELECT id, title, created_at
FROM feed_items
WHERE tenant_id = 42
AND status = 'ready'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Secara logis, aplikasi hanya meminta 20 baris. Namun untuk sampai ke sana, database bisa saja harus membaca, membandingkan, atau melewati ribuan baris terlebih dahulu. Biaya ini makin terasa ketika feed bersifat append-heavy, ada banyak penulisan, dan halaman yang diminta bukan halaman pertama.
Gejala OFFSET/LIMIT yang mulai mahal
- Halaman pertama cepat, halaman ke-50 atau ke-500 jauh lebih lambat.
- Latensi query naik seiring pertumbuhan tabel, meski pola akses tidak berubah.
ORDER BYmemicu sort mahal atau scan index yang panjang.- CPU dan I/O database meningkat ketika pengguna melakukan infinite scroll dalam.
- Query yang sama terlihat stabil di staging, tetapi memburuk di produksi karena distribusi data berbeda.
Pada feed yang terus bertambah, ini bukan anomali. Ini konsekuensi langsung dari cara offset bekerja.
Kenapa OFFSET/LIMIT makin mahal
OFFSET bukan berarti database bisa melompat gratis ke posisi tertentu. Dalam banyak engine database, optimizer tetap harus menemukan urutan hasil, lalu membuang sejumlah baris sebelum mengembalikan halaman yang diminta.
Misalnya:
SELECT id, created_at
FROM feed_items
WHERE tenant_id = 42
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Database perlu memastikan urutan berdasarkan created_at DESC, id DESC. Jika index tidak cocok, ia mungkin harus melakukan sort atas banyak baris. Jika index cocok pun, ia tetap bisa perlu menelusuri baris-baris awal untuk melewati 10.000 hasil pertama.
Biaya ini makin buruk bila:
- kolom filter tidak ada di index yang sama dengan kolom sort
- sort tidak deterministik karena hanya memakai
created_atyang nilainya bisa sama - baris yang diambil memerlukan lookup tambahan ke tabel utama karena index tidak cukup membantu
- filter memiliki selektivitas rendah sehingga terlalu banyak kandidat yang harus dipindai
Jika feed Anda mendukung infinite scroll atau API cursor, paginasi berbasis offset biasanya cocok hanya untuk halaman awal atau kebutuhan admin sederhana. Untuk feed produksi yang besar, ia sering tidak lagi menjadi pilihan terbaik.
Kapan beralih ke keyset pagination
Keyset pagination, sering juga disebut cursor-based pagination, mengambil halaman berikutnya berdasarkan nilai baris terakhir yang sudah ditampilkan, bukan berdasarkan nomor halaman atau offset absolut.
Alih-alih berkata “ambil 20 baris mulai dari offset 10000”, aplikasi berkata “ambil 20 baris setelah item dengan created_at dan id tertentu”. Ini lebih efisien karena database bisa langsung melanjutkan scan dari titik yang relevan di index.
Kapan ini tepat digunakan
- Feed diurutkan berdasarkan kolom yang stabil, misalnya
created_atataupublished_at. - Ada kebutuhan scroll panjang atau polling feed terbaru.
- Jumlah data bertambah terus dan latency halaman lanjut mulai menjadi masalah.
- Nomor halaman absolut tidak terlalu penting bagi pengguna.
Kapan offset masih cukup masuk akal
- Dataset kecil atau hasil query dibatasi ketat oleh filter.
- Pengguna perlu lompat langsung ke halaman tertentu, misalnya halaman 37.
- Daftar administratif internal dengan volume rendah.
Dalam banyak sistem nyata, keduanya bisa dipakai bersamaan: halaman awal untuk UI admin tetap memakai offset, sedangkan endpoint feed publik atau infinite scroll memakai keyset.
Desain query sebelum dan sesudah keyset pagination
Sebelum: OFFSET/LIMIT
SELECT id, tenant_id, status, created_at, payload
FROM feed_items
WHERE tenant_id = 42
AND status = 'ready'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 10000;Kekurangannya:
- biaya bertambah seiring offset
- hasil bisa bergeser saat ada insert baru di depan feed
- pada beberapa kasus memicu scan/sort yang besar
Sesudah: keyset pagination
Misalkan halaman sebelumnya berakhir pada:
created_at = '2026-08-01 10:15:00'id = 987654
Maka halaman berikutnya dapat diambil seperti ini:
SELECT id, tenant_id, status, created_at, payload
FROM feed_items
WHERE tenant_id = 42
AND status = 'ready'
AND (
created_at < '2026-08-01 10:15:00'
OR (created_at = '2026-08-01 10:15:00' AND id < 987654)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;Kenapa ada id di kondisi dan sort? Karena created_at saja sering tidak unik. Jika banyak baris punya timestamp sama, urutan harus tetap deterministik. Pasangan (created_at, id) adalah pola umum untuk itu.
Bentuk cursor yang dikirim ke klien
Cursor biasanya berisi nilai sort terakhir, bukan offset. Contoh isi cursor sebelum diencode:
{
"created_at": "2026-08-01T10:15:00Z",
"id": 987654
}Di API, cursor biasanya diencode agar tidak mudah dimanipulasi secara kasat mata. Namun dari sisi database, inti mekanismenya tetap: lanjutkan dari nilai kunci terakhir.
Desain composite index untuk filter + sort
Keyset pagination tidak akan optimal tanpa index yang sesuai dengan pola query. Untuk query feed, index harus membantu dua hal sekaligus:
- mempersempit kandidat berdasarkan filter
- menyediakan urutan yang cocok dengan
ORDER BY
Untuk query contoh sebelumnya, desain index yang masuk akal adalah:
CREATE INDEX idx_feed_items_tenant_status_created_id
ON feed_items (tenant_id, status, created_at DESC, id DESC);Kenapa urutannya seperti itu?
tenant_iddanstatusadalah filter dengan pencocokan tepat.created_atdanidadalah kunci pengurutan sekaligus cursor.- Urutan kolom penting karena optimizer memanfaatkan prefix index.
Aturan praktis memilih urutan kolom index
- Letakkan kolom filter equality yang paling konsisten dipakai di depan.
- Setelah itu, letakkan kolom sort sesuai urutan
ORDER BY. - Tambahkan tie-breaker unik seperti
idbila kolom sort utama tidak unik.
Kesalahan umum dalam desain index
- Membuat index terpisah untuk filter dan sort, lalu berharap database selalu dapat menggabungkannya secara efisien.
- Hanya meng-index
created_at, padahal query selalu difilter olehtenant_iddanstatus. - Mengurutkan dengan
created_at DESCtetapi index utamanya berfokus ke kolom lain yang tidak membantu pola akses. - Mengabaikan tie-breaker sehingga urutan tidak stabil dan halaman bisa duplikat atau lompat.
Tidak semua database engine memperlakukan arah ASC/DESC secara identik dari sisi implementasi fisik, tetapi secara umum Anda tetap perlu merancang index agar cocok dengan pola filter dan sort aktual. Jika ragu, validasi dengan EXPLAIN.
Membaca EXPLAIN secara dasar
Tujuan EXPLAIN bukan sekadar melihat apakah index dipakai, tetapi memahami bagaimana query dieksekusi. Detail output berbeda antar database, tetapi ada beberapa sinyal universal yang perlu dicari.
Yang ingin Anda lihat
- Query memanfaatkan index yang sesuai dengan filter + sort.
- Jumlah baris yang diperkirakan atau dipindai masuk akal untuk ukuran halaman.
- Tidak ada sort besar di luar index jika seharusnya urutan bisa dipenuhi oleh index.
- Tidak ada full table scan untuk query feed yang sering dipanggil.
Yang perlu diwaspadai
- Sort eksplisit atas banyak baris sebelum
LIMIT. - Scan pada tabel atau index yang terlalu lebar dibanding hasil yang diminta.
- Filter diterapkan setelah terlalu banyak baris dibaca.
- Estimasi cardinality meleset jauh karena statistik tabel tidak akurat.
Contoh interpretasi sederhana
Jika query offset menunjukkan scan besar lalu sort lalu limit, itu tanda klasik bahwa database bekerja jauh lebih banyak daripada hasil akhirnya. Jika query keyset dengan composite index menunjukkan range scan kecil pada index yang tepat, biasanya itu pertanda desain sudah membaik.
EXPLAIN adalah alat verifikasi, bukan jaminan final. Tetap cocokkan dengan metrik runtime nyata seperti latency, rows scanned, buffer/cache hit, dan CPU database.
Konsistensi hasil dan trade-off keyset pagination
Keyset pagination lebih cepat untuk feed besar, tetapi ada trade-off yang perlu dipahami.
Keuntungan utama
- Performa lebih stabil untuk halaman lanjutan.
- Tidak perlu melewati offset besar.
- Lebih cocok untuk infinite scroll dan polling hasil terbaru.
Keterbatasan utama
- Tidak nyaman untuk lompat ke nomor halaman arbitrer.
- Cursor harus dibentuk dari kolom sort yang stabil.
- Implementasi lebih kompleks daripada offset sederhana.
Soal konsistensi hasil
Pada feed yang terus menerima data baru, baik offset maupun keyset sama-sama menghadapi perubahan dataset di antara dua request. Bedanya, keyset biasanya memberi perilaku yang lebih dapat diprediksi untuk “lanjut dari item terakhir yang sudah saya lihat”.
Beberapa situasi yang perlu dipahami:
- Insert baru di depan feed: halaman berikutnya dengan keyset tetap melanjutkan dari cursor sebelumnya, sehingga item baru tidak menggeser posisi halaman yang sedang dijelajahi.
- Update pada kolom sort: jika nilai sort berubah setelah item tampil, item bisa berpindah posisi dan berpotensi terlihat lagi atau terlewat.
- Delete: item yang dihapus di antara dua request tentu tidak akan muncul lagi, dan ini normal.
Jika konsistensi snapshot mutlak diperlukan, Anda memerlukan pendekatan tambahan di level transaksi, materialized view, atau data snapshot. Namun untuk sebagian besar feed aplikasi, keyset dengan urutan stabil sudah cukup praktis.
Anti-pattern yang sering membuat feed tetap lambat
1. ORDER BY tidak deterministik
Contoh:
ORDER BY created_at DESCJika banyak baris punya created_at sama, urutan antar-request dapat berubah. Tambahkan tie-breaker unik:
ORDER BY created_at DESC, id DESC2. Cursor hanya berdasarkan satu kolom non-unik
Ini sering menghasilkan duplikasi atau item hilang di batas halaman. Gunakan pasangan kolom yang mencerminkan urutan final.
3. Index tidak sesuai dengan pola query nyata
Misalnya query selalu memakai WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC, id DESC, tetapi index yang dibuat hanya (created_at, id). Hasilnya, database tetap harus menyaring terlalu banyak baris.
4. SELECT terlalu lebar
Mengambil kolom besar seperti blob, JSON besar, atau payload metadata lengkap untuk setiap item feed dapat memindahkan bottleneck dari index ke I/O atau jaringan. Untuk feed, sering lebih baik ambil kolom ringkas dulu, lalu detail diminta terpisah bila perlu.
5. JOIN berat dimasukkan langsung ke query feed utama
Query feed yang sudah sensitif terhadap latency bisa memburuk jika digabung dengan banyak join, agregasi, atau subquery mahal. Kadang lebih efektif memisahkan: ambil daftar ID feed dulu dengan query yang ringan, lalu lakukan lookup tambahan secara terkontrol.
Kapan bottleneck sebenarnya bukan di index
Tidak semua query lambat dapat diselesaikan dengan menambah index. Beberapa kasus umum:
- Payload baris terlalu besar: pembacaan data lebih mahal daripada pencarian index.
- Cache database buruk: working set lebih besar dari memori sehingga sering terjadi disk I/O.
- Kontensi tulis tinggi: tabel sangat sibuk karena insert/update terus-menerus.
- Query aplikasi berulang: masalah sebenarnya adalah N+1 query atau pola akses berlebihan di level service.
- Join/agregasi mahal: index pada tabel feed sudah baik, tetapi query tetap berat karena operasi lain.
- Partisi data atau tenancy buruk: satu tenant besar mendominasi distribusi data.
Karena itu, evaluasi performa harus berbasis observasi menyeluruh, bukan asumsi bahwa setiap masalah feed pasti selesai dengan keyset pagination.
Metrik yang perlu dipantau
Sebelum dan sesudah perubahan, pantau metrik berikut:
- latency p50, p95, dan p99 untuk endpoint feed
- frekuensi query feed per endpoint atau per tenant
- rows scanned vs rows returned
- penggunaan CPU database
- I/O read dan cache/buffer hit ratio
- durasi sort atau temporary file spill jika tersedia di engine Anda
- rasio error atau timeout di API
- ukuran tabel dan laju pertumbuhan data
Metrik ini penting untuk membedakan dua situasi: query memang lebih efisien, atau query masih lambat tetapi tertutupi cache sementara.
Checklist rollout aman di produksi
1. Profilkan query yang ada
- identifikasi query feed paling sering dan paling lambat
- pisahkan halaman pertama dari halaman lanjutan
- simpan contoh parameter nyata, bukan hanya query generik
2. Rancang index berdasarkan query aktual
- cocokkan urutan kolom index dengan filter dan sort
- hindari membuat terlalu banyak index serupa karena setiap index menambah biaya write
- pastikan ada tie-breaker unik pada urutan
3. Validasi dengan EXPLAIN
- bandingkan rencana query lama dan baru
- cek apakah sort besar berkurang
- cek apakah scan menjadi lebih sempit
4. Tambahkan endpoint atau mode cursor secara bertahap
- jangan paksa semua klien pindah sekaligus
- dukung offset dan cursor sementara selama masa transisi bila perlu
- gunakan feature flag untuk membatasi exposure awal
5. Pastikan format cursor aman dan stabil
- encode cursor dengan format yang mudah divalidasi
- sertakan semua kolom yang diperlukan untuk urutan
- tolak cursor yang rusak atau tidak cocok dengan versi query
6. Uji kasus data berubah di tengah paginasi
- insert item baru saat pengguna sedang scroll
- uji banyak item dengan timestamp yang sama
- uji update/delete untuk melihat perilaku duplikasi atau gap
7. Pantau dampak pada write path
Composite index baru mempercepat baca, tetapi menambah biaya insert/update. Pada sistem ingest metadata atau pipeline AI yang aktif menulis, ini harus diukur.
8. Siapkan rollback plan
- feature flag untuk mematikan mode keyset bila ada masalah
- monitoring khusus untuk query baru
- dokumentasi perilaku API baru bagi klien internal maupun eksternal
Contoh keputusan praktis
Jika Anda memiliki feed item hasil proses pipeline yang terus masuk, dengan filter seperti workspace_id dan status, lalu diurutkan berdasarkan created_at, pendekatan praktis yang sering berhasil adalah:
- pastikan urutan final deterministik, misalnya
created_at DESC, id DESC - buat composite index yang mengikuti filter lalu sort
- pertahankan offset untuk halaman pertama atau dashboard sederhana bila masih memadai
- gunakan keyset pagination untuk halaman lanjutan dan infinite scroll
- ukur hasilnya dengan EXPLAIN dan metrik runtime, bukan asumsi
Ini bukan optimasi prematur. Pada feed yang terus tumbuh, ini sering menjadi perbaikan struktural yang memang diperlukan agar biaya query tidak naik seiring waktu.
Penutup
Index dan keyset pagination untuk query feed yang terus tumbuh adalah kombinasi yang sangat relevan ketika OFFSET/LIMIT mulai menunjukkan biaya tersembunyi. Kuncinya bukan sekadar mengganti mekanisme paginasi, tetapi memastikan urutan query deterministik, composite index cocok dengan filter dan sort, dan perubahan digulirkan dengan observabilitas yang memadai.
Jika feed Anda melambat saat tabel membesar, mulai dari dua pertanyaan sederhana: apakah offset halaman lanjut menjadi mahal, dan apakah index saat ini benar-benar mengikuti pola query utama. Dari sana, keyset pagination biasanya menjadi langkah berikutnya yang paling masuk akal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!