Pola Transactional Outbox dengan PostgreSQL

Foto oleh Arenamontanus on flickr
Ini adalah teknik untuk mempublikasikan event secara andal dengan menulis baris event ke tabel outbox di dalam transaksi basis data yang sama dengan perubahan bisnisnya. Proses relay terpisah kemudian membaca baris yang tertunda dari tabel outbox dan mempublikasikannya ke broker pesan, sehingga event hanya ada jika perubahan bisnisnya benar-benar berhasil commit.
Commit basis data dan publikasi pesan adalah dua operasi independen terhadap dua sistem berbeda tanpa transaksi bersama. Crash, timeout, atau kegagalan jaringan di antara kedua langkah tersebut dapat membuat event hilang sepenuhnya atau justru terkirim lebih dari sekali, karena tidak ada jaminan atomik yang mencakup kedua tindakan tersebut.
Gunakan klausa SELECT FOR UPDATE SKIP LOCKED milik PostgreSQL saat relay melakukan polling ke tabel outbox. Setiap pekerja mengunci satu kelompok baris, dan pekerja lain yang polling pada saat bersamaan otomatis melewati baris yang terkunci tersebut dan mengambil kelompok berikutnya yang belum diklaim, sehingga tidak ada dua pekerja yang mempublikasikan event yang sama.
Tidak dengan sendirinya. Pola outbox menjamin event tidak pernah hilang, memberikan pengiriman setidaknya sekali, tetapi relay yang crash setelah mempublikasikan namun sebelum menandai baris sebagai terkirim tetap dapat menyebabkan duplikasi. Konsumen sebaiknya memperlakukan event sebagai idempoten, misalnya dengan melacak id event yang sudah diproses, agar perilakunya efektif tepat satu kali secara menyeluruh.
Selalu ambil dan publikasikan baris berdasarkan urutan menaik dari kunci utama yang bertambah otomatis pada tabel outbox, bukan berdasarkan cap waktu, dan jangan pernah memproses event untuk id agregat yang sama secara bersamaan di beberapa pekerja relay. Ini menjaga urutan per entitas tetap utuh meskipun entitas yang berbeda dapat dipublikasikan secara paralel.

Foto oleh Arenamontanus on flickr
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.
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.
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.
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.
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 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 |
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.
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.
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.
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.
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.