L
Node.js • Literally Web ID

Membangun Real-Time WebSocket Cluster Berskala Tinggi dengan Node.js dan Redis Pub/Sub

⏱️ 18 menit baca • 📅 14 Oct 2026 • 👁️ 1 views
Membangun Real-Time WebSocket Cluster Berskala Tinggi dengan Node.js dan Redis Pub/Sub

1. Konteks Industri & Analisis Akar Masalah

Dalam lanskap arsitektur aplikasi web modern, kebutuhan akan komunikasi dua arah yang instan (real-time) bukan lagi sekadar fitur tambahan, melainkan sebuah keharusan (core requirement). Dari aplikasi trading keuangan, platform kolaborasi dokumen live, hingga sistem notifikasi instan pada platform ertiaga massal, pengguna menuntut latensi milidetik. Protokol WebSocket lahir untuk menjawab tantangan ini dengan menyediakan saluran komunikasi full-duplex persisten di atas satu koneksi TCP tunggal.

Namun, ketika sebuah aplikasi bertumbuh dari ribuan menjadi ratusan ribu atau jutaan koneksi bersamaan (concurrent connections), arsitektur server tunggal (single-node) akan mengalami kehancuran operasional (bottleneck). Masalah utamanya terletak pada sifat koneksi WebSocket yang stateful. Berbeda dengan protokol HTTP stateless yang dapat di-load balance secara bebas menggunakan pembagi beban tradisional ke sembarang server backend di belakangnya, WebSocket mempertahankan status memori (state) dari sesi klien di dalam ruang memori (RAM) proses Node.js yang melayaninya.

Bottleneck Performa dan Keamanan di Dunia Nyata

  1. Keterbatasan File Descriptor (ULIMIT): Sistem operasi secara default membatasi jumlah berkas atau soket yang dapat dibuka oleh satu proses. Tanpa konfigurasi ulang kernel Linux, sebuah instance Node.js akan mengalami crash saat mencapai batas 1024 atau 65535 koneksi.
  2. Penyalahgunaan Memori (Memory Exhaustion): Setiap objek koneksi WebSocket mengonsumsi memori heap V8 Node.js. Ratusan ribu koneksi idle sekalipun dapat menghabiskan gigabytes RAM.
  3. Isolasi Cluster (The Silo Problem): Ketika Node.js dijalankan di atas banyak proses (menggunakan modul cluster atau didistribusikan ke beberapa server fisik/cloud terpisah di belakang Nginx Load Balancer), klien A yang terhubung ke Server Node.js #1 tidak akan pernah menerima pesan yang dipancarkan oleh klien B yang terhubung ke Server Node.js #2. Pesan terjebak di dalam batas proses lokal masing-masing.

Kondisi Sebelum vs Sesudah Penerapan

  • Sebelum (Arsitektur Monolitik / Multi-Node Terisolasi):

    • Klien terdistribusi secara acak ke berbagai instance backend via Load Balancer.
    • Pesan broadcast dari satu klien hanya diterima oleh klien lain yang kebetulan mendarat di proses server yang sama.
    • Skalabilitas horisontal mustahil dilakukan secara fungsional untuk fitur real-time.
    • Sering terjadi kebocoran memori (memory leak) yang menjatuhkan seluruh aplikasi tanpa peringatan.
  • Sesudah (Arsitektur Horisontal Berbasis Redis Pub/Sub Cluster):

    • Node.js diskalakan secara horizontal di belakang reverse proxy Nginx (menggunakan sticky sessions atau stateless routing).
    • Lapisan Redis Pub/Sub bertindak sebagai message broker terpusat yang menjembatani komunikasi lintas node.
    • Pesan yang diterima oleh Node.js #1 dipublikasikan ke Redis Channel, yang secara instan disiarkan ke Node.js #2, #3, dan seterusnya, untuk diteruskan ke klien masing-masing.
    • Sistem menjadi sangat tangguh (fault-tolerant), mudah diskalakan, dan aman dari kegagalan proses tunggal.

2. Konsep Inti & Deep Dive Arsitektur

Untuk memahami cara kerja WebSocket scaling menggunakan Redis Pub/Sub, kita harus membedah bagaimana aliran data bergerak dari satu klien ke seluruh jaringan klien yang tersebar di berbagai server fisik.

Alur Eksekusi (Data Flow Architecture)

+----------+       +-------------------+       +---------------------+
| Client A | ----> | Nginx Load Balancer| ----> | Node.js Server #1   |
+----------+       +-------------------+       +---------------------+
                                                         |
                                                         v
                                               +---------------------+
                                               | Redis Pub/Sub Broker|
                                               +---------------------+
                                                    ^         ^
         +------------------------------------------+         ||
         |                                                    v
+---------------------+                        +---------------------+
| Node.js Server #2   |                        | Node.js Server #3   |
+---------------------+                        +---------------------+
         ^
         |
+---------------------+
| Client B            |
+---------------------+
  1. Inisiasi Koneksi: Klien melakukan handshake HTTP ke Nginx. Nginx meneruskan permintaan ke salah satu instance Node.js di upstream cluster.
  2. Penyimpanan State Lokal: Node.js menerima koneksi, menetapkan ID unik pada soket, dan menyimpannya di memori lokal (in-memory Map/Set).
  3. Penerbitan Pesan (Publish): Ketika Client A mengirim pesan (misal: chat message), Node.js #1 memprosesnya dan alih-alih hanya mengirimkannya ke klien lokal, ia memanggil perintah redisClient.publish('chat_channel', payloadJSON).
  4. Distribusi Broker: Redis menerima pesan tersebut dan mendistribusikannya ke seluruh subscriber yang mendengarkan chat_channel — termasuk Node.js #1, Node.js #2, dan Node.js #3.
  5. Penerimaan & Penyiaran (Subscribe & Broadcast): Setiap instance Node.js menerima pesan dari Redis melalui event listener, lalu mencari klien yang relevan di memori lokal mereka masing-masing dan menuliskan data ke soket TCP mereka.

3. Perbandingan Komparatif / Analisis Pendekatan

Memilih mekanisme sinkronisasi cluster untuk WebSocket membutuhkan pemahaman mendalam tentang trade-off arsitektural. Berikut adalah perbandingan antara tiga pendekatan populer di industri:

Parameter / Fitur Redis Pub/Sub Apache Kafka RabbitMQ (AMQP)
Latensi Sangat Rendah (< 2ms) Rendah (5-15ms) Sedang (10-30ms)
Kompleksitas Setup Sangat Mudah & Ringan Tinggi (Butuh ZooKeeper/KRaft) Sedang (Erlang VM)
Penyimpanan Pesan (Persistence) Tidak Ada (Fire-and-Forget) Kuat, Berbasis Log Terdistribusi Dapat dikonfigurasi (Queue/Exchange)
Konsumsi Memori & CPU Sangat Rendah Tinggi Sedang
Skalabilitas Throughput Tinggi untuk Real-time messaging Masif (Jutaan pesan/detik) Tinggi
Cocok Untuk WebSocket Chat, Live Notification, Dashboard Event-Sourcing, Data Pipeline, Log Aggregation Task Queue, Kompleks Routing Pesan

[!TIP] Untuk aplikasi WebSocket standar (seperti chat, live tracking, atau collaboration tools), Redis Pub/Sub adalah pilihan terbaik karena latensinya yang mendekati nol dan kesederhanaan operasionalnya. Namun, jika Anda memerlukan message replay (pesan lampau yang terlewat saat klien offline), Anda harus beralih ke Redis Streams atau Apache Kafka.


4. Prasyarat Sistem & Persiapan Lingkungan

Sebelum menulis baris kode pertama, pastikan infrastruktur sistem operasi dan dependensi Anda telah dikonfigurasi dengan benar untuk menangani beban tinggi.

1. Penyesuaian Kernel Linux (/etc/security/limits.conf)

Tingkatkan batas maksimum file descriptor agar server tidak kehabisan soket:

* soft nofile 65535
* hard nofile 65535
root soft nofile 65535
root hard nofile 65535

2. Konfigurasi Redis (/etc/redis/redis.conf)

Pastikan Redis dioptimalkan untuk performa tinggi dan menggunakan kebijakan memori yang tepat:

maxmemory 2gb
maxmemory-policy noeviction
timeout 0
tcp-keepalive 300

3. Struktur Direktori Proyek

ws-cluster-app/
├── package.json
├── server.js
├── cluster.js
└── .env

Inisialisasi proyek Node.js:

mkdir ws-cluster-app && cd ws-cluster-app
npm init -y
npm install ws ioredis dotenv

5. Panduan Implementasi Langkah demi Langkah

Berikut adalah implementasi kode produksi lengkap tanpa menggunakan placeholder.

package.json

{
  "name": "ws-cluster-redis",
  "version": "1.0.0",
  "description": "High-scale WebSocket cluster with Node.js and Redis Pub/Sub",
  "main": "cluster.js",
  "scripts": {
    "start": "node cluster.js"
  },
  "dependencies": {
    "dotenv": "^16.3.1",
    "ioredis": "^5.3.2",
    "ws": "^8.14.2"
  }
}

.env

PORT=3000
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=

server.js

require('dotenv').config();
const http = require('http');
const { WebSocketServer } = require('ws');
const Redis = require('ioredis');

const PORT = process.env.PORT || 3000;
const INSTANCE_ID = `node-${process.pid}`;

// Inisialisasi HTTP Server
const server = http.createServer((req, res) => {
  if (req.url === '/health') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'UP', instance: INSTANCE_ID, connections: clients.size }));
    return;
  }
  res.writeHead(404, { 'Content-Type': 'text/plain' });
  res.end('Not Found');
});

// Inisialisasi WebSocket Server
const wss = new WebSocketServer({ server });

// Penyimpanan klien lokal (Map untuk akses O(1))
const clients = new Map();

// Redis Client untuk Publisher
const pub = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  password: process.env.REDIS_PASSWORD || undefined,
});

// Redis Client untuk Subscriber (Wajib terpisah karena mode sub)
const sub = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  password: process.env.REDIS_PASSWORD || undefined,
});

const CHANNEL_NAME = 'websocket_global_channel';

sub.subscribe(CHANNEL_NAME, (err, count) => {
  if (err) {
    console.error(`[${INSTANCE_ID}] Failed to subscribe to Redis channel:`, err);
  } else {
    console.log(`[${INSTANCE_ID}] Subscribed to Redis channel. Total subscriptions: ${count}`);
  }
});

// Tangkap pesan dari Redis Pub/Sub
sub.on('message', (channel, messageString) => {
  if (channel !== CHANNEL_NAME) return;

  try {
    const parsedMessage = JSON.parse(messageString);
    
    // Jangan kirim balik ke pengirim asli jika berasal dari instance ini (opsional)
    // if (parsedMessage.senderInstance === INSTANCE_ID) return;

    const dataToSend = JSON.stringify(parsedMessage.payload);

    // Broadcast ke seluruh klien yang terhubung ke instance Node.js ini
    for (const [clientId, ws] of clients.entries()) {
      if (ws.readyState === ws.OPEN) {
        ws.send(dataToSend);
      }
    }
  } catch (parseError) {
    console.error(`[${INSTANCE_ID}] Error parsing message from Redis:`, parseError);
  }
});

// Manajemen Koneksi WebSocket
wss.on('connection', (ws, req) => {
  const clientId = generateUniqueId();
  
  // Tandai koneksi aktif untuk heartbeat
  ws.isAlive = true;
  clients.set(clientId, ws);

  console.log(`[${INSTANCE_ID}] Client connected: ${clientId}. Total local clients: ${clients.size}`);

  ws.on('pong', () => {
    ws.isAlive = true;
  });

  ws.on('message', async (rawMessage) => {
    try {
      const message = JSON.parse(rawMessage.toString());
      
      // Publikasikan pesan ke Redis untuk didistribusikan ke seluruh cluster
      const payloadData = {
        senderInstance: INSTANCE_ID,
        senderClient: clientId,
        payload: message,
        timestamp: Date.now()
      };

      await pub.publish(CHANNEL_NAME, JSON.stringify(payloadData));
    } catch (err) {
      console.error(`[${INSTANCE_ID}] Error handling incoming message:`, err);
    }
  });

  ws.on('close', () => {
    clients.delete(clientId);
    console.log(`[${INSTANCE_ID}] Client disconnected: ${clientId}. Total local clients: ${clients.size}`);
  });

  ws.on('error', (error) => {
    console.error(`[${INSTANCE_ID}] WebSocket error on client ${clientId}:`, error);
    ws.terminate();
  });
});

// Mekanisme Heartbeat Ping/Pong untuk mendeteksi Dead Connection
const heartbeatInterval = setInterval(() => {
  for (const [clientId, ws] of clients.entries()) {
    if (ws.isAlive === false) {
      console.log(`[${INSTANCE_ID}] Terminating inactive client: ${clientId}`);
      clients.delete(clientId);
      return ws.terminate();
    }

    ws.isAlive = false;
    ws.ping();
  }
}, 30000);

wss.on('close', () => {
  clearInterval(heartbeatInterval);
});

server.listen(PORT, () => {
  console.log(`[${INSTANCE_ID}] Server running on port ${PORT}`);
});

function generateUniqueId() {
  return 'client_' + Math.random().toString(36).substring(2, 11) + Date.now().toString(36);
}

cluster.js (Memanfaatkan Multi-Core CPU dengan Modul Cluster)

const cluster = require('cluster');
const os = require('os');

if (cluster.isMaster) {
  const numCPUs = os.cpus().length;
  console.log(`Master process ${process.pid} is setting up ${numCPUs} workers...`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker process ${worker.process.pid} died. Spawning a new worker...`);
    cluster.fork();
  });
} else {
  require('./server.js');
}

6. Bedah Kode & Penjelasan Detail Baris Kunci

  1. Pemisahan Redis Client (pub dan sub):

    • Di perpustakaan ioredis, ketika sebuah klien masuk ke mode subscribe, koneksi tersebut dikunci khusus untuk menerima pesan saluran (subscription mode). Klien tersebut tidak dapat lagi menjalankan perintah database biasa seperti SET, GET, atau PUBLISH. Oleh karena itu, kita wajib mendeklarasikan dua instance terpisah: pub untuk menerbitkan pesan dan sub untuk mendengarkan pesan.
  2. Struktur Map Lokal (clients):

    • Penggunaan Map JavaScript ketimbang objek biasa memberikan performa lookup, insertion, dan deletion dengan kompleksitas waktu O(1), sangat krusial saat melakukan iterasi broadcast ke ratusan ribu klien secara bersamaan.
  3. Mekanisme Heartbeat Ping/Pong:

    • ws.isAlive = false diset sebelum mengirimkan sinyal ws.ping(). Jika klien merespons dengan peristiwa pong, bendera diubah kembali menjadi true. Jika dalam interval 30 detik tidak ada respons, soket diputus secara paksa menggunakan ws.terminate() guna menghindari socket hanging yang memboroskan file descriptor.

7. Skenario Produksi & Studi Kasus Dunia Nyata

Konfigurasi Nginx sebagai Load Balancer & Reverse Proxy (/etc/nginx/sites-available/ws-cluster)

Agar Nginx dapat meneruskan lalu lintas WebSocket dan mendukung koneksi persisten keep-alive, konfigurasi berikut wajib diterapkan:

upstream websocket_cluster {
    # Menggunakan IP Hash atau Least Connections untuk distribusi
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

server {
    listen 80;
    server_name ws.enterprise.com;

    location / {
        proxy_pass http://websocket_cluster;
        proxy_http_version 1.1;
        
        # Header wajib untuk upgrade protokol HTTP ke WebSocket
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Timeout perpanjangan untuk koneksi persisten (default 60s terlalu cepat)
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

8. Common Pitfalls, Anti-Patterns & Solusi

  • Anti-Pattern 1: Menggunakan Satu Redis Client untuk Segala Hal.
    • Dampak: Aplikasi mengalami error Connection in subscriber mode saat mencoba melakukan perintah Redis lain setelah melakukan subscribe.
    • Solusi: Selalu pisahkan instance Redis untuk pub dan sub seperti yang ditunjukkan pada kode panduan.
  • Anti-Pattern 2: Tidak Menangani Error Event pada WebSocket Klien.
    • Dampak: Ketika koneksi jaringan terputus secara tiba-tiba tanpa sinyal penutup yang bersih, Node.js dapat mengalami Uncaught Exception dan seluruh proses worker mengalami crash.
    • Solusi: Selalu pasang penanganan event listener .on('error', ...) pada setiap instance koneksi WebSocket.
  • Anti-Pattern 3: Broadcast Tanpa Filter Instance (Echo Loop).
    • Dampak: Pesan dikirim kembali ke pengirim asli secara berlebihan jika tidak dikelola dengan benar, memicu lonjakan penggunaan bandwidth.
    • Solusi: Sertakan pengenal instance unik (senderInstance) pada payload pesan untuk mencegah pengiriman ulang yang tidak perlu.

9. Benchmark, Metrik Kinerja & Tuning Performa

Untuk menguji ketahanan klaster WebSocket yang telah dibangun, gunakan tool pengujian beban modern seperti autocannon atau artillery. Di sini kita menggunakan artillery untuk simulasi concurrent connections.

Skrip Uji Beban Artillery (artillery-config.yml)

config:
  target: "ws://127.0.0.1:3000"
  phases:
    - duration: 60
      arrivalRate: 50
      rampTo: 500
      name: "Ramp up connections"
scenarios:
  - engine: "ws"
    flow:
      - send: "{\"action\": \"ping\", \"message\": \"Hello Cluster\"}"
      - think: 5

Jalankan perintah pengujian:

npx artillery run artillery-config.yml

Metrik Pemantauan Utama (Monitoring Metrics)

  1. CPU Usage per Core: Pastikan beban kerja terdistribusi merata ke seluruh core CPU melalui modul cluster.
  2. Heap Memory Growth: Pantau apakah terjadi peningkatan penggunaan memori heap V8 secara linier yang menandakan adanya memory leak pada penyimpanan struktur data clients Map.
  3. Redis Pub/Sub Latency: Pastikan Redis tidak mengalami antrean pesan (output buffer limit) akibat tingginya frekuensi publikasi.

10. Checklist Penerapan Produksi & Kesimpulan

Sebelum meluncurkan sistem ke lingkungan produksi (Production Deployment), pastikan Anda telah melewati daftar pemeriksaan berikut:

  • Konfigurasi ulimit kernel Linux telah ditingkatkan secara permanen.
  • Redis diamankan menggunakan password strong authentication dan diatur di jaringan privat terisolasi.
  • Nginx telah dikonfigurasi dengan proxy_read_timeout yang memadai untuk koneksi jangka panjang.
  • Mekanisme Heartbeat Ping/Pong aktif untuk mendeteksi zombie connections.
  • Sistem pemantauan (Prometheus + Grafana) terpasang untuk melacak metrik RAM, CPU, dan jumlah koneksi aktif.

Dengan menerapkan arsitektur WebSocket berbasis Redis Pub/Sub ini, aplikasi Anda kini memiliki fondasi yang tangguh, skalabel, dan siap menangani jutaan pengguna secara bersamaan tanpa mengorbankan performa atau stabilitas sistem.

🏷️ Tags: #Nginx #Redis #Websocket #System Architecture #Node.js