Pipeline yang benar secara hasil belum tentu benar secara biaya. Dua pipeline bisa mengembalikan angka yang sama, tetapi yang satu memindai seluruh collection sementara yang lain berhenti di index setelah beberapa document. Selisihnya pada data sejuta baris bukan hitungan persen, melainkan hitungan detik versus menit. Chapter ini merangkum tuas optimasi utama aggregation: urutan tahap, pemakaian index, pengendalian memori, dan cara membaca rencana eksekusi pipeline — ditutup daftar kebiasaan yang layak dijadikan refleks.

Peta materi Chapter ini:

  • Urutan tahap: saring dan pangkas sedini mungkin.
  • $match + $sort sebagai pasangan penikmat index.
  • Batas memori 100 MB dan allowDiskUse.
  • Membaca rencana eksekusi aggregate() dengan explain().

Urutan Tahap: Saring Dulu, Kerja Kemudian #

Aturan emasnya satu: setiap document yang berhasil dibuang lebih awal tidak akan pernah diproses oleh tahap berikutnya. Karena itu $match dan $limit sebaiknya berada sedini mungkin, sementara $group dan $project yang berat diletakkan setelah data mengecil.

Siapkan data uji yang sama seperti Chapter sebelumnya:

js
use latihan_agregasi2
db.buku.drop()
db.buku.insertMany([
  { judul: "Laut Bercerita", penulis: "Leila S. Chudori", harga: 98000, kategori: "fiksi", terjual: 120, tag: ["fiksi", "sastra", "penghargaan"] },
  { judul: "Bumi", penulis: "Tere Liye", harga: 89000, kategori: "fiksi", terjual: 250, tag: ["fiksi", "remaja", "fantasi"] },
  { judul: "Filosofi Teras", penulis: "Henry Manampiring", harga: 89000, kategori: "nonfiksi", terjual: 180, tag: ["filsafat", "pengembangan diri"] },
  { judul: "Atomic Habits", penulis: "James Clear", harga: 115000, kategori: "nonfiksi", terjual: 310, tag: ["pengembangan diri", "terjemahan"] },
  { judul: "Rumah Kaca", penulis: "Tere Liye", harga: 72000, kategori: "fiksi", terjual: 120, tag: ["fiksi", "remaja"] },
  { judul: "Bumi Manusia", penulis: "Pramoedya A. Toer", harga: 105000, kategori: "sastra", terjual: 150, tag: ["sastra", "sejarah"] },
  { judul: "Hujan Bulan Juni", penulis: "Sapardi Djoko Damono", harga: 65000, kategori: "puisi", terjual: 75, tag: ["puisi", "sastra"] },
  { judul: "Ensiklopedia Sains Populer", penulis: "Tim Sains", harga: 250000, kategori: "referensi", terjual: 40, tag: ["referensi", "sains"] }
])

Output yang Dihasilkan:

text
switched to db latihan_agregasi2
true
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3b2f6fd7f9ec03616fde8'),
    '1': ObjectId('6ad3b2f6fd7f9ec03616fde9'),
    '2': ObjectId('6ad3b2f6fd7f9ec03616fdea'),
    '3': ObjectId('6ad3b2f6fd7f9ec03616fdeb'),
    '4': ObjectId('6ad3b2f6fd7f9ec03616fdec'),
    '5': ObjectId('6ad3b2f6fd7f9ec03616fded'),
    '6': ObjectId('6ad3b2f6fd7f9ec03616fdee'),
    '7': ObjectId('6ad3b2f6fd7f9ec03616fdef')
  }
}

Mari kita bandingkan dua pipeline yang hasilnya sama persis — total terjual untuk buku nonfiksi. Versi pertama menghitung dulu, menyaring kemudian:

js
db.buku.aggregate([
  { $group: { _id: "$kategori", total: { $sum: "$terjual" } } },
  { $match: { _id: "nonfiksi" } }
])

Output yang Dihasilkan:

text
[ { _id: 'nonfiksi', total: 490 } ]

Versi kedua menyaring lebih dulu, baru menghitung:

js
db.buku.aggregate([
  { $match: { kategori: "nonfiksi" } },
  { $group: { _id: "$kategori", total: { $sum: "$terjual" } } }
])

Output yang Dihasilkan:

text
[ { _id: 'nonfiksi', total: 490 } ]

Hasil akhirnya sama, tetapi biayanya tidak. Mari kita bedah:

  1. Versi pertama: $group memproses kedelapan document — membangun grup untuk fiksi, nonfiksi, sastra, puisi, dan referensi — sebelum $match membuang empat grup yang tidak diminta. Pada collection satu juta baris dengan ribuan kategori, $group mengalokasikan hash table raksasa untuk kemudian dibuang.
  2. Versi kedua: $match di posisi pertama membuat $group hanya menerima dua document. Mesin eksekusi bahkan bisa memakai index kategori bila tersedia, dan seluruh tahap setelahnya bekerja dengan data yang sudah kecil.
  3. Prinsip umum: taruh $match dan $limit di depan, $skip dan $project di tengah, $group dan $unwind sesudah penyaringan selama logika memungkinkan.

$match + $sort: Pasangan Penikmat Index #

$match di tahap pertama memakai index seperti query find() biasa — dan begitu pula $sort yang berada tepat setelahnya. Pasangan ini penting karena $sort di posisi lanjutan (misalnya setelah $group) harus mengurutkan hasil di memori, sementara $match + $sort di awal dapat memanfaatkan index yang urut, menghapus langkah pengurutan sama sekali.

Buat index lalu lihat mesin eksekusi memakainya:

js
db.buku.createIndex({ kategori: 1, terjual: -1 })
db.buku.aggregate([
  { $match: { kategori: "fiksi" } },
  { $sort: { terjual: -1 } },
  { $project: { _id: 0, judul: 1, terjual: 1 } }
])

Output yang Dihasilkan:

text
kategori_1_terjual_-1
[
  { judul: 'Bumi', terjual: 250 },
  { judul: 'Laut Bercerita', terjual: 120 },
  { judul: 'Rumah Kaca', terjual: 120 }
]

Index majemuk { kategori: 1, terjual: -1 } melayani keduanya sekaligus: kategori difilter lewat batas index, dan terjual sudah terurut menurun di dalam rentang tersebut. Pola ini — buat index yang meniru bentuk $match + $sort — adalah optimasi berbiaya termurah dengan dampak terbesar pada pipeline analitik.

Batas Memori 100 MB dan allowDiskUse #

Tahap yang harus menampung data di memori — $group, $sort, $bucket, $setWindowFields — dibatasi 100 MB per pipeline secara default. Melebihi itu, pipeline berhenti dengan galat; solusinya dua arah: kecilkan data lebih dulu ($match, $limit, $project), atau izinkan tumpahan ke disk:

js
db.buku.aggregate(
  [ { $sort: { terjual: -1 } }, { $group: { _id: null, termahal: { $max: "$harga" } } } ],
  { allowDiskUse: true }
)

Output yang Dihasilkan:

text
[ { _id: null, termahal: 250000 } ]

allowDiskUse: true membuat tahap yang kehabisan memori menulis data sementara ke berkas di direktori _tmp database. Kecepatannya jauh di bawah memori, jadi anggap opsi ini sebagai jaring pengaman, bukan pemecah utama: pipeline yang sehat tetap menyaring dulu agar jarang menyentuh batas ini.

Membaca Rencana Eksekusi Pipeline #

Seperti query, pipeline juga bisa diperiksa rencana eksekusinya — cukup ganti db.buku.aggregate(...) dengan db.buku.explain().aggregate(...):

js
db.buku.explain().aggregate([
  { $match: { kategori: "fiksi" } },
  { $sort: { terjual: -1 } }
])

Output yang Dihasilkan:

text
{
  explainVersion: '1',
  queryPlanner: {
    namespace: 'latihan_agregasi2.buku',
    parsedQuery: { kategori: { '$eq': 'fiksi' } },
    queryHash: 'AD3D8A67',
    planCacheShapeHash: 'AD3D8A67',
    planCacheKey: '82549CEB',
    optimizationTimeMillis: 0,
    optimizedPipeline: true,
    winningPlan: {
      isCached: false,
      stage: 'FETCH',
      inputStage: {
        stage: 'IXSCAN',
        keyPattern: { kategori: 1, terjual: -1 },
        indexName: 'kategori_1_terjual_-1',
        isMultiKey: false,
        direction: 'forward',
        indexBounds: {
          kategori: [ '["fiksi", "fiksi"]' ],
          terjual: [ '[MaxKey, MinKey]' ]
        }
      }
    },
    rejectedPlans: []
  },
  command: {
    aggregate: 'buku',
    pipeline: [ { '$match': { kategori: 'fiksi' } }, { '$sort': { terjual: -1 } } ],
    cursor: {},
    '$db': 'latihan_agregasi2'
  },
  ok: 1
}

Output di atas dipadatkan agar mudah dibaca — serverInfo, serverParameters, queryShapeHash, dan metadata waktu replikasi dipangkas; nilai queryHash dan planCacheKey juga akan berbeda di server Anda. Empat hal yang perlu dicari:

  1. optimizedPipeline: true: MongoDB mengenali bahwa $match + $sort di awal pipeline setara dengan query biasa, lalu mengeksekusinya lewat jalur kursor yang sama — inilah sebabnya pipeline seperti ini mendapat seluruh perlakuan index.
  2. winningPlan berupa FETCH → IXSCAN: pemindaian memakai index kategori_1_terjual_-1. Perhatikan bahwa tidak ada tahap SORT sama sekali — index yang tersimpan terurut sudah memenuhi $sort, jadi langkah pengurutan di memori tidak pernah ada.
  3. indexBounds: kategori terkunci di ["fiksi", "fiksi"] (tepat satu nilai) dan terjual dipindai penuh [MaxKey, MinKey] — konsisten dengan filter satu kategori yang hasilnya lalu diurutkan.
  4. rejectedPlans: []: tidak ada rencana saingan; bila Anda melihat COLLSCAN di winningPlan padahal index seharusnya terpakai, periksa apakah $match benar-benar berada di tahap pertama pipeline.

Kebiasaan yang Layak Dijadikan Refleks #

Kebiasaan yang Layak Dijadikan Refleks #

KebiasaanAlasan
$match selalu di tahap pertama bila memungkinkanMemakai index dan memangkas data sebelum kerja berat
$limit sedini mungkin setelah $sortMenghentikan pemindaian lebih awal pada pola top-N
$project membuang field besar sebelum $groupMenurunkan ukuran document yang mengalir
Hindari $unwind sebelum $match pada array besarMenggandakan jumlah document sebelum disaring
Rancang index meniru $match + $sortMenghapus langkah sort di memori
allowDiskUse hanya sebagai jaring pengamanDisk jauh lebih lambat dari memori
Uji dengan explain() pada data representatifRencana pada 8 document bisa berbeda pada 8 juta

Rangkuman #

  • Saring dan pangkas sedini mungkin: $match pertama, $limit dini, $group setelah data mengecil.
  • $match + $sort di awal pipeline diperlakukan seperti query biasa dan bisa dilayani index majemuk sepenuhnya.
  • Tahap memori ($group, $sort, $setWindowFields) dibatasi 100 MB; kecilkan datanya, dan pakai allowDiskUse hanya sebagai jaring pengaman.
  • db.collection.explain().aggregate(...) memperlihatkan tahap $cursor tempat index dipakai atau dilewatkan.
  • Kebiasaan menulis pipeline yang rapi lebih menentukan daripada menyetel server: urutan tahap adalah optimasi termurah yang Anda miliki.