Query yang sama bisa berjalan cepat atau lambat tergantung satu keputusan yang terjadi dalam hitungan milidetik sebelum eksekusi: index mana yang dipakai. Keputusan itu diambil oleh query planner — komponen yang mengubah filter kita menjadi rencana eksekusi. Kebanyakan waktu planner memilih dengan tepat, tetapi saat pilihan kurang baik, kita perlu memahami cara kerjanya: dari mana kandidatnya berasal, bagaimana pemenang dipilih, dan di mana rencana itu disimpan agar query berikutnya lebih cepat. Chapter ini membuka Section Performance dan Optimization dengan membongkar mekanisme tersebut, lalu menutupnya dengan dua alat kontrol manual: hint() dan index filter.
Peta materi Chapter ini:
- Siklus planner: kandidat index, kompetisi, dan plan cache.
queryHashdanplanCacheKeydi outputexplain().hint()untuk memaksa index — dan bahayanya.- Index filter dan membersihkan plan cache.
Dari Query ke Rencana Eksekusi #
Saat sebuah query find() tiba, planner tidak langsung mengeksekusinya. Planner lebih dulu menyusun daftar rencana: satu rencana tanpa index (COLLSCAN), plus satu rencana untuk setiap index yang bentuknya cocok dengan filter dan urutan sortir. Kalau hanya ada satu kandidat, rencana itu langsung dipakai. Kalau ada beberapa kandidat sehat, planner menjalankan kompetisi kecil: setiap rencana dieksekusi sebagian (dengan batas internal), yang selesai duluan dengan hasil terbanyak menjadi pemenang (winning plan), sisanya tercatat sebagai rejectedPlans.
Pemenang kemudian disimpan di plan cache — query berbentuk sama tidak perlu kompetisi ulang. Siklus lengkapnya:
| Tahap | Yang terjadi | Terlihat di explain sebagai |
|---|---|---|
| 1. Kandidat | Daftar rencana dari semua index yang cocok | winningPlan + rejectedPlans |
| 2. Kompetisi | Rencana diadu dengan eksekusi parsial | tidak tampil (internal) |
| 3. Pemenang | Rencana tercepat dijalankan penuh | winningPlan.stage |
| 4. Cache | Pemenang diingat untuk bentuk query yang sama | isCached: true |
Siapkan data transaksi toko buku yang akan dipakai konsisten di sepanjang section ini:
use latihan_perf
db.penjualan.drop()
db.penjualan.insertMany([
{ _id: 1, waktu: ISODate("2026-03-02T09:15:00Z"), judul_buku: "Laut Bercerita", jumlah: 1, harga_total: 98000, kota: "Jakarta", channel: "toko" },
{ _id: 2, waktu: ISODate("2026-03-02T11:40:00Z"), judul_buku: "Bumi", jumlah: 2, harga_total: 178000, kota: "Bandung", channel: "online" },
{ _id: 3, waktu: ISODate("2026-03-03T10:05:00Z"), judul_buku: "Filosofi Teras", jumlah: 1, harga_total: 89000, kota: "Jakarta", channel: "online" },
{ _id: 4, waktu: ISODate("2026-03-03T14:30:00Z"), judul_buku: "Atomic Habits", jumlah: 3, harga_total: 345000, kota: "Surabaya", channel: "toko" },
{ _id: 5, waktu: ISODate("2026-03-04T08:20:00Z"), judul_buku: "Rumah Kaca", jumlah: 1, harga_total: 72000, kota: "Bandung", channel: "toko" },
{ _id: 6, waktu: ISODate("2026-03-04T13:55:00Z"), judul_buku: "Bumi", jumlah: 1, harga_total: 89000, kota: "Jakarta", channel: "toko" },
{ _id: 7, waktu: ISODate("2026-03-05T09:45:00Z"), judul_buku: "Hujan Bulan Juni", jumlah: 2, harga_total: 130000, kota: "Yogyakarta", channel: "online" },
{ _id: 8, waktu: ISODate("2026-03-05T16:10:00Z"), judul_buku: "Laut Bercerita", jumlah: 1, harga_total: 98000, kota: "Surabaya", channel: "online" },
{ _id: 9, waktu: ISODate("2026-03-06T10:30:00Z"), judul_buku: "Atomic Habits", jumlah: 1, harga_total: 115000, kota: "Jakarta", channel: "online" },
{ _id: 10, waktu: ISODate("2026-03-06T15:25:00Z"), judul_buku: "Bumi", jumlah: 4, harga_total: 356000, kota: "Yogyakarta", channel: "toko" },
{ _id: 11, waktu: ISODate("2026-03-07T09:00:00Z"), judul_buku: "Filosofi Teras", jumlah: 2, harga_total: 178000, kota: "Bandung", channel: "online" },
{ _id: 12, waktu: ISODate("2026-03-07T17:35:00Z"), judul_buku: "Hujan Bulan Juni", jumlah: 1, harga_total: 65000, kota: "Jakarta", channel: "toko" }
])Output yang Dihasilkan:
switched to db latihan_perf
true
{
acknowledged: true,
insertedIds: {
'0': 1,
'1': 2,
'2': 3,
'3': 4,
'4': 5,
'5': 6,
'6': 7,
'7': 8,
'8': 9,
'9': 10,
'10': 11,
'11': 12
}
}Sekarang buat dua index yang sama-sama mampu melayani filter kota — index tunggal dan index majemuk berawalan kota. Dua kandidat sehat inilah pemicu kompetisi:
db.penjualan.createIndex({ kota: 1 })
db.penjualan.createIndex({ kota: 1, waktu: 1 })
for (let i = 0; i < 3; i++) { const n = db.penjualan.find({ kota: "Jakarta" }).itcount() }
db.penjualan.find({ kota: "Jakarta" }).explain()Output yang Dihasilkan:
kota_1
kota_1_waktu_1
{
explainVersion: '1',
queryPlanner: {
namespace: 'latihan_perf.penjualan',
parsedQuery: { kota: { '$eq': 'Jakarta' } },
indexFilterSet: false,
queryHash: 'BBF2744B',
planCacheShapeHash: 'BBF2744B',
planCacheKey: '2CFD3F42',
winningPlan: {
isCached: true,
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
keyPattern: { kota: 1 },
indexName: 'kota_1',
direction: 'forward',
indexBounds: { kota: [ '["Jakarta", "Jakarta"]' ] }
}
},
rejectedPlans: [
{
isCached: false,
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
keyPattern: { kota: 1, waktu: 1 },
indexName: 'kota_1_waktu_1',
direction: 'forward',
indexBounds: {
kota: [ '["Jakarta", "Jakarta"]' ],
waktu: [ '[MinKey, MaxKey]' ]
}
}
}
]
},
command: {
find: 'penjualan',
filter: { kota: 'Jakarta' },
'$db': 'latihan_perf'
},
ok: 1
}Output di atas dipadatkan agar mudah dibaca — nss, multiKeyPaths, serverInfo, serverParameters, queryShapeHash, dan metadata waktu replikasi dipangkas; output explain() dan runCommand() di seluruh chapter ini dipadatkan dengan cara yang sama. Mari kita bedah bagian-bagian pentingnya:
queryPlanner.queryHashdanplanCacheShapeHash: sidik jari bentuk query — kombinasi field yang difilter, diurutkan, dan diproyeksikan. Filter{ kota: "Bandung" }menghasilkan hash yang sama dengan{ kota: "Jakarta" }karena bentuknya identik; hanya nilainya yang berbeda.planCacheKey: kunci cache yang lebih spesifik — bentuk query plus versi katalog index. Saat index dibuat atau dihapus, kunci ini berubah dan cache lama otomatis tidak berlaku.winningPlandenganisCached: true: setelah tiga eksekusi pembuka, pemenang kompetisi sudah tersimpan. Query keempat tinggal memungut rencana dari cache — tahap perencanaan jauh lebih pendek.rejectedPlans: index majemukkota_1_waktu_1kalah kompetisi pada data ini dan tampil sebagai rencana yang dicoret.
Rencana Bisa Berubah #
Pemenang kompetisi bukan keputusan permanen. Planner menilai kandidat berdasarkan perkiraan biaya dari statistik data — seberapa sempit batas index, seberapa banyak document diperkirakan lolos. Ketika sebaran data berubah, pemenang yang berbeda bisa muncul: index yang unggul saat kota Jakarta hanya memiliki lima transaksi bisa kalah setelah ratusan ribu transaksi Jakarta masuk dan index majemuk jauh lebih efisien. Karena itu kebiasaan menguji explain() harus dilakukan pada data yang ukurannya mirip produksi, bukan pada selusin document latihan.
Plan cache sendiri ikut ter-reset dalam beberapa keadaan yang wajar: index pada collection dibuat atau dihapus, server direstart, atau cache dibersihkan manual. Setelah itu, kompetisi berjalan lagi dan pemenang baru tersimpan — proses yang tidak perlu kita picu sendiri di kondisi normal.
Memaksa Rencana dengan hint() #
Ada kalanya kita yakin planner salah pilih — misalnya dari pengamatan executionStats di chapter berikutnya. Method hint() memaksa query memakai index tertentu, melewati kompetisi:
db.penjualan.find({ kota: "Jakarta" }).hint({ kota: 1, waktu: 1 }).explain()Output yang Dihasilkan:
{
explainVersion: '1',
queryPlanner: {
namespace: 'latihan_perf.penjualan',
parsedQuery: { kota: { '$eq': 'Jakarta' } },
indexFilterSet: false,
queryHash: 'BBF2744B',
planCacheShapeHash: 'BBF2744B',
planCacheKey: '2CFD3F42',
winningPlan: {
isCached: false,
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
keyPattern: { kota: 1, waktu: 1 },
indexName: 'kota_1_waktu_1',
direction: 'forward',
indexBounds: {
kota: [ '["Jakarta", "Jakarta"]' ],
waktu: [ '[MinKey, MaxKey]' ]
}
}
},
rejectedPlans: []
},
command: {
find: 'penjualan',
filter: { kota: 'Jakarta' },
hint: { kota: 1, waktu: 1 },
'$db': 'latihan_perf'
},
ok: 1
}hint() menerima pola index ({ kota: 1, waktu: 1 }) atau nama index dalam bentuk string (hint("kota_1_waktu_1")). Pada output di atas, winningPlan langsung memakai index majemuk — perhatikan bahwa kompetisi tidak pernah terjadi: rejectedPlans kosong karena index lain tidak diizinkan masuk daftar kandidat, dan command.hint mencatat paksaan tersebut.
Kekuatan ini sekaligus bahayanya. hint() bekerja sebagai pembekuan keputusan: begitu data tumbuh dan index lain menjadi lebih efisien, query tetap dipaksa ke index yang kita pilih. Bahaya kedua lebih cepat terasa — hint() dengan nama index yang sudah tidak ada membuat query gagal total:
try {
db.penjualan.find({ kota: "Jakarta" }).hint("index_tidak_ada").itcount()
} catch (e) {
print("error:", e.codeName, "-", e.message)
}Output yang Dihasilkan:
error: BadValue - error enumerating plans for query: ns=latihan_perf.penjualanTree: kota $eq "Jakarta"
Sort: {}
Proj: {}
planner returned error :: caused by :: hint provided does not correspond to an existing indexIndex Filter dan Membersihkan Plan Cache #
Kebutuhan “batasi kandidat index untuk bentuk query tertentu” dilayani oleh index filter. Filter ini mengunci daftar index yang boleh dipertimbangkan planner untuk satu bentuk query — berbeda dari hint() karena planner tetap memilih sendiri di antara kandidat yang diizinkan:
db.runCommand({
planCacheSetFilter: "penjualan",
query: { kota: "Jakarta" },
indexes: [ { kota: 1, waktu: 1 } ]
})
db.penjualan.find({ kota: "Jakarta" }).explain()Output yang Dihasilkan:
{ ok: 1 }
{
explainVersion: '1',
queryPlanner: {
namespace: 'latihan_perf.penjualan',
parsedQuery: { kota: { '$eq': 'Jakarta' } },
indexFilterSet: true,
queryHash: 'BBF2744B',
planCacheShapeHash: 'BBF2744B',
planCacheKey: '2CFD3F42',
winningPlan: {
isCached: false,
stage: 'FETCH',
inputStage: {
stage: 'IXSCAN',
keyPattern: { kota: 1, waktu: 1 },
indexName: 'kota_1_waktu_1',
direction: 'forward',
indexBounds: {
kota: [ '["Jakarta", "Jakarta"]' ],
waktu: [ '[MinKey, MaxKey]' ]
}
}
},
rejectedPlans: []
},
ok: 1
}Perhatikan dua perubahan pada queryPlanner: field indexFilterSet bernilai true (ada filter aktif untuk bentuk query ini), dan rejectedPlans kosong — index tunggal kota_1 tidak pernah dijadikan kandidat. Daftar filter yang terpasang bisa dilihat dan dibersihkan:
db.runCommand({ planCacheListFilters: "penjualan" })
db.runCommand({ planCacheClearFilters: "penjualan" })Output yang Dihasilkan:
{
filters: [
{
query: { kota: 'Jakarta' },
sort: {},
projection: {},
indexes: [ { kota: 1, waktu: 1 } ]
}
],
ok: 1
}
{ ok: 1 }Untuk plan cache, perintah pembersihannya planCacheClear — tanpa parameter tambahan seluruh cache collection itu dihapus; dengan parameter query hanya bentuk query tersebut yang dihapus:
db.runCommand({ planCacheClear: "penjualan" })
const cek = db.penjualan.find({ kota: "Jakarta" }).explain()
print("isCached setelah clear:", cek.queryPlanner.winningPlan.isCached)Output yang Dihasilkan:
{ ok: 1 }
isCached setelah clear: falseRangkuman #
- Planner menyusun kandidat dari index yang cocok, mengaduinya lewat eksekusi parsial, lalu menyimpan pemenang di plan cache untuk bentuk query yang sama.
queryHashmengidentifikasi bentuk query;planCacheKeymenambahkan versi katalog index — keduanya tampil diexplain().- Pemenang bergantung statistik data: rencana yang tepat hari ini bisa kalah besok, jadi uji
explain()pada data seukuran produksi. hint()dan index filter memaksa atau mempersempit pilihan planner — berguna untuk diagnosis, berisiko bila dipakai permanen.planCacheClear(seluruh collection atau satu bentuk query) danplanCacheClearFiltersmengembalikan planner ke kondisi bersih.