Cost Reduction Opportunity: Cara Menurunkan Biaya Database Tanpa Mengorbankan Performa
18 min read

Cost Reduction Opportunity: Cara Menurunkan Biaya Database Tanpa Mengorbankan Performa

Database adalah salah satu komponen paling mahal dalam arsitektur sistem modern. Bukan karena database itu mahal secara inheren, melainkan karena cara kita menggunakannya sering tidak efisien — dan ketidakefisienan itu terakumulasi setiap jam, setiap hari, setiap bulan di tagihan cloud. Banyak tim berlomba mengoptimasi layer aplikasi atau memilih instance yang lebih kecil di sisi compute, sementara database dibiarkan tumbuh tanpa evaluasi. Padahal, pola penggunaan database adalah salah satu faktor terbesar yang mendorong cost di sistem yang sudah berjalan di skala produksi.

Artikel ini membahas cost reduction opportunity secara konkret di level database: dari strategi caching, optimasi query, desain fitur yang sadar cost, right-sizing instance, pilihan arsitektur, hingga kebijakan data retention. Setiap bagian dilengkapi contoh nyata, perbandingan pendekatan, dan pola kode yang bisa langsung dijadikan referensi.


Memahami Dari Mana Biaya Database Berasal

Sebelum membahas cara mengurangi biaya, penting untuk memahami dari mana biaya itu muncul. Di layanan managed database seperti AWS RDS, Google Cloud SQL, atau Azure Database, struktur biaya umumnya terdiri dari beberapa komponen:

KomponenFaktor yang MendorongnyaCara Ditagihkan
InstanceJumlah vCPU dan RAMPer jam, terus berjalan
StorageUkuran data + log + backupPer GB per bulan
I/OJumlah read/write ke diskPer juta request (beberapa provider)
Data transferTransfer keluar ke internet / region lainPer GB
Backup storageRetention period backupPer GB per bulan
Read replicaSetiap replica = biaya instance baruPer jam, per replica

Dari tabel ini terlihat bahwa hampir semua komponen dipengaruhi langsung oleh bagaimana kita menggunakan database — bukan hanya seberapa besar database kita.

flowchart TD
    A[Traffic Aplikasi] --> B[Query ke Database]
    B --> C{Tipe Operasi}
    C -- Read --> D[CPU + I/O + Memory]
    C -- Write --> E[CPU + I/O + Lock]
    C -- Aggregasi Berat --> F[CPU tinggi, durasi lama]
    D --> G[Tagihan Instance]
    E --> G
    F --> G
    D --> H[Tagihan I/O]
    E --> H
    G --> I[Total Cost Database]
    H --> I

Pola yang paling sering ditemui di sistem yang biayanya membengkak:

  • Query yang berjalan ribuan kali per menit padahal hasilnya jarang berubah
  • Instance yang di-provision jauh melebihi kebutuhan aktual
  • Data lama yang tidak pernah dibersihkan membuat tabel semakin besar
  • Read replica yang dipasang “untuk jaga-jaga” tapi tidak pernah benar-benar digunakan
  • Desain fitur yang polling database setiap beberapa detik

Gunakan Cache di Mana yang Tepat

Cache adalah alat cost reduction paling langsung dan paling tinggi return on investmentnya. Setiap query yang berhasil dilayani dari cache adalah satu query yang tidak pernah menyentuh database — tidak ada CPU, tidak ada I/O, tidak ada lock, tidak ada koneksi yang terpakai.

Mengapa Cache Sangat Efektif

Setiap query ke database membawa overhead yang nyata:

Tanpa Cache:
  1 request → 1 query → CPU + I/O + memory + koneksi
  1000 request/detik → 1000 query/detik → beban penuh ke database

Dengan Cache (hit rate 90%):
  1000 request/detik → 100 query/detik → 900 request dilayani cache
  Beban database turun 90%

Dengan beban yang turun drastis, kamu bisa menunda scale-up instance, atau bahkan menurunkan tier instance yang sudah ada — efek penghematan langsung terasa di tagihan.

Jenis Cache dan Kapan Menggunakannya

Jenis CacheContoh ToolsCocok UntukCatatan
In-memory distributedRedis, MemcachedData shared antar service, session, leaderboardPerlu manajemen TTL dan invalidation
Application-levelCaffeine (Java), Guava CacheData per-instance, lookup table statisTidak shared antar replica
Query result cacheMySQL Query Cache (deprecated), app-levelHasil query yang identik berulangHarus hati-hati dengan invalidation
HTTP / CDN cacheCloudFront, CloudflareData publik, API response yang tidak user-specificPaling murah, zero database hit
Materialized viewPostgreSQL, MySQLHasil agregasi kompleksUpdate periodik, bukan real-time

Pola Caching yang Umum Digunakan

Cache-Aside (Lazy Loading) — pola paling umum, aplikasi mengecek cache dulu, baru ke database jika miss:

# ANTI-PATTERN: Selalu query database
def get_product(product_id: str):
    return db.query("SELECT * FROM products WHERE id = ?", product_id)

# BENAR: Cache-aside pattern
def get_product(product_id: str):
    cache_key = f"product:{product_id}"
    
    # Cek cache dulu
    cached = redis.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Cache miss: ambil dari database
    product = db.query("SELECT * FROM products WHERE id = ?", product_id)
    
    # Simpan ke cache dengan TTL
    redis.setex(cache_key, ttl=300, value=json.dumps(product))
    
    return product

Write-Through Cache — cache diupdate bersamaan saat data ditulis:

# ANTI-PATTERN: Write langsung ke DB, cache dibiarkan stale
def update_product(product_id: str, data: dict):
    db.execute("UPDATE products SET ... WHERE id = ?", data, product_id)
    # cache tidak diupdate → pembaca dapat data lama

# BENAR: Update DB dan cache secara bersamaan
def update_product(product_id: str, data: dict):
    db.execute("UPDATE products SET ... WHERE id = ?", data, product_id)
    
    # Invalidate atau update cache
    cache_key = f"product:{product_id}"
    redis.delete(cache_key)  # force re-fetch dari DB di request berikutnya
    # atau: redis.setex(cache_key, ttl=300, value=json.dumps(updated_product))

Apa yang Sebaiknya Dicache

KANDIDAT CACHE YANG BAIK:
  ✓ Data konfigurasi (feature flags, setting aplikasi)
  ✓ Catalog produk, daftar kategori
  ✓ Hasil query agregasi (total penjualan hari ini)
  ✓ Profil user (data yang jarang berubah)
  ✓ Lookup table (negara, kota, jenis pembayaran)
  ✓ Hasil pencarian populer

HINDARI DICACHE:
  ✗ Data keuangan real-time (saldo, transaksi aktif)
  ✗ Status order yang berubah cepat
  ✗ Data yang sangat user-specific dengan volume user besar
  ✗ Data dengan konsistensi kritis (stok yang sering berubah cepat)
Cache yang tidak punya invalidation strategy yang jelas lebih berbahaya daripada tidak punya cache. Data stale yang diserve ke user bisa menyebabkan bug yang sulit dilacak. Selalu definisikan: kapan cache harus diinvalidasi, berapa TTL yang masuk akal, dan apa yang terjadi saat cache miss terjadi dalam jumlah besar sekaligus (cache stampede).

Optimasi Query Lambat (Slow Query)

Satu query yang buruk bisa membebani database lebih dari seribu query yang optimal. Bukan hanya dari sisi waktu eksekusi, tapi dari sisi resource yang dikonsumsi — CPU yang tinggi lebih lama, lock yang dipegang lebih panjang, dan buffer pool yang terkuras untuk data yang tidak relevan.

Kenapa Slow Query Mahal

sequenceDiagram
    participant App
    participant DB as Database
    participant CPU as CPU/I/O
    
    App->>DB: Query lambat (full table scan)
    DB->>CPU: Baca jutaan row dari disk
    CPU-->>DB: Data diproses di memory
    Note over DB,CPU: CPU tinggi 5-10 detik
    DB-->>App: Result

    Note over App,DB: Selama query berjalan...
    App->>DB: Query lain datang
    DB->>DB: Menunggu resource tersedia
    Note over DB: Lock contention meningkat
    Note over DB: Query lain ikut melambat

Efek domino ini yang membuat satu slow query bisa membuat sistem terasa lambat secara keseluruhan — dan mendorong tim untuk scale-up instance padahal akar masalahnya adalah query yang tidak optimal.

Tools untuk Monitoring Slow Query

PlatformToolsCara Aktifkan
MySQLSlow Query Logslow_query_log=ON, long_query_time=1
PostgreSQLpg_stat_statements, auto_explainshared_preload_libraries = 'pg_stat_statements'
AWS RDSPerformance InsightsEnable saat buat instance atau via modify
AWS AuroraPerformance Insights + Enhanced MonitoringTersedia di console
APMNew Relic, Datadog, DynatraceInstall agent di aplikasi
Self-hostedPercona Monitoring and ManagementDeploy PMM server

Menggunakan EXPLAIN untuk Memahami Query

-- ANTI-PATTERN: Query dijalankan langsung tanpa dianalisa
SELECT * FROM orders 
WHERE DATE(created_at) = '2024-01-15' 
AND status = 'completed';

-- BENAR: Analisa dulu dengan EXPLAIN ANALYZE
EXPLAIN ANALYZE
SELECT order_id, user_id, total_amount, created_at 
FROM orders 
WHERE created_at >= '2024-01-15 00:00:00' 
  AND created_at < '2024-01-16 00:00:00'
  AND status = 'completed';

Perbedaan dua query di atas bukan hanya soal gaya. Query pertama menggunakan DATE(created_at) — sebuah fungsi yang membuat index pada kolom created_at tidak bisa digunakan (non-sargable). Query kedua menggunakan range yang memungkinkan database memanfaatkan index.

Output EXPLAIN yang perlu diperhatikan:

ANTI-PATTERN yang terlihat di output EXPLAIN:
  ✗ Seq Scan (Sequential Scan) → full table scan, tidak pakai index
  ✗ rows=1000000 → estimasi row yang besar, potensi beban berat
  ✗ cost=0.00..99999 → cost tinggi
  ✗ Filter: (kondisi) → filtering dilakukan setelah scan, bukan di index

TANDA QUERY YANG BAIK:
  ✓ Index Scan atau Index Only Scan
  ✓ rows yang mendekati jumlah aktual hasil
  ✓ Bitmap Index Scan (untuk kondisi multiple)
  ✓ Hash Join atau Merge Join (lebih baik dari Nested Loop untuk data besar)

Pola Optimasi Query yang Berdampak Tinggi

1. Hindari SELECT *

-- ANTI-PATTERN: Menarik semua kolom termasuk yang tidak dipakai
SELECT * FROM users WHERE id = 123;

-- BENAR: Ambil hanya kolom yang diperlukan
SELECT id, name, email, created_at FROM users WHERE id = 123;

Dampaknya bukan hanya bandwidth — query SELECT * mencegah database menggunakan index-only scan, yang berarti database harus membaca halaman data lengkap meski semua informasi yang dibutuhkan sudah ada di index.

2. Index yang Tepat Sasaran

-- ANTI-PATTERN: Tidak ada index untuk kolom yang sering difilter
SELECT * FROM orders WHERE user_id = 456 AND status = 'pending'
ORDER BY created_at DESC;
-- → Full table scan jika tidak ada index yang tepat

-- BENAR: Composite index sesuai pola query
CREATE INDEX idx_orders_user_status_created 
ON orders (user_id, status, created_at DESC);
-- → Index scan langsung ke data yang relevan

3. Hindari Fungsi di Sisi Kolom dalam WHERE

-- ANTI-PATTERN: Fungsi di kolom menghalangi penggunaan index
SELECT * FROM logs WHERE YEAR(created_at) = 2024;
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
SELECT * FROM products WHERE CAST(price AS CHAR) LIKE '1%';

-- BENAR: Kondisi yang bisa memanfaatkan index
SELECT * FROM logs 
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';

SELECT * FROM users WHERE email = '[email protected]';
-- (dengan functional index jika perlu case-insensitive:
--  CREATE INDEX idx_users_email_lower ON users (LOWER(email));)

4. Batasi dengan LIMIT, Jangan Ambil Semua

-- ANTI-PATTERN: Ambil semua data, filter di aplikasi
results = db.query("SELECT * FROM events WHERE type = 'click'")
recent = results[:100]  # filter di aplikasi setelah ambil semua

-- BENAR: Filter dan limit di database
SELECT id, user_id, event_data, created_at 
FROM events 
WHERE type = 'click' 
ORDER BY created_at DESC 
LIMIT 100;

Desain Fitur yang Sadar Cost Sejak Awal

Cost reduction yang paling murah adalah yang dilakukan saat desain, bukan setelah sistem berjalan bertahun-tahun. Setiap keputusan arsitektur yang dibuat saat development akan berdampak pada tagihan database selama sistem itu hidup.

Prinsip Dasar: Database Bukan Tempat untuk Semua Logika

Database adalah tempat menyimpan dan mengambil data. Database bukan message broker, bukan scheduler, bukan engine kalkulasi yang dipanggil ribuan kali per menit untuk hasil yang sama.

Pertanyaan yang harus selalu ditanyakan sebelum query ke database:
  → Apakah data ini bisa di-cache?
  → Apakah perhitungan ini bisa dilakukan di sisi aplikasi?
  → Apakah data ini benar-benar perlu real-time dari database?
  → Apakah ada cara lain yang tidak membutuhkan database sama sekali?

Hitung Dinamis vs Precomputed / Aggregated Data

Salah satu pola yang paling sering membebani database adalah menghitung agregasi besar setiap kali ada request.

-- ANTI-PATTERN: Agregasi besar dijalankan setiap request halaman dashboard
SELECT 
    DATE(created_at) as date,
    COUNT(*) as total_orders,
    SUM(total_amount) as revenue,
    AVG(total_amount) as avg_order_value
FROM orders 
WHERE created_at >= NOW() - INTERVAL 30 DAY
GROUP BY DATE(created_at)
ORDER BY date;
-- → Full scan atau partial scan tabel orders setiap request dashboard dibuka

Jika dashboard ini dibuka 100 kali per menit, query ini berjalan 100 kali per menit — masing-masing memproses jutaan row. Solusinya adalah memindahkan kalkulasi ini ke background:

-- BENAR: Simpan hasil agregasi di tabel terpisah
-- Tabel summary yang diisi oleh background job
CREATE TABLE order_daily_summary (
    summary_date DATE PRIMARY KEY,
    total_orders INT,
    revenue DECIMAL(15,2),
    avg_order_value DECIMAL(10,2),
    updated_at TIMESTAMP
);

-- Background job (cron/worker) yang berjalan setiap menit atau setiap jam
INSERT INTO order_daily_summary (summary_date, total_orders, revenue, avg_order_value)
SELECT 
    DATE(created_at),
    COUNT(*),
    SUM(total_amount),
    AVG(total_amount)
FROM orders 
WHERE created_at >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(created_at)
ON DUPLICATE KEY UPDATE
    total_orders = VALUES(total_orders),
    revenue = VALUES(revenue),
    avg_order_value = VALUES(avg_order_value),
    updated_at = NOW();

-- Query dashboard menjadi trivial
SELECT * FROM order_daily_summary 
WHERE summary_date >= CURDATE() - INTERVAL 30 DAY
ORDER BY summary_date;
-- → Simple lookup ke tabel kecil, tidak ada agregasi berat
flowchart LR
    A[Request Dashboard] --> B{Data dari mana?}
    B -- ANTI-PATTERN --> C[Query langsung orders\ntable besar setiap request]
    B -- BENAR --> D[Query summary table\nsudah terprecompute]
    E[Background Job\nevery 1 min] --> F[Hitung agregasi\ndari orders table]
    F --> D
    C --> G[CPU tinggi, lama]
    D --> H[CPU rendah, cepat]

Polling vs Event-Driven

Pola polling database adalah salah satu pola paling merusak dari sisi cost yang sering tidak disadari.

# ANTI-PATTERN: Polling database setiap N detik
# Bayangkan 1000 service instance melakukan ini bersamaan
while True:
    new_orders = db.query(
        "SELECT * FROM orders WHERE status = 'new' AND processed = 0"
    )
    for order in new_orders:
        process(order)
        db.execute("UPDATE orders SET processed = 1 WHERE id = ?", order.id)
    time.sleep(5)  # polling setiap 5 detik

Dengan 1000 instance berjalan, database menerima 200 query SELECT per detik hanya untuk mengecek “ada order baru tidak?” — sebagian besar hasilnya kosong. Ini murni pemborosan.

# BENAR: Event-driven dengan message queue
# Producer (service yang menerima order)
def create_order(order_data):
    order = db.insert("INSERT INTO orders ...", order_data)
    message_queue.publish("orders.new", {
        "order_id": order.id,
        "data": order_data
    })
    return order

# Consumer (service yang memproses order)
@message_queue.subscribe("orders.new")
def handle_new_order(message):
    order_id = message["order_id"]
    process_order(order_id)

Dengan pendekatan event-driven, query ke database hanya terjadi saat ada order baru yang benar-benar perlu diproses — bukan setiap 5 detik tanpa henti.

PendekatanQuery per menit (1000 instance)LatensiKompleksitas
Polling 5 detik12.000 query/menit≤ 5 detikRendah
Polling 1 detik60.000 query/menit≤ 1 detikRendah
Event-driven (MQ)Sesuai jumlah event aktual< 1 detikSedang
WebSocket + pushMinimalReal-timeSedang-tinggi

Right-Sizing Database Instance

Salah satu sumber pemborosan yang paling diam-diam tapi signifikan adalah instance database yang over-provisioned. Tim sering memilih instance yang lebih besar “untuk jaga-jaga” atau karena pernah mengalami masalah performa, lalu tidak pernah mengevaluasi ulang apakah ukuran tersebut masih relevan.

Cara Mengevaluasi Instance yang Ada

flowchart TD
    A[Mulai Evaluasi Instance] --> B{CPU utilisasi\naverage < 30%?}
    B -- Ya --> C{Memory usage\naverage < 50%?}
    B -- Tidak --> Z[Pertahankan atau scale up\nsesuai kebutuhan]
    C -- Ya --> D{I/O throughput\njuga rendah?}
    C -- Tidak --> Z
    D -- Ya --> E[Kandidat untuk\ndowngrade instance]
    D -- Tidak --> Z
    E --> F{Apakah ada\npeak yang tinggi?}
    F -- Ya, signifikan --> G[Pertimbangkan\nauto-scaling atau\njadwalkan skala]
    F -- Tidak/Occasional --> H[Downgrade instance\n1-2 tier lebih kecil]

Metrik yang perlu dipantau selama minimal 2-4 minggu sebelum memutuskan downgrade:

METRIK YANG PERLU DIEVALUASI:
  □ CPU utilization — average dan peak (bukan hanya average)
  □ Freeable memory — apakah tersedia cukup buffer pool
  □ Read/Write IOPS — aktual vs provisioned
  □ Database connections — peak concurrent connections
  □ Replication lag — jika ada read replica
  □ Query execution time — apakah ada degradasi saat beban tinggi

Evaluasi Read Replica

Read replica adalah biaya ganda — setiap replica ditagih sebesar instance primer. Pertanyaan yang perlu dijawab sebelum mempertahankan read replica:

Read replica layak dipertahankan jika:
  ✓ Ada traffic read yang signifikan dan terukur yang diarahkan ke replica
  ✓ Query analytics/reporting yang berat dijalankan dari replica
  ✓ Ada kebutuhan disaster recovery yang mengharuskan standby di region lain
  ✓ Latency ke replica lebih rendah untuk user di lokasi tertentu

Pertimbangkan hapus atau kurangi read replica jika:
  ✗ Aplikasi masih mostly read dari primary
  ✗ Replica tidak pernah atau jarang digunakan
  ✗ Tim tidak yakin mana query yang ke replica vs primary
  ✗ Replica dipasang "untuk jaga-jaga" tanpa konfigurasi routing yang jelas
Di AWS RDS, gunakan fitur Performance Insights untuk melihat secara visual dari mana beban database berasal. Kombinasikan dengan Enhanced Monitoring untuk melihat utilisasi CPU, memory, dan I/O di level OS. Data ini jauh lebih berguna daripada memutuskan berdasarkan perasaan atau pengalaman insiden lama.

Kurangi Beban Database dengan Arsitektur yang Tepat

Beberapa pola arsitektur secara langsung mengurangi beban database tanpa harus mengurangi fungsionalitas sistem.

Read/Write Separation

Memisahkan traffic read dan write ke koneksi yang berbeda — bahkan jika masih menggunakan database yang sama — memberikan kontrol yang lebih baik dan membuka kemungkinan untuk mengarahkan read ke replica saat dibutuhkan.

# ANTI-PATTERN: Satu koneksi untuk semua
class OrderRepository:
    def get_order(self, order_id):
        return self.db.query("SELECT ...")  # read via koneksi yang sama
    
    def create_order(self, data):
        return self.db.execute("INSERT ...")  # write via koneksi yang sama

# BENAR: Pisahkan koneksi read dan write
class OrderRepository:
    def __init__(self, write_db, read_db):
        self.write_db = write_db  # primary
        self.read_db = read_db    # replica atau primary yang sama
    
    def get_order(self, order_id):
        return self.read_db.query("SELECT ...")
    
    def get_orders_for_user(self, user_id):
        return self.read_db.query("SELECT ...")  # read-heavy, arahkan ke replica
    
    def create_order(self, data):
        return self.write_db.execute("INSERT ...")  # write selalu ke primary
    
    def update_order_status(self, order_id, status):
        return self.write_db.execute("UPDATE ...")

CQRS Sederhana untuk Kasus Read-Heavy

CQRS (Command Query Responsibility Segregation) tidak harus diimplementasi dalam bentuk penuh dengan event sourcing. Versi sederhananya sudah cukup untuk kasus di mana operasi read jauh lebih banyak dari write:

Model Sederhana CQRS:

  COMMAND side (Write):
    → Operasi yang mengubah state (create, update, delete)
    → Validasi, business logic
    → Tulis ke primary database
    → Publish event ke message queue

  QUERY side (Read):
    → Operasi baca saja
    → Data mungkin sedikit delayed (eventual consistency)
    → Bisa baca dari: read replica, cache, atau tabel summary yang dioptimasi
    → Tidak ada validasi bisnis, hanya transformasi data untuk tampilan

Batch Processing Daripada Real-Time Query Berulang

Untuk operasi yang tidak membutuhkan real-time, batch processing jauh lebih efisien:

# ANTI-PATTERN: Kirim notifikasi satu per satu, setiap transaksi
def complete_order(order_id):
    order = db.query("SELECT * FROM orders WHERE id = ?", order_id)
    db.execute("UPDATE orders SET status = 'completed' WHERE id = ?", order_id)
    
    # Trigger notifikasi langsung
    user = db.query("SELECT * FROM users WHERE id = ?", order.user_id)
    email_service.send(user.email, "Order completed", ...)
    
    # Update user stats langsung
    db.execute("UPDATE user_stats SET total_orders = total_orders + 1 WHERE user_id = ?", order.user_id)
    db.execute("UPDATE user_stats SET total_spent = total_spent + ? WHERE user_id = ?", order.total, order.user_id)

# BENAR: Pisahkan concern, batch yang tidak urgent
def complete_order(order_id):
    db.execute("UPDATE orders SET status = 'completed' WHERE id = ?", order_id)
    queue.publish("order.completed", {"order_id": order_id})
    # Selesai — tidak ada query tambahan

# Worker terpisah yang memproses secara batch
@queue.subscribe("order.completed")
def handle_order_completed_batch(messages):
    order_ids = [m["order_id"] for m in messages]
    
    # Satu query untuk semua order dalam batch
    orders = db.query(
        "SELECT o.*, u.email FROM orders o JOIN users u ON o.user_id = u.id WHERE o.id IN (?)",
        order_ids
    )
    
    # Kirim email dalam batch
    email_service.send_batch([...])
    
    # Update stats dalam satu query
    db.execute("""
        UPDATE user_stats us
        JOIN (
            SELECT user_id, COUNT(*) as cnt, SUM(total_amount) as total
            FROM orders WHERE id IN (?)
            GROUP BY user_id
        ) batch ON us.user_id = batch.user_id
        SET us.total_orders = us.total_orders + batch.cnt,
            us.total_spent = us.total_spent + batch.total
    """, order_ids)

Evaluasi Data Retention dan Archiving

Tabel yang besar adalah tabel yang lambat dan mahal. Setiap index pada tabel besar membutuhkan lebih banyak storage. Setiap query harus melewati lebih banyak data untuk menemukan yang relevan. Setiap backup mencakup data yang mungkin sudah tidak pernah dibaca.

Tanda-tanda Data Retention Bermasalah

TANDA YANG PERLU DISELIDIKI:
  □ Tabel logs atau events yang ukurannya terus tumbuh tanpa batas
  □ Query pada tabel besar yang semakin lama semakin lambat
  □ Backup window yang terus memanjang dari bulan ke bulan
  □ Storage cost yang naik lebih cepat dari pertumbuhan bisnis
  □ Data dari tahun lalu yang tidak pernah di-query di production

Strategi Data Retention

flowchart TD
    A[Data Masuk ke Database] --> B[Hot Data\n0-90 hari\nPrimary DB]
    B --> C{Umur data?}
    C -- 90-365 hari --> D[Warm Data\nPartisi terpisah atau\ntabel archive di DB yang sama]
    C -- > 365 hari --> E[Cold Data\nData warehouse\nS3 / GCS / Blob storage]
    D --> F{Pernah diakses?}
    F -- Jarang --> E
    F -- Masih sering --> D
    E --> G{Perlu akses\nanalytik?}
    G -- Ya --> H[BigQuery / Redshift\nAthena / Databricks]
    G -- Tidak --> I[Glacier / Coldline\nArchive tier murah]

Implementasi konkret untuk MySQL/PostgreSQL — menggunakan table partitioning untuk memudahkan archiving:

-- Buat tabel dengan partisi berdasarkan waktu
CREATE TABLE events (
    id BIGINT AUTO_INCREMENT,
    user_id INT,
    event_type VARCHAR(50),
    event_data JSON,
    created_at DATETIME NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
    PARTITION p202401 VALUES LESS THAN (202402),
    PARTITION p202402 VALUES LESS THAN (202403),
    PARTITION p202403 VALUES LESS THAN (202404),
    -- ... dan seterusnya
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

-- Archiving menjadi semudah drop partition (instant, tidak ada scan)
-- Sebelum drop, export ke S3 dulu
ALTER TABLE events DROP PARTITION p202401;
-- → Operasi instant, tidak membebani database, storage langsung berkurang

Dampak Nyata Data Retention

Ukuran TabelQuery SELECT sederhanaBackup durationStorage cost (RDS)
10 juta row50ms15 menit~$20/bulan
100 juta row300ms2 jam~$200/bulan
1 miliar row2-5 detik12+ jam~$2000/bulan
Dengan archivingTetap 50ms (hanya data hot)15 menit~$20/bulan + minimal cold storage
Sebelum menghapus atau mengarsipkan data, pastikan ada kepastian dari sisi legal dan bisnis. Beberapa industri (keuangan, kesehatan) memiliki regulasi yang mengharuskan data disimpan dalam jangka waktu tertentu dengan cara tertentu. Archive ke cold storage tetap memenuhi kewajiban penyimpanan dengan biaya jauh lebih rendah dari primary database.

Ringkasan: Framework Evaluasi Cost Database

Cost reduction di database bukan proyek satu kali. Ini adalah kebiasaan dan mindset yang dibangun ke dalam cara tim bekerja — dari desain fitur, code review, hingga evaluasi infrastruktur berkala.

flowchart TD
    A[Database Cost Terlalu Tinggi?] --> B[Analisa dulu\nbefore action]
    B --> C[Cek slow query log\ndan Performance Insights]
    B --> D[Lihat utilisasi\ninstance CPU/memory/I/O]
    B --> E[Review pola query\ndi aplikasi]
    
    C --> F{Ada slow query?}
    F -- Ya --> G[Optimasi query\nTambah index yang tepat]
    F -- Tidak --> H[Next check]
    
    D --> I{Instance over-provisioned?}
    I -- Ya --> J[Right-size instance\nEvaluasi read replica]
    I -- Tidak --> K[Next check]
    
    E --> L{Ada polling atau\nagregasi berat berulang?}
    L -- Ya --> M[Implementasi cache\natau event-driven]
    L -- Tidak --> N[Cek data retention]
    
    N --> O{Ada data lama\nyang tidak berguna?}
    O -- Ya --> P[Implementasi archiving\ndan retention policy]
    O -- Tidak --> Q[Evaluasi arsitektur\nCQRS atau batch processing]

Urutan prioritas yang disarankan berdasarkan effort vs dampak:

TindakanEffort ImplementasiDampak CostRisiko
Optimasi slow queryRendah-sedangTinggiRendah
Tambah cache untuk hot dataSedangTinggiRendah-sedang
Right-size instanceRendahSedang-tinggiRendah (jika data cukup)
Hapus read replica yang tidak terpakaiRendahTinggi (50% cost)Rendah
Data retention & archivingSedangSedang (jangka panjang tinggi)Rendah
Refactor polling ke event-drivenTinggiSedang-tinggiSedang
Implementasi CQRS/batch processingTinggiSedangSedang

Database yang sehat bukan yang paling besar spesifikasinya. Database yang sehat adalah database yang digunakan dengan benar — query yang efisien, data yang relevan, cache di tempat yang tepat, dan instance yang sesuai kebutuhan aktual. Tim yang membangun kesadaran ini sejak awal development akan memiliki sistem yang lebih scalable, lebih stabil, dan lebih sustainable secara biaya untuk jangka panjang.

  • Cache adalah investasi terbaik — hit rate 80-90% bisa menurunkan beban database lebih dari 80%, memungkinkan penggunaan instance yang lebih kecil.
  • Satu slow query bisa mahal — satu query full table scan yang berjalan ribuan kali per hari lebih mahal dari puluhan query yang dioptimasi dengan baik.
  • Evaluasi instance secara berkala — utilisasi CPU dan memory rata-rata di bawah 30% selama berminggu-minggu adalah sinyal jelas untuk downgrade.
  • Polling adalah silent killer — ganti dengan event-driven atau message queue untuk operasi yang tidak butuh real-time.
  • Precompute agregasi berat — jangan jalankan GROUP BY pada jutaan row setiap request dashboard dibuka; pindahkan ke background job.
  • Data lama = biaya tersembunyi — tentukan retention policy sejak awal, gunakan partisi untuk memudahkan archiving, pindahkan cold data ke storage murah.
  • Right-size, bukan over-provision — pakai data utilisasi aktual minimal 2-4 minggu sebelum memutuskan ukuran instance yang tepat.
  • Desain yang sadar cost lebih murah dari refactoring — setiap pola buruk yang masuk ke production akan terus menghasilkan tagihan sampai diperbaiki.

Portofolio