Anggaran kardinalitas

Alasan ID unik sebagai tags menenggelamkan basis data, dan pentingnya menghitung sebelum merancang.

Daftar isi

Kardinalitas adalah banyaknya nilai unik pada sebuah kolom. Topik ini menjelaskan konsep anggaran kardinalitas, yaitu memprediksi jumlah series yang dihasilkan oleh skema sebelum basis data dijalankan, serta menjelaskan mengapa ID unik yang dijadikan tag menjadi sumber utama basis data time-series yang menenggelamkan diri sendiri. Setelah membaca, setiap kolom yang dijadikan tag memiliki angka perkiraan jumlah series, bukan dugaan.

Apa itu kardinalitas dan kenapa dihitung ?#

Kardinalitas tag pada basis data time-series menentukan ukuran indeks series, yaitu struktur yang memetakan setiap kombinasi nilai tag ke lokasi data. InfluxDB sejak era 1.x dan 2.x mengenal istilah series cardinality, yaitu hasil kali kardinalitas seluruh tag pada sebuah measurement. TimescaleDB tidak memiliki konsep series yang eksplisit, tetapi indeks pada kolom identitas tumbuh sebanding banyaknya nilai unik, sehingga konsekuensinya serupa. Karena itu, anggaran kardinalitas berlaku umum pada basis data time-series apa pun, meskipun istilah teknisnya berbeda.

Penghitungan kardinalitas wajib dilakukan sebelum skema ditetapkan, sebab biaya indeks series tidak proporsional terhadap jumlah baris, melainkan terhadap hasil kali kardinalitas tag. Sebuah skema dengan dua tag yang masing-masing memiliki 100 nilai unik menghasilkan 10.000 series, sedangkan skema dengan tiga tag yang masing-masing 100 nilai unik menghasilkan 1.000.000 series. Pertumbuhan eksponensial ini berarti satu tag berdimensi tinggi cukup untuk membuat indeks meledak, terlepas dari apakah tag lainnya rendah kardinalitas.

Ledakan series: perkalian kardinalitas tag#

Rumus jumlah series adalah hasil kali kardinalitas seluruh tag pada sebuah measurement. Bila tag adalah station_id dengan 50 nilai dan lokasi dengan 3 nilai, jumlah series adalah 150. Bila ditambahkan tag user_id dengan 100.000 nilai, jumlah series melonjak menjadi 5 juta. Pertumbuhan ini bukan tambahan, melainkan perkalian, sehingga setiap tag berdimensi tinggi menjadi pengganda, bukan penambah.

Basis data time-series yang menyimpan indeks series di memori akan kesulitan ketika jumlah series mencapai jutaan. InfluxDB 1.x dan 2.x memiliki batas praktis sekitar 10 juta series per measurement, melampaui itu performa penulisan dan query menurun tajam. InfluxDB 3.x dirancang ulang untuk mengatasi batasan ini, tetapi biaya indeks tetap ada, hanya berpindah dari memori ke penyimpanan kolumnar. TimescaleDB tidak memiliki batas eksplisit, tetapi indeks B-tree pada kolom dengan jutaan nilai unik memperlambat penulisan dan memperbesar ukuran indeks.

Anggaran kardinalitas menuntut keputusan tentang kolom mana yang layak menjadi tag. Aturan praktisnya adalah memilih kolom yang kardinalitasnya jauh lebih rendah dari jumlah baris yang diharapkan, dan yang sering muncul sebagai predikat WHERE. Kolom yang nilainya unik per titik, misalnya ID transaksi atau ID sesi, tidak memenuhi kriteria ini, sebab pertambahannya sebanding dengan jumlah baris. Kolom yang nilainya berulang dalam rentang waktu lama, misalnya region atau service, justru menjadi tag yang baik, sebab pertambahannya lambat dan pola query sering menyaring berdasarkan keduanya.

Menghitung anggaran kardinalitas dengan pandas#

Skrip berikut menghitung jumlah series untuk tiga skenario skema yang berbeda, yaitu tag stasiun saja, tag stasiun dan lokasi, serta tag stasiun dan ID pengguna. Skrip juga menghitung perkiraan ukuran indeks series dengan asumsi konservatif 100 byte per entri. Tujuannya menunjukkan secara konkret bahwa satu tag berdimensi tinggi cukup untuk membuat indeks membengkak dari kilobita menjadi ratusan megabita.

python anggaran_kardinalitas.py
"""Anggaran kardinalitas pada data time-series.

Skrip menghitung jumlah *series* (kombinasi unik dari nilai tag) untuk
tiga skenario skema yang berbeda, yaitu tag stasiun saja, tag stasiun
dan lokasi, serta tag stasiun dan ID pengguna. Tujuannya menunjukkan
bahwa menjadikan ID unik sebagai tag membuat jumlah *series* tumbuh
sebanding jumlah baris, sehingga indeks membengkak dan basis data
menenggelamkan diri sendiri. Skrip juga menghitung perkiraan ukuran
indeks series sebagai ilustrasi biaya.
"""
from __future__ import annotations

import pandas as pd

TITIK_PER_SERIES = 1_000  # asumsi: 1000 titik per series
BYTE_PER_SERIES = 100     # asumsi konservatif: 100 byte per entri indeks series


def hitung_series(tags_cardinalitas: dict[str, int]) -> int:
    """Jumlah series = hasil kali kardinalitas seluruh tag."""
    hasil = 1
    for c in tags_cardinalitas.values():
        hasil *= c
    return hasil


def perkiraan_index_kb(jumlah_series: int) -> float:
    return jumlah_series * BYTE_PER_SERIES / 1024.0


def main() -> None:
    skenario = [
        ("tag station_id saja", {"station_id": 50}),
        ("tag station_id dan lokasi", {"station_id": 50, "lokasi": 3}),
        ("tag station_id dan user_id (high cardinality)", {"station_id": 50, "user_id": 100_000}),
    ]
    print(f"asumsi: {TITIK_PER_SERIES} titik per series, {BYTE_PER_SERIES} byte per entri indeks series")
    print()

    rows = []
    for nama, tags in skenario:
        series = hitung_series(tags)
        kb = perkiraan_index_kb(series)
        baris = series * TITIK_PER_SERIES
        rows.append({
            "skenario": nama,
            "kombinasi_tag": " x ".join(f"{k}={v}" for k, v in tags.items()),
            "jumlah_series": series,
            "total_titik": baris,
            "indeks_series_kb": round(kb, 1),
        })

    df = pd.DataFrame(rows)
    print(df.to_string(index=False))
    print()

    # kontras: user_id diturunkan menjadi field
    print("bila user_id diturunkan menjadi field:")
    series_baru = 50 * 3
    print(f"  kombinasi tag tersisa: station_id=50 x lokasi=3 = {series_baru} series")
    print(f"  indeks series: {perkiraan_index_kb(series_baru):.1f} KB")
    print(f"  catatan: total titik tetap tinggi, tetapi tidak memengaruhi indeks series")
    print()

    s_tanpa = 50 * 3
    s_dengan = 50 * 100_000
    print(f"rasio jumlah series: {s_dengan / s_tanpa:.0f}x lipat saat user_id menjadi tag")


if __name__ == "__main__":
    main()
text Keluaran
asumsi: 1000 titik per series, 100 byte per entri indeks series

                                     skenario                  kombinasi_tag  jumlah_series  total_titik  indeks_series_kb
                          tag station_id saja                  station_id=50             50        50000               4.9
                    tag station_id dan lokasi       station_id=50 x lokasi=3            150       150000              14.6
tag station_id dan user_id (high cardinality) station_id=50 x user_id=100000        5000000   5000000000          488281.2

bila user_id diturunkan menjadi field:
  kombinasi tag tersisa: station_id=50 x lokasi=3 = 150 series
  indeks series: 14.6 KB
  catatan: total titik tetap tinggi, tetapi tidak memengaruhi indeks series

rasio jumlah series: 33333x lipat saat user_id menjadi tag

Keluaran di atas memperlihatkan tiga hal. Pertama, skenario pertama menghasilkan 50 series dengan indeks sekitar 4,9 KB. Kedua, skenario kedua menghasilkan 150 series dengan indeks sekitar 14,6 KB, yaitu pertumbuhan tiga kali lipat sebanding dengan tambahan kardinalitas lokasi. Ketiga, skenario ketiga menghasilkan 5 juta series dengan indeks sekitar 488 MB, yaitu pertumbuhan 33.333 kali lipat dari skenario kedua. Saat user_id diturunkan menjadi field, jumlah series kembali ke 150 dan indeks kembali ke 14,6 KB, meskipun total titik tetap tinggi. Inilah inti anggaran kardinalitas, yaitu memilih tag berdasarkan kardinalitas, bukan berdasarkan keinginan untuk menyaring.

Kesalahan umum#

  • Menjadikan ID transaksi sebagai tag dengan alasan “kelak ingin menyaring berdasarkan ID”. Penyaringan berdasarkan ID unik adalah query berbasis nilai, bukan pola time-series, sehingga sebaiknya dilakukan pada basis data operasional, bukan basis data time-series.
  • Menganggap anggaran kardinalitas hanya relevan untuk InfluxDB 1.x dan 2.x. InfluxDB 3.x dan TimescaleDB tetap membayar biaya indeks, hanya dalam bentuk berbeda, sehingga perhitungan tetap perlu.
  • Menggabungkan beberapa dimensi menjadi satu tag, misalnya station_id_lokasi=STA01-atap. Kolom gabungan mengaburkan kardinalitas dan menyulitkan perhitungan, sebab series-nya tetap hasil kali, tetapi tidak bisa dianalisis per dimensi.

Ringkasan#

  • Anggaran kardinalitas adalah prediksi jumlah series sebelum basis data dijalankan, dihitung sebagai hasil kali kardinalitas seluruh tag.
  • Satu tag berdimensi tinggi cukup untuk membuat indeks series meledak, terlepas dari kardinalitas tag lainnya.
  • ID unik sebaiknya disimpan sebagai field, bukan tag, sebab query berdasarkan ID unik bukan pola time-series.

Referensi#