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.
CreatedAtsaja tidak unik; dua baris bisa punya timestamp yang sama. MenambahkanTransactionIdkeORDER BYdanWHEREmembuat 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,
OFFSETpada 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
OFFSETmembaca 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.