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.
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())switched to db sensor
Dokumen tersimpan: 10Lewati empat dokumen teratas hasil pengurutan, lalu ambil empat dokumen berikutnya.
db.pembacaan.find({}, { _id: 0, menit: 1 }).sort({ menit: 1 }).skip(4).limit(4).forEach(d => print(d.menit))4
5
6
7Keluaran 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 dokumen, posisi awal halaman ke- adalah dokumen, sehingga argumen skip tersusun dari perkalian sederhana. Pola ini berlaku untuk koleksi apa pun tanpa perlu menyimpan posisi di memori aplikasi.
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(", ")) }Halaman 1: 0, 1, 2, 3
Halaman 2: 4, 5, 6, 7
Halaman 3: 8, 9Halaman 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.
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())true
Dokumen tersimpan: 20000Biaya 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.
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){
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.
db.pembacaan.find({ menit: { $gt: 18999 } }, { _id: 0, menit: 1 }).sort({ menit: 1 }).limit(10).forEach(d => print(d.menit))19000
19001
19002
19003
19004
19005
19006
19007
19008
19009Supaya 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.
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))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 pasanganskip((n - 1) * u)denganlimit(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
$gtpada field pengurutan dengan jangkar dari halaman sebelumnya, sehingga dengan indeks hanya 10 entri yang diperiksa di halaman mana pun. explain("executionStats")mengukur biaya nyata melaluitotalDocsExamineduntuk dokumen dantotalKeysExamineduntuk entri indeks.
Referensi#
- cursor.skip() — MongoDB Documentation — Perilaku pemlewatian dokumen pada kursor beserta catatan biayanya.
- cursor.sort() — MongoDB Documentation — Spesifikasi pengurutan yang menjadi dasar urutan halaman.
- MongoDB Manual — MongoDB Documentation — Dokumentasi resmi MongoDB sebagai rujukan umum seluruh topik pada jalur ini.