Ada satu pola pincang di kode backend yang hampir semua orang pernah tulis tanpa sadar. Simpan pesanan ke database dulu, terus publish event-nya ke message broker. Kelihatannya rapi, kan?
# Skenario 1: simpan ke DB dulu, baru publish event
async def create_order(order_data):
async with db.transaction():
order = await db.insert_order(order_data)
# Apa yang terjadi kalau baris ini crash atau kena network timeout?
await message_broker.publish("order.created", order.dict())
Karena khawatir pesannya nggak kekirim, sebagian dari kita lalu mutusin buat balikin urutannya.
# Skenario 2: publish event dulu, baru simpan ke DB
async def create_order(order_data):
await message_broker.publish("order.created", order_data)
# Apa yang terjadi kalau transaksi database gagal (constraint violation)?
async with db.transaction():
await db.insert_order(order_data)
Dua-duanya nyimpen bom waktu yang sama, namanya Dual-Write Problem.
Di sistem terdistribusi, kita emang nggak bisa menjamin dua operasi jaringan yang terpisah—nulis ke database SQL dan publish event ke message broker kayak Apache Kafka atau RabbitMQ—berhasil barengan tanpa mekanisme konsistensi khusus.
Kalau skenario 1 gagal pas publish, pesanannya udah kejual di database tapi email konfirmasi dan instruksi gudang nggak pernah terpicu. Sebaliknya di skenario 2, kalau commit database-nya gagal, event udah terlanjur beredar ke seluruh ekosistem microservices padahal pesanannya sebenernya nggak pernah ada! 😬
Dulu, jawaban teoretis buat masalah ini adalah Two-Phase Commit (2PC) alias XA Transactions. Cuma, di dunia cloud modern 2PC sering dihindari: lambat, ribet, dan sebagian message broker modern nggak mau mendukungnya.
Terus solusinya apa dong? Di sinilah Transactional Outbox Pattern naik kelas jadi standar emas industri.
Poin Penting
- Dual-write problem muncul karena nulis ke database dan publish ke message broker adalah dua operasi jaringan terpisah yang nggak bisa dijamin berhasil barengan.
- Transactional Outbox Pattern menitipkan event ke tabel
outboxdi transaksi database yang sama, jadi nasibnya ikut atomik: tersimpan semua atau nggak sama sekali. - Tabel
outboxbisa dikuras lewat Polling Publisher buat skala kecil sampai menengah, atau Change Data Capture (CDC) buat volume ribuan transaksi per detik. - Jaminan pengirimannya at-least-once, jadi consumer di sisi hilir wajib idempoten supaya event kembar nggak dieksekusi dua kali.
Menunggangi transaksi ACID database
Kunci utama Outbox Pattern itu sederhana aja: jangan ngobrol sama dua sistem sekaligus dalam satu alur request bisnis. Cukup ngobrol sama database lokal kalian sendiri!
Database relasional kayak PostgreSQL atau MySQL udah punya transaksi ACID yang solid dan teruji puluhan tahun. Kalau kita bisa nyelipin event yang mau dikirim ke dalam transaksi database yang sama dengan data bisnisnya, kita dapat jaminan atomik: semua tersimpan, atau nggak ada sama sekali.
Kayak nulis dua hal di satu lembar nota belanja. Kalau notanya robek di tengah jalan, dua-duanya nggak terbaca—nggak mungkin yang satu selamat dan satunya hilang entah ke mana.
Alurnya sebenernya elegan:
- Bikin tabel tambahan bernama
outboxdi database yang sama dengan tabel bisnis kalian (misal tabelorders). - Pas request
create_ordermasuk, aplikasi mulai transaksi database:- Insert baris baru ke tabel
orders. - Insert payload event yang mau dikirim ke tabel
outbox. - Lakukan
COMMIT.
- Insert baris baru ke tabel
- Komponen terpisah (Outbox Relay / worker) baca baris yang belum terkirim dari tabel
outbox, publish ke message broker, lalu tandai barisnya sebagaiprocessedatau hapus.
Isi tabel outbox-nya kayak gimana?
Berikut contoh skema DDL sederhana buat tabel outbox di PostgreSQL:
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(64) NOT NULL, -- contoh: 'Order'
aggregate_id VARCHAR(64) NOT NULL, -- contoh: 'ord-12345'
event_type VARCHAR(64) NOT NULL, -- contoh: 'OrderCreated'
payload JSONB NOT NULL, -- payload event dalam format JSON
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ NULL -- timestamp ketika berhasil di-publish
);
CREATE INDEX idx_outbox_unprocessed ON outbox_events (created_at) WHERE processed_at IS NULL;
Dengan indeks parsial (partial index) di atas, pencarian event yang belum diproses tetap kencang tanpa perlu full table scan.
Dua cara nguras outbox: polling vs CDC
Gimana caranya mindahin data dari tabel outbox ke message broker? Ada dua pendekatan yang umum dipakai.
Polling publisher, buat skala kecil sampai menengah
Background worker sederhana—bisa berupa goroutine di Go, worker Celery/APScheduler di Python, atau scheduler periodik—jalanin query polling tiap beberapa detik:
# Mengambil batch outbox yang belum terkirim dengan row-level lock
async def process_outbox():
async with db.transaction():
events = await db.fetch(
"""
SELECT id, event_type, payload
FROM outbox_events
WHERE processed_at IS NULL
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED
"""
)
for event in events:
await kafka_producer.send(topic=event['event_type'], value=event['payload'])
await db.execute(
"UPDATE outbox_events SET processed_at = NOW() WHERE id = $1",
event['id']
)
Pemakaian FOR UPDATE SKIP LOCKED bikin kalian bisa ngejalanin beberapa instance worker outbox secara paralel tanpa saling berebut baris data (race condition).
Change Data Capture, buat skala masif
Kalau volume transaksinya udah nyampe ribuan per detik, polling terus-menerus ke database malah ngeberatin CPU database.
Sebagai gantinya, pakai alat CDC kayak Debezium. Debezium baca langsung Write-Ahead Log (WAL di Postgres atau binlog di MySQL) di level disk. Begitu ada baris baru mendarat di tabel outbox, Debezium langsung nangkep perubahannya dan nerusin ke topik Kafka secara real-time dengan latensi sub-detik, tanpa ngeberatin query engine database kalian!
Consumernya wajib idempoten, nggak bisa ditawar
Outbox Pattern cuma ngasih jaminan pengiriman At-Least-Once (setidaknya sekali). Artinya, pesannya dijamin nggak bakal hilang, tapi bisa aja terkirim lebih dari sekali kalau ada crash di tengah proses publish dan update statusnya.
Makanya setiap consumer di microservices hilir wajib dirancang idempoten:
- Pakai
idunik event dari outbox sebagai idempotency key. - Simpan ID event yang udah pernah diproses ke cache Redis atau tabel
processed_eventsdi sisi consumer. - Kalau event dengan ID yang sama balik datang, tinggal diabaikan dan langsung kirim ACK.
Jadi, kenapa harus pindah dari dual-write?
Bangun arsitektur microservices yang andal itu bukan soal ngindarin kegagalan jaringan. Justru soal ngerancang sistem yang tetap konsisten pas kegagalan jaringan—yang nggak mungkin dihindari itu—beneran kejadian.
Dengan Transactional Outbox Pattern, kalian bisa tidur nyenyak tanpa mikirin data pesanan hilang entah ke mana atau saldo pelanggan kepotong dua kali. Dua-duanya dijamin jalan bareng, atau nggak jalan sama sekali. Rapi, kan? 🎉
Selamat ber-outbox ria, dan semoga event kalian selalu nyampe tepat waktu walau broker-nya lagi ngambek! 👋