Outbox Pattern di Spring Boot dipakai ketika satu perubahan data harus diikuti oleh publish event ke message broker dan update atau invalidasi cache, tetapi Anda tidak ingin mengambil risiko dual write. Solusinya adalah menyimpan perubahan data bisnis dan catatan event outbox dalam satu transaksi database, lalu proses terpisah akan membaca outbox dan mengirimkan event ke broker secara andal.
Dengan pendekatan ini, sistem tidak bergantung pada asumsi berbahaya seperti “insert ke database berhasil lalu publish ke broker pasti ikut berhasil”. Artikel ini fokus pada implementasi praktis dengan Spring Boot + PostgreSQL + message broker + Redis, termasuk desain tabel outbox, polling publisher, consumer idempoten, strategi retry, batasan ordering, dan cara menjaga cache tetap konsisten walau worker gagal atau pesan terduplikasi.
Mengapa masalah ini sering muncul di produksi
Kasus umumnya sederhana: endpoint menerima request, mengubah status order di PostgreSQL, lalu aplikasi perlu mengirim event OrderUpdated agar worker lain mengirim email, memperbarui denormalized read model, atau menghapus cache Redis. Masalah muncul ketika operasi tersebut dipisah menjadi dua langkah independen:
- Commit transaksi database.
- Publish event ke broker.
Jika langkah pertama berhasil tetapi langkah kedua gagal, data di database sudah berubah namun worker lain tidak pernah tahu. Sebaliknya, jika event terpublish tetapi transaksi database gagal atau rollback, consumer menerima event untuk data yang tidak pernah benar-benar valid.
Itulah akar dual write problem. Dalam sistem async, efek turunannya biasanya:
- Event hilang setelah commit database.
- Duplikasi pesan karena retry producer atau broker.
- Worker memproses lebih dari sekali.
- Cache stale karena invalidasi tidak jalan atau tertunda.
- Ordering terbatas saat beberapa update terjadi cepat pada entitas yang sama.
Arsitektur Outbox Pattern di Spring Boot
Arsitektur praktisnya terdiri dari empat bagian:
- Aplikasi write: menyimpan perubahan bisnis dan record outbox dalam satu transaksi PostgreSQL.
- Outbox publisher: proses terjadwal atau worker internal yang membaca baris outbox yang belum terkirim, lalu publish ke broker.
- Async consumer/worker: menerima event, menjalankan efek samping, dan bersifat idempoten.
- Redis cache: diinvalidasi atau diperbarui berdasarkan event yang sudah diproses dengan aman.
Alurnya seperti ini:
- Request masuk ke service Spring Boot.
- Service mengubah tabel bisnis, misalnya orders.
- Dalam transaksi yang sama, service menambah satu row ke tabel outbox_events.
- Transaksi commit.
- Publisher membaca outbox yang statusnya PENDING.
- Publisher kirim event ke broker.
- Jika publish berhasil, row outbox ditandai PUBLISHED.
- Consumer memproses event, lalu invalidasi atau update cache Redis.
Kunci utamanya: database bisnis dan outbox harus berada dalam transaksi lokal yang sama. Broker tidak ikut dalam transaksi tersebut.
Desain tabel outbox yang realistis
Jangan buat tabel outbox terlalu minimal. Di produksi, Anda butuh kolom untuk retry, audit, dan diagnosis kegagalan.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(200) NOT NULL,
payload JSONB NOT NULL,
headers JSONB,
status VARCHAR(50) NOT NULL,
retry_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP,
last_error TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_outbox_status_next_attempt
ON outbox_events(status, next_attempt_at, created_at);
CREATE INDEX idx_outbox_aggregate
ON outbox_events(aggregate_type, aggregate_id, created_at);Arti kolom yang penting
- id: event ID unik untuk deduplikasi consumer.
- aggregate_type dan aggregate_id: membantu partitioning, ordering terbatas, dan debugging.
- event_type: misalnya OrderPaid, OrderCancelled.
- payload: data yang dibutuhkan consumer. Simpan secukupnya, jangan seluruh object graph.
- status: misalnya PENDING, PUBLISHED, FAILED.
- retry_count, next_attempt_at, last_error: penting untuk retry terkontrol.
Praktik yang sering membantu adalah menyimpan juga event version di payload atau header agar kontrak event bisa berevolusi tanpa merusak consumer lama.
Menulis data bisnis dan outbox dalam satu transaksi
Di Spring Boot, implementasi paling sederhana adalah menulis entitas bisnis dan entitas outbox di dalam method service yang sama dan diberi @Transactional. Ini inti dari pola tersebut.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxEventRepository;
public OrderService(OrderRepository orderRepository,
OutboxEventRepository outboxEventRepository) {
this.orderRepository = orderRepository;
this.outboxEventRepository = outboxEventRepository;
}
@Transactional
public void markOrderPaid(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
order.markPaid();
orderRepository.save(order);
OutboxEvent event = OutboxEvent.pending(
"Order",
order.getId(),
"OrderPaid",
"""
{
"eventId": "%s",
"orderId": "%s",
"status": "PAID"
}
""".formatted(java.util.UUID.randomUUID(), order.getId())
);
outboxEventRepository.save(event);
}
}Mengapa ini bekerja? Karena jika transaksi rollback, perubahan order dan row outbox sama-sama batal. Jika commit berhasil, keduanya sama-sama tersimpan. Anda tidak lagi punya kondisi di mana data berubah tetapi event tidak memiliki jejak yang bisa dipublish kemudian.
Kesalahan umum saat tahap write
- Publish langsung ke broker dari dalam method transaksi. Ini mengembalikan dual write dengan bentuk lain.
- Membuat payload event dari state lama. Pastikan payload dibangun dari state final yang akan di-commit.
- Mengisi payload terlalu besar. Event bukan dump seluruh tabel.
- Tidak menyertakan event ID. Padahal itu krusial untuk idempotensi di sisi consumer.
Publisher outbox: polling yang sederhana tapi efektif
Untuk banyak sistem, polling tabel outbox dari Spring Boot sudah cukup baik dan jauh lebih mudah dioperasikan dibanding solusi yang lebih kompleks. Publisher cukup mengambil batch event berstatus PENDING yang sudah jatuh tempo, mengirimkannya ke broker, lalu menandai sukses atau menjadwalkan retry.
@Component
public class OutboxPublisher {
private final OutboxEventRepository outboxEventRepository;
private final MessageBrokerClient messageBrokerClient;
public OutboxPublisher(OutboxEventRepository outboxEventRepository,
MessageBrokerClient messageBrokerClient) {
this.outboxEventRepository = outboxEventRepository;
this.messageBrokerClient = messageBrokerClient;
}
@Scheduled(fixedDelayString = "${outbox.publisher.delay-ms:1000}")
public void publishPendingEvents() {
var events = outboxEventRepository.lockNextBatchForPublish(100);
for (OutboxEvent event : events) {
try {
messageBrokerClient.publish(
event.getEventType(),
event.getPayload(),
event.getId().toString()
);
event.markPublished();
} catch (Exception ex) {
event.scheduleRetry(ex.getMessage());
}
outboxEventRepository.save(event);
}
}
}Apa yang perlu diperhatikan pada polling
- Batching: ambil event per batch agar beban database dan broker terkontrol.
- Row locking: jika publisher berjalan di beberapa instance, gunakan mekanisme penguncian baris seperti SELECT ... FOR UPDATE SKIP LOCKED agar event yang sama tidak diproses bersamaan oleh banyak publisher.
- Retry backoff: jangan retry ketat setiap detik untuk error yang sama. Tambah jeda bertahap melalui next_attempt_at.
- Status transisi yang jelas: minimal bedakan event yang siap dipublish, sedang dicoba, berhasil, dan gagal permanen jika Anda memakai batas retry.
Polling memang menambah latensi kecil dibanding publish langsung, tetapi sebagai gantinya Anda mendapat reliabilitas yang jauh lebih baik dan proses recovery yang jelas.
Contoh query pengambilan batch
SELECT *
FROM outbox_events
WHERE status = 'PENDING'
AND next_attempt_at <= NOW()
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 100;Query seperti ini berguna saat banyak instance publisher aktif. Masing-masing instance mengambil batch berbeda tanpa saling menunggu terlalu lama.
Consumer idempoten: asumsi dasarnya pesan bisa datang lebih dari sekali
Outbox mengurangi risiko event hilang, tetapi tidak otomatis menjamin pesan hanya diproses sekali. Dalam praktik, broker, publisher retry, crash setelah publish namun sebelum update status, atau redelivery dari broker dapat menyebabkan event yang sama muncul lebih dari sekali.
Karena itu, consumer harus idempoten. Artinya, memproses event yang sama dua kali tidak boleh menimbulkan efek samping ganda.
Pola implementasi idempotensi
Pendekatan yang umum adalah menyimpan jejak event yang sudah diproses dalam tabel terpisah.
CREATE TABLE processed_messages (
consumer_name VARCHAR(150) NOT NULL,
event_id UUID NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (consumer_name, event_id)
);@Service
public class OrderProjectionConsumer {
private final ProcessedMessageRepository processedMessageRepository;
private final OrderProjectionRepository orderProjectionRepository;
private final RedisTemplate<String, String> redisTemplate;
@Transactional
public void handle(OrderPaidEvent event) {
boolean alreadyProcessed = processedMessageRepository
.existsByConsumerNameAndEventId("order-projection", event.eventId());
if (alreadyProcessed) {
return;
}
orderProjectionRepository.upsertPaidStatus(event.orderId(), "PAID");
redisTemplate.delete("order:" + event.orderId());
processedMessageRepository.save(
new ProcessedMessage("order-projection", event.eventId())
);
}
}Jika consumer crash setelah update projection tetapi sebelum menulis processed_messages, broker bisa mengirim ulang event dan ada risiko pemrosesan ganda. Karena itu, operasi efek samping dan pencatatan deduplikasi idealnya berada dalam satu transaksi bila targetnya sama-sama berada di database yang mendukung transaksi lokal.
Untuk Redis, karena tidak satu transaksi dengan PostgreSQL, pendekatan paling aman adalah memilih operasi cache yang memang toleran terhadap duplikasi, misalnya delete key atau set value final, bukan operasi inkremental yang bergantung pada sekali eksekusi.
Menjaga cache Redis tetap konsisten
Masalah cache stale sering lebih mengganggu daripada event hilang, karena gejalanya samar: data di database benar, tetapi user masih melihat nilai lama. Dengan Outbox Pattern, strategi cache sebaiknya mengikuti event yang sudah committed, bukan perubahan yang belum tentu berhasil.
Pilih invalidasi daripada update kompleks bila memungkinkan
Dalam banyak kasus, strategi terbaik adalah cache invalidation. Setelah consumer berhasil memproses event, hapus key Redis terkait. Request berikutnya akan mengambil data terbaru dari database lalu mengisi cache lagi.
- Lebih sederhana.
- Lebih aman terhadap duplikasi.
- Mengurangi risiko logika cache berbeda dari logika database.
Contoh key:
order:{orderId}
customer-orders:{customerId}Kapan perlu update cache langsung
Update cache langsung masuk akal jika biaya cache miss sangat mahal atau ada read path yang sangat sensitif terhadap latency. Namun ini lebih sulit karena:
- Anda harus menjaga format cache konsisten dengan model baca.
- Consumer perlu tahu versi data final yang benar.
- Duplikasi event bisa membuat update berulang.
Jika memilih update langsung, gunakan payload event yang cukup untuk membangun state cache final, bukan delta yang rentan rusak bila ada event yang terlambat atau hilang sementara.
Cache stale tetap bisa terjadi, jadi desain untuk eventual consistency
Outbox bukan transaksi terdistribusi. Artinya tetap ada jeda antara commit database dan saat worker menghapus atau memperbarui cache. Selama jeda itu, pembaca mungkin masih mendapatkan data lama dari Redis.
Mitigasinya:
- TTL yang masuk akal untuk membatasi umur cache stale.
- Invalidate key yang tepat, termasuk key turunan seperti daftar atau summary.
- Versi atau timestamp pada value cache jika pembaca perlu mendeteksi data lawas.
- Read model yang menerima eventual consistency pada fitur non-kritis.
Retry, failure, dan recovery saat worker gagal
Masalah operasional nyata biasanya bukan saat semua sehat, tetapi saat broker melambat, Redis timeout, atau worker crash di tengah proses.
Retry pada publisher outbox
- Gunakan retry dengan backoff, bukan loop agresif.
- Simpan last_error agar operator tahu akar masalah.
- Tentukan batas retry atau status FAILED untuk kasus yang membutuhkan intervensi manual.
Jika publisher berhasil mengirim ke broker tetapi crash sebelum menandai row sebagai PUBLISHED, event dapat dipublish lagi setelah restart. Ini normal. Itulah mengapa consumer idempoten wajib.
Retry pada consumer
Consumer sebaiknya membedakan error yang bisa dicoba ulang dan yang tidak:
- Transient: timeout database, koneksi Redis putus, broker hiccup. Layak retry.
- Permanent: payload invalid, event version tidak didukung, data referensi tidak ada. Biasanya masuk dead-letter atau butuh investigasi.
Jangan menelan exception tanpa jejak. Jika consumer gagal tetapi offset/ack sudah diberikan terlalu cepat, pesan bisa hilang diam-diam.
Recovery operasional yang perlu disiapkan
- Job re-drive untuk mengubah event outbox gagal kembali menjadi pending setelah masalah eksternal selesai.
- Dashboard DLQ atau antrean gagal di broker.
- Playbook insiden: cek backlog outbox, error publisher, lag consumer, dan key cache yang terdampak.
Ordering: jangan mengasumsikan urutan global
Banyak tim baru sadar soal ordering setelah data read model tampak “mundur”. Pada sistem async, urutan global antar semua event hampir selalu mahal atau tidak realistis. Yang biasanya bisa diupayakan adalah ordering per aggregate, misalnya per orderId.
Strategi yang masuk akal
- Gunakan aggregate_id sebagai message key agar broker menempatkan event entitas yang sama ke partisi yang konsisten, jika broker mendukung konsep itu.
- Simpan version atau sequence number pada event sehingga consumer bisa mendeteksi event lama datang belakangan.
- Desain consumer agar operasi bersifat upsert state final, bukan hanya menerapkan delta yang bergantung pada urutan sempurna.
Jika urutan benar-benar kritis dan efek samping tidak boleh tertukar, Anda perlu desain yang lebih ketat di level domain dan partisi, bukan hanya mengandalkan outbox.
Observability yang wajib ada
Outbox Pattern tanpa observability sering terasa “aman di desain, gelap di operasi”. Minimal, sediakan metrik dan log berikut:
Metrik penting
- Jumlah outbox pending.
- Usia event pending tertua.
- Publish success/failure rate.
- Retry count distribution.
- Consumer lag dan jumlah pesan dead-letter.
- Cache invalidation failures.
Logging dan tracing
- Sertakan event_id, aggregate_id, dan correlation_id di log.
- Trace request dari API write sampai publisher dan consumer bila stack observability Anda mendukung distributed tracing.
- Log error dengan konteks yang cukup, tetapi hindari membuang payload sensitif mentah ke log.
Dengan ini, saat ada laporan “status order sudah berubah tapi tampilan belum ikut berubah”, Anda bisa cepat menjawab apakah masalahnya ada di outbox backlog, broker, worker, atau invalidasi cache Redis.
Trade-off dan kapan pola ini tepat
Outbox Pattern sangat cocok jika:
- Satu transaksi database harus memicu proses async lain secara andal.
- Anda ingin menghindari distributed transaction.
- Anda menerima eventual consistency pada worker dan cache.
- Sistem memiliki kebutuhan audit dan recovery yang jelas.
Pola ini mungkin berlebihan jika:
- Aplikasi sangat sederhana dan tidak memakai async worker.
- Efek samping tidak kritis dan boleh hilang.
- Semua operasi masih bisa ditangani sinkron dalam satu boundary kecil tanpa broker.
Biaya yang harus diterima
- Kompleksitas operasional bertambah: tabel outbox, publisher, retry, monitoring.
- Latensi end-to-end meningkat karena ada tahap polling/publish/consume.
- Storage bertambah dan perlu strategi pembersihan data outbox lama.
- Anda tetap harus menangani duplikasi dan eventual consistency.
Checklist implementasi produksi
- Simpan data bisnis dan row outbox dalam satu transaksi.
- Pastikan setiap event punya event ID unik.
- Gunakan tabel outbox dengan kolom status, retry_count, next_attempt_at, last_error.
- Publisher mengambil batch dengan locking yang aman untuk multi-instance.
- Terapkan retry dengan backoff, bukan retry agresif.
- Asumsikan publish bisa duplikat; buat consumer idempoten.
- Simpan jejak processed_messages atau mekanisme deduplikasi setara.
- Untuk cache Redis, utamakan invalidate/delete key daripada update kompleks bila memungkinkan.
- Siapkan metrik backlog outbox, error rate, dan consumer lag.
- Buat proses cleanup/archiving untuk row outbox yang sudah selesai.
- Uji skenario gagal: broker down, Redis timeout, crash setelah publish, dan redelivery consumer.
- Dokumentasikan playbook recovery dan aturan re-drive event gagal.
Penutup
Di Spring Boot, Outbox Pattern adalah cara praktis untuk menjaga konsistensi antara write ke PostgreSQL, publish event ke broker, worker async, dan cache Redis tanpa mengandalkan asumsi rapuh. Pola ini tidak menghilangkan semua masalah distribusi, tetapi memindahkan kegagalan ke bentuk yang lebih dapat diamati, di-retry, dan dipulihkan.
Jika kebutuhan Anda melibatkan event yang tidak boleh hilang, worker yang harus aman terhadap duplikasi, dan cache yang sering stale setelah update data, maka Outbox Pattern biasanya bukan sekadar pola arsitektur yang bagus di atas kertas, melainkan kontrol operasional yang memang dibutuhkan di produksi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!