Membangun Sistem Notifikasi Real-Time Skala Jutaan Pengguna dengan Go, Redis Pub/Sub, dan WebSockets
1. Konteks Industri & Analisis Akar Masalah
Dalam lanskap aplikasi web modern dewasa ini, ekspektasi pengguna terhadap interaktivitas telah bergeser dari request-response tradisional (HTTP polling) menuju pengalaman real-time instan. Keterlambatan hitungan detik dalam pengiriman notifikasi, pembaruan status transaksi, atau pesan obrolan dapat menurunkan metrik retensi pengguna secara drastis. Sistem notifikasi skala jutaan pengguna menghadirkan tantangan teknik yang kompleks pada level arsitektur backend, manajemen memori, penjadwalan CPU, dan batas I/O sistem operasi.
Secara historis, banyak arsitektur awal mengandalkan pendekatan HTTP Short Polling atau Long Polling. Pendekatan ini sangat boros sumber daya karena setiap klien secara berkala membuka koneksi HTTP baru, membebani web server (seperti Nginx atau Apache), memicu siklus inisialisasi proses atau thread di backend (PHP-FPM/Node), dan membebani database relational (MySQL/MariaDB) dengan query SELECT yang berulang-ulang tanpa hasil baru.
[!WARNING] Penggunaan HTTP Polling untuk menangani 1 juta klien aktif secara bersamaan akan melumpuhkan server dalam hitungan menit akibat kehabisan File Descriptors dan lonjakan penggunaan memori pada proses penanganan koneksi.
Perbandingan: Sebelum vs Sesudah Penerapan
-
Sebelum (Pendekatan Legacy / Polling):
- Protokol: HTTP/1.1 Short/Long Polling.
- Beban Jaringan: Overhead header HTTP berulang setiap request (ratusan byte per request per klien).
- Beban Backend: Ribuan proses PHP-FPM atau thread spawned yang mengonsumsi RAM masif (rata-rata 20-30MB per proses).
- Latency: Tinggi (bergantung pada interval poll klien, rata-rata 1-5 detik).
- Skalabilitas: Terbatas pada kapasitas maksimal worker web server (biasanya megap-megap di atas 10.000 koneksi konkuren per node).
-
Sesudah (Pendekatan Modern / Go + Redis Pub/Sub + WebSocket):
- Protokol: WebSocket (RFC 6455) dengan koneksi TCP persisten.
- Beban Jaringan: Overhead minimal setelah handshake awal selesai.
- Beban Backend: Memanfaatkan Go Goroutines yang sangat ringan (hanya ~2KB per goroutine awal) dan I/O non-blocking berbasis epoll Linux.
- Latency: Rendah (sub-millisecond).
- Skalabilitas: Mampu menembus 1.000.000+ koneksi konkuren per mesin dengan tuning kernel yang tepat dan klasterisasi horizontal.
2. Konsep Inti & Deep Dive Arsitektur
Untuk merancang sistem yang mampu menangani jutaan pengguna, kita harus memisahkan tanggung jawab penanganan koneksi klien (Connection Termination) dari logika distribusi pesan (Message Distribution).
- Klien (Web/Mobile): Membuka koneksi TCP/WebSocket persisten ke lapisan Load Balancer (Nginx/Cloudflare).
- Load Balancer / Reverse Proxy: Meneruskan koneksi ke sekumpulan server aplikasi Go (Node Cluster).
- Golang WebSocket Server: Mengelola status koneksi (Client Registry), membaca/menulis frame WebSocket, serta mendengarkan kanal Redis Pub/Sub.
- Redis Pub/Sub: Bertindak sebagai message broker terdistribusi yang menyebarkan pesan lintas node server Go apabila pengguna tujuan terhubung ke node yang berbeda.
+-------------------------------------------------------------+
| Klien (1M+) |
| (Browser / Mobile Apps via WebSocket) |
+------------------------------+------------------------------+
|
(TCP / WSS)
v
+-------------------------------------------------------------+
| Nginx Load Balancer / Reverse Proxy |
| (IP Hash / Sticky Session) |
+-------+----------------------+----------------------+-------+
| | |
v v v
+---------------+ +---------------+ +---------------+
| Go Server #1 | | Go Server #2 | | Go Server #N |
| (Goroutines) | | (Goroutines) | | (Goroutines) |
+-------+-------+ +-------+-------+ +-------+-------+
| | |
+----------------------+----------------------+
|
(Redis TCP Protocol)
v
+-------------------------------------------------------------+
| Redis Pub/Sub Cluster |
| (In-Memory Broker untuk Distribusi Lintas Node) |
+-------------------------------------------------------------+
3. Perbandingan Komparatif / Analisis Pendekatan
| Parameter Komparasi | Golang (Go) | Node.js (V8) | PHP (Swoole / ReactPHP) |
|---|---|---|---|
| Model Concurrency | CSP / Goroutines & Channels | Event Loop (Single Thread + Worker) | Event-driven / Coroutine |
| Memori per Koneksi | ~2 KB - 4 KB | ~10 KB - 30 KB | ~40 KB - 80 KB |
| CPU Utilization | Sangat Efisien (Compiled) | Efisien (JIT Compilation) | Sedang (Tergantung Ekstensi) |
| Kematangan Ekosistem WebSocket | Tinggi (Gorilla/Gobwas) | Sangat Tinggi (ws/Socket.io) | Menengah (Swoole WebSocket) |
| Kemudahan Debugging Concurrency | Sangat Baik (Race Detector built-in) | Menengah (Async Stack Traces) | Menengah |
4. Prasyarat Sistem & Persiapan Lingkungan
Sebelum mulai menulis kode, pastikan sistem operasi Linux (Ubuntu 22.04 LTS / Debian 12) telah disiapkan dengan spesifikasi minimal:
- RAM: 8GB - 16GB (untuk uji coba beban; 64GB+ untuk produksi 1M koneksi).
- CPU: 4-8 Cores.
- Go versi 1.21 atau lebih baru.
- Redis Server versi 7.x.
Penyesuaian Batas Sistem Operasi (File Descriptors)
Secara default, Linux membatasi jumlah file terbuka per proses (biasanya 1024). Kita harus menaikkan limit ini agar kernel tidak menolak koneksi masuk (Too many open files).
Edit file /etc/security/limits.conf:
* soft nofile 1048576
* hard nofile 1048576
root soft nofile 1048576
root hard nofile 1048576
Edit file /etc/sysctl.conf untuk optimasi jaringan kernel TCP:
fs.file-max = 2097152
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
Terapkan perubahan sysctl:
sysctl -p
5. Panduan Implementasi Langkah demi Langkah
A. Inisialisasi Proyek Go
mkdir -p real-time-notification
cd real-time-notification
go mod init notification-service
go get github.com/gorilla/websocket
go get github.com/redis/go-redis/v9
B. Implementasi Hub WebSocket (hub.go)
File ini mengatur registry klien aktif dan mekanisme broadcast pesan di dalam satu instance server.
package main
import (
"encoding/json"
"log"
)
type Message struct {
RecipientID string `json:"recipient_id"`
Event string `json:"event"`
Data string `json:"data"`
}
type ClientManager struct {
clients map[string]*Client
register chan *Client
unregister chan *Client
Broadcast chan Message
}
func NewClientManager() *ClientManager {
return &ClientManager{
clients: make(map[string]*Client),
register: make(chan *Client),
unregister: make(chan *Client),
Broadcast: make(chan Message),
}
}
func (manager *ClientManager) Run() {
for {
select {
case client := <-manager.register:
manager.clients[client.ID] = client
log.Printf("Klien terdaftar: %s", client.ID)
case client := <-manager.unregister:
if _, ok := manager.clients[client.ID]; ok {
delete(manager.clients, client.ID)
close(client.Send)
log.Printf("Klien terputus: %s", client.ID)
}
case message := <-manager.Broadcast:
if client, ok := manager.clients[message.RecipientID]; ok {
jsonMsg, err := json.Marshal(message)
if err == nil {
select {
case client.Send <- jsonMsg:
default:
close(client.Send)
delete(manager.clients, client.ID)
}
}
}
}
}
}
C. Implementasi Klien WebSocket (client.go)
package main
import (
"bytes"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
)
type Client struct {
ID string
Conn *websocket.Conn
Send chan []byte
Manager *ClientManager
}
func (c *Client) ReadPump() {
defer func() {
c.Manager.unregister <- c
c.Conn.Close()
}()
c.Conn.SetReadLimit(maxMessageSize)
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
c.Conn.SetPongHandler(func(string) error {
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, _, err := c.Conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error pembacaan: %v", err)
}
break
}
}
}
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.Conn.Close()
}()
for {
select {
case message, ok := <-c.Send:
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
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(writeWait))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
D. Integrasi Redis Pub/Sub dan Entrypoint Utama (main.go)
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
var ctx = context.Background()
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Sesuaikan untuk produksi dengan validasi Origin ketat
},
}
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
manager := NewClientManager()
go manager.Run()
// Subscriber Redis untuk komunikasi antar server cluster
go func() {
pubsub := rdb.Subscribe(ctx, "global_notifications")
defer pubsub.Close()
ch := pubsub.Channel()
for msg := range ch {
var notification Message
err := json.Unmarshal([]byte(msg.Payload), ¬ification)
if err == nil {
manager.Broadcast <- notification
}
}
}()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("user_id")
if userID == "" {
http.Error(w, "Parameter user_id wajib diisi", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Gagal upgrade websocket: %v", err)
return
}
client := &Client{
ID: userID,
Conn: conn,
Send: make(chan []byte, 256),
Manager: manager,
}
manager.register <- client
go client.WritePump()
go client.ReadPump()
})
log.Println("Server WebSocket berjalan pada port :8080")
err := http.ListenAndServe(":8080", nil)
if err != nil {
log.Fatal("ListenAndServe: ", err)
}
}
6. Bedah Kode & Penjelasan Detail Baris Kunci
- Model Channel
manager.register&manager.unregister: Di Go, akses konkuren ke peta internalclients map[string]*Clienttidak aman secara thread (non-thread-safe). Dengan menggunakan Channel terpusat di dalam goroutineRun(), seluruh operasi penambahan dan penghapusan klien diantrekan secara sekuensial, menghindari fatal error: concurrent map write tanpa perlu menggunakansync.Mutexyang mahal. - Mekanisme Heartbeat Ping/Pong: Pada
WritePump, ticker mengirimkan framePingsetiap 54 detik (90% dari bataspongWait60 detik). Jika klien tidak merespons denganPong(misalnya karena koneksi terputus tiba-tiba di tengah jalan / jaringan seluler berpindah),ReadPumpakan mendeteksi timeout dan memicu pembersihan koneksi secara otomatis. - Redis Pub/Sub Channel: Ketika sebuah layanan backend (misalnya API PHP atau Go worker lain) ingin mengirim notifikasi ke pengguna, ia cukup mempublikasikan payload JSON ke channel Redis
global_notifications. Semua node server Go yang aktif (Horizontal Scaling) akan menerima pesan tersebut secara serentak, lalu memeriksa apakah pengguna yang dituju sedang terhubung ke node lokal mereka.
7. Skenario Produksi & Studi Kasus Dunia Nyata
Bayangkan sebuah platform e-commerce dengan flash sale yang mendatangkan 500.000 pesanan per menit. Setiap kali status pesanan berubah, sistem harus mengirimkan notifikasi real-time ke pembeli dan penjual.
[!TIP] Dalam skenario klaster dengan 5 instance server Go di belakang Nginx Load Balancer, gunakan konfigurasi Nginx IP Hash atau pastikan token autentikasi memetakan koneksi klien secara konsisten jika menggunakan Stateful Routing, meskipun Redis Pub/Sub meniadakan keharusan sesi klien menempel pada satu node spesifik untuk pengiriman pesan lintas node.
Contoh perintah Redis CLI untuk memicu pengiriman notifikasi dari backend lain secara instan:
PUBLISH global_notifications '{"recipient_id":"user_998822","event":"order_shipped","data":"Pesanan Anda telah dikirim via JNE\