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 _id sebagai 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:

js
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:

text
switched to db latihan_perf
true

tersimpan: 40

Dengan 8 halaman berisi 5 item, bandingkan biaya halaman pertama dan halaman terakhir:

js
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:

text
halaman 1 -> nReturned: 5 | keys: 5 | docs: 5

halaman 8 -> nReturned: 5 | keys: 40 | docs: 5

Kedua 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:

js
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:

text
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: 5

Setiap 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:

js
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:

text
halaman 1: [ 1, 2, 3, 4, 5 ]

halaman 2: [ 6, 7, 8, 9, 10 ]

halaman 8 (range) -> nReturned: 5 | keys: 5 | docs: 5

Halaman 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:

js
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:

text
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 #

Aspekskip + limitRange-based ($gt/$lt kunci)
Biaya halaman dalamTumbuh linear (O(n))Konstan (O(log n) + ukuran halaman)
Stabil terhadap data baruTidak — insert menggeser isi halamanYa — posisi relatif kunci tetap
Lompat ke halaman NBisa langsung (skip((N-1)×m))Tidak — harus berjalan halaman demi halaman
Syarat kunciBebasKombinasi kunci harus unik dan terurut konsisten
Cocok untukHalaman admin kecil, ekspor berhalamanUmpan 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 _id sebagai pemecah seri, dan gunakan filter kombinator $or saat batas jatuh di nilai kembar.
  • skip tetap wajar untuk halaman admin kecil dan lompatan langsung; umpan yang digulir terus harus memakai range.