Ketika "Jalan Sekali" Diam-Diam Jadi "Jalan 8×": Pelajaran Menskalakan API ke Banyak Instance


Inget cerita database yang reboot sendiri jam 5 pagi terus API-nya nge-hang 30 detik itu? Nah, ternyata itu baru satu kepala dari monster yang sama. Pas saya gali lebih dalam kenapa aplikasinya bisa lemot tiap pagi, saya nemu tiga akar masalah — dan ketiganya nyambung ke satu kesalahan berpikir yang sama.
Kesalahannya sederhana banget, dan saya yakin banyak dari teman-teman pernah kena juga tanpa sadar:
Banyak kode ditulis dengan asumsi "operasi ini jalan sekali". Begitu kalian tambah instance, diam-diam dia jadi "jalan N kali" — dan nggak ada yang ngasih tau.
Salah satu cron di sistem saya jalan 8 kali tiap tengah malam. Salah satunya nyentuh saldo user. Bayangin refund kebayar 8×. 😨 Ini cerita lengkapnya. 🕵️
Panggungnya: 4 proses identik
Setup-nya sama persis kayak di postmortem sebelumnya, tapi kali ini angka prosesnya yang jadi bintang utama:
- Load balancer di depan, 2 app server.
- Tiap server jalan PM2 cluster, 2 proses per server.
- Jadi total ada 4 proses Node yang identik, semuanya jalanin kode yang sama.
- MySQL dan Redis di server terpisah.
Angka 4 proses ini kelihatan sepele. Tapi dia yang jadi pengali diam-diam di sepanjang cerita ini. Ingat baik-baik: apa pun yang kalian tulis, dia jalan 4 kali, bukan sekali.
Gejalanya? Sama kayak dulu: tiap pagi sebagian request lemot >30 detik atau RTO, dan baru normal habis pm2 reload all. Selama beberapa hari, "solusi"-nya cuma reload manual tiap pagi kayak minum obat. Obat itu ngilangin gejala, bukan penyakitnya.
Cara nemuinnya: proses sehat ≠ sistem sehat
Sebelum ke akar masalahnya, satu pelajaran diagnosis yang menurut saya paling berharga dari semua ini.
Aplikasi bisa keliatan "sehat total" di monitoring tapi tetep lemot. Pas insiden, semua metrik proses hijau: CPU ~1%, event-loop latency ~0.5ms, memori adem. Nggak ada yang melonjak. Kalau cuma ngeliat dashboard PM2, kalian bakal bilang "lah sehat kok, nggak ada masalah".
Cara bacanya:
- Proses sehat + request lemot = bottleneck-nya di luar proses. Dia lagi nungguin sesuatu (DB / Redis / jaringan), bukan lagi ngerjain sesuatu.
- Ukur latency per-endpoint dari luar biar keisolasi:
ttfb=0.000 total=30.005 → server nerima koneksi tapi nggak pernah ngirim satu byte pun = HANG di server
- Angka 30 detik itu bukan kebetulan — itu persis nilai timeout pool koneksi DB (
acquire: 30000). Kalau ada angka timeout yang muncul berulang-ulang, itu petunjuk gede sumber hang-nya di mana.
Pelajarannya: monitoring proses doang nggak cukup. Ukur juga dependency-nya (pool DB, latency Redis) dan latency per-endpoint. Kalau nggak, kalian bakal ngeliat semua hijau sambil user teriak-teriak.
Akar masalah #1: Koneksi mati yang ngendon di pool
Yang ini saya udah kupas tuntas di postmortem sebelumnya, jadi di sini saya rangkum aja biar gambaran tiga akar masalahnya utuh.
Gejala
Endpoint yang paling sering nyentuh DB nge-hang, sementara endpoint yang dilayani dari cache tetep kenceng. Anehnya, pas DB dicek (SHOW FULL PROCESSLIST), DB-nya idle, sehat, tanpa lock — dan query yang nge-hang itu nggak muncul sama sekali di process list.
Kenapa
- Pool dikonfigurasi nahan koneksi idle (
min: 2). Semaleman pas trafik sepi, MySQL nutup koneksi idle yang lewatwait_timeout(default 8 jam). - Koneksinya jadi half-open: socket-nya udah mati, tapi pool masih ngira dia hidup. Driver-nya nggak validasi koneksi sebelum dipake.
- Pagi trafik naik → request ambil koneksi mati → paket query-nya masuk ke black hole: aplikasi nungguin, tapi query-nya nggak pernah nyampe ke DB (makanya nggak nongol di process list) → nge-hang sampe timeout → RTO.
pm2 reload"memperbaiki" karena bikin proses baru dengan koneksi fresh. Obat sementara, bukan solusi.
Solusi
Ubah config pool biar koneksi mati dibuang & diganti otomatis, bukan digantung:
pool: {
max: 10,
- min: 2, // nahan koneksi idle → mati semaleman
+ min: 0, // jangan tahan koneksi idle
- acquire: 30000, // hang 30 detik pas koneksi mati
+ acquire: 10000, // gagal cepat
+ evict: 5000, // sering sapu koneksi mati
},
dialectOptions: {
enableKeepAlive: true,
+ keepAliveInitialDelay: 10000, // TCP keepalive deteksi socket mati lebih cepat
},
Kuncinya: min: 0 ngilangin koneksi yang "ngendon" sampe mati. Fail-fast + keepalive mastiin sisanya diganti, bukan digantung.
Akar masalah #2 (SOROTAN): Cron job di lingkungan multi-instance
Ini jebakan terbesar dan paling sering kelewat pas scaling.
Gejalanya di sini
Log nunjukin tiap cron harian kecatat 8 kali pas tengah malam. Padahal harusnya sekali. Pas pertama liat, saya kira log-nya yang bug. Ternyata cron-nya beneran jalan 8×.
Kenapa: dua lapis penggandaan
Lapis 1 — bug registrasi ganda. Modul scheduler-nya didaftarin dua kali (satu di root module, satu lagi di module cron). Tiap registrasi nge-scan ulang semua handler @Cron terus jadwalin lagi → tiap cron jalan 2× per proses.
Lapis 2 — cluster. Scheduler in-process itu jalan di tiap proses. Dengan 2 server × 2 proses = 4 proses, tiap cron jalan 4×.
2 (registrasi ganda) × 2 (proses/server) × 2 (server) = 8× per cron harian
Kenapa ini bahaya (bukan cuma boros)
Jam 00:00, semua cron nyerbu DB bareng-bareng (di sini: ~3 cron harian × 8 = ~24 operasi berat serentak):
- Lock contention / deadlock — banyak proses nge-
UPDATEbaris yang sama dalam transaksi → saling kunci, ada yang kena lock-wait timeout, koneksi nyangkut. Ini makin memperparah akar masalah #1. - Beban DB berlipat — query agregasi berat (misal full-table scan) dijalanin 8× padahal hasilnya sama persis.
- Bug integritas data — nah ini yang paling bikin merinding. Salah satu cron itu ngelakuin refund saldo ke user. Jalan 8× artinya berpotensi ngasih refund berlipat 2–8×. Cron yang nyentuh duit/saldo wajib dijamin jalan sekali. Titik.
Solusi
Langkah 1 — betulin registrasi ganda. Daftarin scheduler tepat sekali di root module. Ini mangkas 8× → 4×.
Langkah 2 — pastiin jalan sekali lintas cluster. Ada beberapa pola, pilih yang cocok sama cara deploy kalian:
| Pola | Cara | Cocok untuk |
|---|---|---|
| Leader by config | Env CRON_LEADER=1 di satu proses, 0 di lainnya | Deploy dengan env per-server |
| Leader by identity | Guard: NODE_APP_INSTANCE === "0" && hostname === <leader> | Env dipakai bareng; deterministik, tanpa dependency |
| Distributed lock | SET <key> <val> NX PX <ttl> di Redis; pemenang lock yang jalan | Butuh tahan failover otomatis |
| Proses cron terpisah | Jalanin scheduler di 1 proses khusus (instances: 1), terpisah dari proses API | Paling bersih secara arsitektur |
Contoh guard leader (dipasang di awal tiap cron):
export function isCronLeader(): boolean {
const explicit = process.env.CRON_LEADER; // override eksplisit
if (explicit === "1" || explicit === "true") return true;
if (explicit === "0" || explicit === "false") return false;
if (process.env.NODE_APP_INSTANCE === undefined) return true; // single-process (dev)
const leader = process.env.CRON_LEADER_HOST || "app-node-primary";
return process.env.NODE_APP_INSTANCE === "0" && os.hostname() === leader;
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async refundJob() {
if (!isCronLeader()) return; // cuma 1 proses cluster-wide yang lanjut
// ...
}
Catatan penting: guard
NODE_APP_INSTANCE === 0doang nggak cukup kalau ada >1 server — tiap server punya instance 0 sendiri-sendiri. Harus dikombinasi sama identitas server (hostname/env) atau pakai distributed lock. Ini jebakan di dalam jebakan.
Aturan praktis cron di multi-instance
- Daftarin scheduler sekali — waspadai
forRoot()yang kepanggil di banyak module. - Anggap scheduler jalan di tiap proses. Tambah mekanisme "jalan sekali" secara eksplisit.
- Cron yang ngubah uang/saldo/state kritis harus idempotent dan terkunci.
- Kasih log jelas (start & selesai) biar duplikasi langsung keliatan.
Akar masalah #3 (SOROTAN): Database migration di multi-instance
Ini sepupunya masalah cron — dan sama-sama sering diabaikan.
Gejalanya di sini
Pipeline deploy punya dua workflow paralel (satu per server), dua-duanya dipicu tag yang sama. Masing-masing jalanin npm run migrate via SSH ke server-nya — ke database yang sama.
Kenapa ini bahaya
Migration itu operasi "jalan sekali per rilis" — bukan sekali per server. Dua proses migrate jalan barengan ke satu DB bisa bikin:
- Race di tabel meta migration (
SequelizeMetadan sejenisnya) — dua proses baca "migration X belum jalan" terus dua-duanya nyoba jalanin. - Partial / double apply —
CREATE TABLEyang kedua gagal,ALTERdobel, atau migration setengah jalan → skema jadi nggak konsisten. - Deadlock DDL — DDL barengan saling ngunci.
Ini persis pola yang sama kayak bug cron: operasi "sekali" dijalanin N kali karena ada N instance/deploy. Sadar nggak sadar, tema-nya balik lagi ke situ.
Solusi
Jalanin migration tepat sekali per rilis. Beberapa cara:
- Satu langkah migrate aja. Kalau beberapa server sharing satu DB, jalanin migrate dari satu workflow/langkah, jangan tiap server.
# Di workflow server kedua — HAPUS langkah migrate.
# DB dipakai bareng; migrate cukup dari workflow pertama biar jalan sekali per rilis.
- Job migrate terpisah yang jalan sebelum semua server di-restart (fan-out deploy setelah migrate sukses).
- Advisory lock di level DB (misal
GET_LOCK()di MySQL,pg_advisory_lockdi Postgres) di dalam runner migration, biar runner kedua nunggu/skip.
Pertimbangan urutan (yang lebih dalam)
Selain "jalan sekali", ada urutan yang mesti dipikirin:
- Migrate dulu, baru restart aplikasi. Kalau server B restart kode baru sebelum migration server A kelar, aplikasi baru bisa jalan di atas skema lama → error.
- Migration backward-compatible. Selama rilis bergulir (rolling), kode lama dan baru jalan barengan sesaat. Migration idealnya kompatibel dua arah (misal tambah kolom nullable dulu, isi datanya, baru diwajibin di rilis berikutnya) biar nggak mutus kode lama yang masih ngelayanin trafik.
Aturan praktis migration di multi-instance
- Migration = sekali per rilis, bukan sekali per instance.
- Pisahin "migrate" dari "deploy/restart"; migrate dulu, restart kemudian.
- Lindungi runner pakai lock (advisory lock) kalau ada kemungkinan paralel.
- Bikin migration backward-compatible buat rolling deploy.
Benang merahnya: audit operasi "jalan sekali"
Ketiga masalah di atas itu wajah beda dari satu kesalahan berpikir yang sama:
Kode ditulis dengan asumsi satu instance, terus di-scale ke banyak instance tanpa ninjau ulang operasi yang seharusnya global / sekali.
Jadi tiap kali kalian nambah instance (atau server), audit hal-hal ini. Saya tempel checklist ini di catatan sendiri:
- Scheduled jobs / cron — jalan di tiap proses? Udah ada mekanisme "sekali"?
- Database migration — jalan sekali per rilis, bukan per server? Ada lock? Backward-compatible?
- Warmup / seeding / one-time init pas startup — jalan di tiap proses?
- In-memory state (counter, cache lokal, rate-limit) — perlu dipindah ke store bareng (Redis)?
- Connection pool — ukuran & perilakunya (min/idle/keepalive) masih masuk akal dikali jumlah proses?
- Startup side-effects (kirim notif "app started", registrasi webhook) — jalan berkali-kali?
- Fail-fast & timeout — dependency yang lambat/mati berubah jadi error cepat, bukan nge-hang?
Penutup
Gejalanya cuma "tiap pagi lemot", tapi akarnya tiga jebakan multi-instance: koneksi pool yang ngendon, cron yang jalan 8×, dan migration yang berpotensi balapan. Semuanya lolos karena kode-nya bener buat satu instance — dan diam-diam salah buat banyak.
Pelajaran utama yang bisa dibawa ke proyek mana pun:
- Monitoring proses sehat ≠ sistem sehat. Ukur dependency & latency per-endpoint.
- Setiap operasi "sekali" wajib punya rencana eksplisit pas multi-instance — cron dan migration paling utama.
- Fail fast, jangan hang. Timeout di pool, request, dan dependency ngubah kegagalan diam-diam jadi sinyal yang jelas.
- Reload/restart yang "menyembuhkan" itu gejala, bukan solusi — biasanya nandain state (koneksi/memori) yang bocor atau numpuk.
Godaan pas scaling itu selalu "tinggal tambahin server, kelar". Padahal nambah server itu ngaliin semua asumsi kalian. Yang tadinya sekali jadi N kali, dan yang paling nyeremin: kadang kalian baru sadar pas ada yang kebayar refund 8×. 🚀
Versi English ada di sini.