Outbox Pattern ile Güvenilir Olay Yayınlama (Spring, Kafka)
Veritabanına yazıp Kafka'ya mesaj atmak iki ayrı işlemdir ve ikisi arasında bir çökme, sistemleri tutarsız bırakır. Outbox pattern bu dual-write problemini tek transaction'la çözer. Spring Boot'ta polling relay, Debezium ile CDC, sıralama ve idempotency garantileri ve operasyon pratiğini kod örnekleriyle anlatıyoruz.
Bir sipariş kaydedildiğinde stok, fatura ve bildirim servislerinin haberdar olması gerekir; bunun standart yolu bir olay yayınlamaktır. Sorun şurada başlar: veritabanına yazmak ve Kafka'ya mesaj göndermek iki ayrı sistemde iki ayrı işlemdir ve aralarında atomiklik yoktur. Buna dual-write problemi denir ve dağıtık sistemlerdeki tutarsızlıkların en yaygın kaynağıdır.
Outbox pattern bu problemi tek bir fikirle çözer: olayı Kafka'ya değil, iş kaydıyla aynı transaction'da bir outbox tablosuna yazın; ayrı bir bileşen tabloyu Kafka'ya taşısın. Bu yazıda problemi, deseni, Spring Boot'ta iki relay seçeneğini (polling ve Debezium CDC), sıralama ve idempotency garantilerini ve operasyon pratiğini kod örnekleriyle anlatıyoruz.
Dual-write problemi
@Transactional
public void olustur(SiparisTalebi talep) {
Siparis s = repository.save(Siparis.from(talep));
kafka.send("siparis-olaylari", s.getNo(), new SiparisOlusturuldu(s)); // 1) burada gönderilirse:
} // transaction henüz commit olmadı; rollback olursa "hayalet olay"Gönderimi commit'ten sonraya alsanız (@TransactionalEventListener(AFTER_COMMIT)) ikinci senaryo çıkar: commit oldu, Kafka'ya gönderirken uygulama çöktü ya da broker erişilemezdi — sipariş var, olay yok. Yeniden deneme eklemek yardımcı olmaz; süreç ölürse yeniden deneyecek kimse kalmaz. Kafka transaction'ları da çözmez, çünkü veritabanı o transaction'ın parçası değildir.
Desen: olay da bir satırdır
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL, -- 'siparis'
aggregate_id VARCHAR(64) NOT NULL, -- sipariş no → Kafka key (sıralama)
event_type VARCHAR(128) NOT NULL, -- 'SiparisOlusturuldu'
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX outbox_pending_idx ON outbox (next_attempt_at) WHERE sent_at IS NULL;@Transactional
public SiparisNo olustur(SiparisTalebi talep) {
Siparis s = repository.save(Siparis.from(talep));
outbox.save(OutboxKaydi.of("siparis", s.getNo().value(),
"SiparisOlusturuldu", json.write(SiparisOlusturuldu.from(s))));
return s.getNo(); // tek commit: ya ikisi de var, ya hiçbiri
}Artık iş kaydı ile olay atomik olarak birlikte yaşar. Geriye bir soru kalır: outbox satırını Kafka'ya kim taşıyacak?
Relay seçeneği 1: Polling publisher
En basit ve çoğu kurumsal senaryo için yeterli yol: zamanlanmış bir görev bekleyen satırları çeker, gönderir, işaretler. Birden fazla uygulama örneğinin aynı satırı kapmaması için FOR UPDATE SKIP LOCKED kullanılır:
public interface OutboxRepository extends JpaRepository<OutboxKaydi, UUID> {
@Query(value = """
SELECT * FROM outbox
WHERE sent_at IS NULL AND next_attempt_at <= now()
ORDER BY created_at
LIMIT 200
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<OutboxKaydi> bekleyenleriKilitle();
}
@Component
class OutboxRelay {
private final OutboxRepository outbox;
private final KafkaTemplate<String, String> kafka;
private final TransactionTemplate tx;
@Scheduled(fixedDelay = 500)
void calis() {
tx.executeWithoutResult(status -> {
for (OutboxKaydi k : outbox.bekleyenleriKilitle()) {
try {
kafka.send("siparis-olaylari", k.aggregateId(), k.payload())
.get(5, TimeUnit.SECONDS); // broker onayı bekle (acks=all)
k.gonderildi();
} catch (Exception e) {
k.ertele(Duration.ofSeconds((long) Math.min(600, Math.pow(2, k.attempts()))));
}
}
});
}
}Kafka producer ayarları bu yolun parçasıdır: acks=all, enable.idempotence=true, max.in.flight.requests.per.connection=5 (idempotent producer ile sıralama korunur). Gecikme saniyenin altındadır; maliyeti, her 500 ms'de bir küçük bir sorgudur.
Relay seçeneği 2: Debezium ile CDC
Yüksek hacimde ya da çok sayıda serviste polling yerine veritabanının transaction log'unu dinleyen Debezium kullanılır. Outbox tablosuna her INSERT, log'dan okunup Debezium'un outbox event router dönüşümüyle doğru topic'e yönlendirilir:
{
"name": "siparis-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "db", "database.dbname": "erp", "topic.prefix": "erp",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.table.field.event.payload": "payload"
}
}Uygulama kodunda relay yoktur; outbox satırı outbox.event.siparis topic'ine, key olarak sipariş numarasıyla düşer. Gecikme milisaniyeler mertebesindedir; bedeli Kafka Connect kümesi ve veritabanı replikasyon ayarlarının (PostgreSQL'de wal_level=logical) operasyonudur.
Kriter | Polling publisher | Debezium CDC | Spring Modulith registry |
|---|---|---|---|
Gecikme | Yüz ms – birkaç sn | Milisaniyeler | Anında (uygulama içi), dış yayın için ek |
Veritabanı yükü | Sürekli küçük sorgular | Log okuma; sorgu yok | Registry tablosu yazma/güncelleme |
Operasyon karmaşıklığı | Düşük | Orta–yüksek (Kafka Connect) | Düşük |
Sıralama | created_at + key; tek relay'de güçlü | Log sırası; güçlü | Uygulama içi; dış yayında key ile |
Uygun senaryo | Orta hacim, az servis | Yüksek hacim, çok servis | Modüler monolit içi olaylar |
Sıralama ve idempotency: garantinin öbür yarısı
Outbox en az bir kez teslim garantisi verir; relay gönderdikten sonra işaretlemeden önce çökerse aynı olay ikinci kez gider. Bu yüzden iki tamamlayıcı gerekir:
Sıralama: Kafka key olarak aggregate id (sipariş no) kullanıldığında aynı siparişin olayları aynı partition'a, üretim sırasıyla düşer. Farklı siparişler arasında sıra garantisi yoktur ve çoğu zaman gerekmez.
Tüketici idempotency'si: Tüketici, işlediği olay id'lerini kendi transaction'ında bir
processed_eventstablosuna yazar; aynı id ikinci kez gelirse atlar.
@KafkaListener(topics = "siparis-olaylari", groupId = "stok")
@Transactional
public void on(ConsumerRecord<String, String> kayit) {
UUID olayId = UUID.fromString(kayit.headers().lastHeader("id").value()...);
if (islenenler.existsById(olayId)) return; // tekrar teslim: yut
stokServisi.rezerveEt(json.read(kayit.value(), SiparisOlusturuldu.class));
islenenler.save(new IslenenOlay(olayId)); // aynı transaction'da
}Operasyon pratiği
Temizlik: Gönderilen satırlar 7–30 gün sonra silinir ya da arşivlenir; outbox tablosu büyüdükçe polling yavaşlar.
Metrikler: bekleyen satır sayısı, en eski bekleyenin yaşı (outbox lag), deneme sayısı 5'i geçenler. Lag birkaç dakikayı aşınca alarm.
DLQ: Kalıcı hata veren satırlar (serileştirilemeyen payload, izinsiz topic) bir "ölü" duruma alınır ve insan tarafından incelenir; sonsuza kadar denenmez.
Şema evrimi: Payload'a sürüm alanı koyun; tüketiciler eski sürümü de okuyabilmeli. Schema Registry (Avro/Protobuf) çok servisli ortamlarda bu kuralı zorlar.
Tuzaklar
Outbox yazımını transaction dışına almak. Deseni tamamen boşa çıkarır; outbox kaydı iş kaydıyla aynı
@Transactionaliçinde olmalıdır.Dev payload. Olaya tüm belgeyi değil, id + değişen alanları koyun; tüketici gerekirse API'den çeker.
Zaman damgasına göre sıralamaya güvenmek. Çoklu örnekte saatler farklı olabilir; sıralama garantisi key ve partition'dan gelir.
Tüketicide idempotency'yi atlamak. "Bir kez gönderildi" görünse de yeniden teslim her zaman mümkündür.
Sık sorulan sorular
Spring Modulith'in event publication registry'si outbox yerine geçer mi?
Uygulama içi dinleyiciler için evet, aynı garantiyi çok daha az kodla verir. Olayın Kafka'ya dışa aktarımı (@Externalized) commit sonrasında yapılır; kritik dış yayınlarda yeniden yayınlama ayarları ve tüketici idempotency'si yine gereklidir.
Kafka transaction'ları dual-write'ı çözmez mi?
Kafka transaction'ları yalnızca Kafka içindeki yazmaları atomik yapar (tüket–işle–üret). İlişkisel veritabanı bu transaction'a katılamaz; outbox tam olarak bu boşluğu doldurur.
Polling'den CDC'ye ne zaman geçmeli?
Outbox lag'ı sürekli hedefin üstündeyse, veritabanına polling yükü ölçülebilir hale geldiyse ya da onlarca servis aynı deseni kullanıyorsa. Bir-iki servis ve saniyede yüzlerce olay için polling yeterlidir.
Sonuç
Outbox pattern, "veritabanına yaz ve mesaj gönder" işlemini "veritabanına iki satır yaz" işlemine indirger; relay, key tabanlı sıralama ve tüketici idempotency'siyle birlikte dağıtık sistemlerde ulaşılabilir en güçlü tutarlılık garantisini verir. Polling ile başlayın; hacim ve servis sayısı büyüdüğünde Debezium'a geçin.
Mevcut entegrasyonlarınızda kaybolan olayları ve tutarsızlıkları kalıcı olarak çözmek için entegrasyon ve otomasyon ile yazılım tasarımı ve mimari hizmetlerimize göz atabilirsiniz.