Saat halaman list di aplikasi makin lambat seiring pertumbuhan data, penyebabnya sering bukan di Go Fiber, melainkan di pola query OFFSET/LIMIT. Semakin besar nilai OFFSET, database biasanya tetap harus membaca, mengurutkan, lalu melewati banyak baris sebelum mengembalikan hasil yang diminta.
Solusi yang umum untuk data besar adalah cursor pagination. Alih-alih berkata “ambil halaman ke-1000”, client berkata “ambil data setelah item terakhir yang saya terima”. Dengan pendekatan ini, query bisa memanfaatkan index secara lebih efisien, latensi lebih stabil, dan risiko performa turun tajam saat data membesar bisa dikurangi.
Masalah OFFSET/LIMIT pada data besar
Pola klasik pagination biasanya terlihat seperti ini:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;Query di atas tampak sederhana, tetapi saat OFFSET membesar, database tidak otomatis “melompat” langsung ke baris ke-20001. Dalam banyak kasus, database tetap perlu:
- menentukan urutan hasil sesuai
ORDER BY, - memindai index atau baris yang relevan,
- melewati sejumlah besar hasil awal,
- baru mengambil 20 baris berikutnya.
Gejalanya biasanya terlihat jelas di halaman list:
- halaman awal cepat, halaman jauh lebih lambat,
- beban CPU atau I/O database naik saat akses ke halaman dalam,
- response time tidak konsisten,
- query list menjadi salah satu top slow query.
Kenapa bottleneck terjadi: scan dan sort
Ada dua sumber bottleneck utama:
- Scan: database harus membaca banyak entri untuk mencapai offset yang diminta.
- Sort: jika urutan tidak didukung index yang tepat, database perlu melakukan proses sort tambahan.
Jika query Anda mengurutkan data dengan ORDER BY created_at DESC, id DESC tetapi index tidak sesuai, database bisa memilih rencana yang mahal. Bahkan jika sudah ada index, OFFSET besar tetap membuat database membuang banyak kerja hanya untuk melewati baris-baris awal.
Kapan OFFSET masih layak dipakai?
OFFSET/LIMIT tidak selalu salah. Ada kondisi di mana pendekatan ini masih masuk akal:
- dataset kecil atau menengah,
- halaman yang diakses umumnya hanya halaman awal,
- fitur membutuhkan nomor halaman eksplisit seperti “halaman 3 dari 20”,
- laporan internal dengan frekuensi akses rendah,
- query sudah sangat terkontrol dan biaya performanya masih bisa diterima.
Jadi, jangan migrasi hanya karena tren. Migrasi ke cursor pagination paling masuk akal saat:
- jumlah data terus bertambah,
- list sering diakses,
- query menjadi lambat pada offset tinggi,
- pengalaman pengguna lebih penting daripada nomor halaman absolut.
Prinsip cursor pagination yang benar
Dalam cursor pagination, Anda tidak lagi mengirim page=1000, tetapi mengirim penanda posisi terakhir, misalnya:
GET /posts?limit=20&after=eyJjcmVhdGVkX2F0IjoiMjAyNS0wMS0xNVQxMDowMDowMFoiLCJpZCI6MTIzNH0=Cursor tersebut umumnya berisi nilai kolom pengurutan terakhir yang diterima client. Untuk kasus yang umum dan stabil, gunakan kombinasi:
created_atsebagai urutan utama,idsebagai tie-breaker.
Mengapa perlu dua kolom? Karena created_at tidak selalu unik. Jika banyak baris memiliki timestamp yang sama, memakai created_at saja berisiko menyebabkan data duplikat atau terlewat. Menambahkan id membuat urutan menjadi deterministik.
Desain urutan yang stabil
Misalnya Anda ingin menampilkan data terbaru lebih dulu:
ORDER BY created_at DESC, id DESCMaka cursor harus mengikuti urutan yang sama. Untuk mengambil halaman berikutnya setelah item terakhir, kondisi WHERE harus konsisten dengan urutan itu:
WHERE (created_at < $1)
OR (created_at = $1 AND id < $2)Ini penting. Banyak implementasi salah hanya memakai id < ? padahal urutannya berdasarkan created_at, id. Hasilnya pagination menjadi tidak stabil.
Skema tabel dan index yang mendukung
Contoh tabel:
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);Untuk query cursor pagination di atas, gunakan index komposit yang mengikuti kolom pengurutan:
CREATE INDEX idx_posts_created_id_desc
ON posts (created_at DESC, id DESC);Beberapa database tetap bisa memanfaatkan index meski arah urutan tidak selalu ditulis persis sama, tetapi prinsip amannya adalah: samakan urutan kolom dan kebutuhan query. Fokus utama bukan hanya ada index, melainkan index yang mendukung WHERE dan ORDER BY sekaligus.
Implementasi endpoint Go Fiber untuk cursor pagination
Berikut contoh endpoint sederhana di Go Fiber. Contoh ini fokus pada alur inti: membaca parameter limit dan after, mendekode cursor, menjalankan query, lalu mengembalikan cursor berikutnya.
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"log"
"strconv"
"time"
"github.com/gofiber/fiber/v2"
)
type Post struct {
ID int64 `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
}
type Cursor struct {
CreatedAt time.Time `json:"created_at"`
ID int64 `json:"id"`
}
type ListResponse struct {
Data []Post `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
HasMore bool `json:"has_more"`
}
func encodeCursor(c Cursor) (string, error) {
b, err := json.Marshal(c)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func decodeCursor(s string) (*Cursor, error) {
if s == "" {
return nil, nil
}
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return nil, err
}
var c Cursor
if err := json.Unmarshal(b, &c); err != nil {
return nil, err
}
return &c, nil
}
func listPosts(db *sql.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
limit := 20
if v := c.Query("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "limit tidak valid")
}
if n > 100 {
n = 100
}
limit = n
}
cursor, err := decodeCursor(c.Query("after"))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "cursor tidak valid")
}
ctx := context.Background()
var rows *sql.Rows
if cursor == nil {
rows, err = db.QueryContext(ctx, `
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT ?
`, limit+1)
} else {
rows, err = db.QueryContext(ctx, `
SELECT id, title, created_at
FROM posts
WHERE (created_at < ?)
OR (created_at = ? AND id < ?)
ORDER BY created_at DESC, id DESC
LIMIT ?
`, cursor.CreatedAt, cursor.CreatedAt, cursor.ID, limit+1)
}
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "gagal mengambil data")
}
defer rows.Close()
posts := make([]Post, 0, limit+1)
for rows.Next() {
var p Post
if err := rows.Scan(&p.ID, &p.Title, &p.CreatedAt); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "gagal membaca data")
}
posts = append(posts, p)
}
if err := rows.Err(); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "gagal memproses data")
}
hasMore := len(posts) > limit
if hasMore {
posts = posts[:limit]
}
resp := ListResponse{
Data: posts,
HasMore: hasMore,
}
if hasMore && len(posts) > 0 {
last := posts[len(posts)-1]
next, err := encodeCursor(Cursor{
CreatedAt: last.CreatedAt,
ID: last.ID,
})
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "gagal membuat cursor")
}
resp.NextCursor = next
}
return c.JSON(resp)
}
}
func main() {
_ = log.Print
// inisialisasi db diabaikan agar contoh tetap fokus
}Beberapa catatan penting dari implementasi di atas:
- Ambil
limit + 1untuk menentukan apakah masih ada halaman berikutnya. - Batasi nilai limit agar client tidak meminta ribuan baris sekaligus.
- Gunakan base64 JSON sebagai format cursor yang sederhana dan mudah di-debug.
- Jangan percaya input cursor mentah; selalu validasi dan tangani error decode.
Contoh placeholder query menggunakan tanda
?. Sesuaikan dengan driver SQL yang Anda pakai. Beberapa driver memakai format placeholder berbeda.
Query SQL cursor pagination yang didukung index komposit
Untuk halaman pertama:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 21;Untuk halaman berikutnya:
SELECT id, title, created_at
FROM posts
WHERE (created_at < :created_at)
OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT 21;Pola ini bekerja karena database bisa menavigasi data berdasarkan urutan index, bukan menghitung dan membuang ribuan baris seperti pada OFFSET besar.
Alternatif dengan perbandingan tuple
Beberapa database mendukung perbandingan tuple seperti:
WHERE (created_at, id) < (:created_at, :id)Ini bisa lebih ringkas, tetapi dukungan dan optimisasinya bergantung pada database yang digunakan. Jika Anda ingin aman lintas implementasi, bentuk eksplisit dengan OR biasanya lebih mudah dipahami dan diverifikasi.
Mencegah duplikasi atau data terlewat saat ada insert baru
Masalah klasik pagination muncul saat data berubah di antara dua request. Misalnya user membuka halaman pertama, lalu ada baris baru di-insert sebelum request halaman berikutnya.
Apa yang terjadi pada OFFSET?
Pada OFFSET pagination, insert baru di bagian atas dapat menggeser posisi data. Akibatnya:
- beberapa item bisa muncul dua kali,
- beberapa item bisa terlewat.
Bagaimana cursor membantu?
Cursor pagination jauh lebih stabil karena titik lanjutnya berdasarkan nilai item terakhir yang benar-benar diterima client, bukan nomor halaman. Jika ada insert baru dengan created_at lebih baru, item itu akan muncul saat user melakukan refresh dari awal, bukan menyusup ke tengah alur halaman berikutnya.
Meski begitu, ada beberapa hal yang tetap perlu diperhatikan:
- Gunakan kolom urutan yang stabil. Jangan gunakan kolom yang sering berubah, seperti
updated_at, jika tujuan Anda adalah list yang konsisten. - Pakai tie-breaker unik. Kombinasi
created_atdanidmengurangi risiko urutan ambigu. - Hindari update pada kolom pengurutan setelah data dibuat, jika memungkinkan.
Strategi tambahan untuk konsistensi lebih kuat
Untuk kebutuhan tertentu, Anda bisa menambahkan strategi berikut:
- Snapshot window: simpan batas atas saat request pertama, misalnya hanya tampilkan item dengan
created_at <= snapshot_timeselama user masih melanjutkan scrolling. - Immutable ordering: urutkan berdasarkan kolom yang tidak berubah setelah insert.
- Dedup di client: jika ada risiko race condition ringan, client bisa menyimpan set ID yang sudah ditampilkan.
Pendekatan snapshot berguna jika Anda butuh pengalaman “timeline tetap” selama sesi pagination berjalan. Trade-off-nya, user tidak langsung melihat data baru sampai memulai ulang dari halaman pertama.
Cara membaca EXPLAIN secara ringkas
Anda tidak perlu menjadi spesialis query planner untuk mendapat manfaat dari EXPLAIN. Saat membandingkan OFFSET dan cursor pagination, fokus pada beberapa hal berikut:
- Apakah index yang benar dipakai? Cari tanda bahwa query memanfaatkan index pada kolom pengurutan dan filter.
- Apakah ada sort tambahan? Jika planner menunjukkan operasi sort yang mahal, index Anda mungkin belum cocok.
- Berapa banyak baris yang diperkirakan dibaca? Query offset tinggi biasanya menunjukkan pembacaan baris jauh lebih banyak daripada hasil akhir.
Secara praktis, bandingkan dua query:
EXPLAIN SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20000;EXPLAIN SELECT id, title, created_at
FROM posts
WHERE (created_at < :created_at)
OR (created_at = :created_at AND id < :id)
ORDER BY created_at DESC, id DESC
LIMIT 20;Yang ingin Anda lihat pada query cursor adalah rencana yang tetap sempit: memanfaatkan index, membaca lebih sedikit baris, dan menghindari kerja buang-buang akibat offset besar.
Jika hasil
EXPLAINmasih buruk meski sudah memakai cursor, periksa lagi apakah urutan kolom index, kondisiWHERE, danORDER BYbenar-benar selaras.
Trade-off UX dan kompatibilitas API
Trade-off UX
Cursor pagination unggul untuk infinite scroll, feed, activity log, dan list data besar. Namun ada trade-off:
- sulit mendukung “lompat ke halaman 57”,
- nomor halaman absolut tidak alami,
- fitur “total halaman” sering tidak relevan atau mahal dihitung.
Jika produk Anda sangat bergantung pada navigasi halaman bernomor, Anda mungkin perlu mempertahankan OFFSET untuk area tertentu, atau menyediakan dua mode akses yang berbeda sesuai kebutuhan UX.
Kompatibilitas API
Migrasi API tidak selalu harus memutus kompatibilitas. Beberapa pendekatan yang umum:
- Tambahkan endpoint baru, misalnya
/v2/postsdengan cursor pagination. - Dukung dua mode sementara:
page/per_pagedanafter/limit. - Kembalikan metadata baru seperti
next_cursordanhas_moretanpa mengubah struktur item.
Jika Anda menjalankan masa transisi, dokumentasikan prioritas parameter dengan jelas. Misalnya, jika after hadir, abaikan page. Hindari perilaku ambigu.
Checklist migrasi aman dari pagination lama ke cursor pagination
- Identifikasi endpoint yang benar-benar bermasalah
Mulai dari query dengan offset besar yang sering muncul di slow query log. - Tentukan urutan data yang stabil
Pilih kolom seperticreated_atdan tambahkanidsebagai tie-breaker. - Buat index komposit yang sesuai
Pastikan urutan kolom index mendukung filter dan pengurutan query. - Implementasikan cursor yang aman
Encode/decode dengan format yang jelas, validasi input, dan jangan expose struktur internal yang tidak perlu. - Ambil
limit + 1
Gunakan untuk menentukanhas_moredan membuatnext_cursor. - Uji data dengan timestamp kembar
Pastikan tidak ada duplikasi atau item hilang saat banyak row punyacreated_atsama. - Uji saat ada insert baru di tengah pagination
Verifikasi perilaku feed tetap masuk akal untuk UX yang diinginkan. - Bandingkan EXPLAIN sebelum dan sesudah
Pastikan planner benar-benar memakai index yang diharapkan. - Siapkan masa transisi API
Pertahankan mode lama sementara jika ada client yang belum bisa migrasi. - Tambahkan observability
Pantau latency endpoint, error decode cursor, dan pola penggunaan parameter lama vs baru.
Kesalahan umum yang sering terjadi
- Mengurutkan dengan satu kolom yang tidak unik tanpa tie-breaker.
- Menggunakan kolom yang berubah-ubah sebagai basis cursor.
- Index tidak cocok dengan pola
WHEREdanORDER BY. - Masih menghitung total count di setiap request padahal tujuan utamanya menurunkan beban query list.
- Menganggap cursor selalu terenkripsi. Base64 bukan enkripsi; jika cursor memuat data sensitif, pertimbangkan penandatanganan atau tokenisasi.
Penutup
Jika halaman list di aplikasi Go Fiber mulai lambat saat data tumbuh, akar masalahnya sering berasal dari OFFSET/LIMIT yang memaksa database melakukan scan dan sort yang makin mahal. Cursor pagination mengubah pola akses dari “lompat ke halaman tertentu” menjadi “lanjut dari posisi terakhir”, sehingga query lebih ramah index dan performanya lebih stabil pada skala besar.
Kuncinya ada pada desain urutan yang stabil, biasanya created_at plus id, query SQL yang selaras dengan index komposit, serta migrasi API yang hati-hati. OFFSET masih layak untuk kasus sederhana, tetapi untuk feed dan list besar yang terus tumbuh, cursor pagination biasanya menjadi pilihan yang lebih aman dari sisi performa dan konsistensi data.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!