Query terdekat dan query area yang kita kuasai dua chapter terakhir baru berguna kalau skemanya dirancang benar. “Cabang terdekat” menuntut setiap cabang menjadi document sendiri; “toko mana yang melayani alamat ini” menuntut polygon tersimpan per toko; dan lokasi kurir yang berpindah tiap menit menuntut pola update yang berbeda dari lokasi toko yang statis. Chapter penutup section ini merangkai semuanya menjadi satu rancangan skema jaringan toko buku multi-cabang — struktur diputuskan lebih dulu, index mengikuti pola query-nya.
Peta materi Chapter ini:
- Pola satu jaringan banyak cabang: document per cabang + referensi induk.
- Compound index
{ kategori: 1, lokasi: "2dsphere" }untuk query kategori + terdekat. - Area delivery sebagai Polygon per toko, dicari dengan
$geoIntersects. - Lokasi kurir dinamis: update di tempat, bukan tumpukan riwayat.
Satu Jaringan, Banyak Cabang #
Jalan pintas yang menggoda adalah menyematkan seluruh lokasi cabang sebagai array di document induk. Uji cepat berikut membuktikan jalan itu buntu: index 2dsphere menolak field yang berisi array object GeoJSON.
use latihan_geo
db.uji_skema.drop()
db.uji_skema.createIndex({ lokasi: "2dsphere" })
try {
db.uji_skema.insertOne({ nama: "Toko Buku Nusantara", lokasi: [ { type: "Point", coordinates: [106.83, -6.2] }, { type: "Point", coordinates: [107.62, -6.9] } ] })
} catch (e) {
print("Galat: " + e.code + " :: " + e.message)
}
db.uji_skema.drop()Output yang Dihasilkan:
switched to db latihan_geo
true
lokasi_2dsphere
Galat: 510 :: Could not extract geo keys at path 'lokasi' for document with _id ObjectId('6ad3ea902f7dc1fb9b892330'): Point must only contain numeric elements, instead got type object
trueError 510 menegaskan pesannya: Point must only contain numeric elements — satu field lokasi hanya boleh memuat satu geometri, bukan daftar cabang. Rancangan yang benar membalik arahnya: setiap cabang menjadi document penuh di collection cabang, merujuk balik ke induknya lewat jaringanId — pola referensi one-to-many dari Chapter Relasi One-to-Many.
Document induk menyimpan data brand dan subset cabangPopuler untuk halaman depan; seluruh attribute cabang hidup di document cabang sendiri. Kita bangun skemanya:
db.jaringan.drop()
db.cabang.drop()
db.jaringan.insertMany([
{ _id: "buku-nusantara", nama: "Toko Buku Nusantara", pusat: "Jakarta", cabangPopuler: ["Menteng", "Dago", "Cikini"] },
{ _id: "buku-senja", nama: "Toko Buku Senja", pusat: "Surabaya", cabangPopuler: ["Tunjungan", "Malioboro"] }
])
db.cabang.insertMany([
{ jaringanId: "buku-nusantara", nama: "Nusantara Menteng", kategori: "buku", kota: "Jakarta", lokasi: { type: "Point", coordinates: [106.83, -6.2] } },
{ jaringanId: "buku-nusantara", nama: "Nusantara Dago", kategori: "buku", kota: "Bandung", lokasi: { type: "Point", coordinates: [107.62, -6.9] } },
{ jaringanId: "buku-nusantara", nama: "Nusantara Cikini", kategori: "atk", kota: "Jakarta", lokasi: { type: "Point", coordinates: [106.84, -6.18] } },
{ jaringanId: "buku-senja", nama: "Senja Tunjungan", kategori: "buku", kota: "Surabaya", lokasi: { type: "Point", coordinates: [112.73, -7.25] } },
{ jaringanId: "buku-senja", nama: "Senja Malioboro", kategori: "kopi", kota: "Yogyakarta", lokasi: { type: "Point", coordinates: [110.37, -7.8] } }
])
db.cabang.createIndex({ jaringanId: 1 })Output yang Dihasilkan:
true
true
{
acknowledged: true,
insertedIds: { '0': 'buku-nusantara', '1': 'buku-senja' }
}
{
acknowledged: true,
insertedIds: {
'0': ObjectId('6ad3ea902f7dc1fb9b892331'),
'1': ObjectId('6ad3ea902f7dc1fb9b892332'),
'2': ObjectId('6ad3ea902f7dc1fb9b892333'),
'3': ObjectId('6ad3ea902f7dc1fb9b892334'),
'4': ObjectId('6ad3ea902f7dc1fb9b892335')
}
}
jaringanId_1Empat keputusan terkandung di skema ini:
- Document per cabang:
lokasiberada di tingkat atas document cabang — satu geometri per field, persis seperti yang diminta index — dan setiap cabang bisa di-query, di-update, bahkan dihapus tanpa menyentuh induknya. - Referensi
jaringanId: pengganti embed; indexjaringanId_1melayani query “semua cabang milik jaringan ini”. _idbermakna: string"buku-nusantara"menggantikan ObjectId acak — referensi mudah dibaca tanpa query tambahan.cabangPopulersebagai subset: daftar nama untuk halaman brand; daftar lengkap tetap hidup dicabang. Ini pola Chapter Subset Pattern — jaringan dengan ratusan cabang pun tetap ramping karena yang di-embed hanya yang sering dibaca.
Compound Index Kategori dan Lokasi #
Aplikasi kita punya dua pertanyaan utama tentang cabang: “toko buku terdekat mana?” (kategori + lokasi) dan “cabang jaringan X yang terdekat mana?” (jaringanId + lokasi). Keduanya filter equality + query geospatial — pola compound index dari Chapter Geospatial Indexes. Kita buat yang pertama, lalu uji:
db.cabang.createIndex({ kategori: 1, lokasi: "2dsphere" })
db.cabang.find({ kategori: "buku", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] }, $maxDistance: 200000 } } }, { _id: 0, nama: 1, kategori: 1, kota: 1 })
db.cabang.find({ kategori: "buku", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }).explain().queryPlanner.winningPlan.indexNameOutput yang Dihasilkan:
kategori_1_lokasi_2dsphere
[
{ nama: 'Nusantara Menteng', kategori: 'buku', kota: 'Jakarta' },
{ nama: 'Nusantara Dago', kategori: 'buku', kota: 'Bandung' }
]
kategori_1_lokasi_2dsphereQuery pertama mengembalikan dua cabang kategori buku dalam radius 200 km dari Menteng, terurut dari terdekat: Nusantara Menteng (0 km) lalu Nusantara Dago (±117 km). Nusantara Cikini (±2,5 km) tersaring oleh kategori: "atk", dan Senja Tunjungan jauh di Surabaya. Baris explain() menegaskan index yang dipakai: kategori_1_lokasi_2dsphere menangani filter equality dan pencarian geo dalam satu tahap.
Pertanyaan kedua kini bisa diuji — dan gagal dengan cara yang mendidik:
try {
db.cabang.find({ jaringanId: "buku-nusantara", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, nama: 1, kategori: 1 }).toArray()
} catch (e) {
print("Galat: " + e.code + " :: " + e.message.split(":: caused by ::").pop().trim())
}
db.cabang.createIndex({ jaringanId: 1, lokasi: "2dsphere" })
db.cabang.find({ jaringanId: "buku-nusantara", lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, nama: 1, kategori: 1 })Output yang Dihasilkan:
Galat: 291 :: unable to find index for $geoNear query
jaringanId_1_lokasi_2dsphere
[
{ nama: 'Nusantara Menteng', kategori: 'buku' },
{ nama: 'Nusantara Cikini', kategori: 'atk' },
{ nama: 'Nusantara Dago', kategori: 'buku' }
]Error 291 yang sudah kita kenal muncul lagi: $near menolak berjalan tanpa index geospatial yang cocok — dan jaringanId_1 (tanpa field geo) tidak memenuhi. Setelah index { jaringanId: 1, lokasi: "2dsphere" } berdiri, query yang sama mengembalikan tiga cabang buku-nusantara terurut dari terdekat: Menteng (0 km), Cikini (±2,5 km), Dago (±117 km). Pelajaran pemodelannya: setiap pola query equality + lokasi menuntut compound index-nya sendiri — dua pola query, dua index.
Area Delivery sebagai Polygon #
“Apakah alamat saya bisa diantar?” bukan pertanyaan jarak murni — Dago dan Menteng sama-sama melayani Jakarta Selatan dengan batas wilayah yang tidak bundar. Bentuk yang tepat adalah Polygon per toko, disimpan di collection area_delivery dengan referensi kembali ke cabang. Menanyakan “polygon mana yang memuat titik ini” adalah pekerjaan $geoIntersects — saudara $geoWithin yang menguji perpotongan, bukan kontensi penuh:
db.area_delivery.drop()
db.area_delivery.insertMany([
{ toko: "Nusantara Menteng", jaringanId: "buku-nusantara", cakupan: { type: "Polygon", coordinates: [ [[106.80, -6.22], [106.87, -6.22], [106.87, -6.15], [106.80, -6.15], [106.80, -6.22]] ] } },
{ toko: "Nusantara Dago", jaringanId: "buku-nusantara", cakupan: { type: "Polygon", coordinates: [ [[107.55, -6.95], [107.70, -6.95], [107.70, -6.85], [107.55, -6.85], [107.55, -6.95]] ] } }
])
db.area_delivery.createIndex({ cakupan: "2dsphere" })
db.area_delivery.find({ cakupan: { $geoIntersects: { $geometry: { type: "Point", coordinates: [106.83, -6.2] } } } }, { _id: 0, toko: 1 })
db.area_delivery.find({ cakupan: { $geoIntersects: { $geometry: { type: "Point", coordinates: [110.42, -6.97] } } } }, { _id: 0, toko: 1 })Output yang Dihasilkan:
true
{
acknowledged: true,
insertedIds: {
'0': ObjectId('6ad3ea902f7dc1fb9b892336'),
'1': ObjectId('6ad3ea902f7dc1fb9b892337')
}
}
cakupan_2dsphere
[ { toko: 'Nusantara Menteng' } ]Titik alamat di Menteng [106.83, -6.2] berpotongan dengan cakupan Nusantara Menteng — toko itulah yang melayani alamat tersebut. Titik di Semarang [110.42, -6.97] tidak memotong polygon mana pun, dan cursor kosong memang tidak mencetak apa-apa di mongosh — baris terakhir output di atas milik query Menteng saja. Dengan index 2dsphere pada cakupan, pencarian ini tidak memindai seluruh collection meski area-nya bertambah ratusan. Aturan ring Polygon-nya sama seperti Chapter GeoJSON: ring tertutup, titik pertama diulang di akhir.
Lokasi Kurir yang Selalu Berpindah #
Lokasi kurir adalah kebalikan lokasi toko: umurnya menit, bukan tahun. Pola yang tepat: satu document per kurir, posisi di-update di tempat — bukan ditumpuk sebagai riwayat:
db.kurir.drop()
db.kurir.insertOne({ nama: "Budi", status: "aktif", lokasi: { type: "Point", coordinates: [106.82, -6.19] } })
db.kurir.createIndex({ lokasi: "2dsphere" })
db.kurir.updateOne({ nama: "Budi" }, { $set: { lokasi: { type: "Point", coordinates: [106.825, -6.195] } }, $currentDate: { diperbarui: true } })
db.kurir.updateOne({ nama: "Siti" }, { $set: { status: "aktif", lokasi: { type: "Point", coordinates: [106.83, -6.18] } }, $currentDate: { diperbarui: true } }, { upsert: true })
db.kurir.find({ lokasi: { $near: { $geometry: { type: "Point", coordinates: [106.83, -6.2] }, $maxDistance: 5000 } } }, { _id: 0, nama: 1, diperbarui: 1 })Output yang Dihasilkan:
true
{
acknowledged: true,
insertedId: ObjectId('6ad3ea902f7dc1fb9b892338')
}
lokasi_2dsphere
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: ObjectId('6ad3ea919d08d4e3fde1a47d'),
matchedCount: 0,
modifiedCount: 0,
upsertedCount: 1
}
[
{ nama: 'Budi', diperbarui: ISODate('2026-10-17T21:37:21.234Z') },
{ nama: 'Siti', diperbarui: ISODate('2026-10-17T21:37:21.246Z') }
]Mekanismenya empat lapis:
$setmenimpa seluruhlokasi: koordinat lama Budi tertimpa koordinat baru — document tetap seukuran tetap, tidak pernah tumbuh.$currentDate: stempel waktudiperbaruidiperbarui server setiap kali posisi berubah — umur data terlihat tanpa menyimpan riwayatnya.upsert: true: “ping” pertama Siti membuat document-nya langsung — aplikasi kurir tidak perlu membedakan insert dan update.- Query terdekat tetap standar: kurir dalam radius 5 km dari alamat, terurut — Budi (±0,8 km) mendahului Siti (±2,2 km).
Memilih Bentuk Geometri #
Seluruh pola di atas merangkum menjadi satu tabel keputusan — pertanyaan bisnis menentukan bentuk data, dan bentuk data menentukan query:
| Kebutuhan bisnis | Bentuk data | Field contoh | Query utama |
|---|---|---|---|
| Posisi entitas saat ini (cabang, kurir) | Point | lokasi | $near, $geoWithin |
| Cakupan layanan (area delivery, zona kota) | Polygon | cakupan | $geoIntersects, $geoWithin |
| Jalur tempuh (rute pengiriman harian) | LineString | jalur | $geoIntersects |
| Jejak historis per waktu (perjalanan kurir) | time series collection | measurement + metaField | window function |
Tiga baris pertama adalah geometri GeoJSON dari Chapter GeoJSON — rute pengiriman sebagai LineString tinggal masuk ke collection rute dan dicari dengan $geoIntersects saat kita perlu tahu rute mana yang melewati satu titik. Baris keempat bukan geometri, melainkan keputusan collection yang dipicu dimensi waktu. Dengan empat pola ini, aplikasi lokasi jaringan toko buku kita punya tempat untuk semua data geraknya.
Rangkuman #
- Satu jaringan banyak cabang dimodelkan sebagai document per cabang + referensi
jaringanId; array lokasi di document induk ditolak index2dsphere(error 510). - Setiap pola query equality + lokasi butuh compound index-nya sendiri:
{ kategori: 1, lokasi: "2dsphere" }dan{ jaringanId: 1, lokasi: "2dsphere" }. - Area delivery disimpan sebagai Polygon per toko; “toko mana yang melayani titik ini” dijawab
$geoIntersects. - Lokasi kurir dinamis: satu document per kurir,
$setmenimpa posisi,$currentDatemencatat waktunya,upsertuntuk ping pertama — riwayat penuh diserahkan ke time series collection. cabangPopulerdi document induk adalah Subset Pattern: embed yang sering dibaca, sisanya tinggal di-refer.
Data lokasi yang berpindah tiap menit membuka babak berikutnya dari Course ini: collection yang dirancang khusus untuk data berorientasi waktu — time series.