Transactional Outbox Menutup Celah Dual-Write
Sebuah service sering perlu mengubah state di database dan memublikasikan event untuk satu request. Kedua operasi itu tampak berdekatan di kode aplikasi, tetapi melewati boundary durability yang berbeda. Commit database dapat berhasil saat publish ke broker gagal, atau publish dapat berhasil sebelum transaksi database mengalami rollback.
Pemisahan tersebut menghasilkan masalah dual-write. Mengubah urutan dua write independen tidak dapat membuat keduanya atomic dengan sendirinya.
Pola transactional outbox mengubah boundary itu. Request menulis data bisnis dan record outbox dalam transaksi database lokal yang sama. Relay terpisah kemudian memublikasikan record outbox yang sudah commit ke broker. Publikasi menjadi asynchronous, tetapi intent yang durable untuk melakukan publish ikut commit bersama state yang memicunya.
Dua write independen menyisakan failure window
Pertimbangkan service order yang menyimpan order dan mengirim event OrderPlaced.
Implementasi langsung dapat menjalankan:
BEGIN
INSERT order
COMMIT
publish OrderPlacedJika process berhenti setelah COMMIT dan sebelum publish, order sudah ada tanpa event-nya.
Membalik urutan hanya memindahkan celah:
publish OrderPlaced
BEGIN
INSERT order
COMMITSekarang crash, constraint failure, atau transaction abort dapat membuat consumer menerima event untuk order yang tidak pernah commit.
Retry tidak membuat pasangan operasi tersebut atomic. Mengulang operasi database dapat menduplikasi state jika operasi itu tidak idempotent. Mengulang operasi broker dapat menduplikasi delivery. Lebih penting lagi, retry tidak selalu dapat menentukan apakah remote publish yang terputus sudah berlaku, kecuali protokol messaging menyediakan kontrak deduplication atau transactional yang sesuai.
Masalah intinya adalah ownership: database mengendalikan satu keputusan commit dan broker mengendalikan keputusan lain.
Outbox menyatukan state dan intent publikasi
Dengan outbox, transaksi request menulis dua jenis row:
BEGIN;
INSERT INTO orders (id, customer_id, total, status)
VALUES (:id, :customer_id, :total, 'placed');
INSERT INTO outbox (
event_id,
aggregate_id,
event_type,
payload,
created_at
)
VALUES (
:event_id,
:id,
'OrderPlaced',
:payload,
CURRENT_TIMESTAMP
);
COMMIT;Kedua insert memakai transaksi database yang sama. Jika transaksi commit, order dan intent publikasi sama-sama durable. Jika transaksi abort, keduanya tidak muncul sebagai committed state.
Broker sengaja tidak dimasukkan ke transaksi ini. Request tidak mencoba memperluas transaksi database lokal ke sistem messaging yang terpisah.
Outbox row juga bukan sekadar log message. Ia adalah pekerjaan durable yang tetap dapat di-query setelah process request berhenti.
Relay memiliki boundary kedua
Relay memindai atau menerima pekerjaan outbox yang sudah commit lalu melakukan publish:
request
|
v
transaksi database
|-- business row
`-- outbox row
|
commit
|
v
relay
|
v
brokerPolling relay sederhana dapat memilih row yang belum dipublikasikan dalam batch:
SELECT event_id, event_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100;Setelah publish ke broker berhasil, relay menandai row sebagai published atau menghapusnya sesuai desain retention.
Pemisahan ini melepaskan latency request dari availability broker. Gangguan broker sementara dapat membuat outbox row yang sudah commit tetap pending, alih-alih mendorong transaksi bisnis awal ke hasil lintas sistem yang ambigu.
Pemisahan tersebut juga menciptakan queue operasional di dalam database. Depth, age, throughput, dan failure rate perlu dimonitor secara eksplisit.
Relay memiliki duplicate window yang tidak dapat dihapus begitu saja
Outbox menutup celah missing event antara business write dan intent publikasi. Pola ini tidak otomatis memberikan exactly-once delivery.
Misalkan relay menjalankan:
1. publish event E
2. broker menerima E
3. relay crash
4. marker published belum tersimpanSetelah restart, relay melihat E masih pending dan memublikasikannya lagi.
Jika relay menandai row lebih dahulu lalu melakukan publish, failure yang berlawanan muncul: crash di antara kedua langkah dapat membuat event tidak pernah dikirim.
Untuk relay yang memakai API broker biasa dan update database terpisah, at-least-once publication merupakan default yang praktis. Consumer karena itu perlu tahan terhadap duplicate event delivery, biasanya melalui event identifier dan deduplication durable atau state transition yang memang idempotent.
event_id = 7f2c...
delivery pertama -> terapkan efek, catat event_id
delivery kedua -> event_id sudah tercatat, lewati efekMekanisme tepatnya bergantung pada storage dan side effect milik consumer. Deduplication yang hanya disimpan di process memory hilang saat restart dan tidak melindungi beberapa instance consumer.
Claim row memerlukan concurrency control
Lebih dari satu relay worker mungkin diperlukan untuk throughput atau availability. Worker tidak boleh menganggap pending row yang sama sebagai pekerjaan eksklusif tanpa koordinasi.
Pada database yang mendukung locking semantics yang diperlukan, worker dapat melakukan claim batch dengan row lock dan melewati row yang sudah dikunci peer. Desain lain memberi lease atau claim token dengan expiry. Sistem change-data-capture dapat memilih streaming insert outbox yang sudah commit dari database log.
Setiap pilihan mengubah failure handling.
Lock yang hanya ditahan dalam transaksi database pendek tidak cocok dipertahankan melewati broker call yang lambat tanpa biaya. Lease memerlukan aturan expiry dan recovery yang aman. Relay berbasis log memerlukan offset durable serta relasi yang jelas antara posisi log dan acknowledgement broker.
Invariant-nya tetap sama: hanya record outbox yang sudah commit yang boleh dipublikasikan, dan pekerjaan publikasi yang belum selesai harus tetap recoverable.
Ordering memerlukan scope eksplisit
Aplikasi sering mengharuskan event untuk satu aggregate mempertahankan urutan commit sambil membiarkan aggregate lain berjalan secara independen.
ORDER BY created_at global bukan protokol ordering yang lengkap. Timestamp dapat sama, concurrency worker dapat mengubah urutan publish, dan partitioning broker menambah boundary ordering lain.
Jika consumer memerlukan ordering per order, desain dapat membawa sequence monotonic untuk setiap order:
aggregate_id = order-42
sequence = 17
event = OrderAddressChanged
aggregate_id = order-42
sequence = 18
event = OrderConfirmedStrategi relay dan partitioning broker kemudian perlu mempertahankan scope tersebut, sering kali dengan mengarahkan aggregate key yang sama ke ordered partition yang sama.
Global total order jauh lebih membatasi dan dapat menurunkan concurrency secara tajam. Jaminan ordering sebaiknya mengikuti business invariant, bukan langsung memakai scope terluas.
Desain payload memengaruhi coupling
Outbox dapat menyimpan event payload lengkap, reference ke business state, atau field yang cukup bagi relay untuk membentuk message.
Menyimpan payload final saat transaksi berlangsung memberi event representasi stabil dari keputusan yang di-commit. Relay yang kemudian membaca ulang business row yang mutable dapat tanpa sengaja memublikasikan state yang lebih baru, bukan state yang terkait dengan event awal.
Sebagai contoh, order dapat berpindah dari placed ke cancelled sebelum relay yang tertunda memproses outbox row pertama. Jika relay membentuk ulang OrderPlaced dari order row saat ini, message dapat mencampur dua momen lifecycle.
Payload tersimpan menghindari temporal coupling tersebut, tetapi menambah tugas schema management. Producer memerlukan kebijakan versioning, consumer memerlukan compatibility rules, dan field sensitif tidak seharusnya disalin ke outbox tanpa alasan retention dan access yang jelas.
Cleanup tidak boleh berpacu dengan publikasi
Outbox terus bertambah kecuali row dihapus atau diarsipkan. Cleanup karena itu merupakan bagian dari protokol, bukan sekadar database housekeeping.
Row baru boleh masuk tahap cleanup setelah sistem memiliki bukti durable bahwa langkah publikasi yang diwajibkan sudah selesai. Retention delay dapat memberi ruang untuk diagnostic dan replay, tetapi semantics replay perlu hati-hati: memublikasikan ulang row lama tetap merupakan delivery tambahan dan dapat memicu efek jika consumer tidak menanganinya secara aman.
Lifecycle yang berguna:
pending -> claimed -> published -> retained -> deletedTidak setiap implementasi memerlukan semua state tersebut. Properti pentingnya adalah cleanup tidak boleh menghapus satu-satunya copy durable dari pekerjaan yang masih harus dipublikasikan.
Index juga penting. Polling query atas published_at IS NULL sebaiknya tidak berubah menjadi full-table scan berulang saat historical row bertambah. Partitioning atau archival dapat menjadi pilihan pada volume lebih tinggi.
Backpressure berpindah ke outbox
Publikasi asynchronous melindungi request path dari gangguan broker singkat, tetapi tidak menciptakan kapasitas tanpa batas.
Jika producer melakukan commit 5.000 outbox row per detik sementara relay hanya mampu memublikasikan 3.000, backlog tumbuh 2.000 row per detik. Database storage, index maintenance, replication traffic, dan recovery time ikut meningkat.
Signal yang berguna mencakup:
age pending row tertua
jumlah pending row
publish attempt dan failure
throughput relay
claim atau lock contention
latency acknowledgement brokerAge pending tertua sering lebih informatif daripada count saja karena menunjukkan seberapa stale downstream state dapat menjadi.
Capacity policy perlu menentukan respons terhadap outage berkepanjangan. Pilihannya dapat berupa throttling producer, menolak operasi tertentu, menambah kapasitas relay, atau menerima periode downstream lag yang terbatas. Pilihan yang tepat merupakan keputusan product dan consistency, bukan sekadar parameter tuning worker.
Pola ini sengaja mempersempit atomic boundary
Transactional outbox tidak membuat database dan broker berpartisipasi dalam satu atomic commit. Pola ini menghindari kebutuhan distributed atomic commit untuk workflow publikasi event yang umum.
Transaksi lokal menjawab satu pertanyaan: apakah business state dan intent publikasinya commit bersama? Relay menjawab pertanyaan lain: apakah intent yang sudah commit tersebut sudah mencapai sistem messaging?
Pemisahan ini menghasilkan failure model yang lebih mudah ditangani. Missing publication intent dicegah oleh transaksi lokal. Broker outage berubah menjadi backlog yang recoverable. Duplicate publication tetap mungkin terjadi dan harus ditangani secara eksplisit. Ordering, cleanup, schema evolution, dan backpressure tetap menjadi persoalan engineering, bukan asumsi tersembunyi.
Nilai utama pola ini berada pada boundary-nya: business state dan kewajiban untuk publish melewati satu durable commit point, sementara remote delivery berjalan sebagai pekerjaan asynchronous yang dapat dipulihkan.