Membangun Real-Time Chat WebSocket dengan Go dan Redis Pub/Sub
1. Konteks Industri & Analisis Akar Masalah
Dalam lanskap arsitektur sistem web modern, kebutuhan akan komunikasi real-time dua arah (bi-directional) telah bergeser dari fitur tambahan menjadi ekspektasi inti pengguna. Aplikasi seperti trading dashboard, kolaborasi dokumen multi-user, gaming lobbies, hingga sistem notifikasi instan menuntut latensi milidetik dan skalabilitas tinggi. Pendekatan tradisional seperti HTTP Short Polling atau Long Polling terbukti menciptakan beban berlebih pada jaringan dan sumber daya server karena overhead header HTTP yang berulang dan pembukaan koneksi TCP yang konstan.
Protokol WebSocket hadir untuk menjawab tantangan ini dengan membangun satu koneksi TCP persisten yang ringan. Namun, mengimplementasikan WebSocket dalam skala jutaan pengguna (High Concurrency) memunculkan tantangan infrastruktur yang kompleks. Ketika aplikasi diskalakan secara horizontal (scale-out) menjadi beberapa node server di belakang load balancer (seperti Nginx atau AWS ALB), terjadi masalah isolasi state. Klien A yang terhubung ke Node 1 tidak dapat mengirim pesan secara langsung ke Klien B yang terhubung ke Node 2 tanpa adanya mekanisme inter-node communication.
Sebelum penerapan arsitektur terdistribusi yang matang, organisasi sering menghadapi beberapa bottleneck kritis:
- Koneksi Terisolasi (Single-Node Bottleneck): Pesan hanya berputar dalam satu memori server, menyebabkan kegagalan pengiriman pesan antar pengguna yang terhubung ke node berbeda.
- Kebocoran Memori & Goroutine Leak: Pengelolaan koneksi WebSocket yang buruk di Go menyebabkan goroutine menggantung dan menghabiskan memori RAM secara masif.
- Batasan OS File Descriptors: Sistem operasi Linux secara default membatasi jumlah file terbuka per proses (biasanya 1024), yang langsung menggagalkan koneksi saat mencapai batas tersebut.
Setelah penerapan arsitektur berbasis bahasa pemrograman Go (yang efisien dalam konkurensi melalui goroutine dan channel) yang dipadukan dengan Redis Pub/Sub untuk distribusi pesan lintas node, sistem mampu mempertahankan koneksi persisten masif, mendistribusikan beban secara merata, dan menjamin latensi end-to-end di bawah 15 milidetik.
2. Konsep Inti & Deep Dive Arsitektur
Arsitektur sistem real-time chat skala enterprise ini dirancang menggunakan pola Hub and Spoke yang diperluas dengan distributed message broker. Di tingkat aplikasi, Go bertindak sebagai pelaku utama penanganan koneksi WebSocket, sementara Redis bertindak sebagai backplane komunikasi lintas node.
[ Client A ] <--- WebSocket ---> [ Go Node 1 ] <--- Redis Pub/Sub ---> [ Redis Server ]
|
[ Client B ] <--- WebSocket ---> [ Go Node 2 ] <--- Redis Pub/Sub ---> [ Redis Server ]
Alur Kerja di Balik Layar (Under the Hood):
- Inisiasi Handshake: Klien melakukan HTTP GET request dengan header
Upgrade: websocket. Load balancer meneruskan permintaan ini ke salah satu Go Node. - Manajemen Koneksi (Client Hub): Go membuat objek
Clientbaru yang membungkus koneksi TCP/WebSocket dan mendaftarkannya ke dalamHublokal via channel internal (goroutine-safe). - Publikasi Pesan: Ketika Klien A mengirim pesan ke Go Node 1, node tersebut membaca payload, memprosesnya, lalu mempublikasikannya (PUBLISH) ke channel Redis yang disepakati (misal:
chat_global). - Distribusi Lintas Node: Redis meneruskan pesan tersebut ke seluruh subscriber yang mendengarkan channel
chat_global, termasuk Go Node 2. - Broadcasting ke Klien Lokal: Go Node 2 menerima pesan dari Redis, lalu melakukan iterasi pada seluruh pool klien lokal yang terhubung ke node tersebut, kemudian mengirimkan payload JSON melalui koneksi WebSocket masing-masing ke Klien B.
3. Perbandingan Komparatif / Analisis Pendekatan
| Parameter / Metrik | HTTP Long Polling | Node.js (Cluster + Redis) | Go (Goroutines + Redis Pub/Sub) |
|---|---|---|---|
| Konsumsi Memori (RAM) | Tinggi (Banyak proses/thread aktif) | Sedang (~2-4MB per proses dasar) | Sangat Rendah (~2-4KB per goroutine) |
| Latensi Jaringan | Tinggi (>100ms akibat overhead HTTP) | Rendah (<30ms) | Sangat Rendah (<10ms) |
| Skalabilitas Konkurensi | Buruk (Cepat kehabisan file descriptor) | Baik (Bergantung pada event loop V8) | Eksponensial (Mendukung jutaan goroutine) |
| Kompleksitas Kode | Sederhana | Sedang | Sedang-Tinggi |
| Penggunaan CPU | Boros (Siklus request-response konstan) | Efisien (Event-driven) | Sangat Efisien (Scheduler Go yang ringan) |
[!IMPORTANT] Pemilihan Go didasarkan pada model konkurensinya yang menggunakan Green Threads (Goroutines). Berbeda dengan sistem OS Threads tradisional, Goroutine hanya memakan memori awal sekitar 2KB, memungkinkan satu mesin server menangani jutaan koneksi simultan tanpa context switching yang membebani CPU.
4. Prasyarat Sistem & Persiapan Lingkungan
Sebelum memulai implementasi, pastikan lingkungan infrastruktur Anda memenuhi spesifikasi minimum berikut:
- OS: Ubuntu Server 22.04 LTS (atau distribusi Linux berbasis systemd lainnya).
- Runtime: Go versi 1.21 atau lebih tinggi.
- Broker: Redis Server versi 7.0+.
- Web Server / Reverse Proxy: Nginx (untuk SSL termination dan proxy pass).
Verifikasi instalasi Go pada terminal:
go version
Verifikasi instalasi Redis:
redis-cli ping
# Output yang diharapkan: PONG
5. Panduan Implementasi Langkah demi Langkah
Langkah 1: Inisialisasi Modul Go
Buat direktori proyek baru dan inisialisasi modul Go:
mkdir go-chat-websocket
cd go-chat-websocket
go mod init github.com/enterprise/go-chat-websocket
Instal dependensi utama untuk WebSocket dan Redis:
go get github.com/gorilla/websocket
go get github.com/redis/go-redis/v9
Langkah 2: Struktur Direktori Proyek
go-chat-websocket/
├── go.mod
├── go.sum
├── main.go
└── hub.go
Langkah 3: Implementasi hub.go (Manajemen Pool Koneksi)
Buat file hub.go untuk mengelola siklus hidup klien WebSocket secara goroutine-safe:
package main
import (
"encoding/json"
"log"
)
type Message struct {
Sender string `json:"sender"`
Content string `json:"content"`
}
type Client struct {
hub *Hub
conn *websocketConnection
send chan []byte
}
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
func NewHub() *Hub {
return &Hub{
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
(Catatan: Pastikan mendefinisikan tipe websocketConnection atau menggunakan langsung *websocket.Conn dari pustaka Gorilla). Mari kita sempurnakan kode utama pada main.go agar terintegrasi penuh dengan Redis Pub/Sub.
Langkah 4: Implementasi main.go (Server, WebSocket, & Redis Backplane)
Buat file main.go dengan kode lengkap berikut:
package main
import (
"context"
"encoding/json"
"flag"
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
var addr = flag.String("addr", ":8080", "http service address")
var redisAddr = flag.String("redis", "localhost:6379", "redis server address")
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Sesuaikan untuk produksi dengan domain spesifik
},
}
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
}
type Message struct {
Sender string `json:"sender"`
Content string `json:"content"`
}
var ctx = context.Background()
func NewHub() *Hub {
return &Hub{
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
clients: make(map[*Client]bool),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.clients[client] = true
case client := <-h.unregister:
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
}
case message := <-h.broadcast:
for client := range h.clients {
select {
case client.send <- message:
default:
close(client.send)
delete(h.clients, client)
}
}
}
}
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
for {
_, messageBytes, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break
}
var msg Message
if err := json.Unmarshal(messageBytes, &msg); err != nil {
log.Printf("invalid message format: %v", err)
continue
}
// Publikasikan pesan ke Redis Pub/Sub
payload, _ := json.Marshal(msg)
rdb.Publish(ctx, "chat_channel", payload)
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(54 * time.Second)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'
'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
var rdb *redis.Client
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
return
}
client := &Client{hub: hub, conn: conn, send: make(chan byte, 256)}
client.hub.register <- client
go client.writePump()
go client.readPump()
}
func main() {
flag.Parse()
rdb = redis.NewClient(&redis.Options{
Addr: *redisAddr,
Password: "",
DB: 0,
})
hub := NewHub()
go hub.Run()
// Subscribe ke Redis Pub/Sub untuk menerima pesan dari node lain
pubsub := rdb.Subscribe(ctx, "chat_channel")
defer pubsub.Close()
go func() {
ch := pubsub.Channel()
for msg := range ch {
hub.broadcast <- []byte(msg.Payload)
}
}()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
serveWs(hub, w, r)
})
log.Printf("WebSocket server started on %s", *addr)
if err := http.ListenAndServe(*addr, nil); err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
6. Bedah Kode & Penjelasan Detail Baris Kunci
upgrader.CheckOrigin: Dalam kode di atas, fungsi ini mengembalikantrueuntuk mempermudah pengujian pengembangan. Di lingkungan produksi, Anda wajib memvalidasi headerOriginuntuk mencegah serangan Cross-Site WebSocket Hijacking (CSWSH).readPump&writePump: Pola asinkron ganda ini memisahkan operasi pembacaan dan penulisan pada koneksi TCP. Setiap fungsi berjalan pada goroutine independen, memastikan pemblokiran pada proses tulis tidak menghentikan penerimaan data dari klien.ticker.C(Ping/Pong Mechanism): Server secara berkala mengirimkan framePingsetiap 54 detik. Jika klien tidak merespons dengan framePongdalam batas waktu deadline, koneksi dianggap terputus dan dibersihkan dari memori untuk menghindari dead connection leakage.rdb.Publishdanpubsub.Channel(): Integrasi Redis ini memastikan bahwa setiap pesan yang masuk ke satu instance server langsung disiarkan ke Redis, yang kemudian mendistribusikannya secara instan ke seluruh instance Go lain di klaster backend.
7. Skenario Produksi & Studi Kasus Dunia Nyata
Untuk menerapkan sistem ini pada infrastruktur produksi skala besar (misalnya melayani 500.000 koneksi simultan), arsitektur fisik harus dirancang sebagai berikut:
- Load Balancer (Nginx): Ditempatkan di depan beberapa instance Go. Nginx dikonfigurasi dengan IP Hash atau Least Connections dan dukungan proxy timeout yang diperpanjang.
- Konfigurasi Nginx untuk WebSocket:
upstream websocket_cluster {
server 10.0.1.10:8080;
server 10.0.1.11:8080;
}
server {
listen 80;
server_name chat.enterprise.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name chat.enterprise.com;
ssl_certificate /etc/letsencrypt/live/chat.enterprise.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/chat.enterprise.com/privkey.pem;
location /ws {
proxy_pass http://websocket_cluster;
proxy_http_version 1.1;
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;
# Nonaktifkan timeout standar untuk koneksi persisten
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
8. Common Pitfalls, Anti-Patterns & Solusi
- Anti-Pattern 1: Mengabaikan Deadlock pada Channel Hub.
Solusi: Jangan pernah mengirim pesan ke channel dari dalam blok mutex yang mengunci state global. Gunakan pola select non-blocking atau pastikan operasi channel dikelola secara eksklusif oleh goroutine runner utama dalam
Hub.Run(). - Pitfall 2: Memory Leak akibat Goroutine yang Tidak Pernah Keluar.
Solusi: Selalu pastikan setiap pemanggilan
go client.readPump()dango client.writePump()memiliki kondisi keluar yang jelas ketika terjadi error pada koneksi jaringan atau saat channel ditutup. - Pitfall 3: Menggunakan Redis Pub/Sub untuk Persistensi Pesan. Solusi: Redis Pub/Sub bersifat fire-and-forget (pesan tidak disimpan jika klien offline). Untuk fitur riwayat chat (chat history), kombinasikan Redis Pub/Sub dengan database persisten seperti PostgreSQL atau ScyllaDB.
9. Benchmark, Metrik Kinerja & Tuning Performa
Tuning OS File Descriptors (Linux Kernel)
Default sistem operasi Linux membatasi jumlah file terbuka yang dapat ditangani oleh satu proses. Koneksi WebSocket dihitung sebagai file descriptor ($FD$). Untuk menangani ratusan ribu koneksi, Anda harus mengubah konfigurasi sistem:
- Edit
/etc/security/limits.conf:
* soft nofile 1000000
* hard nofile 1000000
root soft nofile 1000000
root hard nofile 1000000
- Tuning Parameter Kernel di
/etc/sysctl.conf:
fs.file-max = 2097152
net.core.somaxconn = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65535 16777216
Terapkan perubahan dengan perintah:
sysctl -p
Pengujian Beban Menggunakan k6
Buat skrip pengujian beban loadtest.js dengan k6:
import ws from 'k6/ws';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 1000 },
{ duration: '1m', target: 5000 },
{ duration: '30s', target: 0 },
],
};
export default function () {
const url = 'ws://localhost:8080/ws';
const res = ws.connect(url, {}, function (socket) {
socket.on('open', function () {
socket.send(JSON.stringify({ sender: 'k6-user', content: 'Hello Enterprise' }));
});
socket.on('message', function (message) {
// Verifikasi pesan diterima kembali
});
socket.setTimeout(function () {
socket.close();
}, 10000);
});
check(res, { 'status is 101': (r) => r && r.status === 101 });
}
Jalankan pengujian:
k6 run loadtest.js
10. Checklist Penerapan Produksi & Kesimpulan
Sebelum meluncurkan sistem ke lingkungan produksi (Go-Live), pastikan checklist berikut telah diverifikasi secara menyeluruh:
- Validasi header
Originpada implementasi WebSocket Server untuk mencegah CSWSH. - Konfigurasi parameter
ulimitdan kernel sysctl Linux untuk mencegah errortoo many open files. - Implementasi mekanisme Heartbeat (Ping/Pong) untuk mendeteksi koneksi half-open atau terputus secara tiba-tiba.
- Penggunaan Redis Cluster atau Redis Sentinel untuk memastikan high availability pada message broker.
- Pemisahan penyimpanan riwayat pesan (chat history) menggunakan database relasional atau NoSQL berkecepatan tinggi, karena Redis Pub/Sub tidak menyimpan riwayat pesan.
Dengan menggabungkan efisiensi konkurensi model Go dan skalabilitas distribusi pesan Redis Pub/Sub, arsitektur perpesanan real-time Anda kini siap menangani jutaan koneksi simultan secara stabil, aman, dan berkinerja tinggi di tingkat enterprise.