Outbox Pattern dipakai ketika aplikasi perlu menyimpan perubahan data ke database dan menerbitkan event ke message broker tanpa risiko event hilang atau terkirim ganda. Masalah utamanya sederhana: jika transaksi database sudah commit tetapi proses publish ke broker gagal atau aplikasi crash, state di database berubah tetapi event tidak pernah keluar.
Masalah kebalikannya juga sering terjadi. Saat aplikasi melakukan retry karena publish terlihat gagal, event yang sama bisa terbit dua kali. Akibatnya sistem downstream menerima duplikasi, ordering menjadi kacau, dan debugging makin sulit. Dengan Outbox Pattern, penulisan data bisnis dan pencatatan event dilakukan dalam satu transaksi database, lalu worker terpisah yang bertugas mengirim event dari tabel outbox ke broker secara andal.
Akar masalah: database dan broker tidak berada dalam satu transaksi
Banyak aplikasi backend melakukan alur seperti ini:
- Simpan perubahan data ke database.
- Publish event ke broker.
Sekilas terlihat cukup, tetapi ada beberapa titik gagal yang nyata di produksi.
Kasus 1: crash setelah commit database sebelum publish
Contoh paling berbahaya:
- Order berhasil disimpan ke tabel orders.
- Transaksi database selesai.
- Sebelum event OrderCreated dipublish ke broker, proses aplikasi mati, timeout, atau pod di-restart.
Hasilnya: data order ada, tetapi event tidak pernah dikirim. Sistem lain seperti pembayaran, notifikasi, atau fulfillment tidak tahu bahwa order baru sudah tercipta.
Kasus 2: retry memicu double publish
Kasus lain:
- Aplikasi mempublish event ke broker.
- Broker sebenarnya menerima event.
- Tetapi aplikasi tidak menerima respons sukses karena timeout jaringan.
- Aplikasi melakukan retry.
Hasilnya: event yang sama bisa terkirim dua kali. Jika consumer tidak idempotent, efek sampingnya bisa serius: stok terpotong dua kali, email terkirim ganda, atau invoice dibuat berulang.
Kenapa transaksi dua fase jarang jadi jawaban praktis
Secara teori, masalah ini bisa diatasi dengan transaksi terdistribusi atau two-phase commit. Dalam praktik modern, pendekatan itu jarang dipilih karena kompleks, rapuh, sulit dioperasikan, dan tidak selalu didukung broker maupun arsitektur layanan yang dipakai. Karena itu, banyak sistem memilih pendekatan eventual consistency dengan Outbox Pattern.
Bagaimana Outbox Pattern bekerja
Inti pola ini adalah: jangan publish ke broker langsung dari alur transaksi aplikasi. Sebagai gantinya, simpan event ke tabel outbox dalam transaksi database yang sama dengan perubahan data bisnis.
Alurnya biasanya seperti ini:
- Aplikasi menerima request.
- Aplikasi membuka transaksi database.
- Data bisnis ditulis, misalnya ke tabel orders.
- Record event ditulis ke tabel outbox.
- Transaksi di-commit.
- Worker terpisah memindai tabel outbox yang belum terkirim.
- Worker mempublish event ke broker.
- Jika sukses, record outbox ditandai sebagai terkirim.
Keuntungan utamanya: jika transaksi database sukses, maka perubahan data dan event outbox pasti sama-sama tersimpan. Jadi event tidak hilang hanya karena proses aplikasi mati sebelum sempat memanggil broker.
Outbox Pattern tidak menjamin broker menerima event tepat satu kali. Yang dijaga adalah atomicity antara perubahan data dan pencatatan event. Karena itu, desain idempotency tetap wajib di sisi publisher maupun consumer.
Desain tabel outbox yang praktis
Tabel outbox tidak harus rumit, tetapi harus cukup untuk mendukung retry, observability, dan debugging. Contoh skema sederhana:
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
headers JSONB NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
retry_count INT NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
locked_at TIMESTAMP NULL,
processed_at TIMESTAMP NULL,
last_error TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_outbox_status_available_at
ON outbox_events (status, available_at, created_at);
CREATE INDEX idx_outbox_aggregate
ON outbox_events (aggregate_type, aggregate_id, created_at);Kolom yang penting
- event_id: identitas unik event untuk deduplikasi di broker atau consumer.
- aggregate_type dan aggregate_id: membantu menjaga ordering per entitas, misalnya per order.
- event_type: jenis event, misalnya OrderCreated.
- payload: isi event dalam format JSON.
- status: misalnya pending, processing, published, failed.
- retry_count: jumlah percobaan publish.
- available_at: waktu kapan event boleh dicoba lagi, berguna untuk backoff.
- locked_at: jejak kapan worker mengambil event, berguna untuk menangani worker yang mati di tengah proses.
- last_error: pesan error terakhir untuk diagnosis.
Jika volume event tinggi, pertimbangkan strategi partitioning, retensi data, atau pemindahan event yang sudah lama ke tabel arsip.
Menulis data bisnis dan outbox dalam satu transaksi
Berikut pseudocode yang menunjukkan inti implementasinya:
begin transaction;
insert into orders (id, customer_id, total_amount, status)
values (:order_id, :customer_id, :total_amount, 'created');
insert into outbox_events (
event_id,
aggregate_type,
aggregate_id,
event_type,
payload,
status,
available_at
) values (
:event_id,
'order',
:order_id,
'OrderCreated',
:payload_json,
'pending',
now()
);
commit;Kenapa cara ini bekerja? Karena database menjamin kedua insert tersebut sukses atau gagal bersama. Tidak ada kondisi di mana order tersimpan tetapi event outbox tidak tercatat, selama keduanya berada dalam transaksi yang sama.
Kesalahan umum adalah tetap mencoba publish ke broker di dalam transaksi yang sama lalu menganggap masalah selesai. Itu justru berisiko membuat transaksi aplikasi menjadi lambat, rentan timeout, dan tetap tidak benar-benar atomik terhadap sistem eksternal.
Polling worker: komponen yang benar-benar mengirim event
Setelah event masuk ke outbox, worker terpisah akan mengambil event yang siap dikirim. Implementasi paling umum adalah polling worker yang berjalan terus-menerus.
Pola pengambilan record
Worker idealnya mengambil batch kecil agar latency tetap rendah dan tidak mengunci terlalu banyak row. Pseudocode SQL yang umum dipakai:
-- Ambil event yang siap diproses
SELECT id, event_id, event_type, payload
FROM outbox_events
WHERE status = 'pending'
AND available_at <= NOW()
ORDER BY created_at
LIMIT 100;
-- Tandai sebagai sedang diproses
UPDATE outbox_events
SET status = 'processing', locked_at = NOW()
WHERE id = :id AND status = 'pending';Di sistem dengan banyak worker paralel, gunakan mekanisme penguncian yang aman agar satu event tidak diambil dua worker sekaligus. Pendekatan spesifik tergantung database, tetapi prinsipnya sama: claim row secara atomik.
Alur worker yang aman
- Ambil event berstatus pending dan sudah melewati available_at.
- Claim event agar worker lain tidak memproses row yang sama.
- Publish ke broker dengan menyertakan event_id sebagai message key atau header jika relevan.
- Jika sukses, ubah status menjadi published dan isi processed_at.
- Jika gagal, naikkan retry_count, simpan last_error, dan atur available_at berikutnya.
Contoh pseudocode:
loop:
events = claim_pending_events(limit=100)
for event in events:
try:
broker.publish(
topic=resolve_topic(event.event_type),
key=event.aggregate_id,
headers={"event_id": event.event_id},
payload=event.payload
)
mark_published(event.id)
except TemporaryError as err:
reschedule(event.id, retry_count + 1, next_backoff_time(), err.message)
except PermanentError as err:
mark_failed(event.id, retry_count + 1, err.message)
sleep(short_interval)Status pemrosesan yang masuk akal
Status tidak harus banyak, tetapi harus jelas.
- pending: belum pernah dicoba atau menunggu jadwal retry.
- processing: sedang dikerjakan worker.
- published: sudah berhasil dipublish.
- failed: tidak akan dicoba lagi tanpa intervensi manual atau job khusus.
Jika worker mati saat status masih processing, Anda perlu mekanisme recovery berdasarkan locked_at. Misalnya, record yang terlalu lama di status processing dianggap stale dan dikembalikan ke pending.
Retry, deduplikasi, dan idempotency
Outbox Pattern tidak menghilangkan kebutuhan retry. Sebaliknya, pola ini membuat retry lebih terkontrol karena semua statusnya tercatat di database.
Strategi retry yang aman
Gunakan backoff bertahap, jangan retry ketat tanpa jeda. Tujuannya:
- mengurangi beban saat broker sedang terganggu,
- mencegah ledakan backlog makin parah,
- memberi waktu untuk pulih jika masalah hanya sementara.
Contoh sederhana:
- retry ke-1: 10 detik
- retry ke-2: 30 detik
- retry ke-3: 2 menit
- retry berikutnya: naik bertahap sampai batas tertentu
Setelah melewati ambang retry, pindahkan ke status failed dan buat alert. Jangan biarkan event gagal tersembunyi selamanya.
Mencegah double publish
Double publish tetap bisa terjadi, misalnya:
- worker berhasil publish, tetapi crash sebelum sempat menandai row sebagai published,
- respons broker timeout padahal pesan sebenarnya sudah diterima,
- mekanisme recovery mengembalikan row processing ke pending padahal publish sebelumnya sukses.
Karena itu, anggap semantik sistem adalah at-least-once delivery. Artinya, publisher dan consumer harus siap menghadapi duplikasi.
Idempotency di sisi consumer
Consumer yang baik tidak memproses event yang sama dua kali. Pendekatan paling umum adalah menyimpan event_id yang sudah diproses.
CREATE TABLE processed_events (
consumer_name VARCHAR(100) NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (consumer_name, event_id)
);Alur consumer:
- Terima event.
- Cek apakah event_id sudah pernah diproses oleh consumer ini.
- Jika belum, proses bisnis dijalankan.
- Simpan tanda bahwa event sudah diproses.
Idealnya, pencatatan processed_events dan efek bisnis consumer juga dilakukan atomik dalam satu transaksi lokal di database consumer.
Kalau operasi consumer secara alami idempotent, misalnya upsert status terakhir berdasarkan versi terbaru, Anda bisa mengurangi kompleksitas deduplikasi eksplisit. Tetapi jangan mengasumsikan semua efek samping bersifat idempotent, terutama email, pembayaran, atau perubahan stok.
Ordering: urutan event tidak otomatis terjaga
Salah satu kesalahan desain yang sering terjadi adalah menganggap outbox selalu menjaga urutan global. Tidak demikian. Worker paralel, retry, dan partition broker dapat membuat urutan antarevent berubah.
Apa yang realistis dijaga
Biasanya yang penting adalah ordering per aggregate, misalnya semua event untuk satu order diproses berurutan. Untuk itu:
- sertakan aggregate_id,
- gunakan message key berdasarkan aggregate_id jika broker mendukung partisi berdasarkan key,
- hindari memproses event untuk aggregate yang sama secara paralel jika urutan benar-benar penting.
Tambahkan versi jika perlu
Jika event harus diproses berurutan, tambahkan nomor versi atau sequence per aggregate. Consumer dapat mendeteksi jika menerima versi 5 sebelum versi 4, lalu menahan atau menolak pemrosesan sesuai aturan bisnis.
Tanpa strategi ini, event retry yang terlambat bisa datang setelah event yang lebih baru dan menyebabkan state mundur.
Backlog worker dan operasi harian
Outbox yang sehat bukan hanya soal desain tabel, tetapi juga operasi harian. Ketika broker lambat atau worker bermasalah, backlog outbox akan menumpuk.
Tanda backlog mulai berbahaya
- jumlah row pending naik terus,
- umur event tertua di outbox makin besar,
- latency dari created_at ke processed_at melewati target,
- retry_count tinggi pada banyak event,
- worker sering restart atau banyak row macet di status processing.
Respons operasional yang biasanya diperlukan
- tambah jumlah worker jika bottleneck ada di publisher,
- kurangi ukuran batch jika transaksi worker terlalu lama,
- naikkan jeda retry jika broker sedang tidak stabil,
- cek apakah payload terlalu besar atau serialisasi gagal,
- pastikan indeks tabel outbox memadai.
Jangan lupa bahwa outbox juga menambah beban write di database utama. Pada throughput tinggi, tabel outbox yang tidak dirawat bisa menjadi hotspot.
Monitoring dan observability untuk Outbox Pattern
Outbox Pattern mudah terlihat aman saat diuji secara lokal, tetapi masalah sebenarnya muncul di produksi. Karena itu, observability bukan tambahan opsional.
Metrik yang sebaiknya ada
- outbox_pending_total: jumlah event menunggu publish.
- outbox_processing_total: jumlah event sedang diproses.
- outbox_failed_total: jumlah event gagal permanen.
- outbox_publish_latency: selisih antara created_at dan processed_at.
- outbox_retry_total: total retry per jenis event.
- worker_claim_rate dan worker_publish_rate: laju pengambilan dan publish.
Log yang berguna
Minimal log harus memuat:
- event_id,
- aggregate_id,
- event_type,
- status transisi,
- error broker atau error serialisasi,
- retry_count.
Tanpa korelasi berbasis event_id, investigasi double publish akan sangat sulit.
Tanda-tanda kegagalan yang perlu di-alert
- event tertua di status pending melewati ambang tertentu,
- lonjakan event failed,
- jumlah row processing yang tidak berubah lama,
- retry rate naik tajam,
- publish success rate turun.
Jika memungkinkan, hubungkan trace request yang membuat data bisnis dengan trace worker yang akhirnya mengirim event tersebut. Ini sangat membantu saat memverifikasi bahwa suatu order memang menghasilkan event yang keluar.
Trade-off dibanding publish langsung dari aplikasi
Kelebihan Outbox Pattern
- mencegah event hilang setelah commit database,
- retry lebih terkontrol dan dapat diaudit,
- state publish terekam jelas di database,
- lebih realistis dibanding transaksi terdistribusi.
Kekurangan dan biaya operasional
- arsitektur bertambah kompleks karena ada worker tambahan,
- delivery menjadi eventual, bukan instan sinkron,
- tetap perlu idempotency karena duplikasi masih mungkin,
- database mendapat beban write dan read tambahan,
- perlu monitoring, cleanup, dan pengelolaan backlog.
Kenapa tidak langsung publish dalam transaksi aplikasi?
Publish langsung tampak lebih sederhana, tetapi hanya aman jika Anda menerima risiko event hilang atau inkonsistensi saat kegagalan terjadi di titik yang salah. Untuk sistem yang event-nya memicu proses penting lintas layanan, kesederhanaan awal sering dibayar mahal saat insiden produksi muncul.
Kapan Outbox Pattern layak dipakai, dan kapan dihindari
Layak dipakai jika
- event memicu proses bisnis penting di layanan lain,
- kehilangan event tidak dapat diterima,
- aplikasi sering mengalami retry, timeout, atau restart otomatis,
- ada kebutuhan audit terhadap status publish.
Bisa dihindari jika
- event hanya untuk logging atau analitik yang boleh hilang,
- sistem masih sederhana dan belum memakai komunikasi asinkron kritis,
- biaya operasional tambahan lebih besar daripada manfaatnya,
- Anda sebenarnya tidak butuh event, cukup panggilan sinkron biasa.
Jangan menerapkan Outbox Pattern hanya karena terlihat sebagai praktik umum. Gunakan ketika risiko inkonsistensi antara database dan broker memang nyata bagi bisnis atau operasi sistem Anda.
Checklist implementasi produksi
- Simpan perubahan data bisnis dan record outbox dalam satu transaksi database.
- Gunakan event_id unik pada setiap event.
- Tambahkan status, retry_count, available_at, locked_at, last_error.
- Pastikan worker melakukan claim row secara atomik.
- Terapkan retry dengan backoff, bukan loop agresif.
- Siapkan mekanisme recovery untuk row processing yang stale.
- Desain consumer agar idempotent.
- Perjelas kebutuhan ordering per aggregate bila memang penting.
- Pasang metrik backlog, latency publish, retry, dan failed event.
- Buat proses cleanup atau archive untuk outbox yang sudah lama.
- Uji skenario crash: setelah commit DB, saat publish timeout, dan saat worker mati sebelum update status.
Penutup
Outbox Pattern untuk atasi double publish dan event hilang bukan sekadar pola arsitektur di atas kertas. Ini adalah respons praktis terhadap kegagalan yang benar-benar terjadi: crash setelah commit, retry yang menggandakan event, backlog worker, dan inkonsistensi antara database dan message broker.
Jika diterapkan dengan benar, pola ini membuat alur publish lebih dapat diandalkan dan lebih mudah diobservasi. Namun keberhasilannya tidak hanya bergantung pada tabel outbox, melainkan juga pada worker yang aman, retry yang terukur, consumer yang idempotent, serta monitoring yang mampu menunjukkan gejala kegagalan sebelum menjadi insiden besar.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!