Tampilan
Kenapa penting
Hampir setiap service backend yang memakai messaging pernah kena bug ini: pesanan tersimpan tapi email konfirmasi tidak pernah terkirim, atau saldo bertambah dua kali karena event yang sama diproses ulang. Penyebabnya bukan Kafka yang "rusak", melainkan dua fakta dasar sistem terdistribusi: kamu tidak bisa meng-commit database dan broker secara atomik, dan broker hanya menjamin pesan sampai minimal sekali. Bab ini membahas dua pola yang dipakai di hampir semua tim yang serius soal konsistensi data — transactional outbox di sisi pengirim dan idempotent consumer di sisi penerima — plus idempotency key untuk REST API. Di interview level senior, pertanyaan "bagaimana menjamin event tidak hilang dan tidak dobel?" hampir pasti muncul.
Konsep inti
Masalah dual write
Bayangkan service pesanan yang menyimpan order lalu mengirim event OrderCreated ke Kafka:
java
// Potongan — versi yang SALAH
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(Order.from(cmd));
kafkaTemplate.send("order-events", order.getId().toString(), toJson(order));
return order;
}Ada dua titik gagal. Kalau send berhasil tetapi commit database gagal (misalnya constraint violation saat flush), service lain menerima event untuk pesanan yang tidak pernah ada. Kalau urutannya dibalik — commit dulu, kirim belakangan — lalu aplikasi crash di antaranya, pesanan ada tetapi event hilang. Inilah dual write: menulis ke dua sistem yang tidak berbagi transaksi. Distributed transaction (XA/2PC) secara teori bisa, tetapi lambat, rumit, dan tidak didukung baik oleh Kafka. Jadi kita butuh pola lain.
Transactional outbox
Idenya sederhana: jangan kirim ke broker di dalam use case. Tulis event ke tabel outbox di database yang sama, dalam transaksi yang sama dengan data bisnisnya. Karena satu transaksi lokal, keduanya commit bersama atau rollback bersama.
sql
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(50) NOT NULL,
aggregate_id VARCHAR(50) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;Lalu ada proses terpisah — relay — yang membaca baris yang belum terkirim, mengirimnya ke Kafka, dan menandainya terkirim. Ada dua cara membangun relay:
| Cara | Cara kerja | Kelebihan | Kekurangan |
|---|---|---|---|
| Polling publisher | Job terjadwal SELECT baris published_at IS NULL tiap detik | Sederhana, cukup Spring + SQL | Latensi = interval polling, beban query |
| CDC (Debezium) | Membaca write-ahead log PostgreSQL, mengubah insert ke outbox jadi pesan Kafka | Latensi rendah, tanpa polling | Infrastruktur tambahan (Kafka Connect + Debezium) |
Debezium punya transformasi bawaan Outbox Event Router yang memetakan kolom tabel outbox ke topic, key, dan payload pesan. Untuk tim kecil, polling publisher biasanya cukup dan lebih mudah di-debug.
Relay tetap at-least-once
Relay bisa crash setelah send sukses tetapi sebelum published_at di-update. Saat jalan lagi, baris itu dikirim ulang. Outbox menjamin event tidak hilang, bukan tidak dobel — makanya consumer tetap wajib idempoten.
At-least-once dan idempotensi
Ingat jaminan pengiriman dari bab Kafka: at-most-once (bisa hilang), at-least-once (bisa dobel), dan exactly-once (hanya di dalam ekosistem Kafka dengan transaksi Kafka, tidak mencakup efek ke database luar). Di praktik, sebagian besar sistem memilih at-least-once lalu membuat penerimanya idempoten: memproses pesan yang sama dua kali menghasilkan keadaan akhir yang sama dengan memprosesnya sekali.
Prinsipnya bisa dilihat dengan Java murni — Set.add mengembalikan false untuk elemen yang sudah ada:
java
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class IdempotentConsumerDemo {
record PaymentEvent(String eventId, String orderId, long amount) {}
static final Set<String> processed = new HashSet<>();
static long balance = 0;
static void handle(PaymentEvent event) {
// add() mengembalikan false bila eventId sudah pernah dicatat
if (!processed.add(event.eventId())) {
System.out.println("Lewati duplikat " + event.eventId());
return;
}
balance += event.amount();
System.out.println("Proses " + event.eventId() + " untuk " + event.orderId());
}
public static void main(String[] args) {
var e1 = new PaymentEvent("evt-1", "ORD-7", 150_000);
var e2 = new PaymentEvent("evt-2", "ORD-8", 80_000);
// Broker at-least-once: evt-1 terkirim dua kali
for (PaymentEvent e : List.of(e1, e2, e1)) {
handle(e);
}
System.out.println("Saldo akhir: " + balance);
}
}text
Proses evt-1 untuk ORD-7
Proses evt-2 untuk ORD-8
Lewati duplikat evt-1
Saldo akhir: 230000Di production, Set di memori tidak cukup: hilang saat restart dan tidak dibagi antar-instance. Versi tahan lamanya adalah tabel database.
Idempotent consumer dengan processed_event
sql
CREATE TABLE processed_event (
consumer_name VARCHAR(100) NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (consumer_name, event_id)
);Kuncinya: insert ke processed_event dan efek bisnis berada di transaksi yang sama. Primary key gabungan memastikan dua instance yang menerima event sama bersamaan tidak bisa sama-sama lolos — salah satunya mendapat konflik. Kolom consumer_name memungkinkan beberapa consumer berbeda memproses event yang sama secara independen.
Beberapa operasi sudah idempoten secara alami, misalnya UPDATE orders SET status = 'PAID' WHERE id = ?. Operasi seperti "tambah saldo", "kurangi stok", atau "kirim email" tidak idempoten, dan di situlah tabel ini wajib.
Idempotency key di REST API
Masalah yang sama muncul di HTTP. Klien mengirim POST /payments, koneksi timeout, klien tidak tahu apakah pembayaran sudah diproses, lalu mengulang. Polanya (dipopulerkan oleh gateway pembayaran): klien membuat UUID unik per niat transaksi dan mengirimnya di header Idempotency-Key. Server menyimpan key beserta respons pertama; request berikutnya dengan key sama langsung dijawab dengan respons tersimpan.
sql
CREATE TABLE idempotency_key (
key VARCHAR(100) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
response_code INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Simpan juga hash body request: key yang sama dengan body berbeda adalah kesalahan klien (422 Unprocessable Content). Request yang masih diproses ketika duplikatnya datang bisa dijawab 409 Conflict. Beri masa berlaku (misalnya 24 jam) dan bersihkan dengan job terjadwal.
Spring Modulith: event publication registry
Bila aplikasimu modular monolith dengan Spring Modulith, kamu mendapat versi outbox yang dikelola framework. Event yang dipublikasikan lewat ApplicationEventPublisher di dalam transaksi dicatat ke tabel event_publication (starter JPA/JDBC), lalu listener @ApplicationModuleListener dipanggil setelah commit. Publikasi yang listener-nya gagal tetap tercatat sebagai belum selesai dan bisa dikirim ulang, termasuk saat aplikasi restart. Modulith juga bisa meneruskan event ke Kafka (event externalization). Konsepnya persis outbox — hanya tabel dan relay-nya disediakan untukmu.
Contoh di dunia backend
Service pesanan menulis order dan baris outbox dalam satu transaksi:
java
// Potongan — Spring Boot 4, Jackson 3
import tools.jackson.databind.json.JsonMapper;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final JsonMapper jsonMapper;
public OrderService(OrderRepository orderRepository, OutboxRepository outboxRepository,
JsonMapper jsonMapper) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
this.jsonMapper = jsonMapper;
}
@Transactional
public Order placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(Order.from(cmd));
var event = new OrderCreated(UUID.randomUUID(), order.getId(), order.getTotal());
outboxRepository.save(new OutboxMessage(
event.eventId(), "Order", order.getId().toString(),
"OrderCreated", jsonMapper.writeValueAsString(event)));
return order; // commit: order + outbox tersimpan bersama, atau tidak sama sekali
}
}Relay polling mengambil batch kecil dan mengirimnya ke Kafka. SKIP LOCKED membuat beberapa instance bisa jalan paralel tanpa mengambil baris yang sama:
java
// Potongan — relay outbox
@Component
public class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
public OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
this.jdbc = jdbc;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishPending() {
List<OutboxRow> rows = jdbc.query("""
SELECT id, aggregate_id, event_type, payload::text AS payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
""",
(rs, i) -> new OutboxRow(rs.getObject("id", UUID.class), rs.getString("aggregate_id"),
rs.getString("event_type"), rs.getString("payload")));
for (OutboxRow row : rows) {
// get() menunggu ack broker; gagal → exception → transaksi rollback → dicoba lagi nanti
kafka.send("order-events", row.aggregateId(), row.payload()).get(5, TimeUnit.SECONDS);
jdbc.update("UPDATE outbox SET published_at = now() WHERE id = ?", row.id());
}
}
}Key pesan aggregateId memastikan semua event untuk satu pesanan masuk partisi yang sama, jadi urutannya terjaga. Di sisi penerima, service pembayaran mencatat event yang sudah diproses:
java
// Potongan — idempotent consumer
@Component
public class OrderCreatedListener {
private final JdbcTemplate jdbc;
private final InvoiceService invoiceService;
public OrderCreatedListener(JdbcTemplate jdbc, InvoiceService invoiceService) {
this.jdbc = jdbc;
this.invoiceService = invoiceService;
}
@KafkaListener(topics = "order-events", groupId = "payment-service")
@Transactional
public void onOrderCreated(OrderCreated event) {
int inserted = jdbc.update("""
INSERT INTO processed_event (consumer_name, event_id)
VALUES ('payment-service', ?)
ON CONFLICT DO NOTHING
""", event.eventId());
if (inserted == 0) {
return; // duplikat: sudah diproses sebelumnya
}
invoiceService.createInvoice(event.orderId(), event.total());
}
}ON CONFLICT DO NOTHING di PostgreSQL membuat duplikat tidak melempar exception — update cukup mengembalikan 0. Bila createInvoice gagal, baris processed_event ikut rollback sehingga event bisa diproses ulang dengan benar.
Pertanyaan interview
1. Apa itu masalah dual write dan bagaimana transactional outbox menyelesaikannya?
Dual write terjadi saat satu operasi harus menulis ke dua sistem tanpa transaksi bersama, misalnya database dan Kafka. Crash di antara kedua penulisan membuat data tidak konsisten: event hilang atau event untuk data yang tidak ada. Outbox mengubah dua penulisan menjadi satu: event ditulis ke tabel di database yang sama dalam transaksi lokal yang sama, lalu relay terpisah mengirimnya ke broker secara at-least-once.
2. Kalau sudah pakai outbox, kenapa consumer masih harus idempoten?
Karena relay bisa mengirim ulang (crash setelah send, sebelum menandai terkirim) dan Kafka sendiri bisa mengirim ulang ke consumer setelah rebalance atau crash sebelum commit offset. Outbox menjamin tidak ada event hilang; idempotensi menjamin event dobel tidak menimbulkan efek ganda. Keduanya saling melengkapi.
3. Bagaimana kamu merancang endpoint pembayaran yang aman di-retry?
Klien mengirim header Idempotency-Key unik per transaksi. Server menyimpan key, hash request, dan respons dalam tabel dengan primary key pada key tersebut. Request ulang dengan key dan body sama mendapat respons tersimpan; body berbeda ditolak; request yang masih berjalan dijawab 409. Key kedaluwarsa setelah periode tertentu dan dibersihkan berkala.
Jebakan umum
- Memanggil
kafkaTemplate.senddi dalam method@Transactionaldan mengira itu atomik dengan commit database — bukan, itu tetap dual write. - Menyimpan daftar event yang sudah diproses di memori (
HashSet) — hilang saat restart dan tidak dibagi antar-instance. - Insert ke
processed_eventdi transaksi terpisah dari efek bisnis, sehingga crash di antaranya membuat event ditandai selesai padahal belum diproses. - Memakai offset Kafka atau timestamp sebagai ID event; pakai
eventIdUUID yang dibuat saat event lahir dan ikut tersimpan di outbox. - Membiarkan tabel
outbox,processed_event, danidempotency_keytumbuh tanpa batas — jadwalkan pembersihan baris lama. - Relay tanpa
LIMITdanSKIP LOCKED: satu transaksi raksasa dan instance yang saling menunggu.
Ringkasan
- Database dan broker tidak bisa di-commit atomik; menulis ke keduanya langsung disebut dual write dan rawan event hilang atau palsu.
- Transactional outbox menulis event ke tabel
outboxdalam transaksi yang sama, lalu relay (polling atau CDC Debezium) mengirimnya ke broker. - Pengiriman nyata hampir selalu at-least-once, sehingga consumer wajib idempoten.
- Idempotent consumer mencatat
eventIddi tabelprocessed_eventber-unique constraint, dalam transaksi yang sama dengan efek bisnis. - REST API yang mengubah uang atau stok sebaiknya mendukung header
Idempotency-Key. - Spring Modulith menyediakan event publication registry — outbox yang dikelola framework.