
Cerita di balik layar optimasi database PostgreSQL. Dari drama connection leak di Kubernetes, index overkill yang bikin bulk insert tewas, sampai cache basi di Redis.
Ada satu mitos klasik di kalangan developer: "Kalau aplikasi lu lemot, naikin aja spek servernya."
Di tempat kerja sebelumnya, gue kebetulan jadi developer yang ngurus semuanya secara end-to-end, dari frontend sampai deployment server di awal. Jadi ya, urusan database dan infra juga jatuhnya ke tangan gue.
Gue juga dulu sempat tergoda pake cara instan ini. Pas database PostgreSQL di sana mulai ngos-ngosan karena dihajar request dari berbagai pod Kubernetes dan workflow automasi, reaksi pertama gue adalah buka dashboard cloud, lalu naikin core CPU dari 2 jadi 4, dan RAM dari 2GB jadi 4GB.
Hasilnya? Aplikasi tetep aja lemot, bahkan sesekali dapet error FATAL: too many clients already. Sementara itu, CPU usage di database malah adem ayem di bawah 10%. Di situlah gue sadar kalau gue baru aja melakukan over-provisioning bodoh untuk menutupi kelemahan arsitektur database gue sendiri.
Optimasi database itu bukan soal seberapa gede RAM server lu, tapi seberapa efisien engine database lu bekerja di balik layar. Berikut adalah beberapa "bekas luka" dan pelajaran mahal yang gue dapet dari ngoprek performa database di production tempat gue kerja sebelumnya.
Ini kesalahan arsitektur paling pertama yang bikin gue facepalm. Di cluster Kubernetes kantor, gue pake Drizzle ORM untuk beberapa microservices. Secara default, setiap kali pod aplikasi lu jalan, Drizzle bakal nge-spawn connection pool sendiri (biasanya default 10-20 koneksi per instance).
Begitu trafik naik dan Kubernetes mulai nge-scale pod aplikasi gue dari 2 replika jadi 10 replika, database PostgreSQL langsung tewas karena kehabisan slot koneksi (connection limit exceeded).
max_connectionsReaksi bodoh kedua gue adalah menaikkan max_connections di Postgres config dari 100 ke 500. Jangan lakukan ini! Setiap koneksi aktif di Postgres itu memakan proses backend fisik (forking process) di OS yang memakan RAM sekitar 10MB per koneksi. Begitu ada 300+ koneksi aktif, database lu bakal sibuk ngurusin context switching antar proses dibanding nge-run query itu sendiri.
Akhirnya gue deploy PgBouncer sebagai perantara (connection pooler) di depan Postgres. PgBouncer bakal nge-keep beberapa koneksi hangat (warm connections) ke database, dan aplikasi kita cuma perlu ngomong ke PgBouncer.
Tapi setup PgBouncer ini ada jebakannya:
Semua orang tahu kalau query SELECT lambat, solusinya tinggal kasih index. Tapi gue pernah sok pintar karena terlalu rajin pasang index.
Di satu tabel log transaksi yang datanya masuk ribuan baris per hari, gue dengan songongnya masang index B-Tree di hampir semua kolom yang sering gue filter (status, created_at, user_id, amount, gateway).
Query baca emang jadi instan (di bawah 5ms). Tapi pas sistem mulai nge-run workflow bulk insert ribuan data transaksi sekaligus, CPU server database langsung spikes 100% dan proses insert-nya jadi lambat banget kayak siput.
Kenapa? Karena Write Tax (biaya operasi tulis). Setiap kali ada baris data baru masuk, Postgres harus memperbarui struktur pohon B-Tree untuk semua index yang gue buat tadi.
EXPLAIN ANALYZE SELECT ... sebelum bikin index. Cari tahu apakah query planner beneran pake index itu (Index Scan) atau malah tetep ngelakuin Seq Scan (Sequential Scan) karena datanya terlalu sedikit atau kardinalitas kolomnya rendah.Selain urusan infra, cara kita nulis query juga sering bikin database nangis. Ini dua antipattern yang paling sering gue temuin (dan gue perbaiki):
SELECT *Dulu gue males nulis nama kolom satu-satu, jadi di kode ORM atau query raw gue sering pake SELECT *. Di production, ini bahaya banget.
SELECT * juga bikin query planner gak bisa ngelakuin Index-Only Scan (mengambil data langsung dari index tanpa perlu baca baris tabel aslinya).Ini penyakit bawaan ORM modern. Lu mau nampilin daftar 50 transaksi beserta data usernya. Kalau lu gak pake Eager Loading atau Join, ORM lu bakal nge-run 1 query buat ambil transaksi, lalu nge-run 50 query tambahan secara berurutan buat nyari user dari masing-masing transaksi tersebut. Database lu bakal capek ngelayanin bolak-balik koneksi jaringan yang gak perlu.
Query database paling cepat adalah query yang gak pernah lu jalankan. Di tempat gue kerja sebelumnya, gue pasang Redis sebagai caching layer di depan database untuk data yang sering dibaca tapi jarang berubah (seperti daftar produk atau konfigurasi sistem).
Gue pake pola Cache-Aside: aplikasi cek Redis -> kalau ada (Cache Hit) langsung balikin -> kalau gak ada (Cache Miss) query ke database -> simpan ke Redis -> balikin ke user.
Tapi caching ini ada jebakan mautnya: Cache Invalidation. Gue pernah lupa nge-set TTL (Time-To-Live) dan gak nge-clear cache Redis pas ada transaksi baru yang mengubah saldo user.
Alhasil, user udah bayar via Midtrans dan saldo di Postgres udah nambah, tapi di dashboard mereka saldonya tetep nol karena aplikasi masih ngebaca data lama dari Redis. User langsung panik dan komplain ke CS.
Pelajaran: Selalu pasang TTL yang masuk akal (jangan pernah set cache tanpa batas waktu kecuali lu bener-bener tahu cara nge-clear-nya), dan pastikan lu nge-run perintah DEL atau update cache di Redis di dalam transaksi yang sama saat data di database berubah.
Biar lu gak perlu ngalamin drama server crash jam 3 pagi kayak gue, ini checklist optimasi database yang wajib lu lakuin:
EXPLAIN ANALYZE.SELECT * di query production lu.Database itu jantung dari aplikasi lu. Rawat dia dengan query yang efisien dan arsitektur koneksi yang rapi, bukan dengan asal naikin kapasitas RAM di dashboard cloud provider lu. wkwk