L
High Availability & Scaling • Literally Web ID

Membangun Real-Time Chat WebSocket dengan Go dan Redis Pub/Sub

⏱️ 18 menit baca • 📅 19 Oct 2026 • 👁️ 5 views
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):

  1. Inisiasi Handshake: Klien melakukan HTTP GET request dengan header Upgrade: websocket. Load balancer meneruskan permintaan ini ke salah satu Go Node.
  2. Manajemen Koneksi (Client Hub): Go membuat objek Client baru yang membungkus koneksi TCP/WebSocket dan mendaftarkannya ke dalam Hub lokal via channel internal (goroutine-safe).
  3. 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).
  4. Distribusi Lintas Node: Redis meneruskan pesan tersebut ke seluruh subscriber yang mendengarkan channel chat_global, termasuk Go Node 2.
  5. 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 mengembalikan true untuk mempermudah pengujian pengembangan. Di lingkungan produksi, Anda wajib memvalidasi header Origin untuk 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 frame Ping setiap 54 detik. Jika klien tidak merespons dengan frame Pong dalam batas waktu deadline, koneksi dianggap terputus dan dibersihkan dari memori untuk menghindari dead connection leakage.
  • rdb.Publish dan pubsub.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:

  1. Load Balancer (Nginx): Ditempatkan di depan beberapa instance Go. Nginx dikonfigurasi dengan IP Hash atau Least Connections dan dukungan proxy timeout yang diperpanjang.
  2. 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() dan go 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:

  1. Edit /etc/security/limits.conf:
* soft nofile 1000000
* hard nofile 1000000
root soft nofile 1000000
root hard nofile 1000000
  1. 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 Origin pada implementasi WebSocket Server untuk mencegah CSWSH.
  • Konfigurasi parameter ulimit dan kernel sysctl Linux untuk mencegah error too 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.

🏷️ Tags: #High Concurrency #Redis #Websocket #System Architecture #Go