# Pola Transactional Outbox dengan PostgreSQL

> Publikasikan event domain secara andal tanpa bug dual-write, menggunakan tabel outbox, relay poller, dan penguncian baris PostgreSQL.

URL: https://www.matthewswong.com/id/blog/transactional-outbox-pattern-postgres/

- Author: Matthews Wong (https://www.matthewswong.com/id/about/)
- Published: 2026-07-09
- Updated: 2026-07-09
- Category: Backend

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.

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.

> 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.

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.

## Sumber

- Dokumentasi PostgreSQL: klausa penguncian SELECT, FOR UPDATE dan SKIP LOCKED
- Wikipedia: Protokol two-phase commit
- Wikipedia: Idempotensi

> **Key takeaway:** 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.

## Source links

- https://www.postgresql.org/docs/current/sql-select.html
- https://en.wikipedia.org/wiki/Two-phase_commit_protocol
- https://en.wikipedia.org/wiki/Idempotence

## FAQ

### Apa itu pola transactional outbox di PostgreSQL?

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.

### Mengapa masalah dual-write terjadi saat mempublikasikan event setelah commit basis data?

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.

### Bagaimana cara mencegah pemrosesan ganda saat menjalankan beberapa pekerja relay outbox?

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.

### Apakah pola outbox dapat menjamin pengiriman event tepat satu kali?

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.

### Bagaimana cara menjaga urutan event tetap benar dengan tabel outbox?

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.

---

- HTML version: https://www.matthewswong.com/id/blog/transactional-outbox-pattern-postgres/
- English: https://www.matthewswong.com/en/blog/transactional-outbox-pattern-postgres/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
