Tiga laporan Bagian 2 berjalan dalam hitungan milidetik — tetapi kecepatan itu menipu. Tanpa index, setiap $match tanggal dan setiap $sort memindai seluruh 2000 document, dan biaya pemindaian tumbuh linear mengikuti jumlah transaksi: 2 juta document berarti seribu kali lebih banyak dokumen diperiksa untuk laporan yang sama. Bagian ini memasang dua index yang menyasar pola akses laporan kita, membuktikan dampaknya lewat output explain() yang asli, lalu menutup dengan allowDiskUse untuk pipeline yang lebih besar dari memori.

Dua Index untuk Dua Pola Kueri #

Laporan yang kita bangun membentuk dua pola akses yang berbeda, dan masing-masing mendapat index sendiri:

IndexMelayaniBentuk kueri
{ tanggal: 1 }Omzet per bulan, harian, trenRentang tanggal ($gte – $lt)
{ "items.kategori": 1, tanggal: 1 }Tren per kategori, laporan kategoriSatu kategori tetap + rentang tanggal

Urutan field pada index compound mengikuti aturan ESR — equality, sort, range: field yang selalu bernilai tetap (items.kategori) diletakkan lebih dulu, field rentang (tanggal) di belakangnya. Index pada field array otomatis menjadi multikey: satu transaksi yang memuat item devops dan database menghasilkan dua entri index items.kategori.

Skrip Optimasi #

js
// optimasi-index.js — index untuk laporan, bukti explain sebelum/sesudah
// Jalankan setelah dataset-seed.js (boleh diulang kapan pun):
//   mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/optimasi-index.js

var ana = db.getSiblingDB("proyek_analitik")

// Buang semua index non-_id agar demo "sebelum index" selalu bisa direproduksi.
function buangIndex() {
  ana.transaksi.getIndexes()
    .filter(function (i) { return i.name !== "_id_" })
    .forEach(function (i) { ana.transaksi.dropIndex(i.name) })
}

function ringkas(judul, rencana) {
  print("==", judul, "==")
  const p = rencana.queryPlanner.winningPlan
  // Explain aggregation membungkus pohon rencana di dalam queryPlan.
  printjson(p.queryPlan ? p.queryPlan : p)
  const e = rencana.executionStats
  print("hasil", e.nReturned,
    "| kunci diperiksa", e.totalKeysExamined,
    "| dokumen diperiksa", e.totalDocsExamined,
    "| waktu", e.executionTimeMillis, "ms")
}

// Kueri laporan omzet Maret: rentang tanggal satu bulan penuh.
const maret = {
  tanggal: {
    $gte: ISODate("2026-03-01T00:00:00Z"),
    $lt: ISODate("2026-04-01T00:00:00Z"),
  },
}

// --- 1. Sebelum index: seluruh collection dipindai (COLLSCAN) ---
buangIndex()
ringkas("sebelum index: find rentang Maret", ana.transaksi.find(maret).explain("executionStats"))

// --- 2. Index tunggal pada tanggal ---
print("membuat index:", ana.transaksi.createIndex({ tanggal: 1 }))
ringkas("sesudah index tanggal: find rentang Maret", ana.transaksi.find(maret).explain("executionStats"))

// --- 3. Proyeksi tertutup: hanya field yang ada di index ---
ringkas(
  "proyeksi tertutup: hanya field tanggal",
  ana.transaksi.find(maret, { _id: 0, tanggal: 1 }).explain("executionStats")
)

// --- 4. Index compound: filter kategori + rentang tanggal ---
print("membuat index:", ana.transaksi.createIndex({ "items.kategori": 1, tanggal: 1 }))
ringkas(
  "index compound: kategori devops, Mei-Juni",
  ana.transaksi.find({
    "items.kategori": "devops",
    tanggal: {
      $gte: ISODate("2026-05-01T00:00:00Z"),
      $lt: ISODate("2026-07-01T00:00:00Z"),
    },
  }).explain("executionStats")
)

// --- 5. Explain pada aggregation: $match memakai index yang sama ---
const rencanaAgg = ana.transaksi.explain("executionStats").aggregate([
  { $match: maret },
  { $group: { _id: null, omzet: { $sum: "$total" } } },
])
ringkas("aggregation $match Maret", rencanaAgg)

// --- 6. allowDiskUse: izinkan stage besar menulis hasil antara ke disk ---
const peringkat = ana.transaksi.aggregate(
  [
    { $unwind: "$items" },
    { $group: {
        _id: "$items.produk",
        omzet: { $sum: { $multiply: ["$items.harga", "$items.jumlah"] } },
    } },
    { $sort: { omzet: -1 } },
    { $limit: 3 },
  ],
  { allowDiskUse: true }
).toArray()
print("== peringkat produk dengan allowDiskUse: true ==")
peringkat.forEach(function (r, i) {
  print(" ", (i + 1) + ".", r._id, "| omzet", r.omzet)
})

Jalankan setelah dataset-seed.js:

bash
mongosh "mongodb://127.0.0.1:27017/?directConnection=true" --file src/optimasi-index.js

Output yang Dihasilkan:

text
== sebelum index: find rentang Maret ==
{
  isCached: false,
  stage: 'COLLSCAN',
  filter: {
    '$and': [
      {
        tanggal: {
          '$lt': ISODate('2026-04-01T00:00:00.000Z')
        }
      },
      {
        tanggal: {
          '$gte': ISODate('2026-03-01T00:00:00.000Z')
        }
      }
    ]
  },
  nss: 'proyek_analitik.transaksi',
  direction: 'forward'
}
hasil 331 | kunci diperiksa 0 | dokumen diperiksa 2000 | waktu 0 ms
membuat index: tanggal_1
== sesudah index tanggal: find rentang Maret ==
{
  isCached: false,
  stage: 'FETCH',
  nss: 'proyek_analitik.transaksi',
  inputStage: {
    stage: 'IXSCAN',
    nss: 'proyek_analitik.transaksi',
    keyPattern: {
      tanggal: 1
    },
    indexName: 'tanggal_1',
    isMultiKey: false,
    multiKeyPaths: {
      tanggal: []
    },
    isUnique: false,
    isSparse: false,
    isPartial: false,
    indexVersion: 2,
    direction: 'forward',
    indexBounds: {
      tanggal: [
        '[new Date(1772323200000), new Date(1775001600000))'
      ]
    }
  }
}
hasil 331 | kunci diperiksa 331 | dokumen diperiksa 331 | waktu 1 ms
== proyeksi tertutup: hanya field tanggal ==
{
  isCached: false,
  stage: 'PROJECTION_COVERED',
  transformBy: {
    _id: 0,
    tanggal: 1
  },
  inputStage: {
    stage: 'IXSCAN',
    nss: 'proyek_analitik.transaksi',
    keyPattern: {
      tanggal: 1
    },
    indexName: 'tanggal_1',
    isMultiKey: false,
    multiKeyPaths: {
      tanggal: []
    },
    isUnique: false,
    isSparse: false,
    isPartial: false,
    indexVersion: 2,
    direction: 'forward',
    indexBounds: {
      tanggal: [
        '[new Date(1772323200000), new Date(1775001600000))'
      ]
    }
  }
}
hasil 331 | kunci diperiksa 331 | dokumen diperiksa 0 | waktu 0 ms
membuat index: items.kategori_1_tanggal_1
== index compound: kategori devops, Mei-Juni ==
{
  isCached: false,
  stage: 'FETCH',
  nss: 'proyek_analitik.transaksi',
  inputStage: {
    stage: 'IXSCAN',
    nss: 'proyek_analitik.transaksi',
    keyPattern: {
      'items.kategori': 1,
      tanggal: 1
    },
    indexName: 'items.kategori_1_tanggal_1',
    isMultiKey: true,
    multiKeyPaths: {
      'items.kategori': [
        'items'
      ],
      tanggal: []
    },
    isUnique: false,
    isSparse: false,
    isPartial: false,
    indexVersion: 2,
    direction: 'forward',
    indexBounds: {
      'items.kategori': [
        '["devops", "devops"]'
      ],
      tanggal: [
        '[new Date(1777593600000), new Date(1782864000000))'
      ]
    }
  }
}
hasil 287 | kunci diperiksa 287 | dokumen diperiksa 287 | waktu 1 ms
== aggregation $match Maret ==
{
  stage: 'GROUP',
  planNodeId: 4,
  inputStage: {
    stage: 'FETCH',
    planNodeId: 2,
    nss: 'proyek_analitik.transaksi',
    inputStage: {
      stage: 'IXSCAN',
      planNodeId: 1,
      nss: 'proyek_analitik.transaksi',
      keyPattern: {
        tanggal: 1
      },
      indexName: 'tanggal_1',
      isMultiKey: false,
      multiKeyPaths: {
        tanggal: []
      },
      isUnique: false,
      isSparse: false,
      isPartial: false,
      indexVersion: 2,
      direction: 'forward',
      indexBounds: {
        tanggal: [
          '[new Date(1772323200000), new Date(1775001600000))'
        ]
      }
    }
  }
}
hasil 1 | kunci diperiksa 331 | dokumen diperiksa 331 | waktu 1 ms
== peringkat produk dengan allowDiskUse: true ==
  1. Kubernetes di Produksi | omzet 199654000
  2. MongoDB untuk Pengembang | omzet 170625000
  3. Rust untuk Sistem Andal | omzet 164670000

Bedah Sebelum dan Sesudah #

  1. buangIndex() di awal menjaga demo tetap jujur: semua index non-_id_ dibuang lebih dulu, sehingga bagian “sebelum” selalu menampilkan COLLSCAN betapa pun sering skrip dijalankan ulang.
  2. Sebelum index, winningPlan berupa COLLSCAN dengan filter tanggal. Statistiknya tercatat: 331 hasil ditemukan setelah memeriksa 2000 dokumen dan 0 kunci index. Perbandingan 331/2000 inilah yang memburuk seiring data tumbuh — database memeriksa enam dokumen untuk setiap satu yang benar-benar dibutuhkan.
  3. Sesudah tanggal_1 dibuat, rencana berubah menjadi FETCH di atas IXSCAN. indexBounds memperlihatkan rentang yang sempit — [new Date(1772323200000), new Date(1775001600000)) adalah 1 Maret sampai 1 April 2026 dalam bentuk milidetik epoch. Kini 331 kunci diperiksa untuk 331 hasil: tidak ada satu pun dokumen yang diperiksa sia-sia.
  4. Proyeksi tertutup menambahkan { _id: 0, tanggal: 1 } pada find(). Karena seluruh field yang diminta tersedia di dalam index, tahap PROJECTION_COVERED membangun hasil langsung dari kunci — dokumen diperiksa 0. Inilah bentuk termurah dari sebuah kueri: yang dibaca hanya index, collection tidak tersentuh.
  5. Index compound menuntaskan pola kategori: isMultiKey: true dengan multiKeyPaths menunjuk items, dan indexBounds mengunci kedua field sekaligus — kategori tepat pada ["devops", "devops"], tanggal pada rentang Mei–Juni. Hasil 287 transaksi diperoleh dari 287 kunci.
  6. Explain pada aggregation membuktikan $match di awal pipeline memakai index yang sama: pohon GROUP ← FETCH ← IXSCAN terbentuk karena $match digeser turun menjadi kueri cursor. Pada MongoDB 9.0 pohon ini terbungkus field queryPlan (berdampingan dengan slotBasedPlan hasil optimasi mesin eksekusi SBE) — fungsi ringkas() memilih format datar mana pun yang muncul.

Membaca indexBounds #

Bagian paling informatif dari explain justru yang paling sering dilewati: indexBounds. Notasinya meniru matematika — kurung siku [ berarti batas ikut dihitung, kurung biasa ) berarti batas dikeluarkan. Rentang [new Date(1772323200000), new Date(1775001600000)) terbaca: mulai 1 Maret 2026 (inklusif) sampai 1 April 2026 (eksklusif). Angka-angka itu adalah milidetik sejak epoch; new Date(1772323200000).toISOString() mengembalikan 2026-03-01T00:00:00.000Z bila kita ingin memastikan.

Bentuk setengah terbuka ini bukan kebetulan — inilah cara menulis “satu bulan penuh” tanpa melewatkan transaksi di detik terakhir. Kueri $lt 1 April mengizinkan transaksi pukul 23.59.59 pada 31 Maret tetap masuk, sesuatu yang $lte 23.59.59 akan lewatkan. Pada index compound, indexBounds memperlihatkan kedua field terkunci sekaligus: items.kategori pada satu titik ["devops", "devops"], tanggal pada rentang — pembacaannya dua dimensi, persis seperti susunan kunci indexnya.

allowDiskUse untuk Pipeline Besar #

Stage seperti $group dan $sort bekerja di memori dan dibatasi sekitar 100 MB per stage — melebihi batas itu, MongoDB menghentikan pipeline dengan error, bukan melambat diam-diam. Opsi { allowDiskUse: true } pada aggregate() mengizinkan stage yang dukung untuk menulis data antara ke berkas sementara di disk.

Skrip menutup dengan pipeline peringkat produk ($unwind → $group → $sort → $limit) yang dijalankan dengan opsi tersebut. Pada 4071 baris item kita, semuanya masih muat di memori dan allowDiskUse tidak berefek — angka peringkat sama persis dengan Bagian 2. Opsi ini adalah asuransi untuk skala berikutnya: dataset jutaan transaksi yang menghasilkan jutaan baris item setelah $unwind akan membuat $group melewati 100 MB, dan tanpa opsi tersebut pipeline berhenti di tengah jalan. Konsekuensinya jujur juga: menulis ke disk lebih lambat daripada memori, jadi nyalakan hanya ketika pipeline memang membutuhkannya.

Sebelum menyalakan opsi ini, periksa dulu apakah pipeline bisa diperkecil: $match sedini mungkin (seperti pada omzetHarian()), $limit sebelum $group ketika laporan memang hanya butuh sebagian data, atau $project untuk memangkas field sebelum stage berat. allowDiskUse menolong pipeline yang benar; ia tidak memperbaiki pipeline yang salah susunan.

Apa Berikutnya #

Laporan kini cepat dan terukur. Bagian terakhir mengemas semuanya menjadi keluaran siap tampil: dashboard multi-metriks dalam satu pipeline $facet, materialisasi laporan harian ke collection laporan_harian dengan $merge, tabel ASCII rapi di terminal, serta jalan membawa data ke chart library eksternal.