Tampilan
Kenapa penting
Begitu sistem terdiri dari beberapa layanan, panggilan HTTP berantai mulai terasa rapuh: satu layanan lambat membuat checkout ikut lambat, satu layanan down membuat semuanya gagal. Di bab sebelumnya kamu sudah memakai retry dan circuit breaker untuk meredam masalah itu. Messaging menawarkan pendekatan lain: alih-alih memanggil dan menunggu, layanan menitipkan pesan ke broker, lalu layanan lain memprosesnya di waktunya sendiri. Hampir semua backend skala menengah ke atas — e-commerce, pembayaran, logistik — memakai broker seperti RabbitMQ atau Kafka. Memahami konsepnya (delivery guarantee, ordering, DLQ) lebih penting daripada menghafal API, karena kesalahan desain di sini muncul sebagai pesan hilang, email terkirim dua kali, atau saldo terpotong ganda.
Konsep inti
Sinkron vs asinkron
Pada komunikasi sinkron, pemanggil menunggu jawaban: order memanggil payment lewat HTTP dan baru lanjut setelah ada respons. Pada komunikasi asinkron, pemanggil mengirim pesan lalu langsung lanjut; pemrosesan terjadi kemudian.
| Aspek | Sinkron (HTTP/gRPC) | Asinkron (broker) |
|---|---|---|
| Pemanggil menunggu? | Ya, sampai respons atau timeout | Tidak, cukup sampai broker menerima pesan |
| Penerima harus hidup? | Ya, saat itu juga | Tidak; pesan menunggu di broker |
| Hasil langsung | Ada (mis. status pembayaran) | Tidak ada; hasil datang lewat event lain |
| Kopling | Pemanggil tahu alamat dan API penerima | Produsen hanya tahu nama queue/topic |
| Kompleksitas | Rendah, mudah di-debug | Lebih tinggi: duplikat, urutan, eventual consistency |
Pakai sinkron bila pemanggil butuh jawaban sekarang — validasi stok sebelum menampilkan "berhasil", otorisasi kartu. Pakai asinkron bila pekerjaan boleh selesai belakangan atau dikerjakan banyak pihak: kirim email, update analitik, sinkron ke gudang.
Kapan memakai broker
Broker (perantara pesan) layak dipakai bila setidaknya satu kondisi ini benar:
- Temporal decoupling — penerima boleh down sebentar tanpa membuat pengirim gagal.
- Meratakan lonjakan — flash sale menghasilkan 5.000 pesanan per menit, tetapi layanan invoice hanya sanggup 500; pesan menumpuk di broker dan diproses sesuai kapasitas.
- Fan-out — satu kejadian (
OrderPlaced) perlu diketahui banyak layanan tanpa pengirim harus memanggil satu per satu. - Pekerjaan lama — membuat laporan PDF atau memproses video tidak cocok ditunggu dalam satu request HTTP.
Jangan pakai broker hanya karena "terlihat modern". Broker menambah komponen yang harus dioperasikan, dimonitor, dan dipahami tim. Untuk satu aplikasi monolit dengan beban kecil, @Async atau tabel job sederhana sering sudah cukup.
Queue vs pub/sub
Ada dua model dasar penyaluran pesan:
| Model | Cara kerja | Contoh |
|---|---|---|
| Queue (point-to-point) | Setiap pesan diambil oleh satu consumer saja | Antrean job "buat invoice PDF" dengan 5 worker |
| Pub/sub (publish–subscribe) | Setiap pesan diterima oleh semua subscriber | OrderPlaced didengar email, analitik, gudang |
Dalam praktik keduanya digabung: tiap layanan berlangganan topic (pub/sub antarlayanan), dan di dalam satu layanan beberapa instance berbagi beban seperti queue. Di RabbitMQ ini diwujudkan dengan exchange yang menyalin pesan ke beberapa queue; di Kafka dengan consumer group — tiap group menerima semua pesan, dan pesan dibagi di antara anggota group. Detail Kafka dibahas di bab berikutnya.
Delivery guarantee
Jaringan bisa putus dan proses bisa crash kapan saja, termasuk di antara "memproses pesan" dan "memberi tahu broker bahwa pesan selesai" (ack). Urutan ack terhadap pemrosesan menentukan jaminan yang kamu dapat:
| Jaminan | Cara | Risiko |
|---|---|---|
| At-most-once | Ack dulu, baru proses | Crash setelah ack → pesan hilang |
| At-least-once | Proses dulu, baru ack | Crash sebelum ack → pesan diproses ulang (duplikat) |
| Exactly-once | Butuh transaksi yang mencakup baca, proses, dan tulis | Mahal; hanya berlaku dalam batas sistem tertentu |
Default yang paling umum dan masuk akal untuk backend bisnis adalah at-least-once + consumer idempoten: lebih baik pesan datang dua kali dan yang kedua diabaikan, daripada pesan pembayaran hilang. Kafka memang menyediakan exactly-once semantics, tetapi jaminannya hanya berlaku untuk alur baca-tulis di dalam Kafka. Begitu consumer menulis ke PostgreSQL atau memanggil API email, efek samping itu tetap bisa terjadi dua kali.
Idempoten
Consumer idempoten menghasilkan efek yang sama walau pesan yang sama diproses berkali-kali — misalnya dengan mencatat eventId yang sudah diproses di tabel ber-constraint unik. Polanya dibahas lengkap di bab idempotensi & outbox.
Ordering
Broker umumnya hanya menjamin urutan dalam satu saluran (satu queue di RabbitMQ, satu partition di Kafka), dan jaminan itu mudah rusak:
- Beberapa consumer paralel di satu queue memproses pesan secara bersamaan; yang selesai duluan belum tentu yang datang duluan.
- Retry mengembalikan pesan gagal ke belakang antrean, sehingga pesan yang datang belakangan bisa diproses lebih dulu.
Untungnya, urutan jarang dibutuhkan secara global. Yang biasanya penting adalah urutan per entitas: OrderPlaced → OrderPaid → OrderShipped untuk pesanan yang sama. Di Kafka ini dicapai dengan memakai orderId sebagai key. Consumer juga sebaiknya defensif: tolak transisi status yang mundur (mis. SHIPPED → PAID).
Retry, dead letter queue, dan backpressure
Pesan yang gagal diproses tidak boleh di-retry tanpa batas — satu pesan rusak (poison message) bisa memblokir antrean atau membakar CPU selamanya. Polanya: retry beberapa kali (idealnya dengan jeda), lalu pindahkan pesan ke dead letter queue (DLQ) untuk diperiksa manusia. Simulasi sederhana berikut memakai ArrayDeque sebagai pengganti broker:
java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
public class DlqDemo {
record Message(String id, String payload, int attempt) {
Message nextAttempt() {
return new Message(id, payload, attempt + 1);
}
}
static final int MAX_ATTEMPTS = 3;
public static void main(String[] args) {
Queue<Message> queue = new ArrayDeque<>();
List<Message> deadLetters = new ArrayList<>();
queue.add(new Message("evt-1", "ORDER-1001", 1));
queue.add(new Message("evt-2", "ORDER-RUSAK", 1));
queue.add(new Message("evt-3", "ORDER-1002", 1));
while (!queue.isEmpty()) {
Message msg = queue.poll();
try {
process(msg);
System.out.println("ACK " + msg.id() + " (percobaan " + msg.attempt() + ")");
} catch (IllegalArgumentException e) {
if (msg.attempt() < MAX_ATTEMPTS) {
System.out.println("NACK " + msg.id() + " -> retry: " + e.getMessage());
queue.add(msg.nextAttempt());
} else {
System.out.println("DLQ " + msg.id() + " setelah " + msg.attempt() + " percobaan");
deadLetters.add(msg);
}
}
}
System.out.println("Isi DLQ: " + deadLetters);
}
static void process(Message msg) {
if (msg.payload().contains("RUSAK")) {
throw new IllegalArgumentException("payload tidak valid");
}
}
}text
ACK evt-1 (percobaan 1)
NACK evt-2 -> retry: payload tidak valid
ACK evt-3 (percobaan 1)
NACK evt-2 -> retry: payload tidak valid
DLQ evt-2 setelah 3 percobaan
Isi DLQ: [Message[id=evt-2, payload=ORDER-RUSAK, attempt=3]]Perhatikan dua hal. Pertama, evt-3 selesai sebelum retry evt-2 — contoh nyata bagaimana retry merusak urutan. Kedua, payload yang memang tidak valid tidak akan sembuh dengan retry; di sistem nyata, bedakan error sementara (database timeout → retry) dari error permanen (JSON rusak → langsung DLQ). DLQ wajib dimonitor: DLQ yang terus bertambah tanpa ada yang melihat sama saja dengan pesan hilang.
Backpressure adalah mekanisme agar consumer tidak dibanjiri melebihi kapasitasnya. Dengan broker, bentuk paling umum adalah pull dengan batas: consumer hanya mengambil pesan sebanyak yang sanggup ia proses (prefetch di RabbitMQ, max.poll.records di Kafka). Sisanya menunggu di broker. Akibatnya terlihat sebagai lag — selisih antara pesan yang masuk dan yang sudah diproses — dan lag yang terus naik adalah sinyal untuk menambah consumer atau mempercepat pemrosesan.
RabbitMQ vs Kafka
Keduanya broker populer, tetapi filosofinya berbeda:
| Aspek | RabbitMQ | Apache Kafka |
|---|---|---|
| Model dasar | Message queue (AMQP): exchange → queue | Log terdistribusi yang append-only: topic → partition |
| Setelah dikonsumsi | Pesan dihapus setelah di-ack | Pesan tetap ada sampai retention habis |
| Replay | Tidak secara alami | Ya, consumer cukup mundurkan offset |
| Routing | Kaya: direct, topic (pola), fanout, headers | Sederhana: per topic, partition ditentukan key |
| Ordering | Per queue (dengan satu consumer) | Per partition |
| Throughput | Tinggi | Sangat tinggi, cocok untuk aliran event besar |
| Cocok untuk | Antrean tugas, routing kompleks, RPC asinkron | Event streaming, fan-out ke banyak tim, audit, analitik |
Aturan praktis: butuh antrean pekerjaan dengan routing fleksibel → RabbitMQ. Butuh aliran event yang dibaca banyak layanan dan bisa diputar ulang → Kafka. Kurikulum ini memakai Kafka karena paling sering dipakai untuk integrasi antar-microservice.
Event vs command
Bedakan dua jenis pesan:
- Command — perintah ke satu penerima tertentu, berbentuk imperatif:
ReserveStock,SendInvoiceEmail. Pengirim berharap sesuatu dikerjakan dan bisa saja ditolak. - Event — fakta yang sudah terjadi, berbentuk lampau:
OrderPlaced,PaymentCaptured. Penerbit tidak peduli siapa yang mendengar; event tidak bisa "ditolak" karena kejadiannya sudah lewat.
Event membuat kopling paling longgar: layanan order tidak perlu diubah ketika tim baru ingin mendengarkan OrderPlaced. Command cocok bila memang ada satu pemilik tanggung jawab yang jelas.
Desain payload event
Event hidup lebih lama daripada kode yang membuatnya: consumer lama dan baru membaca topic yang sama. Payload yang baik selalu memuat metadata berikut:
json
{
"eventId": "8f14e45f-ea2c-4a2b-9b1e-2c1d5a7c3e10",
"eventType": "OrderPlaced",
"version": 1,
"occurredAt": "2026-10-23T07:15:30Z",
"orderId": "ORD-1001",
"customerId": "CUS-77",
"totalAmount": "250000.00",
"currency": "IDR"
}eventId(UUID) — kunci deduplikasi untuk consumer idempoten.eventTypedanversion— consumer tahu cara membaca payload; skema berubah → naikkan versi.occurredAt(UTC, ISO-8601) — kapan kejadian terjadi, bukan kapan pesan dikirim.- ID entitas (
orderId) — sekaligus kandidat key untuk ordering. - Uang sebagai string desimal atau satuan terkecil (sen), bukan
double.
Aturan evolusi: menambah field opsional aman; menghapus, mengganti nama, atau mengubah tipe field adalah breaking change. Consumer harus mengabaikan field yang tidak dikenal, dan perubahan breaking dikirim sebagai versi baru (atau topic baru) sampai semua consumer bermigrasi. Jangan memasukkan data sensitif yang tidak perlu (nomor kartu, password) — event bisa tersimpan berhari-hari dan dibaca banyak tim.
Contoh di dunia backend
Alur checkout toko online yang memadukan sinkron dan asinkron:
text
Klien ──HTTP──> order-service
├─ (sinkron) payment-service: otorisasi kartu → butuh jawaban sekarang
├─ simpan pesanan status PAID
└─ terbitkan event OrderPlaced ──> broker
├─> email-service (kirim konfirmasi)
├─> inventory-service (kurangi stok gudang)
└─> analytics-service (dashboard penjualan)Payload event dimodelkan sebagai record agar immutable dan jelas strukturnya:
java
// Potongan — kontrak event di layanan order
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;
public record OrderPlacedEvent(
UUID eventId,
int version,
Instant occurredAt,
String orderId,
String customerId,
BigDecimal totalAmount,
String currency) {
public static OrderPlacedEvent of(String orderId, String customerId, BigDecimal total) {
return new OrderPlacedEvent(UUID.randomUUID(), 1, Instant.now(), orderId, customerId, total, "IDR");
}
}Keputusan desainnya:
- Otorisasi pembayaran tetap sinkron karena pelanggan harus tahu hasilnya sebelum melihat halaman sukses.
- Email, stok gudang, dan analitik asinkron: bila
email-servicedown, checkout tetap berhasil dan email terkirim begitu layanan pulih. - Ketiga consumer harus idempoten (deduplikasi lewat
eventId), karena broker memakai at-least-once. orderIddipakai sebagai key agar event untuk pesanan yang sama tetap berurutan.- Pesan yang gagal tiga kali di
inventory-servicemasuk DLQ, dan ada alert bila DLQ tidak kosong.
Pertanyaan interview
1. Apa beda at-least-once dan exactly-once, dan mana yang biasanya kamu pilih?
At-least-once menjamin pesan tidak hilang, tetapi bisa terkirim lebih dari sekali karena ack dikirim setelah pemrosesan. Exactly-once butuh transaksi yang mencakup baca, proses, dan tulis, dan biasanya hanya berlaku di dalam satu sistem (mis. Kafka ke Kafka). Untuk backend bisnis yang menulis ke database atau memanggil API lain, pilihan realistis adalah at-least-once ditambah consumer idempoten.
2. Kapan kamu memilih RabbitMQ dan kapan Kafka?
RabbitMQ cocok untuk antrean tugas dan routing yang kaya — pesan dihapus setelah diproses. Kafka cocok untuk aliran event bervolume besar yang dibaca banyak layanan secara independen dan perlu bisa diputar ulang, karena pesan tetap tersimpan selama periode retention.
3. Bagaimana menangani poison message?
Batasi jumlah retry, pakai jeda (backoff) untuk error sementara, lalu pindahkan pesan ke dead letter queue beserta informasi error-nya. Error permanen seperti payload rusak langsung ke DLQ tanpa retry. DLQ dimonitor dengan alert, dan pesan bisa diproses ulang setelah bug diperbaiki.
Jebakan umum
- Menganggap broker menjamin exactly-once end-to-end. Efek ke database atau API eksternal tetap bisa terjadi dua kali; buat consumer idempoten.
- Retry tanpa batas. Satu poison message memblokir antrean. Selalu tetapkan batas retry dan DLQ.
- DLQ tanpa monitoring. Pesan yang "parkir" selamanya di DLQ sama dengan pesan hilang.
- Mengandalkan urutan global. Urutan hanya dijamin per queue/partition dan rusak oleh consumer paralel atau retry.
- Payload tanpa
eventIddan versi. Deduplikasi dan evolusi skema jadi sulit setelah consumer bertambah. - Mengubah atau menghapus field event begitu saja. Consumer lama rusak; tambah field opsional atau naikkan versi.
Ringkasan
- Pakai komunikasi sinkron bila butuh jawaban sekarang; asinkron lewat broker untuk decoupling, fan-out, dan meratakan lonjakan.
- Queue membagi pesan ke satu consumer; pub/sub menyalinnya ke semua subscriber.
- At-least-once + consumer idempoten adalah default yang aman; exactly-once terbatas pada satu sistem.
- Urutan hanya dijamin per saluran; pakai ID entitas sebagai key untuk ordering per entitas.
- Retry terbatas → DLQ yang dimonitor; backpressure membuat consumer mengambil pesan sesuai kapasitas.
- Event adalah fakta lampau dengan
eventId,version, danoccurredAt; evolusi skema dengan menambah field opsional.