Ringkasan Utama
Pola transactional outbox mengatasi masalah dual-write dengan menulis event domain dalam transaksi database yang sama dengan perubahan bisnisnya, lalu meneruskannya lewat relay poller yang memanfaatkan FOR UPDATE SKIP LOCKED di PostgreSQL agar aman diproses banyak worker sekaligus, sehingga event hanya ada jika baris bisnisnya juga ada.
Setiap backend yang menulis ke basis data lalu mempublikasikan event ke antrean pesan pada akhirnya akan menemui bug yang sama: commit basis data berhasil, tetapi pesan tidak pernah sampai ke broker, atau sebaliknya terjadi dan event duplikat terkirim. Ini disebut masalah dual-write, dan muncul di sistem pembayaran, pembaruan inventaris, dan setiap alur kerja di mana layanan lain perlu bereaksi terhadap suatu perubahan.
Pola transactional outbox menyelesaikan ini dengan menulis event ke dalam transaksi basis data yang sama dengan perubahan bisnisnya, lalu merelainya secara terpisah. Pola ini menukar sedikit latensi dan satu tabel tambahan demi jaminan yang biasanya sangat sulit didapat: event tersebut ada jika dan hanya jika baris bisnisnya ada. Tulisan ini membahas skema tabel outbox, jalur penulisan, relay polling yang dibangun di atas penguncian baris PostgreSQL, jaminan urutan, dan kapan pola ini justru berlebihan.
Mengapa dua penulisan ke dua sistem tidak pernah aman
Pendekatan naif terlihat masuk akal di atas kertas: sisipkan baris pesanan, commit, lalu publikasikan event pesanan dibuat ke broker pesan. Masalahnya adalah keduanya merupakan dua sumber daya terpisah tanpa transaksi bersama. Di antara commit dan pemanggilan publikasi, beberapa hal bisa salah.
- Proses mengalami crash setelah commit basis data tetapi sebelum pemanggilan publikasi selesai, sehingga event hilang tanpa jejak.
- Pemanggilan publikasi mengalami timeout atau broker sesaat tidak dapat dijangkau, sehingga pemanggil mencoba ulang dan event duplikat terkirim, atau pemanggil menyerah dan event hilang.
- Transaksi basis data melakukan rollback setelah event sudah terlanjur dipublikasikan, sehingga konsumen di hilir bereaksi terhadap perubahan yang sebenarnya tidak pernah terjadi.
Tidak satu pun dari ini adalah kegagalan yang eksotis. Ini terjadi pada kondisi jaringan yang tidak stabil dan restart proses yang normal, dan semakin buruk di bawah beban tinggi, yang justru saat itulah kebenaran data paling penting. Protokol two-phase commit dirancang untuk menyelesaikan kelas masalah ini pada transaksi terdistribusi, tetapi memerlukan broker untuk ikut berpartisipasi sebagai sumber daya transaksional, yang tidak didukung oleh sebagian besar antrean pesan dan broker event.
Loop percobaan ulang di sisi pemanggil di sekitar pemanggilan publikasi tidak memperbaiki masalah dual-write, itu hanya mengubah jenis kegagalan yang Anda dapatkan. Tanpa kunci idempoten atau transaksi bersama, percobaan ulang mengubah event yang hilang menjadi event duplikat.
Tabel outbox
Ide intinya adalah menambahkan tabel biasa di basis data yang sama dengan tabel bisnis Anda, dan memperlakukan penulisan ke tabel itu tidak berbeda dari menulis ke baris lain mana pun. Tidak ada infrastruktur baru, tidak ada koordinator transaksi terdistribusi, hanya sebuah tabel yang berada di dalam batas transaksi yang sudah Anda kendalikan.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
attempts INT NOT NULL DEFAULT 0,
locked_at TIMESTAMPTZ,
locked_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_pending_idx
ON outbox (available_at)
WHERE status = 'pending';Kolom status dan available_at ada agar relay dapat membedakan event yang masih tertunda dari event yang akan dicoba ulang nanti, dan kolom locked_at serta locked_by ada agar beberapa pekerja relay dapat berjalan bersamaan tanpa memproses ganda baris yang sama. Indeks parsial pada baris tertunda menjaga kueri polling relay tetap cepat bahkan setelah tabel memiliki jutaan baris historis.
Menulis event bersama perubahan bisnis
Jalur penulisan adalah seluruh triknya: bungkus penyisipan baris bisnis dan penyisipan baris outbox dalam satu transaksi basis data. Jika salah satu penulisan gagal, seluruh transaksi melakukan rollback dan kedua baris tidak ada. Jika transaksi berhasil commit, kedua baris ada. Tidak ada celah waktu di mana salah satunya ada tanpa yang lain.
await db.transaction(async (trx) => {
const order = await trx('orders').insert({
customer_id: customerId,
total_cents: totalCents,
status: 'placed',
}).returning('id');
await trx('outbox').insert({
aggregate_type: 'order',
aggregate_id: order[0].id,
event_type: 'order.placed',
payload: JSON.stringify({ orderId: order[0].id, totalCents }),
});
});Simpan payload outbox sebagai dokumen JSON yang datar dan mandiri, bukan sebagai kunci asing yang menunjuk kembali ke baris bisnis. Relay dan konsumen di hilir mana pun harus bisa memproses event tanpa perlu join kembali ke tabel yang mungkin sudah berubah atau terhapus pada saat event akhirnya terkirim.
Relay: polling tanpa saling mengganggu pekerja lain
Relay adalah pekerja latar belakang yang berulang kali melakukan polling ke tabel outbox untuk mencari baris yang tertunda, mempublikasikan setiap baris ke broker, lalu menandainya sebagai terkirim. Menjalankan lebih dari satu pekerja relay demi throughput atau ketersediaan tinggi memunculkan masalah baru: dua pekerja bisa mengambil baris yang sama pada saat bersamaan sehingga terkirim dua kali. Klausa penguncian baris PostgreSQL menyelesaikan ini secara langsung.
SELECT id, event_type, payload
FROM outbox
WHERE status = 'pending' AND available_at <= now()
ORDER BY id
LIMIT 50
FOR UPDATE SKIP LOCKED;SKIP LOCKED memberitahu PostgreSQL untuk melewati baris mana pun yang sudah dikunci oleh transaksi lain, alih-alih menunggu sampai bebas. Setiap pekerja mengunci satu kelompok baris, mempublikasikannya, memperbarui statusnya, lalu commit, dan pekerja lain yang sedang polling pada saat yang sama cukup melewati baris tersebut dan mengambil kelompok berikutnya yang belum diklaim. Ini mengubah tabel outbox menjadi antrean multi-konsumen yang aman tanpa memerlukan pengelola kunci terdistribusi.
| Strategi relay | Latensi umum | Kompleksitas operasional |
|---|---|---|
| Polling interval tetap | Beberapa detik, dibatasi oleh interval polling | Rendah, satu loop pekerja mirip cron |
| Polling ditambah pembangunan lewat LISTEN dan NOTIFY | Kurang dari satu detik pada kasus umum | Sedang, tetap memerlukan polling sebagai cadangan |
| Penangkapan berbasis log dari write-ahead log | Mendekati waktu nyata | Tinggi, memerlukan konektor change-data-capture |
Menjaga urutan event tetap utuh
Sebagian besar konsumen peduli pada urutan kedatangan event untuk entitas yang sama, meski urutan global lintas semua entitas tidak penting. Tiga praktik berikut menjaga urutan tetap utuh tanpa menambahkan layanan penomoran terpisah.
- Gunakan satu kunci utama yang bertambah otomatis pada tabel outbox dan selalu ambil serta publikasikan dalam urutan menaik dari kunci tersebut, bukan berdasarkan cap waktu penyisipan, karena jam sistem dan pengelompokan dapat mengacak urutan cap waktu.
- Bagi pengiriman berdasarkan id agregat saat mempublikasikan ke topik yang terpartisi, sehingga semua event untuk pesanan atau pelanggan yang sama jatuh pada partisi yang sama dan dikonsumsi secara berurutan oleh satu instance konsumen.
- Jangan pernah memparalelkan publikasi dalam satu id agregat yang sama di antara pekerja relay, meskipun SKIP LOCKED memungkinkan pemrosesan bersamaan lintas agregat yang berbeda.
SKIP LOCKED memberi Anda pemrosesan bersamaan yang aman lintas baris, tetapi tidak memberi jaminan urutan lintas pekerja. Jika dua pekerja mengambil dua kelompok baris pada saat bersamaan, baris dari kelompok yang lebih baru bisa terpublikasi sebelum baris dari kelompok yang lebih awal jika pekerja yang lebih awal sesaat lebih lambat.
Membersihkan tabel yang hanya bertumbuh
Tabel outbox yang tidak pernah dipangkas akan menjadi tabel paling lambat di basis data dalam beberapa bulan. Karena setiap baris berstatus tertunda atau sudah terkirim, dan baris yang sudah terkirim hanya berguna untuk audit dan debugging, pembersihannya cukup sederhana.
- Jalankan pekerjaan terjadwal yang menghapus baris terkirim yang lebih tua dari jendela retensi, biasanya tujuh hingga tiga puluh hari tergantung seberapa jauh ke belakang Anda perlu memutar ulang atau melakukan debugging.
- Hapus dalam kelompok kecil, bukan satu pernyataan besar, karena satu penghapusan yang menyentuh jutaan baris dapat menahan kunci cukup lama hingga menghambat kueri polling milik relay itu sendiri.
- Pertimbangkan mempartisi tabel per bulan jika volume event tinggi, sehingga partisi lama dapat langsung dihapus utuh alih-alih dihapus baris demi baris.
Karena tabel outbox adalah tabel biasa, tabel ini sekaligus berfungsi sebagai log audit gratis: Anda dapat menjawab event apa yang terjadi untuk suatu pesanan tertentu, dan kapan, hanya dengan kueri SQL biasa, tanpa perlu membangun penyimpanan event terpisah.
Kapan pola ini tidak sepadan
Pola outbox menambah satu tabel, satu pekerja relay, dan area operasional baru. Pola ini sepadan ketika event yang terlewat atau terduplikasi memiliki biaya bisnis nyata, seperti konfirmasi pembayaran atau penyesuaian inventaris. Pola ini berlebihan pada beberapa situasi umum berikut.
- Event tersebut murni bersifat penasihat, seperti petunjuk pembatalan cache, di mana pesan yang sesekali hilang paling banter menyebabkan pembacaan basi yang akan memperbaiki dirinya sendiri pada penulisan berikutnya.
- Konsumen Anda sudah dapat mentoleransi duplikasi dan pesan yang terlewat dengan menurunkan ulang keadaan dari sinkronisasi penuh berkala, sehingga jaminan pengiriman yang ketat menjadi tidak diperlukan.

