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.

AspekDatabase TradisionalMessage QueueEvent Streaming
Yang disimpanState terkiniTask/pesan sementaraUrutan event permanen
Akses dataPull (query)Push ke consumerPull oleh consumer (offset)
Setelah dibacaTetap adaDihapus dari queueTetap ada (retention)
ReplayTidak applicableSangat sulitNative — reset offset
ConsumerBanyak, baca bersamaanBiasanya satu per messageBanyak, independen
UrutanTidak adaTergantung implementasiTerjamin per partisi
SkalabilitasVertikal (umumnya)Horizontal dengan effortHorizontal native
Cocok untukCRUD, transaksionalTask queue, background jobPipeline 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact