skip dan paginasi

Membangun paginasi berbasis skip beserta pertimbangan biaya performanya.

Daftar isi

Koleksi yang tumbuh besar membuat seluruh hasil query tidak layak dikirim sekaligus ke pengguna. Paginasi (pagination) memecah hasil menjadi halaman-halaman kecil yang diminta sesuai kebutuhan, dan cara paling umum menyusunnya adalah pasangan skip dan limit pada kursor. Topik ini membangun paginasi berbasis skip dari nol dan mengukur biayanya secara nyata pada koleksi 20.000 dokumen. Paginasi berbasis rentang kemudian diperkenalkan sebagai alternatif yang biayanya tetap sama di halaman mana pun.

Melewatkan dokumen dengan skip#

Metode skip menerima satu bilangan bulat yang menyatakan jumlah dokumen teratas yang dilewati sebelum hasil query dikirim. Server tetap membaca dokumen yang dilewati, hanya saja dokumen tersebut tidak dikirim ke klien. Karena itu skip hampir selalu dipasangkan dengan sort agar dokumen yang dilewati memiliki urutan yang jelas, dan dengan limit agar halaman tidak bertambah panjang tanpa kendali.

Siapkan koleksi contoh berisi sepuluh catatan pengukuran suhu pada basis data sensor.

mongosh
use sensor
const hasil = db.pembacaan.insertMany([{ menit: 0, sensor: "S-01", suhu: 24.6 }, { menit: 1, sensor: "S-01", suhu: 25.1 }, { menit: 2, sensor: "S-02", suhu: 23.8 }, { menit: 3, sensor: "S-02", suhu: 24.4 }, { menit: 4, sensor: "S-03", suhu: 22.9 }, { menit: 5, sensor: "S-03", suhu: 23.5 }, { menit: 6, sensor: "S-01", suhu: 25 }, { menit: 7, sensor: "S-02", suhu: 24.2 }, { menit: 8, sensor: "S-03", suhu: 23.1 }, { menit: 9, sensor: "S-01", suhu: 24.8 }])
print("Dokumen tersimpan: " + db.pembacaan.countDocuments())
text Keluaran
switched to db sensor

Dokumen tersimpan: 10

Lewati empat dokumen teratas hasil pengurutan, lalu ambil empat dokumen berikutnya.

mongosh
db.pembacaan.find({}, { _id: 0, menit: 1 }).sort({ menit: 1 }).skip(4).limit(4).forEach(d => print(d.menit))
text Keluaran
4
5
6
7

Keluaran memuat menit 4 sampai 7, yaitu dokumen kelima sampai kedelapan hasil urutan. Empat dokumen pertama benar-benar dilewati, bukan diambil lalu dibuang, karena server memulai pengiriman dari dokumen kelima.

Membangun paginasi dengan skip dan limit#

Paginasi berukuran tetap hanya membutuhkan dua angka, yaitu ukuran halaman dan nomor halaman. Apabila setiap halaman berukuran uu dokumen, posisi awal halaman ke-nn adalah (n−1)×u (n - 1) \times u dokumen, sehingga argumen skip tersusun dari perkalian sederhana. Pola ini berlaku untuk koleksi apa pun tanpa perlu menyimpan posisi di memori aplikasi.

mongosh
for (let hal = 1; hal <= 3; hal++) { const d = db.pembacaan.find({}, { _id: 0, menit: 1 }).sort({ menit: 1 }).skip((hal - 1) * 4).limit(4).toArray(); print("Halaman " + hal + ": " + d.map(x => x.menit).join(", ")) }
text Keluaran
Halaman 1: 0, 1, 2, 3
Halaman 2: 4, 5, 6, 7
Halaman 3: 8, 9

Halaman ketiga hanya berisi dua dokumen karena data sudah habis, dan halaman di luar cakupan data menghasilkan daftar kosong tanpa error. Pola nomor halaman ini nyaman dipakai untuk navigasi halaman 1, 2, 3 yang tampak pada aplikasi web, tetapi biayanya perlu diukur pada koleksi yang besar.

Mengukur biaya skip pada koleksi besar#

Ganti isi koleksi dengan kumpulan besar berisi 20.000 catatan agar biaya skip terlihat pada angka yang bermakna. Data dibuat sekaligus dengan Array.from yang mengisi field menit secara unik dari 0 sampai 19.999.

mongosh
db.pembacaan.drop()
const hasilBesar = db.pembacaan.insertMany(Array.from({ length: 20000 }, (_, m) => ({ menit: m, sensor: "S-0" + (m % 8 + 1), suhu: 20 + (m % 90) / 10 })))
print("Dokumen tersimpan: " + db.pembacaan.countDocuments())
text Keluaran
true

Dokumen tersimpan: 20000

Biaya query diukur dengan explain("executionStats"), yaitu mode yang meminta statistik eksekusi nyata dari server. Field nReturned menghitung dokumen yang dikembalikan, sedangkan totalDocsExamined menghitung dokumen yang benar-benar dibaca dari koleksi. Waktu eksekusi sengaja tidak ditampilkan karena nilainya berubah-ubah antar eksekusi, sedangkan jumlah dokumen yang diperiksa stabil untuk data yang sama dan lebih objektif.

mongosh
const ukur = (lewat) => { const st = db.pembacaan.find().skip(lewat).limit(10).explain("executionStats").executionStats; printjson({ skip: lewat, dikembalikan: st.nReturned, diperiksa: st.totalDocsExamined }) }
ukur(0)
ukur(19000)
text Keluaran
{
  skip: 0,
  dikembalikan: 10,
  diperiksa: 10
}

{
  skip: 19000,
  dikembalikan: 10,
  diperiksa: 19010
}

Kedua query mengembalikan sepuluh dokumen, tetapi dokumen yang diperiksa berbeda jauh. Halaman pertama berhenti setelah 10 dokumen, sedangkan halaman dengan skip 19.000 memaksa server membaca 19.010 dokumen terlebih dahulu karena skip tidak dapat melompat langsung ke posisi yang dicari. Biaya paginasi offset dengan demikian tumbuh linear terhadap posisi halaman, kira-kira sebesar skip ditambah limit.

Alternatif: paginasi berbasis rentang#

Paginasi berbasis rentang tidak menomori halaman, melainkan menentukan halaman berikutnya dari nilai jangkar, yaitu nilai field pengurutan pada dokumen terakhir halaman sebelumnya. Teknik ini dikenal sebagai keyset pagination karena halaman ditentukan oleh kunci pengurutan, bukan oleh nomor halaman. Query halaman berikutnya memakai filter $gt pada field pengurutan, diikuti sort menaik dan limit. Halaman yang sama dengan skip 19.000 pada data contoh diperoleh dengan filter menit lebih besar dari 18999.

mongosh
db.pembacaan.find({ menit: { $gt: 18999 } }, { _id: 0, menit: 1 }).sort({ menit: 1 }).limit(10).forEach(d => print(d.menit))
text Keluaran
19000
19001
19002
19003
19004
19005
19006
19007
19008
19009

Supaya server dapat melompat langsung ke nilai jangkar tanpa membaca dokumen sebelumnya, field pengurutan perlu diberi indeks, yaitu struktur pencarian tambahan yang memuat nilai field secara terurut. Bandingkan biaya kedua cara pada halaman yang sama menggunakan totalKeysExamined, yaitu jumlah entri indeks yang dilalui server.

mongosh
db.pembacaan.createIndex({ menit: 1 })
const ukurIndeks = (nama, q) => { const st = q.explain("executionStats").executionStats; printjson({ cara: nama, kunciDiperiksa: st.totalKeysExamined, dokumenDiperiksa: st.totalDocsExamined }) }
ukurIndeks("skip 19000", db.pembacaan.find().sort({ menit: 1 }).skip(19000).limit(10))
ukurIndeks("rentang setelah 18999", db.pembacaan.find({ menit: { $gt: 18999 } }).sort({ menit: 1 }).limit(10))
text Keluaran
menit_1

{
  cara: 'skip 19000',
  kunciDiperiksa: 19010,
  dokumenDiperiksa: 10
}

{
  cara: 'rentang setelah 18999',
  kunciDiperiksa: 10,
  dokumenDiperiksa: 10
}

Kedua cara mengembalikan halaman yang sama, tetapi skip tetap melalui 19.010 entri indeks, sedangkan filter rentang hanya menyentuh 10 entri. Keunggulan ini bertahan di halaman mana pun karena pencarian rentang tidak peduli seberapa jauh posisinya dari awal koleksi. Konsekuensinya, navigasi hanya bergerak maju berurutan lewat tombol berikutnya, dan loncatan acak ke halaman ke-n memang tidak ditawarkan oleh teknik ini.

Ringkasan#

  • skip(n) melewati n dokumen teratas hasil pengurutan, dan pasangan skip((n - 1) * u) dengan limit(u) menyusun halaman berukuran tetap.
  • Biaya paginasi offset tumbuh linear terhadap posisi halaman, terbukti dari 10 dokumen diperiksa pada skip 0 melawan 19.010 dokumen pada skip 19.000.
  • Paginasi berbasis rentang memakai filter $gt pada field pengurutan dengan jangkar dari halaman sebelumnya, sehingga dengan indeks hanya 10 entri yang diperiksa di halaman mana pun.
  • explain("executionStats") mengukur biaya nyata melalui totalDocsExamined untuk dokumen dan totalKeysExamined untuk entri indeks.

Referensi#