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 sepertik6/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:
- Pondasi Kernel: Membuka sumbatan OS agar Linux mengizinkan jutaan file descriptor terbuka secara bersamaan.
- Engine WebSocket di Go: Memangkas memory footprint dari ~12KB per koneksi menjadi di bawah ~2KB menggunakan
gobwas/wsdansync.Pool. - 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:
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:
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 dilimits.confsering kali diabaikan oleh unit service. Selalu tambahkanLimitNOFILE=1048576di 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:
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:
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:
// 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:
// 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, misalnyalimit_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:
# 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:
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:
# 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 availableataucannot 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 (
SPUBLISHdanSSUBSCRIBE) yang hanya mengirim pesan ke node yang menampung hash slot channel tersebut.
- Penyebab: Anda menggunakan Pub/Sub global biasa (
-
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_rmemdan pastikan nilai minimum serta default-nya berada di angka4096 87380. Nilai 4KB membuat soket idle nyaris tidak memakan ruang memori sistem.