Ordering Behavior #

Dalam perancangan sistem terdistribusi asinkron, menjaga urutan pesan (ordering) adalah salah satu tantangan terbesar yang sering kali memicu bug konsistensi data yang sangat mahal harganya. Banyak aplikasi bisnis yang menuntut agar kronologi peristiwa yang diterbitkan oleh produsen diproses oleh konsumen dengan urutan yang sama persis. Sebagai contoh, di dalam sistem e-commerce, event OrderCreated harus selalu diproses sebelum OrderPaid, dan OrderPaid harus diproses sebelum OrderShipped. Jika urutan ini kacau (misalnya event pembayaran diproses terlebih dahulu sebelum catatan pembuatan pesanan dibuat di database konsumen), sistem kita akan mengalami kegagalan logika bisnis.

Meskipun RabbitMQ dan Kafka sama-sama menawarkan jaminan urutan pesan, keduanya menggunakan mekanisme arsitektural yang sangat berbeda untuk mencapainya. Perbedaan ini menentukan bagaimana kita dapat melakukan skala horizontal pada aplikasi konsumen tanpa merusak integritas urutan data. Artikel ini akan membedah secara mendalam perbandingan jaminan urutan pesan antara RabbitMQ dan Kafka di lingkungan produksi.

Jaminan Urutan FIFO di RabbitMQ #

Secara teoretis, RabbitMQ menjamin urutan pesan berbasis FIFO (First-In, First-Out) di dalam satu antrean. Pesan yang diterbitkan oleh produsen dan mendarat di antrean dijamin akan dikirimkan ke konsumen berurutan sesuai urutan kedatangannya. Namun, di dunia nyata, jaminan FIFO ini sangat mudah rusak akibat beberapa kondisi operasional berikut:

1. Competing Consumers (Konsumen Paralel) #

Ketika kita melakukan scaling horizontal dengan menghubungkan banyak konsumen paralel ke satu antrean untuk membagi beban kerja, broker RabbitMQ akan membagikan pesan secara bergiliran (round-robin).

flowchart TD
    subgraph Flow1["Aliran 1"]
        A["Pesan A (1)"] --> C1["Konsumen 1 (Memproses Lambat...)"] --> F1["Selesai pada detik ke-5"]
    end
    subgraph Flow2["Aliran 2"]
        B["Pesan B (2)"] --> C2["Konsumen 2 (Memproses Cepat!)"] --> F2["Selesai pada detik ke-1"]
    end

Meskipun Pesan A dikirim lebih dulu daripada Pesan B, Konsumen 2 dapat menyelesaikan pemrosesan Pesan B jauh lebih cepat daripada Konsumen 1 menyelesaikan Pesan A. Akibatnya, urutan logika bisnis akhir di database kita menjadi terbalik.


2. Manual Requeue (requeue = true) #

Ketika konsumen mengalami kegagalan transient dan menolak pesan menggunakan basic.nack(requeue=true), broker RabbitMQ akan mengembalikan pesan tersebut ke antrean asal. Broker biasanya meletakkan pesan tersebut kembali di posisi kepala antrean (head). Namun, karena ada jeda waktu pemrosesan dan kegagalan, pesan tersebut akhirnya akan diproses setelah pesan-pesan berikutnya yang sudah terlanjur dikirim ke konsumen lain, merusak urutan asli yang diterbitkan oleh produsen.


3. Priority Queues (Antrean Prioritas) #

Jika kita mengonfigurasi antrean RabbitMQ sebagai antrean prioritas menggunakan argumen x-max-priority, pesan dengan nilai prioritas lebih tinggi yang masuk belakangan akan otomatis melompati pesan-pesan lama dengan prioritas rendah di dalam antrean. FIFO urutan asli rusak demi mendahulukan kepentingan darurat.

Solusi Jaminan Urutan Strict FIFO di RabbitMQ #

Jika sistem kita menuntut jaminan urutan mutlak di RabbitMQ, kita terpaksa mengorbankan skalabilitas horizontal konsumen dengan menerapkan pola Single Active Consumer (SAC).

Dengan menyetel argumen "x-single-active-consumer": true saat deklarasi antrean, broker RabbitMQ menjamin hanya akan ada satu konsumen aktif yang memproses pesan secara serial. Konsumen lain yang terhubung akan bertindak sebagai standby (cadangan). Jika konsumen aktif mati, broker akan menunjuk salah satu konsumen cadangan sebagai aktif baru.

Pola ini menjaga urutan FIFO tetap utuh, namun membatasi throughput sistem karena pemrosesan berjalan sepenuhnya satu per satu (single-threaded).


Jaminan Urutan Per Partisi di Kafka #

Apache Kafka menyelesaikan dilema antara skalabilitas horizontal dan jaminan urutan dengan cara yang sangat elegan melalui konsep Partitioning.

Kafka tidak menjamin urutan pesan secara global di seluruh topik. Jaminan urutan di Kafka berlaku secara mutlak di tingkat partisi topik. Setiap partisi adalah satu berkas log linier terisolasi yang hanya bisa ditambah (append-only). Pesan yang ditulis ke partisi 0 dijamin akan dibaca berurutan dari indeks offset terkecil hingga terbesar.

Untuk mengunci agar pesan dari entitas bisnis yang sama selalu masuk ke partisi yang sama, produsen Kafka wajib menyertakan Partition Key (Kunci Partisi) saat mengirimkan pesan.

flowchart TD
    Producer["Produsen"] -->|"Kirim Pesan dengan Key: 'user_123'"| Hash["Hashing Algorithm (MurmurHash3)<br>Tentukan Partisi Tujuan: Partisi 1"]
    Hash --> Topic["\"Topic Partisi 1<br>[Event 1"][Event 2][Event 3]"]
    Topic -->|"Selalu Diproses Serial"| Consumer["Konsumen A"]

Ketika produsen mengirim pesan dengan key "user_123", Kafka secara internal menjalankan fungsi hashing (umumnya MurmurHash3) terhadap key tersebut untuk menentukan indeks partisi tujuan (misalnya partisi 1). Selama jumlah partisi tidak berubah, seluruh event yang membawa key "user_123" dijamin akan selalu mendarat di partisi 1 secara berurutan.


Mengapa Skalabilitas Kafka Tidak Merusak Urutan #

Bagaimana Kafka memastikan urutan tetap terjaga ketika kita memiliki banyak konsumen paralel di dalam satu Consumer Group? Jawabannya terletak pada mekanisme penguncian partisi (Partition Locking).

Di Kafka, broker menjamin bahwa satu partisi log hanya boleh dikonsumsi oleh maksimal satu instansi konsumen di dalam satu Consumer Group pada satu waktu.

Jika kita memiliki 4 partisi topik:

  • Partisi 0 dikunci untuk Konsumen A
  • Partisi 1 dikunci untuk Konsumen B
  • Partisi 2 dikunci untuk Konsumen C
  • Partisi 3 dikunci untuk Konsumen D

Meskipun kita memproses pesan dengan 4 konsumen paralel yang menghasilkan throughput sangat tinggi, setiap konsumen hanya fokus membaca partisi yang dikunci untuknya secara serial.

Karena seluruh event milik "user_123" berada di partisi 1, maka hanya Konsumen B yang berhak memproses pesan tersebut secara serial dari offset awal hingga akhir. Tidak ada risiko competing consumers dari konsumen lain yang dapat menyalip urutan pemrosesan "user_123".

Kafka berhasil memberikan kombinasi terbaik: skalabilitas horizontal tinggi di tingkat topik global, namun tetap mempertahankan jaminan urutan serial yang ketat di tingkat entitas individu.


Dampak Rebalancing Partisi pada Jaminan Urutan Kafka #

Meskipun model partition-locking pada Apache Kafka secara teoritis menjamin urutan pesan per partisi, terdapat satu momen krusial dalam siklus hidup klaster Kafka di mana jaminan urutan ini dapat terancam secara tidak terduga, yaitu saat terjadinya Partition Rebalancing (Penyeimbangan Ulang Partisi).

Rebalancing adalah proses di mana Kafka memindahkan kepemilikan partisi dari satu konsumen ke konsumen lainnya di dalam Consumer Group yang sama. Peristiwa ini dipicu oleh:

  1. Konsumen baru bergabung ke dalam grup (misalnya saat kita melakukan scale out layanan).
  2. Konsumen lama meninggalkan grup (karena aplikasi crash, shutdown, atau mengalami garbage collection pause yang terlalu lama sehingga memicu heartbeat timeout).
  3. Terjadi perubahan jumlah partisi pada topik.

Ketika rebalance terjadi, Kafka akan menghentikan sementara proses konsumsi data (stop-the-world). Partisi yang sebelumnya dipegang oleh Konsumen A akan dilepaskan dan dialokasikan ke Konsumen B.

Bahaya terhadap urutan pesan muncul jika Konsumen A sedang memproses Pesan 10 dari Partisi 1, namun belum sempat melakukan commit offset ke broker saat koneksinya terputus. Ketika Partisi 1 dialihkan ke Konsumen B, Konsumen B akan mulai membaca dari offset terakhir yang ter-commit (yaitu sebelum Pesan 10). Akibatnya, Konsumen B akan memproses kembali Pesan 10 (terjadi duplikasi).

Jika Konsumen A ternyata belum sepenuhnya mati (hanya mengalami delay jaringan sementara) dan tetap menyelesaikan penulisan Pesan 10 ke database miliknya, maka ada risiko Konsumen A dan Konsumen B menulis data ke database secara paralel untuk entitas yang sama. Hal ini memicu kondisi balapan (race condition) yang merusak urutan logis pemrosesan di tingkat database konsumen kita.

Untuk memitigasi risiko ini, kita wajib merancang konsumen Kafka agar mendengarkan event callback rebalance (Consumer Rebalance Listener). Sebelum partisi dilepaskan, konsumen harus memastikan seluruh proses in-flight diselesaikan dan offset di-commit secara sinkron.


Mekanisme Internal Single Active Consumer di RabbitMQ #

Di sisi lain, mari kita bedah bagaimana RabbitMQ mengelola fitur Single Active Consumer (SAC) untuk menjamin urutan strict FIFO di tingkat antrean. Ketika kita menyetel parameter "x-single-active-consumer": true saat deklarasi antrean, broker RabbitMQ mengaktifkan mesin pemantau konsumer yang terdistribusi secara internal.

Ketika ada tiga konsumen (Konsumen A, B, dan C) terhubung ke antrean SAC tersebut:

  1. Broker RabbitMQ akan memilih satu konsumen secara eksklusif (misalnya Konsumen A) berdasarkan waktu koneksi pertama atau prioritas, dan menandainya sebagai status Active.
  2. Dua konsumen lainnya (Konsumen B dan C) akan ditandai dengan status Passive (atau standby). Broker tidak akan mengirimkan satu pun pesan ke konsumen pasif ini.
  3. Seluruh pesan di antrean utama akan didorong (pushed) secara serial hanya ke Konsumen A. Hal ini menjamin urutan FIFO mutlak 100% karena hanya ada satu thread konsumen yang mengeksekusi pesan.

Proses pemulihan kegagalan (failover) di SAC berjalan sebagai berikut: Jika koneksi TCP Konsumen A terputus secara tiba-tiba atau heartbeat Erlang mendeteksi Konsumen A tidak responsif, broker RabbitMQ akan langsung mencabut status Active dari Konsumen A. Broker kemudian mengevaluasi daftar konsumen pasif yang sedang menganggur, memilih salah satunya (misal Konsumen B), mengubah statusnya menjadi Active, dan mulai mengirimkan pesan sisa di antrean ke Konsumen B.

Tantangan dari pola SAC ini adalah terjadinya Failover Latency (latensi pemulihan). Selama proses deteksi kegagalan konsumen aktif (yang bergantung pada konfigurasi heartbeat interval RabbitMQ, biasanya default 60 detik), antrean akan membeku sementara waktu dan tidak ada pesan yang diproses. Hal ini kontras dengan Kafka di mana rebalancing diatur secara berkala oleh Group Coordinator, namun SAC RabbitMQ menawarkan kesederhanaan topologi yang luar biasa tanpa perlu memikirkan manajemen partisi log.


Tabel Perbandingan Jaminan Urutan #

Berikut adalah tabel komparatif mengenai karakteristik jaminan urutan antara RabbitMQ dan Kafka:

Dimensi UrutanRabbitMQApache Kafka
Cakupan JaminanPer antrean (queue-wide).Per partisi topik (partition-wide).
Dampak Competing ConsumersMerusak urutan pemrosesan di sisi konsumen secara dinamis.Urutan tetap terjaga berkat penguncian partisi per konsumen grup.
Dampak Penolakan (Retry)Pesan requeue merusak urutan FIFO jika ada konsumen paralel.Urutan tetap terjaga karena offset konsumen tidak dapat melompati data gagal.
Batas Konkurensi UrutTerbatas pada 1 konsumen aktif (SAC) jika butuh strict FIFO.Terbatas pada jumlah partisi topik aktif yang dideklarasikan.
Metode Penentuan UrutanBerdasarkan waktu kedatangan pesan di broker.Berdasarkan Kunci Partisi (Partition Key) yang dikirim produsen.

Implementasi Kode Go (Golang) #

Untuk memahami perbedaan taktis, berikut adalah contoh implementasi bahasa Go untuk produsen dan konsumen Kafka menggunakan library kafka-go yang memanfaatkan Partition Key untuk menjamin urutan transaksi keuangan per akun pengguna.

1. Produsen Kafka (Mengirim Pesan dengan Partition Key) #

Produsen harus mengirimkan kunci partisi yang konsisten agar transaksi per akun selalu mendarat di partisi yang sama.

package main

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

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

type TransactionEvent struct {
	AccountNo string    `json:"account_no"`
	Action    string    `json:"action"`
	Amount    float64   `json:"amount"`
	Timestamp time.Time `json:"timestamp"`
}

func main() {
	// Inisialisasi penulis ke Kafka
	w := &kafka.Writer{
		Addr:     kafka.TCP("localhost:9092"),
		Topic:    "user-transactions",
		Balancer: &kafka.Hash{}, // Menggunakan algoritma hash berdasarkan Key pesan
	}
	defer w.Close()

	ctx := context.Background()

	// Simulasi kronologi mutasi rekening pengguna
	events := []TransactionEvent{
		{AccountNo: "ACC-9876", Action: "DEPOSIT", Amount: 500000.0, Timestamp: time.Now()},
		{AccountNo: "ACC-9876", Action: "WITHDRAW", Amount: 200000.0, Timestamp: time.Now().Add(1 * time.Second)},
		{AccountNo: "ACC-9876", Action: "TRANSFER", Amount: 100000.0, Timestamp: time.Now().Add(2 * time.Second)},
	}

	for _, ev := range events {
		bytes, _ := json.Marshal(ev)

		// Kita wajib menyertakan AccountNo sebagai Key pesan agar data
		// selalu dikirim ke partisi log Kafka yang sama secara konsisten.
		err := w.WriteMessages(ctx, kafka.Message{
			Key:   []byte(ev.AccountNo), // KEY UTAMA PENENTU URUTAN PARTISI
			Value: bytes,
		})
		if err != nil {
			log.Fatalf("Gagal menulis event ke Kafka: %v", err)
		}
		log.Printf("[PRODUCER] Sukses mengirim event %s untuk akun %s", ev.Action, ev.AccountNo)
	}
}

2. Konsumer Kafka (Menerima Pesan Secara Urut) #

Konsumen akan membaca partisi log secara serial, menjamin event diproses secara kronologis berurutan tanpa ada tumpang tindih.

package main

import (
	"context"
	"encoding/json"
	"log"
	"os"
	"os/signal"
	"syscall"

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

type TransactionEvent struct {
	AccountNo string  `json:"account_no"`
	Action    string  `json:"action"`
	Amount    float64 `json:"amount"`
}

func main() {
	r := kafka.NewReader(kafka.ReaderConfig{
		Brokers: []string{"localhost:9092"},
		Topic:   "user-transactions",
		GroupID: "transaction-ledger-group", // Konsumen group
	})
	defer r.Close()

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	log.Println("[INFO] Konsumen transaksi aktif. Menunggu event...")

	go func() {
		for {
			msg, err := r.ReadMessage(ctx)
			if err != nil {
				log.Printf("Gagal membaca: %v", err)
				break
			}

			var ev TransactionEvent
			_ = json.Unmarshal(msg.Value, &ev)

			// Karena partition locking, seluruh event milik AccountNo yang sama
			// dijamin akan diproses secara serial dan urut oleh konsumen ini.
			log.Printf("[CONSUMER] Akun: %s | Action: %s | Nilai: %.2f (Partisi: %d, Offset: %d)",
				ev.AccountNo, ev.Action, ev.Amount, msg.Partition, msg.Offset)
		}
	}()

	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
	<-sigChan
}

Anti-Pattern vs Solusi Praktis #

Kesalahan umum dalam perancangan partisi di Kafka dapat menghancurkan jaminan urutan pesan secara total. Berikut adalah anti-pattern yang wajib dihindari:

Anti-Pattern: Mengosongkan Key (Null Key) pada Event yang Membutuhkan Urutan Kronologis #

Menerbitkan event transaksi keuangan ke Kafka tanpa menyertakan Partition Key (Key = nil atau Key = ""), dengan mengasumsikan broker Kafka akan mengelola urutan secara otomatis.

Mengapa ini salah? #

Ketika produsen mengirim pesan ke Kafka dengan key kosong, pembagi beban default (Default Balancer) milik Kafka akan mendistribusikan pesan-pesan tersebut secara acak atau berbasis round-robin ke seluruh partisi topik yang tersedia demi meratakan beban penulisan disk.

Akibatnya:

  • Event 1 (OrderCreated) masuk ke Partisi 0.
  • Event 2 (OrderPaid) masuk ke Partisi 1.
  • Event 3 (OrderShipped) masuk ke Partisi 2.

Karena masing-masing partisi dibaca oleh konsumen yang berbeda secara paralel di dalam consumer group, konsumen yang membaca partisi 2 dapat memproses OrderShipped terlebih dahulu sebelum konsumen partisi 0 sempat memproses OrderCreated, memicu kegagalan integritas data di database utama.

Solusi Praktis #

Selalu definisikan Partition Key yang logis dan konsisten untuk seluruh data bisnis yang memiliki ketergantungan urutan kronologis. Gunakan pengenal entitas unik tingkat data bisnis—seperti account_id, order_id, atau user_id—sebagai kunci pesan. Pastikan pula jumlah partisi pada topik Kafka tidak diubah secara sembarangan saat sistem sedang aktif berjalan, karena perubahan jumlah partisi akan mengubah hasil kalkulasi hashing key yang dapat mengalihkan entitas yang sama ke partisi yang berbeda.


Ringkasan #

  • Urutan RabbitMQ — Jaminan FIFO RabbitMQ berlaku per antrean. Namun jaminan ini rentan rusak saat bersanding dengan competing consumers, prioritas antrean, atau pesan requeue.
  • Pola SAC RabbitMQ — Untuk menjaga urutan strict di RabbitMQ, kita harus mengaktifkan Single Active Consumer (SAC) yang mengunci pemrosesan menjadi satu konsumen tunggal secara serial (mengorbankan skalabilitas horizontal).
  • Urutan Kafka — Jaminan urutan Kafka berlaku di tingkat partisi topik secara linear, bukan secara global di tingkat topik.
  • Mekanisme Partition Key — Produsen menggunakan Partition Key (seperti user_id) untuk mengunci agar event dari entitas yang sama selalu dikirim ke partisi yang sama melalui algoritma hash.
  • Mekanisme Partition Locking — Kafka menjamin satu partisi hanya dikonsumsi oleh satu konsumen dalam grup, memungkinkan skalabilitas horizontal di tingkat topik tanpa merusak urutan per entitas data.
  • Bahaya Null Key — Mengirimkan pesan tanpa partition key (null key) akan memicu pembagian round-robin acak antar partisi yang merusak urutan kronologis pemrosesan event.

← Sebelumnya: Use Case Comparison   Berikutnya: Replay Capability →

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