20 Des 2025·6 menit membaca

Pola outbox di PostgreSQL untuk integrasi API yang andal

Pelajari pola outbox untuk menyimpan event di PostgreSQL, lalu mengirimkannya ke API pihak ketiga dengan retry, pengurutan, dan deduplikasi.

Pola outbox di PostgreSQL untuk integrasi API yang andal

Mengapa integrasi bisa gagal meskipun aplikasi Anda bekerja

Sering terlihat aksi "berhasil" di aplikasi Anda sementara integrasi di belakangnya gagal diam-diam. Tulisan database Anda cepat dan andal. Panggilan ke API pihak ketiga tidak selalu demikian. Itu menciptakan dua dunia: sistem Anda mengatakan perubahan terjadi, tetapi sistem eksternal tidak pernah mendengarnya.

Contoh umum: pelanggan membuat pesanan, aplikasi Anda menyimpannya di PostgreSQL, lalu mencoba memberitahu penyedia pengiriman. Jika penyedia timeout selama 20 detik dan permintaan Anda menyerah, pesanan tetap nyata, tetapi pengiriman tidak pernah dibuat.

Pengguna mengalami ini sebagai perilaku yang membingungkan dan tidak konsisten. Event yang hilang tampak seperti "tidak terjadi apa-apa." Event duplikat terlihat seperti "mengapa saya dikenai biaya dua kali?" Tim support juga kesulitan karena sulit menentukan apakah masalah ada di aplikasi Anda, jaringan, atau mitra.

Retry membantu, tetapi retry saja tidak menjamin kebenaran. Jika Anda retry setelah timeout, Anda mungkin mengirim event yang sama dua kali karena tidak tahu apakah mitra menerima permintaan pertama. Jika Anda retry dengan urutan yang salah, Anda mungkin mengirim "Order shipped" sebelum "Order paid."

Masalah ini biasanya muncul dari concurrency normal: banyak worker memproses paralel, banyak server aplikasi menulis pada saat yang sama, dan antrean "best effort" di mana timing berubah saat beban tinggi. Mode kegagalan bersifat terduga: API turun atau lambat, jaringan kehilangan permintaan, proses crash di momen yang salah, dan retry menciptakan duplikat ketika tidak ada yang menegakkan idempotensi.

Pola outbox ada karena kegagalan-kegagalan ini wajar terjadi.

Apa itu pola outbox dalam istilah sederhana

Pola outbox sederhana: saat aplikasi Anda melakukan perubahan penting (mis. membuat pesanan), aplikasi juga menulis sebuah catatan kecil "event untuk dikirim" ke tabel database, dalam transaksi yang sama. Jika commit database berhasil, Anda tahu data bisnis dan catatan event ada bersama.

Setelah itu, worker terpisah membaca tabel outbox dan mengirimkan event-event tersebut ke API pihak ketiga. Jika sebuah API lambat, turun, atau timeout, permintaan pengguna utama tetap berhasil karena tidak menunggu panggilan eksternal.

Ini menghindari kondisi canggung ketika Anda memanggil API di dalam request handler:

  • Pesanan tersimpan, tetapi panggilan API gagal.
  • Panggilan API berhasil, tetapi aplikasi Anda crash sebelum menyimpan pesanan.
  • Pengguna mengulang, dan Anda mengirim hal yang sama dua kali.

Pola outbox terutama membantu dengan event yang hilang, kegagalan parsial (database ok, API eksternal tidak ok), pengiriman ganda yang tidak disengaja, dan retry yang lebih aman (Anda bisa mencoba lagi nanti tanpa menebak).

Ini tidak memperbaiki semuanya. Jika payload salah, aturan bisnis Anda salah, atau API pihak ketiga menolak data, Anda tetap perlu validasi, penanganan error yang baik, dan cara untuk memeriksa serta memperbaiki event yang gagal.

Merancang tabel outbox di PostgreSQL

Tabel outbox yang baik sengaja dibuat sederhana. Harus mudah ditulis, mudah dibaca, dan sulit disalahgunakan.

Berikut skema dasar praktis yang bisa Anda sesuaikan:

create table outbox_events (
  id            bigserial primary key,
  aggregate_id  text not null,
  event_type    text not null,
  payload       jsonb not null,
  status        text not null default 'pending',
  created_at    timestamptz not null default now(),
  available_at  timestamptz not null default now(),
  attempts      int not null default 0,
  locked_at     timestamptz,
  locked_by     text,
  meta          jsonb not null default '{}'::jsonb
);

Memilih ID

Menggunakan bigserial (atau bigint) menjaga pengurutan sederhana dan index cepat. UUID bagus untuk keunikan lintas sistem, tetapi mereka tidak diurutkan berdasarkan waktu pembuatan, yang bisa membuat polling kurang dapat diprediksi dan index lebih berat.

Kompromi umum: pertahankan id sebagai bigint untuk pengurutan, dan tambahkan event_uuid terpisah jika Anda butuh identifier stabil untuk dibagikan antar layanan.

Index yang penting

Worker Anda akan melakukan query pola yang sama sepanjang hari. Sebagian besar sistem membutuhkan:

  • Index seperti (status, available_at, id) untuk mengambil event pending berikutnya secara berurutan.
  • Index pada (locked_at) jika Anda berencana meng-expire lock yang kadaluwarsa.
  • Index seperti (aggregate_id, id) jika Anda kadang mengirim per aggregate secara berurutan.

Jaga payload tetap stabil

Simpan payload kecil dan dapat diprediksi. Simpan apa yang penerima benar-benar butuhkan, bukan seluruh baris Anda. Tambahkan versi eksplisit (mis. di meta) sehingga Anda dapat mengubah field dengan aman.

Gunakan meta untuk routing dan konteks debugging seperti tenant ID, correlation ID, trace ID, dan dedup key. Konteks ekstra itu berguna saat support perlu menjawab "apa yang terjadi pada pesanan ini?"

Cara menyimpan event dengan aman bersama penulisan bisnis Anda

Aturan paling penting sederhana: tulis data bisnis dan event outbox dalam transaksi database yang sama. Jika transaksi commit, keduanya ada. Jika rollback, keduanya tidak ada.

Contoh: pelanggan membuat pesanan. Dalam satu transaksi Anda insert baris pesanan, item pesanan, dan satu baris outbox seperti order.created. Jika satu langkah gagal, Anda tidak ingin event "created" lolos ke dunia.

Satu event atau banyak?

Mulailah dengan satu event per aksi bisnis ketika memungkinkan. Lebih mudah dipahami dan lebih murah untuk diproses. Bagi menjadi beberapa event hanya ketika konsumen yang berbeda benar-benar membutuhkan timing atau payload yang berbeda (mis. order.created untuk fulfillment dan payment.requested untuk billing). Menghasilkan banyak event untuk satu klik meningkatkan retry, masalah pengurutan, dan penanganan duplikasi.

Payload apa yang harus disimpan?

Biasanya Anda memilih antara:

  • Snapshot: simpan field kunci seperti saat aksi terjadi (total pesanan, mata uang, customer ID). Ini menghindari pembacaan tambahan nanti dan menjaga pesan tetap stabil.
  • Reference ID: simpan hanya ID pesanan dan biarkan worker memuat detail nanti. Ini menjaga outbox kecil, tetapi menambah pembacaan dan bisa berubah jika pesanan diedit.

Titik tengah praktis adalah identifier plus snapshot kecil dari nilai krusial. Ini membantu penerima bertindak cepat dan membantu debugging.

Jaga batasan transaksi tetap ketat. Jangan panggil API pihak ketiga di dalam transaksi yang sama.

Mengirim event ke API pihak ketiga: loop worker

Tangani pengurutan dengan cara praktis
Pertahankan urutan event per pelanggan atau pesanan tanpa memperlambat seluruh sistem.
Buat Proyek

Setelah event ada di outbox, Anda butuh worker yang membacanya dan memanggil API pihak ketiga. Bagian ini yang membuat pola menjadi integrasi yang andal.

Polling biasanya opsi paling sederhana. LISTEN/NOTIFY bisa mengurangi latensi, tetapi menambah komponen dan tetap perlu fallback ketika notifikasi terlewat atau worker restart. Untuk kebanyakan tim, polling stabil dengan batch kecil lebih mudah dijalankan dan di-debug.

Klaim baris dengan aman

Worker harus mengklaim baris agar dua worker tidak pernah memproses event yang sama bersamaan. Di PostgreSQL, pendekatan umum adalah memilih batch menggunakan row locks dan SKIP LOCKED, lalu menandainya sebagai sedang diproses.

Alur status praktis:

  • pending: siap dikirim
  • processing: dikunci oleh worker (gunakan locked_by dan locked_at)
  • sent: terkirim berhasil
  • failed: dihentikan setelah percobaan maksimal (atau dipindahkan untuk review manual)

Jaga batch kecil agar tidak membebani database. Batch 10–100 baris, berjalan setiap 1–5 detik, adalah titik awal yang umum.

Saat panggilan berhasil, tandai baris sent. Saat gagal, tambahkan attempts, set available_at ke waktu di masa depan (backoff), hapus lock, dan kembalikan ke pending.

Logging yang membantu (tanpa membocorkan rahasia)

Log yang baik membuat kegagalan bisa ditindaklanjuti. Log id outbox, tipe event, nama tujuan, jumlah percobaan, timing, dan status HTTP atau kelas error. Hindari body request, header auth, dan response penuh. Jika perlu korelasi, simpan request ID yang aman atau hash alih-alih payload mentah.

Aturan pengurutan yang bekerja di sistem nyata

Terapkan pola secara visual
Gunakan business logic visual untuk menulis data dan mengantri event dalam satu transaksi.
Buat Backend

Banyak tim mulai dengan “kirim event sesuai urutan saat kita membuatnya.” Masalahnya “urutan yang sama” jarang bersifat global. Jika Anda memaksa antrean global, satu customer yang lambat atau API yang fluktuatif bisa menahan semua orang.

Aturan praktis: pertahankan urutan per grup, bukan untuk seluruh sistem. Pilih kunci pengelompokan yang sesuai cara dunia luar memandang data Anda, seperti customer_id, account_id, atau aggregate_id seperti order_id. Pastikan pengurutan di dalam setiap grup sambil mengirim banyak grup secara paralel.

Worker paralel tanpa merusak urutan

Jalankan beberapa worker, tetapi pastikan dua worker tidak memproses grup yang sama bersamaan. Pendekatan biasa adalah selalu mengirim event pending paling awal untuk sebuah aggregate_id dan mengizinkan paralelisme antar aggregate berbeda.

Sederhanakan aturan klaim:

  • Hanya kirim event pending paling awal per grup.
  • Izinkan paralelisme antar grup, bukan di dalam grup.
  • Klaim satu event, kirim, update status, lalu lanjut.

Ketika satu event memblokir sisanya

Nantinya akan ada satu event “beracun” yang gagal berjam-jam (payload buruk, token dicabut, outage provider). Jika Anda menegakkan urutan per grup secara ketat, event-event berikut dalam grup itu harus menunggu, tetapi grup lain harus terus berjalan.

Kompromi yang bekerja adalah membatasi retry per event. Setelah itu, tandai failed dan jeda hanya grup itu sampai seseorang memperbaiki akar masalah. Ini menjaga satu pelanggan bermasalah tidak memperlambat semua orang.

Retry tanpa membuatnya lebih buruk

Retry adalah titik di mana setup outbox yang baik menjadi dapat diandalkan atau berisik. Tujuannya sederhana: coba lagi ketika kemungkinan berhasil besar, dan berhenti cepat ketika tidak.

Gunakan exponential backoff dan batas keras. Contoh: 1 menit, 2 menit, 4 menit, 8 menit, lalu berhenti (atau lanjut dengan delay maksimal seperti 15 menit). Selalu tetapkan jumlah percobaan maksimal agar satu event buruk tidak menyumbat sistem selamanya.

Tidak setiap kegagalan harus di-retry. Tetapkan aturan jelas:

  • Retry: timeout jaringan, connection reset, DNS hiccups, dan HTTP 429 atau 5xx.
  • Jangan retry: HTTP 400 (bad request), 401/403 (masalah otentikasi), 404 (endpoint salah), atau error validasi yang bisa dideteksi sebelum mengirim.

Simpan state retry di baris outbox. Tambahkan attempts, set available_at untuk percobaan berikutnya, dan rekam ringkasan error yang singkat dan aman (kode status, kelas error, pesan yang dipangkas). Jangan simpan payload lengkap atau data sensitif di field error.

Rate limit butuh perlakuan khusus. Jika menerima HTTP 429, hormati Retry-After bila tersedia. Jika tidak, backoff lebih agresif untuk menghindari retry storm.

Dasar-dasar deduplikasi dan idempotensi

Pertahankan jalan keluar ke kode sumber
Ekspor Go, Vue3, dan Kotlin atau SwiftUI nyata ketika Anda butuh kendali penuh.
Hasilkan Kode

Jika Anda membangun integrasi API yang andal, asumsikan event yang sama bisa dikirim dua kali. Worker bisa crash setelah pemanggilan HTTP tapi sebelum mencatat sukses. Timeout bisa menyembunyikan keberhasilan. Retry bisa tumpang tindih dengan percobaan pertama yang lambat. Pola outbox mengurangi event yang hilang, tetapi tidak mencegah duplikasi sendiri.

Pendekatan paling aman adalah idempotensi: pengiriman berulang menghasilkan hasil yang sama seperti satu pengiriman. Saat memanggil API pihak ketiga, sertakan idempotency key yang stabil untuk event dan tujuan tersebut. Banyak API mendukung header; jika tidak, letakkan key di body request.

Key sederhana adalah tujuan plus event ID. Untuk event dengan ID evt_123, selalu gunakan sesuatu seperti destA:evt_123.

Di sisi Anda, cegah pengiriman ganda dengan menyimpan log pengiriman keluar dan menerapkan aturan unik seperti (destination, event_id). Bahkan jika dua worker berkompetisi, hanya satu yang bisa membuat catatan "kami sedang mengirim ini."

Webhook juga bisa duplikat

Jika Anda menerima callback webhook (mis. "delivery confirmed" atau "status updated"), perlakukan sama. Provider melakukan retry, dan Anda bisa melihat payload yang sama berkali-kali. Simpan ID webhook yang sudah diproses, atau hitung hash stabil dari message ID provider dan tolak yang duplikat.

Berapa lama menyimpan data

Simpan baris outbox sampai Anda mencatat sukses (atau kegagalan final yang Anda terima). Simpan log pengiriman lebih lama, karena itu jejak audit saat seseorang bertanya, "Sudahkah kita mengirim ini?"

Pendekatan umum:

  • Baris outbox: hapus atau arsip setelah sukses plus jendela keamanan singkat (hari).
  • Log pengiriman: simpan ber-minggu atau ber-bulan, berdasarkan kebutuhan kepatuhan dan support.
  • Idempotency key: simpan setidaknya selama kemungkinan retry (dan lebih lama untuk duplikasi webhook).

Langkah demi langkah: mengimplementasikan pola outbox

Putuskan apa yang akan Anda publikasikan. Jaga event kecil, terfokus, dan mudah diputar ulang. Aturan yang baik: satu fakta bisnis per event, dengan data cukup agar penerima bisa bertindak.

Bangun dasar

Pilih nama event yang jelas (mis. order.created, order.paid) dan versi schema payload Anda (mis. v1, v2). Versioning memungkinkan Anda menambah field nanti tanpa merusak konsumen lama.

Buat tabel outbox PostgreSQL dan tambahkan index untuk query yang sering worker jalankan, terutama (status, available_at, id).

Perbarui alur penulisan Anda agar perubahan bisnis dan insert outbox terjadi dalam satu transaksi database. Itu jaminan inti.

Tambah pengiriman dan kontrol

Rencana implementasi sederhana:

  • Definisikan tipe event dan versi payload yang bisa Anda dukung jangka panjang.
  • Buat tabel outbox dan index.
  • Sisipkan baris outbox bersamaan dengan perubahan data utama.
  • Bangun worker yang mengklaim baris, mengirim ke API pihak ketiga, lalu mengubah status.
  • Tambahkan penjadwalan retry dengan backoff dan state failed saat percobaan habis.

Tambahkan metrik dasar agar Anda mendeteksi masalah lebih awal: lag (usia event unsent tertua), laju pengiriman, dan tingkat kegagalan.

Contoh sederhana: mengirim event pesanan ke layanan eksternal

Berikan visibilitas yang jelas untuk support
Tambahkan auth dan buat admin tool untuk meninjau event yang pending, terkirim, dan gagal.
Mulai Sekarang

Pelanggan membuat pesanan di aplikasi Anda. Dua hal harus terjadi di luar sistem Anda: penyedia billing harus menagih kartu, dan penyedia pengiriman harus membuat pengiriman.

Dengan pola outbox, Anda tidak memanggil API tersebut di dalam request checkout. Sebaliknya, Anda menyimpan pesanan dan satu event outbox dalam transaksi PostgreSQL yang sama, sehingga Anda tidak pernah berakhir dengan "pesanan tersimpan, tapi notifikasi tidak ada" (atau sebaliknya).

Baris outbox tipikal untuk event pesanan mungkin mencakup aggregate_id (ID pesanan), event_type seperti order.created, dan payload JSONB dengan total, item, dan detail tujuan.

Worker kemudian mengambil baris pending dan memanggil layanan eksternal (baik dalam urutan yang ditentukan atau dengan memancarkan event terpisah seperti payment.requested dan shipment.requested). Jika satu provider turun, worker mencatat percobaan, menjadwalkan coba lagi dengan memajukan available_at, dan melanjutkan. Pesanan tetap ada, dan event akan dicoba lagi nanti tanpa memblokir checkout baru.

Pengurutan biasanya "per pesanan" atau "per pelanggan." Tegakkan bahwa event dengan aggregate_id sama diproses satu per satu sehingga order.paid tidak pernah tiba sebelum order.created.

Deduplikasi menjaga Anda dari penagihan dua kali atau pembuatan dua pengiriman. Kirim idempotency key ketika pihak ketiga mendukungnya, dan simpan catatan pengiriman tujuan sehingga retry setelah timeout tidak memicu aksi kedua.

Pemeriksaan cepat sebelum Anda rilis

Ubah workflow Anda jadi perangkat lunak
Hasilkan backend, UI web, dan aplikasi mobile dari satu proyek dengan kode sumber bersih.
Bangun Aplikasi

Sebelum Anda mempercayakan integrasi untuk memindahkan uang, memberi tahu pelanggan, atau menyinkronkan data, uji edge case: crash, retry, duplikasi, dan banyak worker.

Pemeriksaan yang menangkap kegagalan umum:

  • Pastikan baris outbox dibuat dalam transaksi yang sama dengan perubahan bisnis.
  • Verifikasi sender aman dijalankan di banyak instance. Dua worker tidak boleh mengirim event yang sama bersamaan.
  • Jika pengurutan penting, definisikan aturan dalam satu kalimat dan tegakkan dengan kunci stabil.
  • Untuk setiap tujuan, tentukan bagaimana mencegah duplikasi dan bagaimana membuktikan "kita sudah mengirim."
  • Definisikan exit: setelah N percobaan, pindahkan event ke failed, simpan ringkasan error terakhir, dan sediakan aksi reprocess sederhana.

Reality check: Stripe mungkin menerima request tetapi worker Anda crash sebelum menyimpan sukses. Tanpa idempotensi, retry bisa menyebabkan aksi ganda. Dengan idempotensi dan catatan pengiriman tersimpan, retry menjadi aman.

Langkah berikutnya: roll out tanpa mengganggu aplikasi Anda

Rollout adalah titik di mana proyek outbox biasanya berhasil atau terhenti. Mulailah kecil sehingga Anda melihat perilaku nyata tanpa mempertaruhkan seluruh lapisan integrasi.

Mulai dengan satu integrasi dan satu tipe event. Misalnya, hanya kirim order.created ke satu vendor API sementara semuanya lain tetap seperti sebelumnya. Itu memberi Anda baseline bersih untuk throughput, latensi, dan tingkat kegagalan.

Buat masalah terlihat lebih awal. Tambahkan dashboard dan alert untuk outbox lag (berapa banyak event menunggu, dan seberapa tua event tertua) dan tingkat kegagalan (berapa banyak yang terjebak di retry). Jika Anda bisa menjawab "apakah kita tertinggal sekarang?" dalam 10 detik, Anda akan menangkap masalah sebelum pengguna melakukannya.

Miliki rencana reprocess aman sebelum insiden pertama. Tentukan apa yang dimaksud dengan “reprocess”: retry payload yang sama, membangun ulang payload dari data saat ini, atau mengirim untuk review manual. Dokumentasikan kasus mana yang aman untuk dikirim ulang dan mana yang perlu pemeriksaan manusia.

Jika Anda membangun ini dengan platform no-code seperti AppMaster (appmaster.io), struktur yang sama tetap berlaku: tulis data bisnis dan baris outbox bersama di PostgreSQL, lalu jalankan proses backend terpisah untuk mengirim, retry, dan menandai event sebagai terkirim atau gagal.

FAQ

When should I use the outbox pattern instead of calling the API directly?

Gunakan pola outbox saat aksi pengguna memperbarui database Anda dan harus memicu pekerjaan di sistem lain. Ini paling berguna ketika timeout, jaringan yang tidak stabil, atau outage pihak ketiga bisa menyebabkan kondisi “tersimpan di aplikasi kami, tapi tidak di pihak mereka”.

Why does the outbox insert need to be in the same transaction as the business write?

Menulis baris bisnis dan baris outbox dalam satu transaksi database memberi Anda jaminan tunggal: keduanya ada bersama atau keduanya tidak ada. Ini mencegah kegagalan parsial seperti “panggilan API berhasil tapi pesanan tidak tersimpan” atau “pesanan tersimpan tapi panggilan API tidak terjadi”.

What fields should an outbox table include to be practical?

Default yang baik adalah id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, plus field lock seperti locked_at dan locked_by. Ini menyederhanakan pengiriman, penjadwalan retry, dan concurrency tanpa membuat tabel terlalu rumit.

What indexes matter most for an outbox table in PostgreSQL?

Baseline umum adalah index pada (status, available_at, id) sehingga worker dapat cepat mengambil batch event yang siap dikirim secara berurutan. Tambah index lain hanya ketika Anda benar-benar query berdasarkan field tersebut, karena index ekstra memperlambat insert.

Should my worker poll the outbox table or use LISTEN/NOTIFY?

Polling adalah cara paling sederhana dan paling dapat diprediksi untuk kebanyakan tim. Mulai dengan batch kecil dan interval singkat, lalu tuning berdasarkan beban dan lag; loop sederhana lebih mudah di-debug ketika terjadi masalah.

How do I prevent two workers from sending the same outbox event?

Klaim baris menggunakan lock tingkat baris sehingga dua worker tidak memproses event yang sama secara bersamaan, biasanya dengan SKIP LOCKED. Setelah itu tandai baris sebagai processing dengan timestamp lock dan worker ID, kirim, lalu tandai sent atau kembalikan ke pending dengan available_at di masa depan.

What’s the safest retry strategy for outbox deliveries?

Gunakan exponential backoff dengan batas maksimal percobaan, dan retry hanya untuk kegagalan yang kemungkinan bersifat sementara. Timeout, kesalahan jaringan, dan HTTP 429/5xx adalah kandidat retry yang layak; kesalahan validasi dan kebanyakan 4xx dianggap final sampai data atau konfigurasi diperbaiki.

Does the outbox pattern guarantee exactly-once delivery?

Jangan menganggap pola outbox menjamin exactly-once. Duplikasi masih mungkin—misalnya worker crash setelah panggilan HTTP tapi sebelum mencatat sukses. Gunakan idempotency key yang stabil per tujuan dan per event, dan simpan catatan pengiriman dengan constraint unik sehingga worker yang berkompetisi tidak dapat membuat dua pengiriman.

How do I handle ordering without slowing down the whole system?

Pertahankan urutan dalam satu grup, bukan secara global. Gunakan kunci pengelompokan seperti aggregate_id (ID pesanan) atau customer_id, proses hanya satu event pada satu waktu per grup, dan izinkan paralelisme di antara grup yang berbeda sehingga satu pelanggan yang lambat tidak memblokir semua orang.

What should I do with a “poison” event that keeps failing?

Setelah jumlah percobaan maksimal tercapai, tandai sebagai failed, simpan ringkasan error yang singkat dan aman, dan hentikan pemrosesan event-event berikutnya untuk grup yang sama sampai penyebab utama diperbaiki. Ini membatasi dampak dan mencegah retry tak berujung sekaligus menjaga grup lain tetap berjalan.

Mudah untuk memulai
Ciptakan sesuatu yang menakjubkan

Eksperimen dengan AppMaster dengan paket gratis.
Saat Anda siap, Anda dapat memilih langganan yang tepat.

Memulai