Transactional Outbox mencegah event bisnis hilang ketika satu operasi harus mengubah database sekaligus menerbitkan pesan ke queue atau event broker. Intinya, aplikasi tidak langsung melakukan dua operasi terpisah tersebut. Aplikasi menyimpan perubahan bisnis dan catatan event ke tabel outbox dalam satu transaksi database, lalu proses relay menerbitkan event yang sudah committed ke broker.
Masalah yang diselesaikan adalah dual write: database dan broker adalah dua sistem berbeda tanpa transaksi atomik bersama. Tanpa pola ini, kegagalan kecil pada jaringan, proses aplikasi, atau acknowledgement broker dapat membuat data dan event tidak konsisten.
Mengapa dual write dapat kehilangan atau menggandakan event?
Misalkan layanan Order harus menyimpan order baru ke PostgreSQL lalu menerbitkan OrderCreated ke queue agar layanan pembayaran memprosesnya.
- Database berhasil, publish gagal. Aplikasi melakukan
COMMITorder, lalu broker tidak dapat dihubungi atau proses crash sebelum publish. Order ada, tetapi event hilang. Konsumen tidak pernah tahu order tersebut dibuat. - Publish berhasil, database gagal. Aplikasi mengirim event lebih dahulu, lalu transaksi database rollback. Konsumen menerima event untuk order yang sebenarnya tidak pernah ada.
- Publish berhasil, penandaan gagal. Relay mengirim pesan dan broker menerimanya, tetapi relay crash sebelum mencatat bahwa event sudah dipublikasikan. Saat relay hidup kembali, event dikirim ulang. Ini menghasilkan duplikasi.
- Timeout yang ambigu. Client mengalami timeout ketika publish. Client tidak dapat menyimpulkan apakah broker belum menerima pesan atau sudah menerimanya. Retry tanpa perlindungan akan berpotensi menggandakan pesan.
Menambahkan retry pada publish saja tidak menyelesaikan masalah pertama. Menggunakan transaksi database saja juga tidak mencakup broker. Transactional Outbox memindahkan batas atomik ke satu sistem yang memang mendukung transaksi: database aplikasi.
Arsitektur Transactional Outbox
- Handler aplikasi memvalidasi command dan mengubah data bisnis.
- Dalam transaksi PostgreSQL yang sama, handler menambahkan satu baris event pada tabel
outbox_events. - Setelah transaksi committed, worker atau relay mengambil event outbox yang belum terbit.
- Relay menerbitkan event ke broker dengan
event_idyang stabil. - Jika publish terkonfirmasi, relay menandai event sebagai published. Jika gagal atau hasilnya ambigu, relay menjadwalkan percobaan ulang.
- Consumer memproses pesan secara idempoten karena event dapat dikirim lebih dari sekali.
Transactional Outbox memberikan at-least-once delivery: event committed pada akhirnya akan dicoba kirim sampai berhasil atau masuk jalur penanganan kegagalan. Pola ini bukan exactly-once end-to-end, karena crash setelah broker menerima pesan tetapi sebelum outbox ditandai published tetap memungkinkan pengiriman ulang.
Skema tabel outbox di PostgreSQL
Contoh berikut menggunakan ID event yang dibuat aplikasi. Payload sebaiknya berisi fakta yang cukup untuk consumer, bukan objek ORM atau struktur internal yang rapuh.
CREATE TABLE outbox_events (
event_id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
aggregate_sequence BIGINT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_by TEXT,
lock_until TIMESTAMPTZ,
last_error TEXT,
CONSTRAINT outbox_aggregate_sequence_unique
UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);
CREATE INDEX outbox_pending_idx
ON outbox_events (next_attempt_at, created_at)
WHERE published_at IS NULL;
CREATE INDEX outbox_aggregate_idx
ON outbox_events (aggregate_type, aggregate_id, aggregate_sequence);
Kolom aggregate_id adalah identitas entitas bisnis, misalnya ID order. aggregate_sequence adalah urutan event dalam satu agregat, misalnya event pertama untuk sebuah order bernilai 1. Kolom ini penting bila consumer membutuhkan urutan seperti OrderCreated sebelum OrderPaid.
lock_until adalah lease, bukan lock database jangka panjang. Lease mencegah beberapa relay memproses event yang sama secara bersamaan dan akan kedaluwarsa apabila worker mati. Index parsial menjaga query event pending tetap efisien ketika data published bertambah banyak.
Menulis data bisnis dan outbox dalam satu transaksi
Aturan utamanya sederhana: jangan commit perubahan bisnis sebelum insert outbox berhasil, dan jangan publish ke broker dari dalam transaksi database. Publish jaringan dapat lambat atau ambigu; menahannya di dalam transaksi akan memperpanjang lock database tanpa membuat operasi menjadi atomik dengan broker.
function createOrder(command):
eventId = newUuid()
begin database transaction
order = insert into orders (
id, customer_id, status, total_amount
) values (
command.orderId, command.customerId, 'CREATED', command.total
)
insert into outbox_events (
event_id,
aggregate_type,
aggregate_id,
aggregate_sequence,
event_type,
payload,
headers
) values (
eventId,
'order',
order.id,
1,
'OrderCreated',
toJson({ orderId: order.id, customerId: order.customerId,
total: order.totalAmount }),
toJson({ correlationId: command.correlationId })
)
commit transaction
return order
Jika COMMIT gagal, order dan outbox sama-sama tidak ada. Jika commit berhasil, keduanya sama-sama ada dan relay dapat menerbitkan event walaupun proses request sudah selesai atau aplikasi sempat restart.
Menentukan sequence per agregat
Jangan menghitung sequence dengan pola MAX(sequence) + 1 tanpa pengendalian konkurensi. Dua transaksi dapat membaca nilai maksimum yang sama. Pilihan yang lebih aman adalah menyimpan versi agregat pada tabel utama dan menaikkannya dengan update bersyarat atau row lock dalam transaksi yang sama. Nilai versi hasil update digunakan sebagai aggregate_sequence pada event.
Merancang relay publisher yang tahan gagal
Relay dapat berupa proses terpisah, worker dalam service yang sama, atau deployment khusus. Ia mengambil batch kecil event yang siap dicoba, memasang lease secara atomik, lalu melakukan publish di luar transaksi database.
Mengklaim pekerjaan dengan lease
PostgreSQL menyediakan FOR UPDATE SKIP LOCKED untuk membagi pekerjaan di antara beberapa worker tanpa menunggu row yang sedang diklaim worker lain. Contoh berikut mengklaim event yang belum published, sudah waktunya dicoba, dan tidak sedang memiliki lease aktif.
WITH candidates AS (
SELECT event_id
FROM outbox_events
WHERE published_at IS NULL
AND next_attempt_at <= now()
AND (lock_until IS NULL OR lock_until < now())
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100
)
UPDATE outbox_events o
SET locked_by = :worker_id,
lock_until = now() + interval '60 seconds',
attempts = attempts + 1
FROM candidates c
WHERE o.event_id = c.event_id
RETURNING o.*;
Durasi lease harus lebih lama daripada waktu publish normal, tetapi tidak terlalu lama sehingga pemulihan setelah worker mati tertunda. Bila publish bisa memakan waktu lama, worker dapat memperpanjang lease dengan hati-hati. Setelah event diklaim, selesaikan transaksi claim terlebih dahulu; jangan mempertahankan row lock saat memanggil broker.
Pseudocode publish, retry, dan acknowledgement
function relayLoop():
events = claimReadyEvents(workerId, batchSize = 100)
for event in events:
try:
broker.publish(
topic = topicFor(event.event_type),
key = event.aggregate_id,
messageId = event.event_id,
headers = event.headers,
body = event.payload
)
markPublished(event.event_id, workerId)
catch transientError as err:
delay = exponentialBackoffWithJitter(event.attempts)
scheduleRetry(event.event_id, workerId, now() + delay, err)
catch permanentError as err:
markFailedForReview(event.event_id, workerId, err)
markPublished harus melakukan update bersyarat, misalnya hanya jika locked_by masih cocok dengan worker saat ini. Update tersebut mengisi published_at dan menghapus lease. scheduleRetry juga menghapus lease, menyimpan ringkasan error, dan mengisi next_attempt_at.
Gunakan exponential backoff dengan jitter, misalnya interval bertambah secara eksponensial hingga batas maksimum lalu diacak sedikit. Backoff menghindari banjir retry saat broker bermasalah. Batasi jumlah retry otomatis untuk error yang tampak permanen, seperti payload tidak valid atau topic tidak tersedia, lalu arahkan event ke status gagal yang terlihat oleh operator. Jangan membuang event hanya karena retry habis.
Idempotensi consumer adalah bagian wajib
Broker mungkin mendukung deduplikasi dalam kondisi tertentu, tetapi aplikasi tidak boleh bergantung penuh pada fitur itu untuk menjamin exactly-once lintas database dan queue. Consumer harus menganggap pesan dapat datang lebih dari sekali, termasuk setelah restart atau redelivery broker.
Pendekatan umum adalah membuat tabel inbox atau processed messages di database consumer dengan event_id sebagai primary key. Simpan catatan tersebut dan perubahan bisnis consumer dalam transaksi yang sama.
begin database transaction
inserted = insert into processed_events (event_id, processed_at)
values (:eventId, now())
on conflict (event_id) do nothing
returning event_id
if inserted exists:
applyBusinessEffect(message)
commit transaction
acknowledgeMessage()
Jika pesan duplikat, insert tidak menghasilkan row dan efek bisnis tidak diulang. Acknowledge dilakukan setelah transaksi consumer commit. Untuk efek eksternal seperti mengirim email atau memanggil API pihak ketiga, gunakan idempotency key berbasis event_id, atau gunakan outbox lagi pada sisi consumer bila efek tersebut harus dipublikasikan secara andal.
Urutan event: per agregat, bukan urutan global
Urutan global lintas semua order biasanya mahal dan jarang diperlukan. Yang lebih realistis adalah menjamin atau menangani urutan per agregat. Gunakan aggregate_id sebagai key/partition key saat publish agar event untuk agregat yang sama cenderung masuk jalur yang sama pada broker yang mendukung partisi.
Namun, key broker saja belum cukup. Jika relay mengambil event paralel, event sequence 2 dapat selesai publish sebelum sequence 1. Untuk kebutuhan urutan ketat, pilih salah satu strategi berikut:
- Klaim dan publish hanya event dengan sequence terkecil yang belum published untuk setiap agregat.
- Proses satu agregat secara serial, sambil tetap memparalelkan agregat berbeda.
- Biarkan consumer menyimpan sequence terakhir dan menolak, menunda, atau mengambil ulang event yang melompati urutan.
Strategi pertama dan kedua menambah kompleksitas serta dapat menurunkan throughput untuk agregat yang sangat aktif. Banyak sistem memilih consumer yang tahan event terlambat atau out-of-order bila aturan bisnis mengizinkan.
Polling outbox versus CDC
Polling dengan relay aplikasi
Pendekatan contoh di atas melakukan polling tabel outbox. Keunggulannya adalah sederhana, mudah diuji, dan logika retry serta routing broker berada di kode aplikasi. Kekurangannya adalah ada query periodik ke database dan latency bergantung pada interval polling. Gunakan index yang tepat, batch terbatas, dan interval adaptif agar polling kosong tidak membebani database.
Change Data Capture (CDC)
CDC membaca perubahan database, misalnya dari write-ahead log PostgreSQL, lalu meneruskan insert pada tabel outbox ke sistem streaming atau broker. CDC biasanya menarik pada throughput tinggi karena tidak memerlukan polling query dari aplikasi dan dapat memperoleh perubahan committed dengan latency rendah.
Trade-off CDC adalah operasional yang lebih kompleks: pengelolaan replication slot atau mekanisme setara, retensi log database, izin akses, pemantauan connector, evolusi skema, dan prosedur replay. CDC juga tidak menghapus kebutuhan idempotensi consumer; connector dapat mengirim ulang data setelah restart. Pilih polling ketika kebutuhan operasional sederhana dan volume masih sesuai, lalu pertimbangkan CDC ketika beban polling atau latency menjadi masalah nyata dan tim siap mengoperasikannya.
Pembersihan, monitoring, dan pemulihan operasional
Pembersihan data outbox
Jangan menghapus event segera setelah published_at terisi. Retensi beberapa waktu membantu audit, investigasi, dan replay terbatas. Setelah masa retensi disepakati, hapus atau arsipkan event published secara bertahap dalam batch kecil agar tidak membuat transaksi besar dan bloat berlebihan. Event yang gagal permanen sebaiknya dipisahkan atau diberi status yang tidak ikut dibersihkan otomatis sebelum ditinjau.
Metrik dan alert yang perlu ada
- Outbox lag: umur event unpublished tertua dan selisih jumlah event created versus published.
- Publish success/failure: jumlah publish berhasil, gagal sementara, gagal permanen, dan timeout per event type atau destination.
- Retry: distribusi attempts, jumlah event yang terus retry, serta ukuran backlog ready-to-publish.
- Lease: jumlah event dengan lease kedaluwarsa, yang dapat menandakan worker crash atau publish terlalu lambat.
- Consumer: lag consumer, jumlah duplicate event, kegagalan idempotency store, dan pesan yang masuk jalur gagal.
Alert yang paling berguna biasanya bukan hanya saat ada satu kegagalan publish, melainkan saat umur event pending melampaui target operasional atau backlog terus tumbuh. Sertakan event_id, aggregate_id, correlation ID, dan error terakhir dalam log terstruktur untuk memudahkan penelusuran dari request hingga consumer.
Prosedur pemulihan saat insiden
- Pastikan perubahan bisnis memang committed dan cek baris outbox berdasarkan
event_idatau correlation ID. - Periksa backlog, error terakhir, dan kesehatan broker atau connector. Hindari langsung menandai event published secara manual.
- Jika worker mati, biarkan lease kedaluwarsa atau jalankan prosedur aman untuk melepaskan lease lama setelah memastikan worker tersebut benar-benar tidak aktif.
- Setelah penyebab diperbaiki, jadwalkan ulang event gagal dengan tetap memakai
event_idyang sama. Consumer idempoten harus aman terhadap replay. - Untuk event yang sudah pernah published tetapi efek consumer diragukan, lakukan replay terkontrol dan pantau deduplikasi consumer.
- Dokumentasikan event type, rentang waktu, serta aggregate yang terdampak sebelum replay massal.
Kesalahan implementasi yang sering terjadi
- Mengirim ke queue langsung dari request handler dan menganggap retry aplikasi sudah cukup.
- Menandai event published sebelum broker mengonfirmasi penerimaan.
- Menahan transaksi database atau row lock selama panggilan jaringan ke broker.
- Tidak memakai ID event stabil, sehingga retry menghasilkan pesan yang tidak dapat dideduplikasi.
- Menghapus outbox terlalu cepat sehingga investigasi dan replay tidak mungkin dilakukan.
- Mengasumsikan FIFO broker otomatis menjamin urutan ketika producer atau relay mempublikasikan secara paralel.
- Tidak menguji crash di antara publish broker dan update
published_at.
Uji pola ini dengan fault injection: paksa crash setelah commit bisnis, setelah claim lease, tepat setelah broker menerima pesan, dan sebelum acknowledgement consumer. Hasil yang benar bukan berarti tidak pernah ada duplikasi; hasil yang benar adalah tidak ada event committed yang hilang dan efek duplikasi dapat ditangani dengan aman.
Dengan transaksi lokal untuk data bisnis dan outbox, relay yang dapat retry, serta consumer idempoten, Transactional Outbox memberi fondasi yang jelas untuk integrasi event-driven yang tahan kegagalan parsial tanpa mengklaim jaminan exactly-once yang tidak realistis.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!