Event Streaming #
Di balik sistem-sistem besar yang kita gunakan sehari-hari — feed media sosial yang selalu terkini, deteksi penipuan kartu kredit yang terjadi dalam milidetik, dashboard analytics yang bergerak real-time — ada satu fondasi yang sama: aliran event yang terus mengalir dan diproses secara berkelanjutan. Event streaming bukan hanya cara untuk mengirim pesan dari satu service ke service lain. Ia adalah paradigma berpikir tentang data sebagai sesuatu yang bergerak, bukan sesuatu yang diam menunggu di-query. Panduan ini membahas event streaming dari prinsip dasarnya, perbedaannya yang fundamental dengan message queue dan database tradisional, anatomi platform streaming, empat use case nyata di industri, tantangan teknis yang sering diremehkan, hingga best practice yang membedakan implementasi yang bertahan dengan yang akhirnya menjadi masalah operasional.
Apa Itu Event Streaming? #
Event streaming adalah paradigma di mana event diproduksi, disimpan, dan dikonsumsi secara berkelanjutan oleh satu atau banyak sistem. Kata kunci yang membedakannya dari paradigma lain adalah berkelanjutan — stream tidak punya awal dan akhir yang terdefinisi seperti sebuah file atau batch query. Ia terus mengalir.
Perbedaan cara berpikir yang paling fundamental adalah tentang apa yang disimpan dan diakses:
Database tradisional — menyimpan state saat ini:
Tabel orders: {id: 1, status: "shipped", updated_at: "2026-04-17"}
Query: "Apa status order #1 sekarang?" → "shipped"
Pertanyaan yang tidak bisa dijawab: "Kapan statusnya berubah dari apa ke apa?"
Event streaming — menyimpan urutan perubahan:
Stream order-events:
offset 0: OrderCreated {order_id: 1, total: 250000, at: "2026-04-17 09:00"}
offset 1: PaymentReceived {order_id: 1, payment_id: "PAY-001", at: "09:05"}
offset 2: OrderPacked {order_id: 1, warehouse: "JKT-1", at: "10:30"}
offset 3: OrderShipped {order_id: 1, tracking: "JNE-123", at: "11:00"}
Query: "Apa status order #1 sekarang?" → replay semua event → "shipped"
Query: "Kapan transisi ke shipped?" → offset 3, "11:00"
Query: "Berapa lama dari payment ke shipped?" → offset 1 vs 3 → 1 jam 55 menit
Event streaming tidak fokus pada state akhir, melainkan pada perjalanan perubahan state itu sendiri. Ini yang membuka kemampuan seperti audit trail lengkap, time-travel debugging, dan rebuild state dari titik manapun dalam sejarah.
Event Streaming vs Message Queue vs Database #
Ketiga paradigma ini sering dicampuradukkan. Memahami perbedaannya penting untuk memilih alat yang tepat.
| Aspek | Database Tradisional | Message Queue | Event Streaming |
|---|---|---|---|
| Yang disimpan | State terkini | Task/pesan sementara | Urutan event permanen |
| Akses data | Pull (query) | Push ke consumer | Pull oleh consumer (offset) |
| Setelah dibaca | Tetap ada | Dihapus dari queue | Tetap ada (retention) |
| Replay | Tidak applicable | Sangat sulit | Native — reset offset |
| Consumer | Banyak, baca bersamaan | Biasanya satu per message | Banyak, independen |
| Urutan | Tidak ada | Tergantung implementasi | Terjamin per partisi |
| Skalabilitas | Vertikal (umumnya) | Horizontal dengan effort | Horizontal native |
| Cocok untuk | CRUD, transaksional | Task queue, background job | Pipeline data, audit, analytics |
Yang paling sering menimbulkan kebingungan adalah perbedaan message queue dengan event streaming. Analoginya: message queue seperti conveyor belt di pabrik — item diambil satu per satu oleh pekerja dan setelah diambil hilang dari belt. Event streaming seperti rekaman video — bisa ditonton berkali-kali oleh banyak penonton, dan penonton baru bisa mulai dari awal tanpa mempengaruhi penonton lain.
Message Queue (RabbitMQ, SQS):
Producer → [msg1, msg2, msg3] → Consumer A mengambil msg1 → msg1 hilang
Consumer B mengambil msg2 → msg2 hilang
Cocok untuk: distribusi kerja, satu pekerjaan ditangani satu worker
Event Streaming (Kafka):
Producer → [evt1, evt2, evt3, ...] → Consumer A baca dari offset 0
→ Consumer B baca dari offset 0 (independen)
→ Consumer C baca dari offset 2 (bisa pilih)
Event tetap ada sampai retention period habis
Cocok untuk: multiple consumer, replay, audit trail, real-time pipeline
Anatomi Platform Event Streaming #
Topic dan Partition #
Topic adalah named stream — channel logis tempat event sejenis dikumpulkan. order-events, user-events, payment-events adalah contoh topic.
Partition adalah unit fisik di dalam topic. Satu topic dibagi menjadi beberapa partition yang masing-masing adalah log terurut yang independen. Partition adalah kunci skalabilitas horizontal Kafka.
Topic: order-events (3 partitions)
Partition 0: [evt offset 0] [evt offset 1] [evt offset 3] [evt offset 6] ...
Partition 1: [evt offset 0] [evt offset 2] [evt offset 4] [evt offset 7] ...
Partition 2: [evt offset 0] [evt offset 1] [evt offset 5] [evt offset 8] ...
Producer menentukan partition berdasarkan partition key.
Semua event dengan key yang sama masuk ke partition yang sama
→ ordering terjamin untuk key tersebut.
Pemilihan partition key adalah keputusan desain yang kritis:
// ANTI-PATTERN: tanpa partition key — event untuk order yang sama bisa masuk
// partition berbeda, ordering tidak terjamin
producer.Publish("order-events", event)
// BENAR: gunakan order_id sebagai partition key
// Semua event untuk order yang sama selalu di partition yang sama
producer.PublishWithKey("order-events", event.OrderID, event)
// Dampaknya: consumer yang memproses event order #123 selalu menerima
// urutan: OrderCreated → PaymentReceived → OrderShipped
// bukan urutan acak
Offset dan Consumer Group #
Offset adalah nomor urut setiap event dalam sebuah partition. Consumer mengontrol sendiri offset mana yang sudah dibaca — ini yang membuat replay menjadi native di event streaming.
Partition 0: [0: OrderCreated] [1: OrderPaid] [2: OrderShipped] [3: OrderDelivered]
↑
Consumer group "notification-service"
sudah baca sampai offset 1 (OrderPaid)
Saat restart, mulai dari offset 2
Consumer group adalah mekanisme untuk horizontal scaling consumer. Setiap consumer dalam grup mendapat subset partition yang berbeda — beban dibagi merata.
Topic order-events: 6 partitions
Consumer Group "payment-service" (3 instances):
Instance A → Partition 0, 1
Instance B → Partition 2, 3
Instance C → Partition 4, 5
Consumer Group "email-service" (2 instances):
Instance X → Partition 0, 1, 2
Instance Y → Partition 3, 4, 5
Kedua group baca event yang sama secara independen.
Menambah instance di "payment-service" tidak mempengaruhi "email-service".
Jumlah instance consumer dalam satu group tidak bisa melebihi jumlah partition. Jika ada 6 partition dan 8 consumer instance, 2 instance akan idle. Ini artinya jumlah partition adalah batas atas paralelisme untuk sebuah consumer group — desain jumlah partition harus mempertimbangkan kebutuhan scaling di masa depan.
Retention dan Log Compaction #
Berbeda dengan message queue yang menghapus pesan setelah dikonsumsi, event streaming menyimpan event selama retention period yang dikonfigurasi — bisa berhari-hari, berminggu-minggu, atau bahkan selamanya (infinite retention).
Pilihan retention strategy:
Time-based retention:
retention.ms = 604800000 # 7 hari
Event lebih dari 7 hari otomatis dihapus
Size-based retention:
retention.bytes = 10737418240 # 10 GB per partition
Event terlama dihapus saat ukuran melebihi batas
Log compaction:
Hanya simpan event terbaru untuk setiap key
Cocok untuk: state snapshots, user profiles, config changes
Event lama untuk key yang sama di-compact menjadi satu entry terbaru
Empat Use Case Utama di Industri #
1. Microservices Communication #
Event streaming sebagai backbone komunikasi antar microservice menggantikan synchronous API call untuk workflow yang tidak butuh respons langsung.
Tanpa event streaming (synchronous chain):
Order Service ──POST──→ Payment Service ──POST──→ Inventory Service ──POST──→ Email Service
│ │ │ │
semua harus UP semua harus UP semua harus UP jika gagal
order stuck
Dengan event streaming:
Order Service ──publish order.created──→ Kafka
│
┌─────────────────┼──────────────────┐
↓ ↓ ↓
Payment Service Inventory Service Email Service
(consume, (consume, (consume,
proses async) proses async) proses async)
Order Service tidak tahu dan tidak peduli siapa yang consume.
Jika Email Service down, event terakumulasi — diproses saat kembali online.
2. Real-Time Analytics dan Stream Processing #
Event streaming memungkinkan analitik yang dihitung langsung dari stream, bukan dari batch query ke database yang sudah dingin.
// Contoh: hitung revenue per menit menggunakan stream processing
// (konsep Kafka Streams / Flink)
func buildRevenueStream(orderEvents KStream) KTable {
return orderEvents.
Filter(func(e OrderEvent) bool {
return e.Type == "order.paid"
}).
GroupBy(func(e OrderEvent) string {
// Grup per menit
return e.OccurredAt.Truncate(time.Minute).Format(time.RFC3339)
}).
Aggregate(
func() int64 { return 0 },
func(key string, event OrderEvent, agg int64) int64 {
return agg + event.Amount
},
)
// Hasilnya: tabel {minute → total_revenue} yang terupdate real-time
// tanpa menunggu batch job berjalan
}
Use case nyata: deteksi fraud real-time (pola transaksi anomali dalam window 5 menit), live leaderboard, monitoring latency API secara real-time.
3. Change Data Capture (CDC) #
CDC adalah teknik untuk mengambil setiap perubahan di database (INSERT, UPDATE, DELETE) dan mempublikasikannya sebagai event ke stream. Ini memungkinkan sinkronisasi antar sistem tanpa polling atau trigger yang mahal.
Database PostgreSQL
│
│ (binlog / WAL reading)
↓
Debezium Connector ──────→ Kafka topic: postgres.orders
│
┌───────────┼────────────┐
↓ ↓ ↓
Elasticsearch Redis Cache Analytics DB
(search index) (invalidate) (data warehouse)
Setiap kali row di tabel orders berubah:
→ Elasticsearch index diupdate otomatis
→ Redis cache di-invalidate
→ Data warehouse mendapat record baru
Tanpa satu pun query polling atau trigger di database utama.
4. Event Sourcing sebagai Source of Truth #
Dalam event sourcing, stream event adalah database utama — bukan tambahan. State aplikasi direkonstruksi dengan me-replay event dari awal.
// Rebuild state akun dari event stream
func RebuildAccountState(accountID string, events []AccountEvent) Account {
account := Account{ID: accountID, Balance: 0}
for _, event := range events {
switch event.Type {
case "account.credited":
account.Balance += event.Amount
case "account.debited":
account.Balance -= event.Amount
case "account.frozen":
account.Frozen = true
case "account.unfrozen":
account.Frozen = false
}
}
return account
// Keunggulan: bisa rebuild state di titik waktu manapun
// cukup replay events sampai timestamp tertentu
}
Tantangan Teknis yang Sering Diremehkan #
Ordering Hanya Dijamin Per Partition #
Ini adalah salah satu kesalahpahaman paling umum: event streaming tidak menjamin ordering global di seluruh topic. Ordering hanya dijamin di dalam satu partition.
ANTI-PATTERN: order ID berbeda di partition berbeda, urutan tidak terjamin
Partition 0: OrderCreated(id=1) ... OrderShipped(id=2) ...
Partition 1: OrderCreated(id=2) ... OrderShipped(id=1) ...
Consumer mungkin melihat OrderShipped(id=1) sebelum OrderCreated(id=1)
karena keduanya di partition berbeda.
BENAR: gunakan partition key = entity ID
Partition 0: OrderCreated(id=1), PaymentReceived(id=1), OrderShipped(id=1)
Partition 1: OrderCreated(id=2), PaymentReceived(id=2), OrderShipped(id=2)
Semua event untuk order #1 selalu di partition yang sama → ordering terjamin.
Exactly-Once Semantics #
Exactly-once — setiap event diproses tepat satu kali, tidak kurang tidak lebih — adalah jaminan yang paling sulit dan paling mahal di sistem terdistribusi. Dalam praktiknya, at-least-once + idempotent consumer adalah solusi yang jauh lebih realistis dan lebih umum digunakan.
Tiga delivery semantic:
At-most-once: event mungkin hilang, tidak pernah duplikat
→ acceptable untuk metrics non-kritis, log statistik
At-least-once: event pasti sampai, tapi mungkin duplikat
→ PALING UMUM — consumer harus idempotent
→ acceptable untuk hampir semua use case dengan idempotency
Exactly-once: tidak ada yang hilang, tidak ada duplikat
→ sangat mahal (distributed transaction)
→ Kafka transactions bisa mencapai ini tapi dengan overhead besar
→ hanya untuk kasus yang benar-benar butuh (financial ledger)
// BENAR: at-least-once + idempotent consumer — solusi paling pragmatis
func (c *PaymentConsumer) ProcessPaymentEvent(ctx context.Context, event PaymentEvent) error {
// Cek apakah event ini sudah pernah diproses
if processed, _ := c.idempotencyStore.Exists(ctx, event.EventID); processed {
// Commit offset tetap dilakukan — tidak ada masalah
return nil
}
if err := c.processPayment(ctx, event); err != nil {
// Jangan commit offset — event akan di-retry
return err
}
// Tandai sebagai processed dan commit offset
c.idempotencyStore.Set(ctx, event.EventID, time.Now())
return nil
}
Schema Evolution dengan Schema Registry #
Ketika banyak producer dan consumer beroperasi di atas event yang sama, perubahan schema bisa merusak consumer yang belum di-deploy ulang. Schema Registry menjadi komponen wajib untuk mengelola ini.
Tanpa Schema Registry:
Producer kirim event dengan format baru {amount_cents: 25000}
Consumer lama masih baca {amount: 250.00}
→ Consumer rusak, tidak ada warning sebelumnya
Dengan Schema Registry (Confluent Schema Registry):
1. Producer mendaftarkan schema baru → Registry cek kompatibilitas
2. Registry tolak jika breaking change (hapus field wajib, ganti tipe)
3. Registry izinkan backward-compatible change (tambah field optional)
4. Consumer mendapat schema ID dari message header → deserialize dengan benar
// BENAR: gunakan Avro/Protobuf dengan Schema Registry
// bukan plain JSON tanpa kontrak
// Schema OrderCreated v1
type OrderCreatedV1 struct {
OrderID string `avro:"order_id"`
UserID string `avro:"user_id"`
TotalCents int64 `avro:"total_cents"`
}
// Schema OrderCreated v2 — backward compatible (tambah field optional)
type OrderCreatedV2 struct {
OrderID string `avro:"order_id"`
UserID string `avro:"user_id"`
TotalCents int64 `avro:"total_cents"`
PromoCode *string `avro:"promo_code,omitempty"` // baru, optional
ShippingAddress *string `avro:"shipping_address,omitempty"` // baru, optional
}
// Consumer v1 tetap bisa baca event v2 karena field baru bersifat optional
Best Practice Desain Event Streaming #
Event adalah Kontrak Publik #
Begitu sebuah event topic dipublikasikan dan ada consumer yang bergantung padanya, ia menjadi kontrak yang harus dijaga. Perlakukan setiap event type seperti public API — perubahan breaking harus dikelola dengan serius.
Prinsip desain event sebagai kontrak:
□ Nama event harus berbasis fakta (OrderCreated, bukan CreateOrder)
□ Semua field wajib harus backward-compatible — tidak boleh dihapus/diganti tipe
□ Field baru selalu optional dengan default value
□ Gunakan versioning (order.created.v2) untuk breaking change
□ Jangan publish event internal implementation detail ke topic yang dikonsumsi luar
Partition Key Strategy #
Pemilihan partition key menentukan distribusi beban dan jaminan ordering. Tiga strategi umum:
Strategi 1 — Entity ID (paling umum)
Key: order_id, user_id, product_id
Manfaat: ordering per entity terjamin
Trade-off: distribusi tidak merata jika ada hot key (user dengan transaksi sangat banyak)
Strategi 2 — Hash-based
Key: hash(entity_id) % num_partitions
Manfaat: distribusi merata
Trade-off: ordering tidak terjamin lintas partition
Strategi 3 — Round-robin (tanpa key)
Manfaat: distribusi paling merata, throughput maksimal
Trade-off: tidak ada ordering sama sekali
Cocok untuk: event yang benar-benar independen (log, metrics)
Consumer Lag Monitoring Wajib #
Consumer lag adalah metrik paling penting di event streaming — selisih antara offset terbaru yang di-produce dengan offset terbaru yang sudah di-consume oleh sebuah consumer group.
Consumer lag = latest_offset - consumer_committed_offset
Lag = 0 → consumer real-time, tidak ada backlog
Lag = 1000 → ada 1000 event yang belum diproses
Lag terus naik → consumer tidak bisa mengimbangi kecepatan producer
→ butuh scale out consumer atau optimize processing
// BENAR: monitor consumer lag dan alert saat melebihi threshold
func monitorConsumerLag(client sarama.Client, group, topic string) {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
lag, err := calculateLag(client, group, topic)
if err != nil {
log.Errorf("failed to get consumer lag: %v", err)
continue
}
metrics.Gauge("kafka.consumer.lag",
float64(lag),
metrics.Tags{"group": group, "topic": topic},
)
if lag > 10000 {
alert.Fire("consumer_lag_critical",
fmt.Sprintf("group=%s topic=%s lag=%d", group, topic, lag))
}
}
}
Anti-Pattern yang Harus Dihindari #
// ✗ Event terlalu kecil dan terlalu banyak — overhead komunikasi tinggi
kafka.Publish("user.name.first.updated", event)
kafka.Publish("user.name.last.updated", event)
kafka.Publish("user.email.updated", event)
// ✓ Satu event bermakna di level bisnis
kafka.Publish("user.profile.updated", event)
// ✗ Event berisi command, bukan fakta
kafka.Publish("send-welcome-email", event) // command
kafka.Publish("process-payment", event) // command
// ✓ Event berisi fakta yang sudah terjadi
kafka.Publish("user.registered", event) // fakta
kafka.Publish("order.payment.received", event) // fakta
// ✗ Tidak menggunakan partition key untuk event yang butuh ordering
producer.SendMessage(&sarama.ProducerMessage{
Topic: "order-events",
Value: sarama.ByteEncoder(payload),
// tidak ada Key — ordering tidak terjamin
})
// ✓ Selalu set partition key untuk event yang perlu ordered per entity
producer.SendMessage(&sarama.ProducerMessage{
Topic: "order-events",
Key: sarama.StringEncoder(event.OrderID), // ← ordering terjamin per order
Value: sarama.ByteEncoder(payload),
})
// ✗ Consumer commit offset sebelum processing selesai
// → jika crash setelah commit tapi sebelum proses, event hilang
msg := consumer.Receive()
consumer.CommitOffset(msg.Offset) // ← terlalu cepat
processEvent(msg) // crash di sini = event hilang
// ✓ Commit offset SETELAH processing berhasil
msg := consumer.Receive()
if err := processEvent(msg); err != nil {
return err // jangan commit — event akan di-retry
}
consumer.CommitOffset(msg.Offset) // ← commit hanya setelah berhasil
Ringkasan #
- Event streaming menyimpan urutan perubahan (event log), bukan state terkini — ini yang membuka kemampuan replay, audit trail, dan time-travel debugging.
- Bukan message queue: event streaming menyimpan event setelah dikonsumsi (retention), mendukung banyak consumer independen, dan native replay; message queue menghapus pesan setelah dikonsumsi.
- Topic dan partition: topic adalah channel logis, partition adalah unit fisik yang memungkinkan horizontal scaling dan paralelisme.
- Ordering hanya dijamin per partition — gunakan entity ID sebagai partition key agar semua event untuk satu entity selalu terurut.
- Consumer group: setiap group mendapat seluruh event secara independen; instance dalam satu group berbagi beban partition.
- At-least-once + idempotent consumer adalah trade-off yang paling pragmatis — exactly-once sangat mahal dan jarang benar-benar diperlukan.
- Schema Registry wajib untuk lingkungan multi-team — mencegah breaking change schema secara tidak sengaja.
- Consumer lag adalah metrik kesehatan paling penting — monitoring dan alerting harus ada sebelum masalah terdeteksi oleh user.
- Event adalah kontrak publik — perlakukan seperti public API: backward-compatible change untuk evolusi minor, versioning untuk breaking change.
← Sebelumnya: Event-Driven Berikutnya: Inversion of Control →