Di toko buku kita, mayoritas buku mengumpulkan kurang dari lima puluh ulasan. Tetapi satu-dua judul bestseller — sebut saja Atomic Habits — mengumpulkan ratusan ribu. Kalau skema dirancang untuk yang ekstrem (semua ulasan di collection terpisah), buku-buku biasa kehilangan jalur baca satu query; kalau dirancang untuk yang biasa (semua ulasan embed), bestseller menabrak 16 MB. Outlier Pattern menolak pilihan itu: populasi umum tetap memakai desain normal, dan satu-dua ekstrem dipisahkan representasinya dengan bendera isOutlier.
Masalah: Ekstrem yang Mendikte Desain #
Kesalahan klasik perancang skema adalah mendesain untuk nilai maksimum, bukan untuk distribusi. Statistik jumlah ulasan per buku condong ekstrem: rata-rata rendah, tetapi ekornya panjang — persis contoh di blog resmi MongoDB, di mana satu produk populer dapat berjuta ulasan sementara sisanya di bawah ratusan. Query rata-rata harus tetap cepat, namun data ekor panjang tetap harus tersimpan utuh. Desain tunggal tidak bisa melayani keduanya; desain ganda dengan penanda eksplisit bisa.
Angka-angkanya memperlihatkan ketimpangannya: kalau 99% buku di bawah lima puluh ulasan dan satu bestseller menyimpan 250.000, maka desain embed-murni memaksa seluruh collection mengikuti kebutuhan satu document, dan desain refer-murni memperlakukan jutaan query cepat demi satu document juga. Outlier Pattern menolak keduanya dengan satu pengakuan jujur: populasi umum dan ekstrem memang berbeda, jadi biarkan representasinya berbeda — ditandai bendera isOutlier, bukan ditanggung seluruh collection.
Solusi: Pisahkan Representasi Outlier #
Document buku normal embed ulasannya. Document buku outlier sengaja tidak punya field ulasan sama sekali — hanya counter jumlahUlasan dan bendera isOutlier; ulasannya hidup di collection ulasan:
use latihan_modeling
db.dropDatabase()
db.buku.insertMany([
{ _id: 101, judul: "Bumi", harga: 89000, jumlahUlasan: 2, ulasan: [ { pembaca: "Budi", bintang: 4 }, { pembaca: "Siti", bintang: 5 } ] },
{ _id: 102, judul: "Laut Bercerita", harga: 98000, jumlahUlasan: 3, ulasan: [ { pembaca: "Dewi", bintang: 5 }, { pembaca: "Rina", bintang: 4 }, { pembaca: "Agus", bintang: 4 } ] },
{ _id: 103, judul: "Atomic Habits", harga: 115000, jumlahUlasan: 250000, isOutlier: true }
])
db.ulasan.insertMany([
{ bukuId: 103, pembaca: "Budi", bintang: 5, teks: "Mengubah kebiasaan saya.", waktu: ISODate("2026-12-05T08:00:00Z") },
{ bukuId: 103, pembaca: "Siti", bintang: 5, teks: "Sangat praktis.", waktu: ISODate("2026-12-05T09:30:00Z") },
{ bukuId: 103, pembaca: "Dewi", bintang: 4, teks: "Butuh pembacaan ulang.", waktu: ISODate("2026-12-06T07:15:00Z") }
])
db.buku.find({}, { judul: 1, jumlahUlasan: 1, isOutlier: 1 })Output yang Dihasilkan:
switched to db latihan_modeling
{ ok: 1, dropped: 'latihan_modeling' }
{ acknowledged: true, insertedIds: { '0': 101, '1': 102, '2': 103 } }
{
acknowledged: true,
insertedIds: {
'0': ObjectId('6ad39b39a4366d64684ef1bf'),
'1': ObjectId('6ad39b39a4366d64684ef1c0'),
'2': ObjectId('6ad39b39a4366d64684ef1c1')
}
}
[
{ _id: 101, judul: 'Bumi', jumlahUlasan: 2 },
{ _id: 102, judul: 'Laut Bercerita', jumlahUlasan: 3 },
{
_id: 103,
judul: 'Atomic Habits',
jumlahUlasan: 250000,
isOutlier: true
}
]Perhatikan baris terakhir: Atomic Habits menampilkan counter 250.000 tanpa satupun byte ulasan — document-nya tetap sekecil buku mana pun. Tiga ulasan contoh di ulasan hanyalah puncak gunung es; collection itu bisa menampung jutaan baris tanpa perlu menyentuh document induknya.
Divergensi Jalur Baca dan Tulis #
Aplikasi membaca isOutlier lalu memilih jalur. Jalur normal — satu query:
db.buku.findOne({ _id: 101 }, { judul: 1, jumlahUlasan: 1, ulasan: 1 })Output yang Dihasilkan:
{
_id: 101,
judul: 'Bumi',
jumlahUlasan: 2,
ulasan: [ { pembaca: 'Budi', bintang: 4 }, { pembaca: 'Siti', bintang: 5 } ]
}Jalur outlier — dua query, dengan pagination seperti Subset Pattern:
db.buku.findOne({ _id: 103 }, { judul: 1, jumlahUlasan: 1, isOutlier: 1 })
db.ulasan.find({ bukuId: 103 }, { pembaca: 1, bintang: 1, teks: 1, _id: 0 }).sort({ waktu: -1 }).limit(2)Output yang Dihasilkan:
{
_id: 103,
judul: 'Atomic Habits',
jumlahUlasan: 250000,
isOutlier: true
}
[
{ pembaca: 'Dewi', bintang: 4, teks: 'Butuh pembacaan ulang.' },
{ pembaca: 'Siti', bintang: 5, teks: 'Sangat praktis.' }
]Jalur tulis juga berbeda. Ulasan baru pada buku normal berupa satu $push plus kenaikan counter — dan untuk keamanan growth, $push kita batasi lima ulasan terakhir ala Chapter Document Growth. Ulasan baru pada outlier berupa insert biasa:
db.buku.updateOne(
{ _id: 101 },
{ $push: { ulasan: { $each: [ { pembaca: "Tono", bintang: 3 } ], $slice: -5 } }, $inc: { jumlahUlasan: 1 } }
)
db.ulasan.insertOne({ bukuId: 103, pembaca: "Rina", bintang: 5, teks: "Rekomendasi!", waktu: ISODate("2026-12-06T10:00:00Z") })
db.buku.updateOne({ _id: 103 }, { $inc: { jumlahUlasan: 1 } })Output yang Dihasilkan:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: ObjectId('6ad39b39a4366d64684ef1c2')
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}Migrasi Saat Buku Biasa Menjadi Ekstrem #
Buku normal yang ulasannya melewati ambang harus bermigrasi. Ambil Laut Bercerita yang (misal) ambang embed kita lima: pindahkan array ulasannya ke collection terpisah, nyalakan bendera, hapus arraynya:
const buku = db.buku.findOne({ _id: 102 })
db.ulasan.insertMany(buku.ulasan.map(u => ({ ...u, bukuId: 102 })))
db.buku.updateOne({ _id: 102 }, { $set: { isOutlier: true }, $unset: { ulasan: "" } })
db.buku.findOne({ _id: 102 }, { judul: 1, jumlahUlasan: 1, isOutlier: 1 })Output yang Dihasilkan:
{
acknowledged: true,
insertedIds: {
'0': ObjectId('6ad39b39a4366d64684ef1c3'),
'1': ObjectId('6ad39b39a4366d64684ef1c4'),
'2': ObjectId('6ad39b39a4366d64684ef1c5')
}
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{ _id: 102, judul: 'Laut Bercerita', jumlahUlasan: 3, isOutlier: true }Bedah migrasinya:
findOneke variabelbuku— menampung document lama; baris ini diam di REPL karena hasilnya masuk variabel.buku.ulasan.map(...)— setiap elemen array disalin menjadi document mandiri dengan tambahanbukuId: 102; tiga ulasan embed berubah menjadi tiga baris diulasan.$set+$unsetdalam satu update — benderaisOutlierdinyalakan dan fieldulasandihapus sekaligus; sejak baris ini, buku tersebut dilayani jalur baca/tulis outlier.
Deteksi ambang biasanya dijalankan tugas latar: cari document tanpa isOutlier yang jumlahUlasan-nya melewati ambang, lalu jalankan migrasi seperti di atas. Pola Schema Versioning bisa menandai perubahan bentuk ini kalau aplikasi perlu membedakan lebih dari dua kondisi.
Field jumlahUlasan sendiri layak diperhatikan: pada buku normal nilainya konsisten dengan panjang array, tetapi pada outlier nilainya adalah satu-satunya kebenaran yang tersimpan di document induk — persis pola counter yang diperbarui $inc tiap ulasan baru masuk. Inilah contoh langka di mana duplikasi bukan salinan tampilan, melainkan satu-satunya representasi ringkas dari data yang hidup di tempat lain.
Rangkuman #
- Outlier Pattern memisahkan representasi entitas ekstrem (bendera
isOutlier+ data di collection terpisah) dari populasi umum yang tetap embed. - Document outlier hanya menyimpan counter, sehingga tetap kecil berapa pun jumlah datanya yang sebenarnya.
- Aplikasi bercabang pada
isOutlieruntuk jalur baca (embed vs collection + pagination) dan jalur tulis ($pushvsinsertOne). - Migrasi ke outlier: salin array ke collection terpisah, nyalakan bendera, hapus array — biasanya dipicu tugas latar yang mengawasi counter.