Langsung ke konten
~/rifqi_abrory

Halaman

Beranda
Tentang
Stack
Pengalaman
Proyek
Tulisan
Kontak
Semua proyek

Proyek

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Artikel

Angular signals untuk halaman admin sehari-hari
Typed reactive forms di Angular, tanpa any
Error API yang konsisten di ASP.NET Core dengan ProblemDetails
Meneruskan CancellationToken sampai ke lapisan terbawah di ASP.NET Core
Graceful shutdown untuk service HTTP di Go
Idempotency key: membuat API pembayaran aman untuk di-retry
Form kontak yang tahan spam dengan Next.js Server Actions
Form yang ramah offline di React Native: draft dan antrean submit
Validasi request dan penanganan error di Spring Boot 3
Keyset pagination di SQL Server: ketika OFFSET mulai lambat

Aksi

Ganti ke mode gelap
Read in English
Salin alamat email
Semua artikel

Keyset pagination di SQL Server: ketika OFFSET mulai lambat

Kenapa OFFSET/FETCH melambat di halaman yang dalam, bagaimana keyset (seek) pagination mengatasinya, dan index yang membuatnya bekerja.

3 menit baca

Di halaman ini

Pendekatan yang biasa

Sebagian besar endpoint daftar dimulai dengan OFFSET ... FETCH:

SELECT TransactionId, PolicyNumber, Amount, CreatedAt
FROM dbo.Transactions
ORDER BY CreatedAt DESC, TransactionId DESC
OFFSET 20000 ROWS FETCH NEXT 50 ROWS ONLY;

Mudah dipahami dan langsung cocok dengan "halaman 401 berisi 50 baris". Masalahnya ada pada apa yang harus dikerjakan SQL Server untuk sampai ke sana: ia membaca 20.050 baris pertama secara berurutan lalu membuang 20.000 di antaranya. Halaman 1 cepat; halaman 401 membaca data ratusan kali lebih banyak. Di tabel besar yang sibuk, halaman dalam dan proses ekspor yang menelusuri semua halaman menjadi query paling lambat di sistem.

Ada masalah kedua yang lebih diam-diam: kalau ada baris baru yang masuk saat pengguna berpindah halaman, baris-baris bergeser antar halaman. Pengguna melihat data ganda atau malah melewatkan data.

Keyset pagination

Daripada mengatakan "lewati N baris", katakan "lanjutkan setelah baris terakhir yang saya lihat". Client mengirim kembali sort key dari baris terakhir di halaman saat ini:

DECLARE @AfterCreatedAt DATETIME2 = '2026-10-01T08:15:00';
DECLARE @AfterId BIGINT = 918273;

SELECT TOP (50) TransactionId, PolicyNumber, Amount, CreatedAt
FROM dbo.Transactions
WHERE CreatedAt < @AfterCreatedAt
   OR (CreatedAt = @AfterCreatedAt AND TransactionId < @AfterId)
ORDER BY CreatedAt DESC, TransactionId DESC;

Dua detail membuatnya benar:

  • Tiebreaker yang unik. CreatedAt saja tidak unik; dua baris bisa punya timestamp yang sama. Menambahkan TransactionId ke ORDER BY dan WHERE membuat posisinya tidak ambigu.
  • Arah yang sama di semua tempat. Urutan menurun berarti "lebih kecil dari" di filter.

Index yang membuatnya cepat

Keyset pagination hanya cepat jika ada index yang sesuai dengan urutan sort, sehingga SQL Server bisa langsung melompat (seek) ke titik awal dan membaca 50 baris:

CREATE INDEX IX_Transactions_CreatedAt_Id
    ON dbo.Transactions (CreatedAt DESC, TransactionId DESC)
    INCLUDE (PolicyNumber, Amount);

Kolom INCLUDE membuat index ini mencakup seluruh query, sehingga tidak perlu lookup ke tabel dasar. Bandingkan kedua query dengan SET STATISTICS IO ON: logical reads versi offset bertambah seiring nomor halaman, sedangkan versi keyset relatif datar seberapa pun dalamnya.

Kalau daftar punya filter yang selalu ada, misalnya cabang atau agen, taruh kolom itu di depan index: (BranchId, CreatedAt DESC, TransactionId DESC).

Bentuk API-nya

Tampilkan cursor yang opaque alih-alih nomor halaman. Mengenkode kedua nilai dalam Base64 mencegah client bergantung pada detail internal:

{
  "items": [ "..." ],
  "nextCursor": "MjAyNi0xMC0wMVQwODoxNTowMHw5MTgyNzM="
}

Request pertama tidak membawa cursor. Request berikutnya mengirim ?cursor=... dari respons sebelumnya. Ketika nextCursor bernilai null, tidak ada baris lagi. Trik sederhana untuk mengetahuinya: minta TOP (51) ke database, kembalikan 50, dan buat cursor hanya kalau baris ke-51 ada.

Trade-off

Keyset pagination tidak gratis:

  • Tidak bisa lompat ke halaman 37. Pengguna bisa maju (dan mundur, dengan query terbalik), tetapi tidak ke halaman sembarang. Untuk infinite scroll, daftar di aplikasi mobile, ekspor, dan job sinkronisasi, itu tidak masalah. Untuk grid back-office di mana pengguna benar-benar melompat antar halaman, OFFSET pada query yang terfilter dan ber-index sering kali sudah cukup.
  • Jumlah total dihitung terpisah. COUNT(*) pada tabel besar punya biaya sendiri. Tampilkan "1.000+" atau hitung secara asinkron kalau UI tidak benar-benar membutuhkannya.
  • Pilihan urutan melipatgandakan index. Setiap urutan sort yang Anda tawarkan butuh index sendiri agar tetap cepat. Tawarkan sedikit pilihan urutan, dan pilih dengan sengaja.

Ringkasan

  • OFFSET membaca lalu membuang setiap baris yang dilewati, sehingga halaman dalam makin lambat.
  • Keyset pagination melanjutkan dari key terakhir yang terlihat: biaya per halaman tetap, hasilnya stabil.
  • Selalu tambahkan tiebreaker unik dan index dengan urutan yang sama.
  • Kembalikan cursor yang opaque; pakai paging offset hanya jika lompat ke nomor halaman memang dibutuhkan.