# Arsitektur Event-Driven Berbasis Apache Kafka dan Go untuk High-Throughput System

> Diterbitkan oleh Literally Web ID pada 21 Oct 2026
> Sumber: https://literally.web.id/articles/arsitektur-event-driven-berbasis-apache-kafka-dan-go-untuk-high-throughput-system

---

## 1. Titik Awal Masalah: Mengapa Arsitektur Lama Sering Ambruk di Produksi

Ingatkah Anda saat lonjakan trafik kampanye 11.11 atau *flash sale* tengah malam melumpuhkan sistem backend monolitik berbasis PHP atau Node.js yang terhubung langsung ke MySQL? Panggilan *database connection timeout* membanjiri kanal Slack tim SRE, CPU loncat ke 100%, dan barisan antrean HTTP request berguguran satu per satu. Masalah klasik ini bukan karena kode Anda buruk, melainkan karena pola komunikasi *synchronous request-response* yang Anda paksa bekerja di bawah beban jutaan operasi per detik.

Dalam ekosistem modern yang melibatkan integrasi Nginx, Cloudflare CDN, microservices Go, hingga penyimpanan objek S3, sistem *tightly coupled* adalah bom waktu. Ketika satu layanan hilir (downstream service) melambat—misalnya karena tabel basis data kehabisan koneksi—seluruh rantai pemrosesan ikut tersendat. Pengguna akhir melihat layar putih, sementara server web berjuang mempertahankan proses PHP-FPM atau worker Node.js yang tertahan menunggu respons. Kita butuh penyangga. Kita butuh pemisah transaksional yang andal.

Di sinilah arsitektur *event-driven* berbasis Apache Kafka masuk sebagai penyelamat. Alih-alih membuat layanan saling menunggu secara langsung, kita membalik paradigma: lepaskan event ke dalam sebuah log terdistribusi yang sangat cepat, biarkan produsen bernapas lega, dan biarkan konsumen memproses data tersebut secara asinkron sesuai kapasitas mereka sendiri. Tapi tunggu, memindahkan beban ke Kafka tanpa pemahaman mendalam tentang partisi dan mekanisme Go concurrency justru akan menciptakan mimpi buruk baru di level infrastruktur. Mari kita bedah bagaimana cara kerjanya di balik layar.

## 2. Di Balik Layar: Bagaimana Sistem Ini Bekerja Tanpa Sihir

Banyak orang menganggap Kafka adalah antrean pesan (*message queue*) biasa seperti RabbitMQ atau Redis Pub/Sub. Anggapan ini keliru besar dan sering berujung pada desain arsitektur yang salah kaprah. Kafka sebenarnya adalah sebuah *distributed commit log*. Bayangkan sebuah buku besar (*ledger*) digital raksasa yang tidak pernah menghapus data begitu dibaca, melainkan menyimpannya berdasarkan retensi waktu atau ukuran tertentu. Data ditulis secara sekuensial ke disk, memanfaatkan *page cache* sistem operasi secara maksimal sehingga kecepatan tulisnya mendekati memori RAM.

Kunci utama skalabilitas Kafka terletak pada konsep **Partisi**. Sebuah topik (*topic*) dipecah menjadi beberapa partisi fisik yang tersebar di kluster broker. Di sinilah letak keajaibannya: urutan data dijamin *hanya di dalam satu partisi yang sama*. Ketika sebuah event masuk, Kafka menentukan partisi tujuan berdasarkan kunci (*key*) yang dikirimkan oleh produsen—biasanya menggunakan algoritma hashing seperti MurmurHash. Jika Anda mengirim data pesanan dengan kunci `user_id_123`, semua event milik pengguna tersebut dijamin masuk ke partisi yang sama dan diproses secara berurutan.

Di sisi hilir, **Consumer Group** adalah mekanisme orkestrasi yang membuat sistem ini mampu menangani jutaan event per detik. Bayangkan Anda memiliki 10 instance aplikasi backend Go yang tergabung dalam satu consumer group. Kafka secara otomatis mendistribusikan partisi-partisi dari sebuah topik ke masing-masing instance tersebut. Jika topik Anda memiliki 32 partisi, maka beberapa instance Go akan memegang lebih dari satu partisi, sementara instance lainnya memegang sisanya. Jika salah satu instance Go mati karena *out of memory* atau crash, Kafka secara otomatis memicu proses *rebalance*, memindahkan partisi yang ditinggalkan ke instance yang masih hidup tanpa kehilangan satu byte pun data berkat *committed offset* yang tersimpan rapi.

Transisi mulus dari pemahaman konseptual ini menuntut kita pada sebuah persimpangan krusial: bagaimana cara memilih pustaka klien Go yang tepat dan merancang strategi kompromi yang tidak mengorbankan integritas data?

## 3. Pilihan Pendekatan & Kompromi yang Harus Diambil

Begitu kita memutuskan untuk mengadopsi Kafka dengan bahasa Go, pertanyaan pertama yang muncul di meja arsitek adalah: Pustaka klien mana yang harus digunakan? Di dunia Go, ada dua pemain utama yang mendominasi percakapan di GitHub dan kanal komunitas: `segmentio/kafka-go` dan `confluentinc/confluent-kafka-go`.

Mari kita lakukan komparasi jujur di lapangan:

*   **`segmentio/kafka-go`**: Ditulis murni dalam bahasa Go (*pure Go*). Keunggulannya luar biasa dari sisi kemudahan *cross-compilation*, manajemen dependensi tanpa CGO, dan kode yang sangat bersih serta mudah dibaca. Sangat cocok untuk tim yang ingin menghindari kerumitan pustaka C/C++ di dalam biner aplikasi Go mereka.
*   **`confluentinc/confluent-kafka-go`**: Merupakan pembungkus (*wrapper*) di atas pustaka `librdkafka` (C). Performa *throughput*-nya sedikit lebih unggul di skenario ekstrem dengan beban jaringan yang masif, karena pustaka C-nya sangat matang dalam hal manajemen memori tingkat rendah dan kompresi batch. Namun, harganya mahal: Anda harus memastikan kompiler C (`gcc`) tersedia di lingkungan *build* Docker Anda, dan *debugging* isu memori kadang memerlukan pemahaman mendalam tentang CGO.

Untuk sistem skala besar yang menuntut stabilitas *deployment* CI/CD yang cepat dan bersih, saya secara pribadi lebih merekomendasikan `segmentio/kafka-go` untuk sebagian besar kasus penggunaan backend web modern, kecuali jika Anda berurusan dengan jutaan pesan per detik per instance yang membutuhkan optimasi *throughput* hingga titik darah penghabisan.

Kompromi berikutnya berkaitan dengan jaminan pengiriman (*delivery semantics*): *At-least-once* vs *At-most-once* vs *Exactly-once*. Di dunia nyata, *Exactly-once* adalah ilusi mahal yang mengorbankan performa jaringan secara drastis. Pendekatan paling bijak dan pragmatis adalah **At-least-once**, di mana kita memastikan tidak ada event yang hilang dengan risiko memproses duplikat event, yang kemudian kita tangani di tingkat aplikasi menggunakan mekanisme *idempotency* (misalnya menggunakan UUID transaksi atau Redis deduplication cache).

Sebelum kita menulis baris kode pertama, mari kita siapkan fondasi infrastruktur dasarnya agar siap tempur.

## 4. Meracik Lingkungan: Persiapan & Prasyarat Lapangan

Kita tidak akan membangun sistem ini di atas awan angan-angan. Mari kita siapkan *stack* lokal menggunakan Docker Compose yang merepresentasikan kluster Kafka produksi minimal dengan Zookeeper (atau KRaft mode untuk Kafka modern, tapi mari gunakan kombinasi standar yang stabil dengan Zookeeper untuk saat ini) serta layanan Redis untuk manajemen *idempotency* nanti.

Buat sebuah berkas `docker-compose.yml` di direktori kerja Anda:

```yaml
version: '3.8'

services:
  zookeeper:
  image: confluentinc/cp-zookeeper:7.3.0
  container_name: zookeeper
  environment:
  ZOOKEEPER_CLIENT_PORT: 2181
  ZOOKEEPER_TICK_TIME: 2000

  kafka:
  image: confluentinc/cp-kafka:7.3.0
  container_name: kafka
  depends_on:
  - zookeeper
  ports:
  - "9092:9092"
  environment:
  KAFKA_BROKER_ID: 1
  KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
  KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_HOST://localhost:29092
  KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
  KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
  KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1

  redis:
  image: redis:7-alpine
  container_name: redis_cache
  ports:
  - "6379:6379"
```

Jalankan kluster tersebut dengan perintah:

```bash
docker-compose up -d
```

> [!NOTE]
> Pastikan port `9092` tidak sedang digunakan oleh layanan lokal lain seperti instance MySQL atau Nginx bawaan sistem Anda. Gunakan perintah `netstat -an | grep 9092` untuk memastikannya.

Dengan infrastruktur yang kini berjalan di latar belakang, saatnya melompat ke inti teknis berikutnya: merancang kode produsen dan konsumen Go yang tangguh menghadapi gangguan jaringan.

## 5. Langkah Implementasi: Membangun Solusi yang Tangguh

Membuat produsen Kafka di Go bukan sekadar memanggil fungsi `WriteMessages`. Kita harus memikirkan tentang *connection pooling*, penanganan kesalahan saat broker mendadak sibuk, dan *batching* otomatis untuk efisiensi jaringan. Mari kita buat struktur proyek Go sederhana dan mulai dengan produsen (*producer*) yang tangguh.

Inisialisasi modul Go:

```bash
go mod init kafka-high-throughput
go get github.com/segmentio/kafka-go
```

Berikut adalah implementasi produsen Go (`producer.go`) yang memanfaatkan *writer configuration* optimal untuk *high-throughput*:

```go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"time"

	"github.com/segmentio/kafka-go"
)

type OrderEvent struct {
	OrderID   string    `json:"order_id"`
	UserID    string    `json:"user_id"`
	Amount    float64   `json:"amount"`
	Timestamp time.Time `json:"timestamp"`
}

func main() {
	// Konfigurasi Writer dengan batching dan kompresi untuk performa tinggi
	writer := &kafka.Writer{
		Addr:         kafka.TCP("localhost:9092"),
		Topic:        "order-events",
		Balancer:     &kafka.Hash{}, // Memastikan key yang sama masuk ke partisi yang sama
		BatchSize:    100,           // Kumpulkan hingga 100 pesan sebelum dikirim
		BatchTimeout: 10 * time.Millisecond,
		RequiredAcks: kafka.RequireOne, // Kompromi kecepatan vs durabilitas (1 = acknowledged by leader)
		Async:        true,           // Mengirim secara asinkron untuk throughput maksimal
	}
	defer writer.Close()

	ctx := context.Background()

	// Simulasi pengiriman event
	for i := 1; i <= 5; i++ {
		event := OrderEvent{
			OrderID:   fmt.Sprintf("ORD-%d", i),
			UserID:    fmt.Sprintf("USR-%d", i%3), // Disebar ke beberapa partisi berdasarkan UserID
			Amount:    150.50 + float64(i),
			Timestamp: time.Now(),
		}

		payload, err := json.Marshal(event)
		if err != nil {
			log.Printf("Gagal marshal JSON: %v", err)
			continue
		}

		err = writer.WriteMessages(ctx, kafka.Message{
			Key:   []byte(event.UserID),
			Value: payload,
		})

		if err != nil {
			log.Printf(
				"Gagal menulis pesan %s: %v",
				event.OrderID,
				err,
			)
		} else {
			fmt.Printf("Berhasil mengirim event: %s\n", event.OrderID)
		}
	}
}
```

Produsen sudah siap mengirim data dalam bentuk *batch* dengan aman. Tantangan berikutnya yang jauh lebih menantang adalah membangun konsumen (*consumer*) yang tidak hanya membaca data dengan cepat, tetapi juga tidak boleh kehilangan offset ketika terjadi kegagalan sistem di tengah jalan. Mari kita bedah baris-baris kode krusial yang sering menjadi jebakan maut bagi para engineer.

## 6. Membedah Baris Kode Krusial yang Kerap Menjebak

Bagian paling rawan dalam arsitektur Kafka di Go adalah manajemen *commit offset*. Jika Anda melakukan *commit offset* secara otomatis sebelum data benar-benar selesai diproses oleh basis data hilir (misalnya MySQL atau Redis), lalu aplikasi Anda *crash*, maka data tersebut hilang selamanya dari pandangan sistem (*data loss*). Sebaliknya, jika Anda lupa melakukan *commit*, aplikasi akan membaca ulang pesan yang sama selamanya (*infinite loop of failure*).

Berikut adalah implementasi konsumen Go (`consumer.go`) yang mengimplementasikan manajemen *commit offset* secara manual dan penanganan konkurensi berbasis *worker pool*:

```go
package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/segmentio/kafka-go"
)

func main() {
	// Inisialisasi Reader dengan Consumer Group
	r := kafka.NewReader(kafka.ReaderConfig{
		Brokers:  []string{"localhost:9092"},
		Topic:    "order-events",
		GroupID:  "order-processing-group",
		MinBytes: 10e3, // 10KB
		MaxBytes: 10e6, // 10MB
		// Jangan gunakan AutoCommit! Kita commit manual setelah sukses diproses.
		// Watchdog timeout untuk menjaga heartbeat tetap aktif
	})
	defer r.Close()

	// Channel untuk menangkap sinyal terminasi OS (Graceful Shutdown)
	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

	ctx, cancel := context.WithCancel(context.Background())
	go func() {
		<-sigChan
		fmt.Println("\nMenerima sinyal shutdown, menghentikan konsumen...")
		cancel()
	}()

	fmt.Println("Konsumen aktif dan mendengarkan event...")

	for {
		// FetchMessage membaca pesan tanpa melakukan auto-commit offset
		msg, err := r.FetchMessage(ctx)
		if err != nil {
			// Jika context dibatalkan, keluar dari loop dengan bersih
			if ctx.Err() != nil {
				break
			}
			log.Printf("Error saat mengambil pesan: %v", err)
			continue
		}

		// Simulasi pemrosesan bisnis yang memakan waktu (misal: simpan ke MySQL / hitung analitik)
		err = processBusinessLogic(msg.Value)
		if err != nil {
			log.Printf(
				"Gagal memproses pesan [Offset: %d]: %v. Mengirim ke DLQ...",
				msg.Offset,
				err,
			)
			// Di sinilah fungsi Dead-Letter Queue (DLQ) berperan
			sendToDLQ(msg, err)
		}

		// Commit offset secara manual HANYA setelah pesan sukses diproses atau dilempar ke DLQ
		err = r.CommitMessages(ctx, msg)
		if err != nil {
			log.Printf("Gagal melakukan commit offset: %v", err)
		}
	}

	fmt.Println(
		"Konsumen berhasil dimatikan dengan aman (Graceful Shutdown selesai).",
	)
}

func processBusinessLogic(payload []byte) error {
	// Simulasi logika bisnis sukses
	// fmt.Printf("Memproses payload: %s\n", string(payload))
	return nil
}

func sendToDLQ(msg kafka.Message, processingErr error) {
	// Implementasi pengiriman pesan gagal ke topik terpisah (order-events-dlq)
	fmt.Printf(
		"[DLQ] Pesan dengan key %s dan offset %d dipindahkan karena: %v\n",
		string(msg.Key),
		msg.Offset,
		processingErr,
		)
}
```

> [!WARNING]
> Jangan pernah mengabaikan error dari `CommitMessages`. Jika operasi commit gagal berulang kali karena koneksi broker terputus, aplikasi harus segera memicu *circuit breaker* atau menghentikan proses baca demi mencegah inkonsistensi *offset state*.

Kode di atas menangani alur normal dan kegagalan satuan, namun apa yang terjadi ketika sistem menghadapi lonjakan trafik ribuan kali lipat di dunia nyata? Mari kita uji ketahanannya di bab berikutnya.

## 7. Bertahan di Bawah Tekanan: Skenario Traffic Nyata

Di atas kertas, kode di atas terlihat sempurna. Namun, mari kita bayangkan skenario nyata di lini produksi: saat *flash sale* dimulai, jutaan pengguna menekan tombol *checkout* dalam detik yang sama. Produsen Go Anda mengirim 50.000 pesan per detik ke Kafka. Broker Kafka mulai mengalami lonjakan penggunaan memori, dan basis data hilir (MySQL) mulai merespons lambat karena kehabisan *thread pool*.

Jika konsumen Anda memproses pesan secara sekuensial (*single-threaded*), *lag* di Kafka (*consumer lag*) akan meroket dari 0 menjadi jutaan pesan dalam hitungan menit. Pengguna akan mengeluh pesanan mereka baru diproses setengah jam kemudian.

Untuk mengatasi tekanan ini, kita harus mengubah pola konsumsi menjadi **Concurrent Worker Pool** di dalam aplikasi Go. Alih-alih satu goroutine membaca dan memproses satu per satu, kita membaca pesan dari Kafka, memasukkannya ke dalam sebuah *channel* penyangga (*buffered channel*), lalu mendistribusikannya ke sejumlah *worker goroutine* yang berjalan paralel. Namun, ingat batasan emasnya: **Paralelisasi pemrosesan tidak boleh merusak urutan event dalam partisi yang sama**. Oleh karena itu, *worker pool* harus dikelola per partisi atau kita harus memastikan bahwa mekanisme *idempotency* di level basis data mampu menangani pemrosesan paralel dari entitas unik yang sama.

Ketika sebuah event gagal diproses secara permanen (misalnya karena format JSON rusak atau skema basis data menolak data tersebut), kita tidak boleh membiarkan antrean macet. Kita butuh tempat pembuangan khusus yang elegan.

## 8. Jebakan Klasik & Cara Menghindarinya Sebelum Meledak

Selama bertahun-tahun merancang infrastruktur event-driven, saya melihat pola kesalahan berulang yang dilakukan oleh para engineer pemula maupun menengah. Berikut adalah daftar jebakan klasik beserta cara menghindarinya:

1.  **Mengabaikan Dead-Letter Queue (DLQ)**: Ketika sebuah pesan gagal diproses, beberapa pengembang mencoba mengulanginya (*retry*) tanpa batas waktu (*infinite retry*). Akibatnya, pesan beracun (*poison pill*) tersebut memblokir seluruh partisi, membuat konsumen macet total. **Solusi**: Terapkan batas maksimum *retry* (misalnya 3 kali). Jika tetap gagal, kirim pesan tersebut secara otomatis ke topik khusus bernama `[nama-topik]-dlq` beserta metadata error-nya, lalu lakukan *commit offset* pada pesan aslinya agar sistem tetap bergerak.
2.  **Salah Memilih Jumlah Partisi**: Menambah jumlah partisi di tengah jalan adalah hal yang rumit dan dapat merusak urutan pengiriman jika tidak dilakukan dengan hati-hati. Terlalu sedikit partisi membatasi skalabilitas konsumen; terlalu banyak partisi membebani memori broker Kafka karena setiap partisi membutuhkan alokasi *file descriptor* dan *buffer* di disk.
3.  **Lupa Mengatur `MaxPollIntervalMs` atau Timeout Serupa**: Di Go, jika proses bisnis Anda memakan waktu lebih lama dari batas waktu *heartbeat* Kafka karena operasi I/O yang lambat, broker akan mengira konsumen Anda mati (*dead*) dan memicu *rebalance* massal yang berujung pada kekacauan kluster (*rebalance storm*).

Mari kita ukur sejauh mana performa sistem yang telah kita bangun dengan melakukan uji beban langsung di titik batasnya.

## 9. Uji Nyali: Mengukur Performa di Titik Batas

Arsitektur yang hebat dibuktikan dengan data, bukan klaim kosong. Untuk menguji seberapa tangguh sistem Go dan Kafka yang kita bangun, kita bisa memanfaatkan alat uji beban standar industri seperti `k6` atau utilitas bawaan Kafka sendiri, yaitu `kafka-producer-perf-test.sh`.

Mari kita jalankan pengujian *throughput* langsung ke kontainer Kafka lokal kita menggunakan alat bawaan dari dalam kontainer broker:

```bash
docker exec -it kafka kafka-producer-perf-test \
  --topic order-events \
  --num-records 100000 \
  --record-size 1024 \
  --throughput 10000 \
  --producer-props bootstrap.servers=localhost:9092
```

Perintah di diatas akan menyemburkan 100.000 pesan dengan ukuran masing-masing 1 KB ke dalam topik `order-events` dengan target kecepatan 10.000 pesan per detik. Anda akan melihat metrik langsung di terminal: berapa *latency* rata-rata, *max latency*, dan total megabit per detik yang berhasil dilewatkan oleh sistem.

Di sisi aplikasi Go, pantau penggunaan memori menggunakan pprof atau metrik Prometheus sederhana untuk memastikan tidak ada *memory leak* akibat penumpukan pointer di dalam *buffered channel* worker pool.

Sebelum Anda menekan tombol *merge* ke cabang `main` dan merilisnya ke lingkungan produksi, pastikan Anda melengkapi *checklist* operasional berikut.

## 10. Catatan Lapangan & Checklist Sebelum Rilis

Sebagai penutup dari panduan komprehensif ini, simpan dan cetak *checklist* operasional berikut di dinding ruang kerja tim Anda sebelum menerbitkan arsitektur event-driven ke lingkungan produksi:

*   [ ] **Replikasi Topik Minimal 3**: Untuk lingkungan produksi (*production-grade*), pastikan faktor replikasi (*replication factor*) topik Kafka minimal diset ke 3 dengan `min.insync.replicas=2` demi menghindari kehilangan data saat satu broker mati mendadak.
*   [ ] **Idempotensi di Hilir**: Pastikan basis data atau layanan penyimpanan Anda (seperti Redis cache atau tabel MySQL) mendukung operasi *upsert* atau pengecekan *idempotency* berbasis UUID transaksi untuk mengantisipasi pengiriman ganda (*at-least-once delivery*).
*   [ ] **Monitoring & Alerting**: Pasang pemantauan metrik **Consumer Lag** menggunakan alat seperti Prometheus dan Grafana. Atur *alert* otomatis ke PagerDuty atau Slack jika *consumer lag* melebihi 10.000 pesan selama lebih dari 5 menit.
*   [ ] **Graceful Shutdown yang Sempurna**: Pastikan penanganan sinyal `SIGTERM` diimplementasikan dengan benar di aplikasi Go Anda, sehingga setiap *consumer* menyelesaikan sisa pemrosesan pesan di memori sebelum memutus koneksi secara elegan.
*   [ ] **Retensi dan Pembersihan Disk**: Pastikan kebijakan retensi log Kafka dikonfigurasi dengan benar (berdasarkan waktu atau ukuran) agar disk server broker tidak meledak penuh.

Arsitektur event-driven bukan sekadar tren teknologi, melainkan sebuah komitmen disiplin rekayasa perangkat lunak. Dengan memadukan ketangguhan Apache Kafka dan kecepatan konkurensi Go, sistem Anda kini siap berdiri kokoh di bawah terjangan badai trafik apa pun di dunia nyata.