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:
| Komponen | Faktor yang Mendorongnya | Cara Ditagihkan |
|---|---|---|
| Instance | Jumlah vCPU dan RAM | Per jam, terus berjalan |
| Storage | Ukuran data + log + backup | Per GB per bulan |
| I/O | Jumlah read/write ke disk | Per juta request (beberapa provider) |
| Data transfer | Transfer keluar ke internet / region lain | Per GB |
| Backup storage | Retention period backup | Per GB per bulan |
| Read replica | Setiap replica = biaya instance baru | Per 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 --> IPola 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 Cache | Contoh Tools | Cocok Untuk | Catatan |
|---|---|---|---|
| In-memory distributed | Redis, Memcached | Data shared antar service, session, leaderboard | Perlu manajemen TTL dan invalidation |
| Application-level | Caffeine (Java), Guava Cache | Data per-instance, lookup table statis | Tidak shared antar replica |
| Query result cache | MySQL Query Cache (deprecated), app-level | Hasil query yang identik berulang | Harus hati-hati dengan invalidation |
| HTTP / CDN cache | CloudFront, Cloudflare | Data publik, API response yang tidak user-specific | Paling murah, zero database hit |
| Materialized view | PostgreSQL, MySQL | Hasil agregasi kompleks | Update 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 melambatEfek 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
| Platform | Tools | Cara Aktifkan |
|---|---|---|
| MySQL | Slow Query Log | slow_query_log=ON, long_query_time=1 |
| PostgreSQL | pg_stat_statements, auto_explain | shared_preload_libraries = 'pg_stat_statements' |
| AWS RDS | Performance Insights | Enable saat buat instance atau via modify |
| AWS Aurora | Performance Insights + Enhanced Monitoring | Tersedia di console |
| APM | New Relic, Datadog, Dynatrace | Install agent di aplikasi |
| Self-hosted | Percona Monitoring and Management | Deploy 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.
| Pendekatan | Query per menit (1000 instance) | Latensi | Kompleksitas |
|---|---|---|---|
| Polling 5 detik | 12.000 query/menit | ≤ 5 detik | Rendah |
| Polling 1 detik | 60.000 query/menit | ≤ 1 detik | Rendah |
| Event-driven (MQ) | Sesuai jumlah event aktual | < 1 detik | Sedang |
| WebSocket + push | Minimal | Real-time | Sedang-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 Tabel | Query SELECT sederhana | Backup duration | Storage cost (RDS) |
|---|---|---|---|
| 10 juta row | 50ms | 15 menit | ~$20/bulan |
| 100 juta row | 300ms | 2 jam | ~$200/bulan |
| 1 miliar row | 2-5 detik | 12+ jam | ~$2000/bulan |
| Dengan archiving | Tetap 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:
| Tindakan | Effort Implementasi | Dampak Cost | Risiko |
|---|---|---|---|
| Optimasi slow query | Rendah-sedang | Tinggi | Rendah |
| Tambah cache untuk hot data | Sedang | Tinggi | Rendah-sedang |
| Right-size instance | Rendah | Sedang-tinggi | Rendah (jika data cukup) |
| Hapus read replica yang tidak terpakai | Rendah | Tinggi (50% cost) | Rendah |
| Data retention & archiving | Sedang | Sedang (jangka panjang tinggi) | Rendah |
| Refactor polling ke event-driven | Tinggi | Sedang-tinggi | Sedang |
| Implementasi CQRS/batch processing | Tinggi | Sedang | Sedang |
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 BYpada 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.