Situs mati setiap pagi, dan pm2 mencatat satu baris
Situs berjalan normal seharian. Besok paginya, halaman apa pun yang menyentuh database mengembalikan 500. Restart pm2 memperbaikinya. Besok paginya terjadi lagi.
Log-nya cuma ini:
Error: Connection lost: The server closed the connection.
at PromiseConnection.execute (/app/node_modules/mysql2/promise.js:112:22)
code: 'PROTOCOL_CONNECTION_LOST',
fatal: true
Penyebabnya ada di file yang tidak pernah disentuh sejak hari pertama proyek:
// config/db.js
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
});
module.exports = connection;
Satu koneksi, dibuat sekali saat boot, dipakai seluruh aplikasi selamanya. Yang terjadi tiap malam: trafik sepi, koneksi menganggur delapan jam, MySQL menutupnya sesuai wait_timeout (default 28.800 detik — persis 8 jam), dan objek koneksi di sisi Node tidak tahu apa-apa sampai query berikutnya gagal.
Artikel ini membedah apa bedanya createConnection dan createPool, benchmark keduanya di bawah beban bersamaan, langkah migrasi yang tidak mengharuskan menyentuh semua file route, dan cara menentukan connectionLimit yang aman di VPS kecil.
Anatomi createConnection
createConnection membuat satu soket TCP ke MySQL. Semua yang dikirim melalui soket itu diserialisasi — protokol MySQL tidak mendukung banyak query berjalan bersamaan pada satu koneksi.
Konsekuensinya sering mengejutkan orang yang menganggap Node "asinkron jadi paralel":
console.time('serial');
await Promise.all([
conn.execute('SELECT SLEEP(1)'),
conn.execute('SELECT SLEEP(1)'),
conn.execute('SELECT SLEEP(1)'),
]);
console.timeEnd('serial');
serial: 3018.442ms
Promise.all memang menjalankan ketiganya "bersamaan" di sisi JavaScript, tapi driver mengantrekannya di satu soket. Tiga detik, bukan satu.
Terjemahannya ke situasi nyata: kalau ada satu query laporan analytics yang butuh 800 ms, setiap pengunjung yang membuka halaman apa pun selama 800 ms itu ikut menunggu. Bukan hanya yang membuka halaman laporan.
Tiga mode kegagalan createConnection di produksi:
1. wait_timeout. Koneksi menganggur ditutup server. Query berikutnya melempar PROTOCOL_CONNECTION_LOST, dan objeknya tidak menyambung ulang sendiri — semua query setelah itu gagal sampai proses direstart.
2. Restart MySQL. Maintenance, apt upgrade, OOM killer. Efeknya sama dan permanen sampai restart aplikasi.
3. Satu query berat memblokir semuanya. Tidak ada error, tapi TTFB seluruh situs naik mengikuti query paling lambat yang sedang berjalan.
Tambalan yang sering ditemukan di Stack Overflow — dan kenapa sebaiknya tidak dipakai:
// Anti-pola: ping tiap 5 menit agar koneksi tidak idle
setInterval(() => connection.ping(), 5 * 60 * 1000);
Ini mengatasi wait_timeout dan tidak mengatasi restart MySQL, tidak mengatasi query blocking, dan tidak mengatasi soket yang mati karena NAT timeout. Kamu menambah kode untuk menutupi satu dari tiga gejala.
Cara kerja pool
createPool mengelola sekumpulan koneksi. Setiap pool.execute() meminjam koneksi yang bebas, menjalankan query, lalu mengembalikannya.
// config/db.js
const mysql = require('mysql2');
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: Number(process.env.DB_PORT) || 3306,
waitForConnections: true, // antre bila semua sibuk, jangan langsung error
connectionLimit: 10, // maksimum koneksi bersamaan
queueLimit: 0, // 0 = antrean tak terbatas
enableKeepAlive: true, // TCP keepalive: cegah NAT/firewall memutus diam-diam
keepAliveInitialDelay: 10_000,
idleTimeout: 60_000, // tutup koneksi menganggur sebelum MySQL yang menutup
});
module.exports = pool;
Empat opsi terakhir yang membuat perbedaannya:
waitForConnections: true— saat kesepuluh koneksi sibuk, request ke-11 mengantre. Denganfalse, ia langsung dapat error. Antre hampir selalu perilaku yang benar untuk aplikasi web.queueLimit: 0— antrean tak terbatas. Ini nyaman, tapi berarti lonjakan trafik akan menumpuk permintaan sampai timeout HTTP. Untuk endpoint publik,queueLimit: 100memberi gagal-cepat yang lebih jujur daripada semua orang menunggu 30 detik.enableKeepAlive: true— paket TCP keepalive menjaga jalur tetap hidup melewati NAT dan firewall stateful, yang biasanya memutus koneksi diam setelah 5–15 menit tanpa memberi tahu siapa pun.idleTimeoutdi bawahwait_timeoutMySQL — pool menutup koneksi menganggurnya lebih dulu, jadi tidak pernah ada koneksi mati yang dipinjamkan ke request.
Yang tidak dilakukan pool, dan sering disalahpahami: pool tidak membuat satu query jadi lebih cepat. Query 800 ms tetap 800 ms. Yang berubah adalah query itu tidak lagi menahan sembilan permintaan lain.
Benchmark: 200 request bersamaan
Skrip pengukurnya sederhana, dan sengaja memakai query campuran — kebanyakan cepat, sedikit lambat — karena itulah bentuk beban situs sungguhan.
// bench.js
const mysql = require('mysql2/promise');
async function bench(label, runner) {
const t0 = Date.now();
const latencies = [];
await Promise.all(Array.from({ length: 200 }, async (_, i) => {
const s = Date.now();
// 1 dari 20 request adalah query berat
await runner(i % 20 === 0 ? 'SELECT SLEEP(0.3)' : 'SELECT 1');
latencies.push(Date.now() - s);
}));
latencies.sort((a, b) => a - b);
console.log(`${label}: total ${Date.now() - t0} ms | ` +
`p50 ${latencies[100]} ms | p95 ${latencies[190]} ms | p99 ${latencies[198]} ms`);
}
(async () => {
const conn = await mysql.createConnection(config);
await bench('single ', (q) => conn.execute(q));
await conn.end();
const pool = mysql.createPool({ ...config, connectionLimit: 10 });
await bench('pool(10) ', (q) => pool.execute(q));
const pool25 = mysql.createPool({ ...config, connectionLimit: 25 });
await bench('pool(25) ', (q) => pool25.execute(q));
await pool.end(); await pool25.end();
})();
Hasil pada VPS 2 vCPU / 2 GB, MySQL 8 di host yang sama:
single : total 3421 ms | p50 1690 ms | p95 3280 ms | p99 3390 ms
pool(10) : total 412 ms | p50 38 ms | p95 310 ms | p99 340 ms
pool(25) : total 361 ms | p50 22 ms | p95 305 ms | p99 335 ms
Yang perlu dibaca dari tabel ini bukan angka totalnya, tapi p50. Pada koneksi tunggal, latensi tengah adalah 1,69 detik — padahal 95% query-nya adalah SELECT 1 yang seharusnya selesai dalam hitungan milidetik. Setiap request cepat terjebak di belakang query lambat yang kebetulan lebih dulu masuk antrean. Inilah bentuk teknis dari keluhan "situsnya kadang lemot, kadang tidak".
Perhatikan juga selisih pool(10) dan pool(25): total hanya membaik 12%, p95 praktis sama. Menambah koneksi setelah titik tertentu tidak menambah throughput — batasnya sudah pindah ke CPU dan disk MySQL. Ini alasan menaikkan connectionLimit ke 100 bukan solusi, melainkan cara memindahkan antrean dari aplikasi ke database.
Migrasi tanpa menyentuh semua route
Kabar baiknya: pool mysql2 mengekspos query() dan execute() dengan tanda tangan yang sama seperti connection. Untuk mayoritas kode, migrasi adalah satu file.
- const connection = mysql.createConnection({ ... });
- module.exports = connection;
+ const pool = mysql.createPool({ ..., connectionLimit: 10, enableKeepAlive: true });
+ module.exports = pool;
Route yang sudah menulis db.query(sql, params, cb) atau await db.execute(sql, params) berjalan tanpa perubahan.
Tiga hal yang tidak ikut aman dan wajib dicari sebelum deploy:
1. Transaksi. Ini yang paling berbahaya karena tidak melempar error — ia hanya diam-diam salah:
// SALAH pada pool: setiap perintah bisa memakai koneksi berbeda.
// COMMIT bisa mendarat di koneksi yang tidak pernah melihat BEGIN-nya.
await pool.query('START TRANSACTION');
await pool.query('UPDATE ...');
await pool.query('COMMIT');
// BENAR: pinjam satu koneksi, pakai untuk seluruh transaksi, kembalikan.
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
await conn.execute('UPDATE works SET sort_order = ? WHERE id = ?', [a.order, b.id]);
await conn.execute('UPDATE works SET sort_order = ? WHERE id = ?', [b.order, a.id]);
await conn.commit();
} catch (err) {
await conn.rollback();
throw err;
} finally {
conn.release(); // wajib, termasuk saat error
}
finally { conn.release() } bukan formalitas. Satu jalur error yang lupa melepas koneksi akan menghabiskan pool sepuluh kebocoran kemudian — dan gejalanya adalah situs yang menggantung total, bukan error yang bisa dilacak.
2. LAST_INSERT_ID() sebagai query terpisah. Nilainya per-koneksi. Pada pool, query kedua bisa mendarat di koneksi lain dan mengembalikan ID milik operasi orang lain. Pakai result.insertId dari hasil INSERT, jangan query terpisah.
3. Variabel sesi dan SET apa pun. SET time_zone, SET NAMES, temporary table — semuanya melekat pada satu koneksi. Pindahkan ke konfigurasi pool (timezone, charset) supaya berlaku untuk setiap koneksi baru.
Cari ketiganya sebelum deploy:
grep -rn "START TRANSACTION\|beginTransaction\|LAST_INSERT_ID\|CREATE TEMPORARY\|SET @" routes/ utils/ scripts/
Kalau hasilnya kosong, migrasimu benar-benar satu file.
Menentukan connectionLimit di VPS kecil
Angka default 10 cocok untuk sebagian besar situs, tapi pastikan tidak melampaui kapasitas MySQL. Periksa dulu:
SHOW VARIABLES LIKE 'max_connections'; -- default 151 di MySQL 8
SHOW STATUS LIKE 'Max_used_connections'; -- puncak sejak restart terakhir
SHOW VARIABLES LIKE 'wait_timeout'; -- pastikan idleTimeout pool di bawah ini
Rumus yang aman:
connectionLimit × jumlah instance pm2 ≤ max_connections − 10
Sisa 10 itu untuk cadangan admin — supaya kamu masih bisa masuk lewat CLI ketika aplikasi menghabiskan pool dan perlu didiagnosis.
Contoh: max_connections = 151, pm2 cluster 4 instance → (151 − 10) / 4 = 35. Batas atasnya 35, tapi jangan langsung pakai batas atas. Setiap koneksi MySQL memakan memori untuk buffer per-thread; pada VPS 2 GB, 140 koneksi aktif akan lebih dulu memicu OOM killer daripada melayani trafiknya.
Titik awal yang wajar: connectionLimit: 10 per instance, lalu naikkan hanya kalau kamu melihat bukti antrean:
// Pantau kedalaman antrean — logging kalau pool sedang jenuh
setInterval(() => {
const q = pool.pool._connectionQueue.length;
if (q > 0) console.warn(`[db] ${q} query mengantre, semua koneksi sibuk`);
}, 10_000);
Kalau baris itu tidak pernah muncul selama sepekan, connectionLimit kamu sudah cukup. Menaikkannya hanya menambah beban memori tanpa menambah kecepatan.
Terakhir, matikan pool dengan rapi saat proses berhenti supaya deploy tidak meninggalkan koneksi menggantung:
for (const sig of ['SIGINT', 'SIGTERM']) {
process.on(sig, async () => {
await pool.end();
process.exit(0);
});
}
Langkah hari ini
- Cek apakah kamu memakai koneksi tunggal:
grep -rn "createConnection" config/ utils/. - Jalankan
greptransaksi/LAST_INSERT_IDdi atas. Hasilnya menentukan apakah migrasi ini satu file atau butuh beberapa perbaikan. - Catat
max_connectionsdanMax_used_connectionsdari MySQL. Hitung batas atas dengan rumus di atas. - Ubah
config/db.jskecreatePooldenganconnectionLimit: 10,enableKeepAlive: true, danidleTimeoutdi bawahwait_timeout. - Perbaiki setiap blok transaksi ke pola
getConnection+finally release. - Jalankan
bench.jsdi atas terhadap staging, sebelum dan sesudah. Simpan angka p50-nya — itu bukti paling langsung bahwa "kadang lemot" sudah hilang.
Langkah 1–4 muat dalam satu jam. Yang paling penting justru langkah 5, karena satu transaksi yang lolos akan menghasilkan data yang salah tanpa satu pun error di log.




