Clustering & Scaling — Node.js

Satu proses Node.js = satu thread untuk JavaScript. Komputer modern punya 4, 8, 16 CPU core — tapi aplikasi Node.js default hanya pakai satu. Solusinya…

Satu proses Node menjalankan JavaScript-mu di satu thread, jadi cuma memakai satu core CPU. Di server 4 core, tiga core lainnya menganggur. Clustering menjalankan beberapa salinan catatan-api sekaligus, satu per core, di belakang port 3000 yang sama. Bayangin warung yang buka empat loket. Ada satu penerima tamu di pintu yang mengarahkan pembeli bergiliran: loket 1, 2, 3, 4, lalu kembali ke 1. Tiap loket punya buku catatannya sendiri, dan yang ditulis loket 1 gak diketahui loket 2. Di Node, penerima tamu itu primary, loketnya worker, dan buku catatannya adalah memori tiap proses.

🧪 Tebakan cepat: catatan-api jalan dengan 4 worker. Kamu POST /catatan untuk "Olahraga" dan dapat 201. Lalu kamu GET /catatan. Berapa catatan yang kamu terima? Tebak dulu, jawabannya ada di bagian "Memori gak dibagi antar worker".

Satu proses, satu core

Di pelajaran Event Loop dan Worker Threads kamu sudah lihat: kode JavaScript jalan di satu thread. Worker Threads memindahkan hitungan berat ke thread lain di dalam proses yang sama. Clustering beda: dia menjalankan proses Node utuh beberapa kali, masing-masing dengan event loop dan memorinya sendiri. Berapa yang muat? os.availableParallelism() (Node 18.14 ke atas) memberi jumlah core yang boleh dipakai, misalnya 4. Versi lama memakai os.cpus().length dari pelajaran Built-in Modules.

Membuat cluster dengan modul cluster

app.js tetap seperti biasa. Tambahkan satu logger yang mencetak pid (nomor proses) worker yang menjawab, lalu buat file cluster.js:

// app.js: logger di paling atas, sisanya sama
app.use((req, res, next) => {
  console.log(`[worker ${process.pid}] ${req.method} ${req.url}`);
  next();
});
// cluster.js
const cluster = require("node:cluster");
const os = require("node:os");

if (cluster.isPrimary) {
  const jumlah = os.availableParallelism();
  console.log(`Primary ${process.pid} jalan, fork ${jumlah} worker`);
  for (let i = 0; i < jumlah; i++) {
    cluster.fork(); // jalankan file ini lagi sebagai worker
  }
  cluster.on("exit", (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} mati (${signal || code}), fork pengganti`);
    cluster.fork();
  });
} else {
  require("./app.js"); // worker: jalankan server seperti biasa
}

Hasil node cluster.js (pid-nya contoh, tiap kali beda):

$ node cluster.js
Primary 4810 jalan, fork 4 worker
Server jalan di http://localhost:3000
Server jalan di http://localhost:3000
Server jalan di http://localhost:3000
Server jalan di http://localhost:3000

Empat baris "Server jalan", satu dari tiap worker. Output semua worker muncul di terminal primary.

Siapa menjawab request: round-robin

Primary menerima tiap koneksi baru dan memberikannya ke worker bergiliran (round-robin). Lima kali curl -s localhost:3000/catatan mencetak ini di terminal server:

Request ke- Worker yang menjawab
1 [worker 4811] GET /catatan
2 [worker 4812] GET /catatan
3 [worker 4813] GET /catatan
4 [worker 4814] GET /catatan
5 [worker 4811] GET /catatan, giliran kembali ke awal

Giliran ke-i (mulai 0) jatuh ke worker nomor i % jumlahWorker: sisa bagi itulah yang membuat urutannya berputar. Pembagiannya per koneksi, bukan per request: browser yang menjaga satu koneksi tetap terbuka (keep-alive) mengirim request-request berikutnya ke worker yang sama. Di Windows, pembagiannya diserahkan ke sistem operasi.

Memori gak dibagi antar worker

Array catatan di app.js ada di memori tiap worker, jadi ada empat salinan yang terpisah. Nyalakan ulang cluster, lalu:

Langkah Request Worker catatan di worker itu Jawaban
1 POST /catatan "Olahraga" 4811 3 201, id: 3
2 GET /catatan 4812 2 2 catatan, "Olahraga" gak ada
3 GET /catatan 4813 2 2 catatan
4 GET /catatan 4814 2 2 catatan
5 GET /catatan 4811 3 3 catatan

Itu jawaban tebakannya: tergantung giliran. Cuma worker yang menyimpan yang memberi 3, tiga lainnya memberi 2. Lebih parah lagi, POST berikutnya yang jatuh ke worker 4812 juga membuat id: 3, dengan isi yang beda. Obatnya: simpan data di luar proses, di database (pelajaran Koneksi Database) atau Redis, supaya semua worker membaca tempat yang sama. Hal yang sama berlaku untuk session di memori dan penghitung rate limit.

Worker mati, primary menggantinya

Error yang gak ditangkap mematikan proses. Tanpa cluster, seluruh server ikut mati. Dengan cluster, cuma satu worker:

Worker 4812 mati (1), fork pengganti
Server jalan di http://localhost:3000

Request berikutnya dibagi ke worker yang masih hidup, dan pengganti dengan pid baru (misalnya 4815) ikut antre giliran. Isi memori worker yang mati hilang bersamanya.

PM2 cluster mode: yang biasa dipakai

Di production, cluster.js jarang ditulis sendiri. PM2 (process manager untuk Node) melakukan hal yang sama dengan modul cluster, ditambah restart otomatis, log, dan reload tanpa downtime:

$ pm2 start app.js -i 4 --name catatan-api
[PM2] Starting /home/budi/catatan-api/app.js in cluster_mode (4 instances)
[PM2] Done.

🤔 Cluster, Worker Threads, atau replika container?

Situasi Pilih
API di VPS 4 core, satu core 100% saat ramai cluster atau PM2 cluster mode
Satu endpoint menghitung berat (bikin PDF, hash) Worker Threads
Jalan di Kubernetes atau Docker Swarm 1 proses per container, tambah replika
API kecil yang lebih banyak menunggu database Gak perlu, 1 proses cukup

Cluster menambah berapa banyak request yang bisa dijawab bersamaan, bukan kecepatan satu request. Request yang makan 3 detik CPU tetap 3 detik.

Di latihan: cluster tiruan

Simulator latihan gak bisa mem-fork proses, jadi kode awalnya memberi object cluster tiruan: fork() membuat worker dengan pid 101, 102, 103, dan mencetak Forked worker pid=.... Kamu mencetak Primary 1 forking 3 workers, mem-fork 3 worker dengan for, lalu membagi 6 request dengan cluster.workers[i % cluster.workers.length] sambil mencetak "Request " + (i + 1) + " handled by worker " + worker.pid. Request ke-4 kembali ke worker 101.

⚠️ Jebakan yang sering ketemu

TL;DR: Satu proses Node memakai satu core. cluster membuat primary (cluster.isPrimary, dulu isMaster) yang mem-fork satu worker per core (os.availableParallelism()), dan semuanya berbagi port 3000. Primary membagi koneksi bergiliran (round-robin): giliran ke-i jatuh ke worker i % jumlah. Tiap worker punya memori sendiri, jadi data harus di database atau Redis, bukan di array. Saat worker mati, cluster.on("exit") mem-fork pengganti. Di production, pakai PM2: pm2 start app.js -i max. Cluster menambah kapasitas, bukan kecepatan satu request.

Yang akan kamu pelajari