Arsitektur Distributed WebSocket Cluster dengan Redis Pub/Sub
1. Konteks Industri & Analisis Akar Masalah
Dalam lanskap pengembangan web modern, kebutuhan akan komunikasi real-time dua arah (bidirectional) telah bergeser dari fitur tambahan menjadi standar fundamental. Aplikasi seperti dasbor finansial berkecepatan tinggi, platform kolaborasi dokumen real-time, sistem chatting enterprise, hingga notifikasi IoT menuntut latensi milidetik. Protokol WebSocket menjadi standar emas untuk menangani beban ini karena ia memelihara koneksi TCP persisten, memotong overhead HTTP handshake yang berulang pada setiap pengiriman data.
Namun, ketika sebuah aplikasi bertumbuh dan trafik melonjak dari ribuan menjadi jutaan pengguna bersamaan (concurrent users), satu server backend tunggal (single instance) akan mencapai titik jenuh (bottleneck). Batasan ini umumnya bukan pada kapasitas memori atau CPU semata, melainkan pada batasan deskriptor file sistem operasi (file descriptors) dan kapasitas jaringan untuk menangani jutaan koneksi TCP aktif.
Untuk mengatasi hal ini, arsitek infrastruktur menerapkan penskalaan horizontal (horizontal scaling) dengan menempatkan beberapa node server WebSocket di belakang sebuah Load Balancer (seperti Nginx atau HAProxy) menggunakan algoritma Sticky Sessions. Meskipun pendekatan ini mendistribusikan beban koneksi klien, ia memperkenalkan masalah arsitektural yang masif: Isolasi Memori Sesi (State Fragmentation).
Kondisi Sebelum Penerapan (Single Instance atau Multi-Node Tanpa Sinkronisasi)
- Klien A terhubung ke Node Backend 1.
- Klien B terhubung ke Node Backend 2.
- Ketika Klien A mengirim pesan yang ditujukan kepada Klien B, Node 1 menerima payload tersebut.
- Karena Node 1 tidak memiliki referensi memori atau tabel routing lokal yang mengetahui bahwa Klien B terhubung ke Node 2, pesan tersebut gagal dikirim atau hilang, kecuali jika kedua klien kebetulan mendarat di node server fisik yang sama.
Kondisi Sesudah Penerapan (Distributed WebSocket Cluster dengan Redis Pub/Sub)
- Node 1 dan Node 2 dihubungkan oleh lapisan message broker terpusat menggunakan Redis Pub/Sub.
- Ketika Klien A mengirim pesan ke Klien B melalui Node 1, Node 1 mempublikasikan pesan tersebut ke sebuah channel Redis.
- Node 2, yang melakukan subscribe ke channel tersebut, segera menerima pesan secara asinkron dan meneruskannya ke Klien B yang terhubung secara lokal.
- Sistem tetap sinkron, skalabel, dan tahan terhadap kegagalan node individual.
[!IMPORTANT] Mengandalkan sticky sessions saja pada Load Balancer bukanlah solusi jangka panjang untuk arsitektur terdistribusi. Kegagalan pada satu node atau penyeimbangan ulang trafik akan merusak state koneksi jika tidak didukung oleh distributed message broker seperti Redis.
2. Konsep Inti & Deep Dive Arsitektur
Arsitektur kluster WebSocket terdistribusi mengandalkan pemisahan peran (separation of concerns) antara manajemen koneksi klien (connection termination) dan perutean pesan lintas server (cross-server message routing).
Alur Eksekusi Data (Data Flow)
+----------+ +-------------------+ +---------------------+
| Client A | <---> | Nginx Load Balancer| <---> | Node Backend Server 1|
+----------+ +-------------------+ +---------------------+
|
v
+----------+ +-------------------+ +---------------------+
| Client B | <---> | Nginx Load Balancer| <---> | Node Backend Server 2|
+----------+ +-------------------+ +---------------------+
^ ^
| |
+------- Redis Pub/Sub -----+
(Message Broker)
- Koneksi Masuk: Klien melakukan HTTP GET dengan header
Upgrade: websocketke Load Balancer. Load Balancer meneruskan koneksi ini ke salah satu Node Backend (misal: Node 1). - Registrasi Sesi Lokal: Node 1 mencatat pemetaan
ClientID -> WebSocket Connection Objectdi dalam memori lokalnya. - Publikasi Pesan: Ketika ada event yang harus disiarkan (broadcast) atau dikirim ke pengguna di node lain, Node 1 memanggil perintah Redis
PUBLISH channel_name payload. - Konsumsi & Forwarding: Redis mendistribusikan payload tersebut ke semua node yang melakukan subscribe ke
channel_name(termasuk Node 2). - Pengiriman ke Klien: Node 2 menerima pesan dari Redis, memeriksa tabel routing lokalnya, menemukan objek koneksi untuk Klien B, lalu menuliskan frame data langsung ke soket TCP Klien B.
3. Perbandingan Komparatif / Analisis Pendekatan
Berikut adalah analisis mendalam mengenai tiga pendekatan arsitektur untuk menangani penskalaan WebSocket:
| Parameter Komparasi | Pendekatan A: Sticky Sessions Saja | Pendekatan B: Redis Pub/Sub Cluster | Pendekatan C: Apache Kafka / RabbitMQ |
|---|---|---|---|
| Kompleksitas Implementasi | Rendah | Sedang | Tinggi |
| Latensi Pesan | Sangat Rendah (Lokal) | Rendah (< 5ms) | Sedang (10-50ms) |
| Penggunaan Sumber Daya (RAM/CPU) | Sangat Rendah | Sedang | Tinggi |
| Ketahanan Pesan (Persistence) | Tidak Ada | Volatile (In-Memory) | Persisten di Disk |
| Skalabilitas Horizontal | Terbatas (Bergantung LB) | Tinggi (Jutaan Klien) | Sangat Tinggi |
| Kasus Penggunaan Ideal | Aplikasi skala kecil / MVP | Chat, Notifikasi, Dashboard | Event-sourcing kompleks, FinTech |
[!TIP] Redis Pub/Sub sangat ideal untuk WebSocket karena karakternya yang fire-and-forget (tidak menyimpan pesan di disk, sehingga latensi minimal). Jika aplikasi Anda menuntut jaminan bahwa pesan tidak boleh hilang meskipun server down, gunakan Kafka atau RabbitMQ sebagai alternatif.
4. Prasyarat Sistem & Persiapan Lingkungan
Sebelum membangun kluster, pastikan infrastruktur dasar siap dan memenuhi spesifikasi produksi:
Spesifikasi Minimum Environment
- OS: Ubuntu Server 22.04 LTS (x86_64)
- Runtime Backend: Node.js v20.x LTS / Go 1.22+
- Message Broker: Redis Server 7.2+
- Load Balancer: Nginx 1.24+ (mendukung proxy_pass upgrade HTTP)
- Kernel Tuning (
/etc/sysctl.conf) untuk menangani jutaan file descriptors:
fs.file-max = 2097152
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
Terapkan perubahan kernel di atas dengan perintah terminal:
sudo sysctl -p
5. Panduan Implementasi Langkah demi Langkah
Berikut adalah implementasi penuh menggunakan Node.js, Express, pustaka ws, dan klien ioredis untuk terhubung ke kluster Redis.
Struktur Direktori Proyek
websocket-cluster/
├── package.json
├── server.js
└── ecosystem.config.js
1. package.json
{
"name": "websocket-cluster-node",
"version": "1.0.0",
"description": "Distributed WebSocket Server with Redis Pub/Sub",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"ioredis": "^5.3.2",
"ws": "^8.16.0"
}
}
2. server.js (Kode Aplikasi Utama)
const http = require('http');
const { WebSocketServer } = require('ws');
const Redis = require('ioredis');
// Konfigurasi port dari environment variable agar bisa di-scaling
const PORT = process.env.PORT || 3000;
const INSTANCE_ID = process.env.INSTANCE_ID || `node-${Math.random().toString(36).substring(7)}`;
// Inisialisasi HTTP Server dan WebSocket Server
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`WebSocket Server Instance: ${INSTANCE_ID}\n`);
});
const wss = new WebSocketServer({ server });
// Inisialisasi Redis Client untuk Publisher dan Subscriber
// Redis Pub/Sub membutuhkan koneksi terpisah untuk sub dan pub
const redisPub = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
});
const redisSub = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: process.env.REDIS_PORT || 6379,
});
const GLOBAL_CHANNEL = 'websocket_cluster_channel';
// Map lokal untuk menyimpan koneksi klien yang terhubung ke instance ini
// Key: userId, Value: WebSocket Connection Object
const localClients = new Map();
// Subscribe ke channel Redis
redisSub.subscribe(GLOBAL_CHANNEL, (err, count) => {
if (err) {
console.error(`[${INSTANCE_ID}] Failed to subscribe to Redis channel:`, err);
} else {
console.log(`[${INSTANCE_ID}] Subscribed to Redis channel successfully. Active channels: ${count}`);
}
});
// Handler ketika pesan diterima dari Redis Pub/Sub (berasal dari node lain atau node ini sendiri)
redisSub.on('message', (channel, messageString) => {
if (channel === GLOBAL_CHANNEL) {
try {
const parsedMessage = JSON.parse(messageString);
const { targetUserId, senderId, payload } = parsedMessage;
// Jika pesan ditujukan untuk user tertentu yang terhubung ke instance ini
if (targetUserId) {
const clientSocket = localClients.get(targetUserId);
if (clientSocket && clientSocket.readyState === WebSocket.OPEN) {
clientSocket.send(JSON.stringify({
from: senderId,
data: payload,
viaInstance: INSTANCE_ID
}));
}
} else {
// Broadcast ke seluruh klien lokal yang terhubung ke instance ini
localClients.forEach((clientSocket, userId) => {
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.send(JSON.stringify({
from: senderId,
data: payload,
viaInstance: INSTANCE_ID
}));
}
});
}
} catch (e) {
console.error(`[${INSTANCE_ID}] Error parsing incoming Redis message:`, e);
}
}
});
// Handler koneksi WebSocket masuk
wss.on('connection', (ws, req) => {
// Contoh ekstraksi userId dari URL query string (misal: ws://domain.com/ws?userId=user_123)
const urlParams = new URLSearchParams(req.url.split('?')[1]);
const userId = urlParams.get('userId') || `guest-${Math.random().toString(36).substring(7)}`;
console.log(`[${INSTANCE_ID}] Client connected: ${userId}`);
localClients.set(userId, ws);
ws.on('message', async (data) => {
try {
const messageObj = JSON.parse(data.toString());
console.log(`[${INSTANCE_ID}] Received message from ${userId}:`, messageObj);
// Publikasikan pesan ke Redis Pub/Sub agar seluruh node kluster dapat memprosesnya
const broadcastPayload = {
targetUserId: messageObj.targetUserId || null, // null jika broadcast
senderId: userId,
payload: messageObj.payload
};
await redisPub.publish(GLOBAL_CHANNEL, JSON.stringify(broadcastPayload));
} catch (e) {
console.error(`[${INSTANCE_ID}] Error processing client message:`, e);
}
});
ws.on('close', () => {
console.log(`[${INSTANCE_ID}] Client disconnected: ${userId}`);
localClients.delete(userId);
});
ws.on('error', (error) => {
console.error(`[${INSTANCE_ID}] WebSocket error for ${userId}:`, error);
localClients.delete(userId);
});
});
server.listen(PORT, () => {
console.log(`[${INSTANCE_ID}] WebSocket server running on port ${PORT}`);
});
3. Konfigurasi Nginx Load Balancer (/etc/nginx/sites-available/websocket_cluster)
upstream websocket_backend {
# Menggunakan ip_hash untuk memastikan sticky sessions berdasarkan IP klien
ip_hash;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
server {
listen 80;
server_name ws.enterprise.local;
location / {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
# Header krusial untuk upgrade koneksi 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;
# Timeout panjang agar koneksi WebSocket tidak terputus prematur
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
6. Bedah Kode & Penjelasan Detail Baris Kunci
- Pemisahan
redisPubdanredisSub: Dalam spesifikasi klien Redis Node.js (ioredis), ketika sebuah koneksi masuk ke mode subscriber (SUBSCRIBE), koneksi tersebut tidak dapat menjalankan perintah lain selain menerima pesan dari channel yang di-subscribe. Oleh karena itu, kita wajib menginisialisasi dua instance klien Redis terpisah: satu untuk mempublikasikan data (redisPub) dan satu lagi untuk mendengarkan pesan (redisSub). - Manajemen Memori
localClients(Map): Struktur dataMapdigunakan untuk menyimpan referensi objek soket berdasarkanuserId. Kompleksitas waktu pencarian (lookup) adalah $O(1)$, memastikan pengiriman pesan lokal berlangsung secara instan bahkan ketika satu node melayani puluhan ribu koneksi. - Directive
ip_hashdi Nginx: Meskipun kita menggunakan Redis Pub/Sub untuk mengatasi isolasi state, penerapanip_hashpada Load Balancer membantu mengurangi beban cross-server message broadcasting yang tidak perlu apabila komunikasi cenderung terjadi antar klien di kluster yang sama.
7. Skenario Produksi & Studi Kasus Dunia Nyata
Bayangkan sebuah platform perdagangan saham (Stock Trading Platform) dengan 500.000 pengguna aktif harian yang menerima pembaruan harga instan.
Ketika terjadi pergerakan harga saham emiten tertentu, matching engine backend menulis harga baru ke Redis. Melalui mekanisme Redis Pub/Sub, seluruh node WebSocket backend (misal 10 server node di balik Auto-Scaling Group) secara simultan menerima event harga baru tersebut. Setiap node kemudian menyiarkan pembaruan harga ke ribuan klien yang terhubung ke masing-masing node secara paralel tanpa ada jeda atau inkonsistensi data harga.
8. Common Pitfalls, Anti-Patterns & Solusi
- Anti-Pattern: Menggunakan Satu Koneksi Redis untuk Pub dan Sub
- Dampak: Menimbulkan error “Connection in subscriber mode, only subscriber commands may be used”.
- Solusi: Selalu pisahkan instance koneksi
Redisuntuk operasi tulis (PUBLISH) dan baca (SUBSCRIBE).
- Pitfall: Kebocoran Memori (Memory Leak) pada
localClientsMap- Dampak: Ketika koneksi klien terputus secara tidak normal (misalnya jaringan seluler terputus tiba-tiba), event
closekadang tidak langsung terpicu, menyebabkan memori server membengkak. - Solusi: Implementasikan mekanisme heartbeat (ping/pong frame WebSocket) secara berkala setiap 30 detik untuk mendeteksi koneksi mati dan membersihkan memori secara proaktif.
- Dampak: Ketika koneksi klien terputus secara tidak normal (misalnya jaringan seluler terputus tiba-tiba), event
9. Benchmark, Metrik Kinerja & Tuning Performa
Untuk menguji ketahanan kluster, gunakan load testing tool khusus WebSocket seperti artillery atau skrip kustom berbasis Go/Node.js.
Contoh Perintah Pengujian Menggunakan Artillery
Buat file konfigurasi load-test.yml:
config:
target: "http://ws.enterprise.local"
phases:
- duration: 60
arrivalRate: 100
rampTo: 500
engines:
ws: {}
scenarios:
- engine: "ws"
flow:
- connect: "/?userId=user_{{ $randomString() }}"
- send: '{"targetUserId": null, "payload": "Hello Cluster"}'
- think: 5
Jalankan pengujian:
artillery run load-test.yml
Tuning Lanjutan: Pastikan parameter ulimit -n dinaikkan pada sistem operasi agar server tidak mengalami error Too many open files saat melayani ratusan ribu koneksi TCP.
10. Checklist Penerapan Produksi & Kesimpulan
- Konfigurasi kernel sysctl untuk max file descriptors telah diterapkan.
- Pemisahan instance
redisPubdanredisSubterkonfigurasi dengan benar di kode backend. - Mekanisme Heartbeat (Ping/Pong) aktif untuk mendeteksi dead connection.
- Load Balancer dikonfigurasi dengan timeout panjang dan proxy upgrade headers yang valid.
- Monitoring metrik CPU, RAM, dan jumlah koneksi aktif Redis & Node.js terhubung ke sistem APM (Prometheus/Grafana).
Dengan mengintegrasikan kluster WebSocket dan Redis Pub/Sub secara tepat, arsitek infrastruktur dapat memastikan skalabilitas horisontal yang mulus, latensi rendah, serta ketersediaan sistem yang tinggi untuk aplikasi real-time enterprise.