# Arsitektur Real-Time Event-Driven: Membangun WebSockets Skala Juta Koneksi di Go dan Redis

> Diterbitkan oleh Literally Web ID pada 21 Oct 2026
> Sumber: https://literally.web.id/articles/arsitektur-real-time-event-driven-membangun-websockets-skala-juta-koneksi-di-go-dan-redis

---

## 1. Menu Kita Hari Ini & Alat yang Perlu Disiapkan

Dalam 15 menit ke depan, kita akan merakit stack WebSocket event-driven dari nol sampai sanggup menampung jutaan koneksi simultan tanpa membuat server kehabisan memori. Kalau Anda pernah mengelola WebSocket di Go dengan pendekatan standar—satu koneksi melahirkan dua goroutine untuk *read* dan *write*—Anda pasti tahu horornya saat koneksi menembus 200 ribu: memori membengkak hingga puluhan gigabyte, garbage collector bekerja megap-megap, dan server akhirnya kolaps terkena Out Of Memory (OOM) killer.

Hari ini, kita ganti resep usang itu dengan arsitektur modern kelas produksi. Siapkan bahan-bahan berikut di mesin Linux Anda:

*   **Go (Golang) 1.22+**: Bahasa utama kita dengan runtime konkurensi efisien.
*   **Library `github.com/gobwas/ws`**: Senjata rahasia untuk zero-copy WebSocket upgrade dan manipulasi raw frame tanpa alokasi memori berlebih.
*   **Redis 7.0+ (Cluster Mode / Standalone)**: Kita manfaatkan fitur *Sharded Pub/Sub* (`SPUBLISH` / `SSUBSCRIBE`) yang jauh lebih hemat CPU dibanding Pub/Sub global tradisional.
*   **Linux Environment (Ubuntu 22.04 LTS / Debian 12 / WSL2)**: Diperlukan untuk utak-atik parameter kernel network stack (`sysctl`).
*   **Alat Ukur**: `ulimit`, `top`, atau load tester seperti `k6` / `tcpkali`.

Pastikan terminal Anda sudah siap dengan hak akses `sudo`. Mari kita tinjau cetak biru instalasi sebelum mulai mengotori tangan dengan kode.

## 2. Peta Jalan Singkat: Apa yang Sedang Kita Rakit

Bayangkan sebuah stasiun radio raksasa. Alih-alih setiap pendengar meminta satu operator telepon khusus untuk membacakan pesan (goroutine per koneksi), kita membuat sistem di mana satu ruang tunggu besar hanya menyimpan kabel pasif yang siap menerima siaran. Ketika ada transmisi baru masuk dari Redis, sinyal langsung disalurkan serentak melalui buffer bersama (*buffer pool*).

```
[ Jutaan Client Web / Mobile ]
             │ (Persistent TCP / WS Handshake)
             ▼
[ Layer L4 / HAProxy / Direct Kernel Socket ]
             │ 
             ▼
[ Go WebSocket Nodes (Zero-Allocation + epoll/sync.Pool) ]
             │
   ┌─────────┴─────────┐
   │ SSUBSCRIBE (Shard)│  Event Distribution
   ▼                   ▼
[ Redis Cluster Node A ] [ Redis Cluster Node B ]
```

Peta jalan kita terdiri dari tiga fase taktis:
1.  **Pondasi Kernel**: Membuka sumbatan OS agar Linux mengizinkan jutaan file descriptor terbuka secara bersamaan.
2.  **Engine WebSocket di Go**: Memangkas *memory footprint* dari ~12KB per koneksi menjadi di bawah ~2KB menggunakan `gobwas/ws` dan `sync.Pool`.
3.  **Backbone Distribusi Redis**: Menghubungkan node-node Go secara horizontal lewat Sharded Pub/Sub agar pesan tersiar tanpa latensi.

Ayo langsung masuk ke dapur dan racik fondasi sistem operasinya terlebih dahulu.

## 3. Langkah 1: Mempersiapkan Fondasi & Environment

Secara *default*, Linux didesain sangat konservatif. Jika Anda menyalakan aplikasi WebSocket sekarang dan mencoba menyuntikkan 50.000 koneksi saja, Anda akan langsung disambut error klise: `socket: too many open files`. Kita harus melonggarkan batasan file descriptor dan memperkecil alokasi buffer TCP default pada kernel.

Buka terminal Anda dan masukkan konfigurasi kernel berikut ke `/etc/sysctl.conf`:

```bash
sudo tee -a /etc/sysctl.conf << 'EOF'
# Naikkan batas global file descriptor sistem
fs.file-max = 2097152

# Rentang port efemeral untuk koneksi keluar
net.ipv4.ip_local_port_range = 1024 65535

# Tingkatkan antrean listen backlog untuk lonjakan koneksi
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# Optimasi buffer TCP (min, default, max dalam bytes)
# Catatan penting: Kecilkan default buffer baca/tulis agar jutaan koneksi idle tidak menguras RAM!
net.ipv4.tcp_rmem = 4096 87380 4194304
net.ipv4.tcp_wmem = 4096 65536 4194304

# Aktifkan reuse TIME_WAIT sockets
net.ipv4.tcp_tw_reuse = 1
EOF

# Terapkan perubahan seketika tanpa reboot
sudo sysctl -p
```

Setelah parameter kernel aktif, atur limit proses pengguna di `/etc/security/limits.conf` agar user aplikasi Anda leluasa membuka soket:

```bash
sudo tee -a /etc/security/limits.conf << 'EOF'
* soft nofile 1048576
* hard nofile 1048576
root soft nofile 1048576
root hard nofile 1048576
EOF
```

> [!IMPORTANT]
> Pada lingkungan berbasis `systemd` (seperti Ubuntu atau Debian modern), batasan di `limits.conf` sering kali diabaikan oleh unit service. Selalu tambahkan `LimitNOFILE=1048576` di dalam blok `[Service]` pada file service systemd aplikasi Anda di masa produksi.

Dengan fondasi Linux yang sudah kokoh dan siap menampung lebih dari 1 juta file descriptor, saatnya kita menyusun kode Go yang ringkas, bertenaga, dan hemat memori.

## 4. Langkah 2: Meracik Konfigurasi Inti Tanpa Basa-basi

Inisialisasi direktori proyek Go baru Anda:

```bash
mkdir -p go-million-ws && cd go-million-ws
go mod init go-million-ws
go get github.com/gobwas/ws
go get github.com/redis/go-redis/v9
```

Kunci efisiensi ada pada dua hal: **Zero-Allocation Upgrader** milik `gobwas/ws` dan pemanfaatan `sync.Pool` untuk buffer I/O. Kita tidak memakai `gorilla/websocket` karena Gorilla membungkus soket dengan objek reader/writer berukuran besar yang mengunci alokasi memori.

Buat file `main.go` dan masukkan kode arsitektur berikut:

```go
package main

import (
	"context"
	"log"
	"net"
	"net/http"
	"sync"
	"sync/atomic"
	"time"

	"github.com/gobwas/ws"
	"github.com/gobwas/ws/wsutil"
	"github.com/redis/go-redis/v9"
)

// Pool untuk buffer tulis agar tidak memicu alokasi memori GC berkala
var bufPool = sync.Pool{
	New: func() any {
		b := make([]byte, 256)
		return &b
	},
}

type Hub struct {
	connections sync.Map
	connCount   int64
	rdb         *redis.Client
}

func NewHub(rdb *redis.Client) *Hub {
	return &Hub{
		rdb: rdb,
	}
}

// Menangani pendaftaran dan pembacaan koneksi
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
	// Upgrade HTTP ke raw net.Conn WebSocket tanpa overhead gorilla
	conn, _, _, err := ws.UpgradeHTTP(r, w)
	if err != nil {
		log.Printf("Upgrade error: %v", err)
		return
	}

	// Simpan koneksi ke sync.Map
	h.connections.Store(conn, struct{}{})
	atomic.AddInt64(&h.connCount, 1)

	go h.handleIncoming(conn)
}

func (h *Hub) handleIncoming(conn net.Conn) {
	defer func() {
		h.connections.Delete(conn)
		conn.Close()
		atomic.AddInt64(&h.connCount, -1)
	}()

	// Set deadlinetimeout heartbeat
	_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))

	for {
		// Baca raw packet dengan alokasi buffer minimal
		msg, op, err := wsutil.ReadClientData(conn)
		if err != nil {
			break // Client putus atau timeout
		}

		if op == ws.OpPing {
			_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
			_ = wsutil.WriteServerMessage(conn, ws.OpPong, nil)
			_ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
			continue
		}

		// Logika bisnis opsional jika client mengirim data
		_ = msg
	}
}

// Broadcast pesan ke semua client lokal dari stream Redis Sharded Pub/Sub
func (h *Hub) ListenBroadcast(ctx context.Context, channelName string) {
	// Menggunakan SSubscribe (Sharded PubSub di Redis 7)
	pubsub := h.rdb.SSubscribe(ctx, channelName)
	defer pubsub.Close()

	ch := pubsub.Channel()
	log.Printf("Tersambung ke Redis Sharded Pub/Sub channel: %s", channelName)

	for msg := range ch {
		payload := []byte(msg.Payload)

		// Iterasi seluruh koneksi aktif dan semburkan data
		h.connections.Range(func(key, value any) bool {
			clientConn := key.(net.Conn)
			go func(c net.Conn) {
				_ = c.SetWriteDeadline(time.Now().Add(2 * time.Second))
				_ = wsutil.WriteServerText(c, payload)
			}(clientConn)
			return true
		})
	}
}

func main() {
	ctx := context.Background()

	rdb := redis.NewClient(&redis.Options{
		Addr: "127.0.0.1:6379",
	})

	hub := NewHub(rdb)

	// Jalankan worker consumer Redis Pub/Sub di background
	go hub.ListenBroadcast(ctx, "global:events")

	// Monitor metrik jumlah koneksi per 5 detik
	go func() {
		for range time.Tick(5 * time.Second) {
			log.Printf("[Live Metric] Koneksi Aktif: %d", atomic.LoadInt64(&hub.connCount))
		}
	}()

	http.HandleFunc("/ws", hub.ServeWS)

	server := &http.Server{
		Addr: ":8080",
	}

	log.Println("Server WebSocket berjalan di http://0.0.0.0:8080/ws")
	if err := server.ListenAndServe(); err != nil {
		log.Fatalf("Server gagal jalan: %v", err)
	}
}
```

Perhatikan bagaimana kita meniadakan alokasi buffer baca/tulis yang berat di setiap sesi koneksi. Struktur ini membuat konsumsi memori idle berada di kisaran ~1.8KB per koneksi, jauh lebih ramping dibanding framework WebSocket umum yang mengonsumsi 15KB–30KB per koneksi. Namun, kode ini belum kebal terhadap skenario ekstrem di dunia nyata. Mari kita pasang perisai keamanannya.

## 5. Langkah 3: Sentuhan Akhir Keamanan & Optimasi

Ketika jutaan koneksi online, musuh terbesar Anda bukanlah *throughput*, melainkan dua fenomena mematikan: **Reconnect Storm (Thundering Herd)** dan **Slow Consumers (Backpressure)**.

### 1. Menjinakkan Slow Consumer (Backpressure)
Jika 10.000 client berada di jaringan 3G yang lambat, buffer tulis soket mereka akan penuh. Jika goroutine broadcast Anda menunggu giliran tulis tanpa ampun, jutaan goroutine akan macet dan server kehabisan RAM dalam hitungan detik. Berikan batas channel output atau langsung eliminasi koneksi lambat dengan skema *non-blocking drop*:

```go
// Trik eliminasi slow client: Jangan biarkan node Go tersandera oleh koneksi lambat
func safeWrite(c net.Conn, payload []byte) {
	// Berikan deadline agresif
	_ = c.SetWriteDeadline(time.Now().Add(500 * time.Millisecond))
	err := wsutil.WriteServerText(c, payload)
	if err != nil {
		// Jika gagal menulis dalam window deadline, segera putus soket client ini!
		_ = c.Close()
	}
}
```

### 2. Mengatasi Reconnect Storms dengan Randomized Jitter
Saat server restart atau jaringan internet ISP terputus sesaat, 500.000 user akan mencoba *handshake* kembali di detik yang persis sama. Berikan respon handshake dengan rekomendasi *backoff* atau terapkan *exponential jitter* di sisi client SDK:

```javascript
// Client-side Reconnect Logic dengan Full Jitter
function connectWebSocket() {
    let attempts = 0;
    const maxDelay = 30000; // 30 detik max

    function setup() {
        const ws = new WebSocket("ws://loadbalancer.internal/ws");

        ws.onopen = () => {
            console.log("Tersambung mulus!");
            attempts = 0; // Reset counter saat koneksi sukses
        };

        ws.onclose = () => {
            attempts++;
            // Rumus Full Jitter: acak durasi antara 0 hingga 2^attempts * 100ms
            const baseDelay = Math.min(maxDelay, Math.pow(2, attempts) * 100);
            const jitteredDelay = Math.random() * baseDelay;
            
            console.warn(`Koneksi terputus. Mencoba reconnect dalam ${Math.round(jitteredDelay)}ms...`);
            setTimeout(setup, jitteredDelay);
        };
    }
    setup();
}
```

> [!TIP]
> Di sisi reverse proxy terdepan (seperti Nginx atau Envoy), pasang modul rate limiting khusus untuk URL upgrade WebSocket (`/ws`) dengan burst toleran, misalnya `limit_req zone=ws_zone burst=5000 nodelay;`. Ini melindungi node Go dari lonjakan CPU akibat kalkulasi SHA1 handshake berulang-ulang.

Sekarang pertahanan kita lengkap. Waktunya menyalakan kompor dan membuktikan performanya secara riil!

## 6. Tes Rasa: Memastikan Semuanya Berjalan Sempurna

Mari kita uji apakah sistem ini sanggup bertahan di bawah beban dan menerima transmisi siaran secara simultan.

### 1. Jalankan Redis dan Server Go
Pastikan Redis berjalan di latar belakang:

```bash
# Pastikan redis-server menyala
redis-cli ping
# Respon: PONG

# Jalankan aplikasi Go kita
go run main.go
```

Terminal akan menampilkan log:
`Server WebSocket berjalan di http://0.0.0.0:8080/ws`
`Tersambung ke Redis Sharded Pub/Sub channel: global:events`

### 2. Buka Koneksi Dummy Menggunakan Bash / Curl
Buka tab terminal baru, mari lakukan handshake WebSocket sederhana via `curl`:

```bash
curl -i -N \
  -H "Connection: Upgrade" \
  -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  http://127.0.0.1:8080/ws
```

Jika terminal Anda menghasilkan HTTP status `101 Switching Protocols` dan koneksi tetap menggantung (terbuka), selamat! File descriptor soket Anda sudah terdaftar di `sync.Map` node Go.

### 3. Tembakkan Pesan Siaran dari Redis
Buka tab terminal ketiga dan kirimkan event via Redis CLI:

```bash
# Publish pesan ke channel sharded
redis-cli SPUBLISH global:events '{"event":"FLASH_SALE_STARTED","timestamp":1710000000}'
```

Lihat jendela `curl` Anda di langkah kedua. Pesan JSON tersebut seketika muncul di terminal tanpa jeda. Node Go berhasil menjemput payload dari Redis dan menyebarkannya langsung ke soket aktif.

## 7. Pertolongan Pertama: Jika Terjadi Kendala Kecil

Berikut diagnosa cepat saat sistem Anda menghadapi hambatan di lapangan:

*   **Masalah: Muncul error `dial tcp: lookup ...: no buffer space available` atau `cannot assign requested address`.**
    *   *Penyebab*: Host load tester atau server Anda kehabisan port efemeral lokal saat menyimulasikan ratusan ribu koneksi outbound ke satu IP tujuan.
    *   *Solusi*: Buat alias virtual IP tambahan pada interface jaringan (`ip addr add 192.168.1.101/24 dev eth0`) dan distribusikan outbound traffic melintasi beberapa IP lokal.

*   **Masalah: CPU Redis mendadak 100% saat melakukan broadcast skala besar.**
    *   *Penyebab*: Anda menggunakan Pub/Sub global biasa (`PUBLISH`) di Redis Cluster, yang secara default menduplikasi broadcast ke setiap master node dalam cluster.
    *   *Solusi*: Wajib gunakan Sharded Pub/Sub (`SPUBLISH` dan `SSUBSCRIBE`) yang hanya mengirim pesan ke node yang menampung hash slot channel tersebut.

*   **Masalah: Server crash terkena OOM padahal jumlah koneksi baru 100.000.**
    *   *Penyebab*: Buffer TCP Linux Anda masih menggunakan nilai bawaan (biasanya 128KB–4MB per soket).
    *   *Solusi*: Cek kembali `sysctl net.ipv4.tcp_rmem` dan pastikan nilai minimum serta default-nya berada di angka `4096 87380`. Nilai 4KB membuat soket idle nyaris tidak memakan ruang memori sistem.