Membuat time series collection hanya butuh tiga baris, tetapi dua keputusan di dalamnya menentukan performa bertahun-tahun: apa yang menjadi metaField, dan seberapa besar rentang setiap bucket. Kesalahan kecil pada keduanya tidak terasa saat data masih ratusan document, lalu muncul sebagai storage bengkak dan query lambat saat jutaan pembacaan masuk. Chapter ini merancang schema untuk jaringan stasiun cuaca kita, menguji jebakan query pada metaField, membuktikan pola anti yang memecah bucket, dan menutup dengan aturan mengubah granularity.

Memilih metaField #

metaField adalah identitas seri data — nilai yang sama akan dikelompokkan ke bucket yang sama. Karena itu pilihannya harus memenuhi tiga syarat:

  • Jarang atau tidak pernah berubah — identitas seri harus stabil sepanjang umur data.
  • Sering dipakai sebagai filter query — index otomatis dibangun di atas metaField + timeField, jadi field inilah pintu masuk query tercepat.
  • Kecil dan berulang — disimpan sekali per bucket; makin ramping semakin banyak pengukuran yang muat dalam satu bucket.

Untuk stasiun cuaca, identitas seri yang benar adalah sensor_id dan kota — keduanya menempel pada perangkat fisik dan tidak berubah. Data numerik seperti suhu dan kelembapan TIDAK boleh masuk metaField; keduanya berubah setiap menit dan berfungsi sebagai metrik yang diukur. Aturan praktisnya: metaField menjawab “dari mana datanya”, measurement menjawab “apa yang terukur”.

Saat metaField berupa subdocument, ada satu jebakan query yang wajib diketahui. Kita buat collection-nya dulu:

js
use latihan_timeseries
db.dropDatabase()
db.createCollection("cuaca", { timeseries: { timeField: "waktu", metaField: "sensor", granularity: "minutes" } })
db.cuaca.insertMany([
  { waktu: ISODate("2026-12-10T02:00:00Z"), sensor: { sensor_id: "JKT-01", kota: "Jakarta" }, suhu: 27.4, kelembapan: 78 },
  { waktu: ISODate("2026-12-10T02:01:00Z"), sensor: { sensor_id: "JKT-01", kota: "Jakarta" }, suhu: 27.6, kelembapan: 77 },
  { waktu: ISODate("2026-12-10T02:02:00Z"), sensor: { sensor_id: "JKT-01", kota: "Jakarta" }, suhu: 27.9, kelembapan: 75 },
  { waktu: ISODate("2026-12-10T02:03:00Z"), sensor: { sensor_id: "BDG-07", kota: "Bandung" }, suhu: 22.1, kelembapan: 85 }
])
db.cuaca.find({ "sensor.kota": "Jakarta" }).count()
db.cuaca.find({ sensor: { sensor_id: "JKT-01", kota: "Jakarta" } }).count()

Output yang Dihasilkan:

text
switched to db latihan_timeseries
{ ok: 1, dropped: 'latihan_timeseries' }
{ ok: 1 }
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3e05bd121372af81df06a'),
    '1': ObjectId('6ad3e05bd121372af81df06b'),
    '2': ObjectId('6ad3e05bd121372af81df06c'),
    '3': ObjectId('6ad3e05bd121372af81df06d')
  }
}
3
0

Dua count() di atas memfilter data yang sama, tetapi hasilnya 3 dan 0. Filter kedua memakai kesamaan subdocument utuh, yang mensyaratkan field dalam urutan yang persis sama dengan yang tersimpan — dan server menyimpan meta bucket dalam urutan field tersortir (kota sebelum sensor_id). Kita menulis { sensor_id, kota }, tersimpan sebagai { kota, sensor_id }, dan kesamaan utuh gagal.

Granularity: Menyetel Rentang Bucket #

granularity memberi tahu server seberapa rapat data dari satu sumber datang. Nilainya menentukan rentang maksimum satu bucket:

NilaiRentang maksimum satu bucketCocok untuk data yang datang
"seconds"1 jamsetiap detik (default)
"minutes"24 jamsetiap menit sampai setiap jam
"hours"30 harisetiap beberapa jam

Pemilihannya mengikuti frekuensi datanya: sensor cuaca kami mengirim setiap menit, jadi "minutes" — bucket 24 jam menampung sekitar 1.440 pembacaan per sensor. Granularity terlalu halus ("seconds" untuk data per menit) membuat bucket terbentuk banyak dan jarang penuh; terlalu kasar ("hours") membuat query satu hari harus memindai bucket 30 hari lalu membuang sebagian besar isinya. Batas fisiknya juga ada: satu bucket menampung maksimum 1.000 pengukuran atau 125 KB data.

Granularity bukan keputusan permanen — tetapi hanya boleh dinaikkan, tidak diturunkan. Kita buktikan keduanya:

js
db.runCommand({ collMod: "cuaca", timeseries: { granularity: "hours" } })
db.runCommand({ collMod: "cuaca", timeseries: { granularity: "seconds" } })

Output yang Dihasilkan:

text
{
  ok: 1,
  '$clusterTime': {
    clusterTime: Timestamp({ t: 1792270427, i: 2 }),
    signature: {
      hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0),
      keyId: Long('0')
    }
  },
  operationTime: Timestamp({ t: 1792270427, i: 2 })
}
Uncaught 
MongoServerError[InvalidOptions]: Invalid transition for timeseries.granularity. Can only transition from 'seconds' to 'minutes' or 'minutes' to 'hours'.

collMod pertama sukses menaikkan bucket dari rentang 24 jam ke 30 hari; bucket lama tidak ditulis ulang, hanya data baru yang mengikuti aturan baru. collMod kedua menolak penurunan — mengecilkan rentang bucket berpotensi memecah data yang sudah rapat, dan server melarangnya. Alternatif presisi sejak MongoDB 6.3: tetapkan bucketMaxSpanSeconds dan bucketRoundingSeconds (keduanya wajib sama) sebagai pengganti granularity.

Pola Anti: metaField yang Berubah Tiap Titik #

Sekarang pembuktian penting: apa yang terjadi bila data yang berubah-ubah dimasukkan ke metaField? Bayangkan tim kita menempelkan level baterai sensor — angka yang menurun sepanjang hari — ke dalam sensor:

js
db.createCollection("cuaca_baterai", { timeseries: { timeField: "waktu", metaField: "sensor", granularity: "minutes" } })
db.cuaca_baterai.insertMany([
  { waktu: ISODate("2026-12-10T02:00:00Z"), sensor: { sensor_id: "JKT-01", baterai: 85 }, suhu: 27.4 },
  { waktu: ISODate("2026-12-10T02:01:00Z"), sensor: { sensor_id: "JKT-01", baterai: 84 }, suhu: 27.6 },
  { waktu: ISODate("2026-12-10T02:02:00Z"), sensor: { sensor_id: "JKT-01", baterai: 83 }, suhu: 27.9 }
])
db.runCommand({ find: "cuaca_baterai", rawData: true, filter: {} })

Output yang Dihasilkan:

text
{ ok: 1 }
{
  acknowledged: true,
  insertedIds: {
    '0': ObjectId('6ad3e05bd121372af81df06e'),
    '1': ObjectId('6ad3e05bd121372af81df06f'),
    '2': ObjectId('6ad3e05bd121372af81df070')
  }
}
{
  cursor: {
    firstBatch: [
      {
        _id: ObjectId('6b1a07a0387cb63c7f8ee9b7'),
        control: {
          version: 2,
          min: {
            waktu: ISODate('2026-12-10T02:00:00.000Z'),
            suhu: 27.9,
            _id: ObjectId('6ad3e05bd121372af81df070')
          },
          max: {
            waktu: ISODate('2026-12-10T02:02:00.000Z'),
            suhu: 27.9,
            _id: ObjectId('6ad3e05bd121372af81df070')
          },
          count: 1
        },
        meta: { baterai: 83, sensor_id: 'JKT-01' },
        data: {
          suhu: Binary.createFromBase64('AQBmZmZmZuY7QAA=', 7),
          waktu: Binary.createFromBase64('CQDAna9dogEAAAA=', 7),
          _id: Binary.createFromBase64('BwBq0+Bb0SE3Kvgd8HAA', 7)
        }
      },
      {
        _id: ObjectId('6b1a07a0387cb63c7f8ee9b8'),
        control: {
          version: 2,
          min: {
            waktu: ISODate('2026-12-10T02:00:00.000Z'),
            suhu: 27.4,
            _id: ObjectId('6ad3e05bd121372af81df06e')
          },
          max: {
            waktu: ISODate('2026-12-10T02:00:00.000Z'),
            suhu: 27.4,
            _id: ObjectId('6ad3e05bd121372af81df06e')
          },
          count: 1
        },
        meta: { baterai: 85, sensor_id: 'JKT-01' },
        data: {
          suhu: Binary.createFromBase64('AQBmZmZmZmY7QAA=', 7),
          waktu: Binary.createFromBase64('CQAAya1dogEAAAA=', 7),
          _id: Binary.createFromBase64('BwBq0+Bb0SE3Kvgd8G4A', 7)
        }
      },
      {
        _id: ObjectId('6b1a07a0387cb63c7f8ee9b9'),
        control: {
          version: 2,
          min: {
            waktu: ISODate('2026-12-10T02:00:00.000Z'),
            suhu: 27.6,
            _id: ObjectId('6ad3e05bd121372af81df06f')
          },
          max: {
            waktu: ISODate('2026-12-10T02:01:00.000Z'),
            suhu: 27.6,
            _id: ObjectId('6ad3e05bd121372af81df06f')
          },
          count: 1
        },
        meta: { baterai: 84, sensor_id: 'JKT-01' },
        data: {
          suhu: Binary.createFromBase64('AQCamZmZmZk7QAA=', 7),
          waktu: Binary.createFromBase64('CQBgs65dogEAAAA=', 7),
          _id: Binary.createFromBase64('BwBq0+Bb0SE3Kvgd8G8A', 7)
        }
      }
    ],
    id: Long('0'),
    ns: 'latihan_timeseries.cuaca_baterai'
  },
  ok: 1,
  '$clusterTime': {
    clusterTime: Timestamp({ t: 1792270427, i: 5 }),
    signature: {
      hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0),
      keyId: Long('0')
    }
  },
  operationTime: Timestamp({ t: 1792270427, i: 5 })
}

Tiga pembacaan dari sensor yang sama menghasilkan TIGA bucket, masing-masing berisi count: 1 — urutan keluaran rawData tidak mengikuti waktu, tetapi isinya persis tiga bucket terpisah. Karena baterai ikut menjadi bagian identitas seri, setiap penurunan angka baterai memutus kesamaan meta dan memaksa server membuka bucket baru. Semua keuntungan format kolom — metadata sekali, kompresi nilai sejenis — hangus: cardinality bucket melonjak, storage membengkak, dan index pada metaField dipenuhi nilai unik.

Perbaikannya sederhana: baterai adalah metrik, jauhkan dari sensor dan simpan sebagai field pengukuran biasa. Schema yang benar tetap sensor: { sensor_id, kota }, dan baterai duduk bersebelahan dengan suhu di luar meta.

Rangkuman #

  • metaField = identitas seri: stabil, sering difilter, kecil. Data numerik yang berubah tiap titik adalah metrik, bukan meta.
  • Filter metaField ber-subdocument selalu pakai dot notation; kesamaan subdocument utuh bergantung urutan field yang tersimpan.
  • granularity mengikuti frekuensi data (seconds/minutes/hours → bucket 1 jam/24 jam/30 hari); hanya boleh dinaikkan lewat collMod.
  • metaField yang bervariasi per titik memecah bucket — terbukti tiga pembacaan menghasilkan tiga bucket count: 1.