L
Artificial Intelligence & LLM • Literally Web ID

Membangun Sistem Notifikasi Real-Time Skala Jutaan Pengguna dengan Go, Redis Pub/Sub, dan WebSockets

⏱️ 15 menit baca • 📅 14 Oct 2026 • 👁️ 1 views
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).

  1. Klien (Web/Mobile): Membuka koneksi TCP/WebSocket persisten ke lapisan Load Balancer (Nginx/Cloudflare).
  2. Load Balancer / Reverse Proxy: Meneruskan koneksi ke sekumpulan server aplikasi Go (Node Cluster).
  3. Golang WebSocket Server: Mengelola status koneksi (Client Registry), membaca/menulis frame WebSocket, serta mendengarkan kanal Redis Pub/Sub.
  4. 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), &notification)
			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 internal clients map[string]*Client tidak aman secara thread (non-thread-safe). Dengan menggunakan Channel terpusat di dalam goroutine Run(), seluruh operasi penambahan dan penghapusan klien diantrekan secara sekuensial, menghindari fatal error: concurrent map write tanpa perlu menggunakan sync.Mutex yang mahal.
  • Mekanisme Heartbeat Ping/Pong: Pada WritePump, ticker mengirimkan frame Ping setiap 54 detik (90% dari batas pongWait 60 detik). Jika klien tidak merespons dengan Pong (misalnya karena koneksi terputus tiba-tiba di tengah jalan / jaringan seluler berpindah), ReadPump akan 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\
🏷️ Tags: #Nginx #High Concurrency #Performance #Sysadmin #Web Server