Daftar transaksi yang tumbuh terus tidak bisa ditampilkan sekaligus — aplikasi memotongnya menjadi halaman. Dua cara memotong yang paling terkenal adalah skip() + limit() dan filter rentang, dan keduanya benar secara hasil tetapi berbeda jauh secara biaya saat halaman makin dalam. Chapter ini membedah kedua strategi dengan executionStats sebagai alat ukur, menutup jebakan kunci sortir yang tidak unik, lalu menutup dengan tabel keputusan kapan masing-masing pantas dipakai.
Peta materi Chapter ini:
- Biaya tersembunyi
skip(): makin dalam halaman, makin banyak yang diperiksa. - Range-based pagination dengan
_idsebagai penanda posisi. - Jebakan kunci sortir tidak unik dan solusi kombinatornya.
- Tabel perbandingan dan panduan kapan
skip()masih wajar.
Biaya Tersembunyi skip + limit #
skip(n).limit(m) terasa alami karena bahasanya persis “lompati n, ambil m”. Mekanismenya di server tidak sesyum itu: untuk mengetahui dokumen mana yang menjadi item ke-n+1, server harus berjalan melewati n document terlebih dulu dan membuangnya. Biaya halaman tumbuh linear dengan kedalamannya. Siapkan 40 transaksi sintetis:
use latihan_perf
db.penjualan.drop()
const jamPerPos = [9, 12, 15, 15, 18, 18, 21, 21]
const data = Array.from({ length: 40 }, (_, idx) => {
const i = idx + 1
return {
_id: i,
waktu: new Date(Date.UTC(2026, 2, 2 + Math.floor(idx / 8), jamPerPos[idx % 8], 0, 0)),
judul_buku: ["Laut Bercerita", "Bumi", "Filosofi Teras", "Atomic Habits", "Rumah Kaca", "Hujan Bulan Juni"][idx % 6],
jumlah: 1 + (idx % 3),
harga_total: (1 + (idx % 3)) * 89000,
kota: ["Jakarta", "Bandung", "Surabaya", "Yogyakarta"][idx % 4],
channel: idx % 2 === 0 ? "toko" : "online"
}
})
const hasil = db.penjualan.insertMany(data)
print("tersimpan:", db.penjualan.countDocuments({}))Output yang Dihasilkan:
switched to db latihan_perf
true
tersimpan: 40Dengan 8 halaman berisi 5 item, bandingkan biaya halaman pertama dan halaman terakhir:
const h1 = db.penjualan.find().sort({ _id: 1 }).skip(0).limit(5).explain("executionStats").executionStats
const h8 = db.penjualan.find().sort({ _id: 1 }).skip(35).limit(5).explain("executionStats").executionStats
print("halaman 1 -> nReturned:", h1.nReturned, "| keys:", h1.totalKeysExamined, "| docs:", h1.totalDocsExamined)
print("halaman 8 -> nReturned:", h8.nReturned, "| keys:", h8.totalKeysExamined, "| docs:", h8.totalDocsExamined)Output yang Dihasilkan:
halaman 1 -> nReturned: 5 | keys: 5 | docs: 5
halaman 8 -> nReturned: 5 | keys: 40 | docs: 5Kedua halaman mengembalikan 5 document, tetapi halaman ke-8 memeriksa 40 kunci index — seluruh collection — karena 35 document pertama harus dilalui lalu dibuang. Pola pertumbuhannya terlihat utuh bila kita ukur semua halaman:
for (let halaman = 1; halaman <= 8; halaman++) {
const s = db.penjualan.find().sort({ _id: 1 }).skip((halaman - 1) * 5).limit(5).explain("executionStats").executionStats
print("halaman", halaman, "-> keys:", s.totalKeysExamined, "| docs:", s.totalDocsExamined)
}Output yang Dihasilkan:
halaman 1 -> keys: 5 | docs: 5
halaman 2 -> keys: 10 | docs: 5
halaman 3 -> keys: 15 | docs: 5
halaman 4 -> keys: 20 | docs: 5
halaman 5 -> keys: 25 | docs: 5
halaman 6 -> keys: 30 | docs: 5
halaman 7 -> keys: 35 | docs: 5
halaman 8 -> keys: 40 | docs: 5Setiap halaman menambah 5 pemeriksaan. Pada 40 document ini hanya angka mainan; pada daftar 2 juta transaksi dengan halaman ke-10.000, setiap kunjungan ke halaman dalam memaksa server menelusuri ratusan ribu kunci hanya untuk dibuang. Pengguna yang mengklik “halaman berikutnya” berulang kali menimbulkan biaya yang makin mahal di setiap klik — total biaya seluruh perjalanan tumbuh kuadratik.
Range-Based Pagination #
Strategi kedua memakai posisi terakhir yang sudah dibaca: “ambil 5 berikutnya yang _id-nya lebih besar dari item terakhir halaman sebelumnya”. Server cukup melompat tepat ke posisi itu di index — tidak ada document yang dilewati untuk dibuang:
const hal1 = db.penjualan.find().sort({ _id: 1 }).limit(5).toArray()
print("halaman 1:", hal1.map(d => d._id))
const batas = hal1[hal1.length - 1]._id
const hal2 = db.penjualan.find({ _id: { $gt: batas } }).sort({ _id: 1 }).limit(5).toArray()
print("halaman 2:", hal2.map(d => d._id))
const s = db.penjualan.find({ _id: { $gt: 35 } }).sort({ _id: 1 }).limit(5).explain("executionStats").executionStats
print("halaman 8 (range) -> nReturned:", s.nReturned, "| keys:", s.totalKeysExamined, "| docs:", s.totalDocsExamined)Output yang Dihasilkan:
halaman 1: [ 1, 2, 3, 4, 5 ]
halaman 2: [ 6, 7, 8, 9, 10 ]
halaman 8 (range) -> nReturned: 5 | keys: 5 | docs: 5Halaman kedua diambil dengan { _id: { $gt: 5 } }, dan simulasi halaman ke-8 (_id > 35) memeriksa tepat 5 kunci — sama dengan halaman pertama. Biaya per halaman konstan di kedalaman mana pun: itulah properti yang membuat range-based pagination tetap responsif pada data sebesar apa pun.
Jebakan Kunci Sortir Tidak Unik #
Range-based punya satu syarat yang tidak bisa ditawar: kombinasi kunci sortir harus unik. _id aman karena dijamin unik, tetapi pagination yang alami secara bisnis sering memakai field seperti waktu — dan waktu bisa kembar. Data kita sengaja berisi transaksi dengan waktu sama; lihat apa yang terjadi pada batas halaman yang jatuh di tengah pasangan kembar:
const p1 = db.penjualan.find().sort({ waktu: -1, _id: 1 }).limit(5).toArray()
print("halaman 1:", p1.map(d => d._id))
print("batas: _id", p1[4]._id, "pada", p1[4].waktu.toISOString())
const p2naif = db.penjualan.find({ waktu: { $lt: p1[4].waktu } }).sort({ waktu: -1, _id: 1 }).limit(5).toArray()
print("halaman 2 (naif):", p2naif.map(d => d._id))
const p2benar = db.penjualan.find({
$or: [
{ waktu: { $lt: p1[4].waktu } },
{ waktu: p1[4].waktu, _id: { $gt: p1[4]._id } }
]
}).sort({ waktu: -1, _id: 1 }).limit(5).toArray()
print("halaman 2 (kombinator):", p2benar.map(d => d._id))Output yang Dihasilkan:
halaman 1: [ 39, 40, 37, 38, 35 ]
batas: _id 35 pada 2026-03-06T15:00:00.000Z
halaman 2 (naif): [ 34, 33, 31, 32, 29 ]
halaman 2 (kombinator): [ 36, 34, 33, 31, 32 ]Batas halaman pertama jatuh pada _id 35 yang ber-waktu 15:00. Transaksi _id 36 ber-waktu persis sama, tetapi versi naif { waktu: { $lt: batas } } menjatuhkannya — ia tidak lolos filter $lt, padahal belum pernah tampil. Hasilnya: transaksi 36 tidak pernah muncul di halaman mana pun. Versi kombinator memperbaikinya dengan syarat ganda: ambil semua yang waktu-nya lebih kecil, atau yang waktu-nya sama tapi _id-nya lebih besar dari batas. Transaksi 36 kembali tampil di halaman kedua.
Kapan Memakai yang Mana #
| Aspek | skip + limit | Range-based ($gt/$lt kunci) |
|---|---|---|
| Biaya halaman dalam | Tumbuh linear (O(n)) | Konstan (O(log n) + ukuran halaman) |
| Stabil terhadap data baru | Tidak — insert menggeser isi halaman | Ya — posisi relatif kunci tetap |
| Lompat ke halaman N | Bisa langsung (skip((N-1)×m)) | Tidak — harus berjalan halaman demi halaman |
| Syarat kunci | Bebas | Kombinasi kunci harus unik dan terurut konsisten |
| Cocok untuk | Halaman admin kecil, ekspor berhalaman | Umpan tak berujung (feed), API publik, gulir tanpa batas |
skip() bukan kesalahan — pada halaman 1 sampai 5 dari daftar ratusan item, biayanya masih murah dan kemampuan “lompat ke halaman 17” memang hanya bisa ia berikan. Masalah muncul ketika pola aksesnya gulir terus ke dalam (infinite scroll, umpan notifikasi, penelusuran hasil pencarian); di situ range-based adalah satu-satunya pilihan yang sehat. Banyak aplikasi memakai keduanya sekaligus: skip untuk navigasi bernomor di area admin, range untuk umpan pengguna.
Rangkuman #
skip(n).limit(m)memaksa server menelusuri dan membuang n document; biaya halaman tumbuh linear dengan kedalaman.- Range-based (
{ kunci: { $gt: batas } }) melompat tepat ke posisi batas; biaya per halaman konstan. - Kunci sortir harus unik — pasangkan kunci bisnis dengan
_idsebagai pemecah seri, dan gunakan filter kombinator$orsaat batas jatuh di nilai kembar. skiptetap wajar untuk halaman admin kecil dan lompatan langsung; umpan yang digulir terus harus memakai range.