Kegagalan pengiriman task ke antrean (message broker) sering terjadi akibat pemisahan mutasi database dan pemanggilan API broker. Jika database melakukan COMMIT namun jaringan ke broker terputus sebelum publikasi pesan tuntas, task hilang secara permanen (lost task). Sebaliknya, jika pesan terkirim tetapi transaksi database gagal melakukan ROLLBACK, worker akan memproses data yang tidak valid. Kondisi ini dikenal sebagai dual-write problem.
Mengadopsi filosofi pencatatan kerja deterministik seperti gagasan a place for everything, sistem backend membutuhkan satu sumber kebenaran tunggal untuk setiap siklus hidup instruksi. Pola Database Outbox mengatasi tantangan reliabilitas ini dengan menjadikan database transaksional sebagai tempat terpusat untuk menyimpan entitas bisnis sekaligus status pesan yang harus dikirimkan.
Akar Masalah: Dual-Write dan Silent Failure
Pola naive yang umum ditemukan pada aplikasi backend:
BEGIN TRANSACTION;
INSERT INTO orders (id, user_id, amount) VALUES (101, 1, 50000);
-- Masalah: Jika baris berikut berhasil, tapi commit gagal, pesan phantom terkirim.
-- Sebaliknya, jika commit berhasil tapi broker down, task hilang.
Queue::publish('order_created', {'order_id': 101});
COMMIT;Dua sistem terdistribusi (RDBMS dan Message Broker) tidak berbagi konteks transaksi atomik tanpa protokol two-phase commit (2PC) yang lambat dan rapuh. Solusinya adalah menghapus penulisan langsung ke broker dari siklus transaksi bisnis utama.
Arsitektur Database Outbox
Pada pola Outbox, tabel tambahan bernama outbox_tasks ditambahkan ke dalam database yang sama dengan entitas bisnis. Setiap mutasi data menyertakan pencatatan payload antrean dalam transaksi ACID lokal yang sama.
1. Desain Skema Database
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TYPE outbox_status AS ENUM ('PENDING', 'PROCESSING', 'PUBLISHED', 'FAILED');
CREATE TABLE outbox_tasks (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
payload JSONB NOT NULL,
status outbox_status DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
max_retries INT DEFAULT 5,
last_error TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_outbox_fetch ON outbox_tasks (status, created_at)
WHERE status IN ('PENDING', 'PROCESSING');2. Penulisan Transaksional Tunggal
BEGIN;
INSERT INTO orders (id, user_id, amount)
VALUES (101, 1, 50000);
INSERT INTO outbox_tasks (id, aggregate_type, aggregate_id, payload)
VALUES (
gen_random_uuid(),
'Order',
'101',
'{"event": "OrderCreated", "order_id": 101, "amount": 50000}'::jsonb
);
COMMIT;Status transaksi kini bersifat biner: pesanan dan pencatatan task antrean berhasil secara bersamaan atau dibatalkan seutuhnya.
Mencegah Race Condition via SKIP LOCKED
Komponen Relay Worker bertugas membaca tabel outbox secara berkala lalu mempublikasikan data ke broker (seperti RabbitMQ, Kafka, atau Redis Streams). Saat menjalankan multi-instance publisher, pembacaan rentan terhadap race condition di mana dua worker mengambil task yang sama.
Gunakan klausa FOR UPDATE SKIP LOCKED untuk memastikan konkurensi aman tanpa memblokir worker lain:
-- Mengambil batch task tanpa lock contention
WITH claimable_tasks AS (
SELECT id
FROM outbox_tasks
WHERE status = 'PENDING'
ORDER BY created_at ASC
LIMIT 50
FOR UPDATE SKIP LOCKED
)
UPDATE outbox_tasks
SET status = 'PROCESSING', updated_at = NOW()
FROM claimable_tasks
WHERE outbox_tasks.id = claimable_tasks.id
RETURNING outbox_tasks.*;SKIP LOCKED mengabaikan baris yang sedang dikunci oleh worker lain, sehingga throughput pemrosesan paralel tetap tinggi tanpa terjadi deadlocking pada tabel database.
Idempotent Consumer
Pola Outbox menjamin pengiriman bertipe at-least-once (minimal satu kali). Koneksi jaringan yang putus setelah broker menerima pesan tetapi sebelum status outbox berubah menjadi PUBLISHED dapat menyebabkan pengiriman ulang.
Sisi konsumen antrean wajib mengimplementasikan idempodensi menggunakan tabel deduplikasi:
BEGIN;
-- Cek apakah event sudah pernah diproses
INSERT INTO processed_events (event_id, processed_at)
VALUES ('task-uuid-dari-outbox', NOW())
ON CONFLICT (event_id) DO NOTHING;
-- Jika baris gagal masuk, batalkan eksekusi logika bisnis
-- (Gunakan pengecekan affected rows pada level kode aplikasi)
COMMIT;Dead-Letter Handling dan Crash Recovery
Jika proses publisher mati mendadak saat memegang status PROCESSING, task akan tertahan tanpa batas waktu. Perlu mekanisme rekonsiliasi berkala untuk memulihkan task yang menggantung atau menandainya sebagai dead-letter jika melampaui batas percobaan.
Query Rekonsiliasi (Sweep Engine)
-- 1. Mengembalikan task macet (misal: timeout 5 menit) ke antrean
UPDATE outbox_tasks
SET status = 'PENDING',
retry_count = retry_count + 1,
updated_at = NOW()
WHERE status = 'PROCESSING'
AND updated_at < NOW() - INTERVAL '5 minutes'
AND retry_count < max_retries;
-- 2. Memindahkan task fatal ke Dead-Letter / FAILED
UPDATE outbox_tasks
SET status = 'FAILED',
last_error = 'Max retries exceeded without ACK',
updated_at = NOW()
WHERE status = 'PROCESSING'
AND updated_at < NOW() - INTERVAL '5 minutes'
AND retry_count >= max_retries;Ringkasan Trade-Off
Pola Database Outbox menukar beban I/O database dan latensi minimal (karena proses polling atau CDC) dengan jaminan reliabilitas mutlak terhadap integritas status antrean data. Pola ini mengeliminasi data inconsistency dan memastikan tidak ada task yang hilang di tengah jalan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!