in

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.

İş kaydı ve outbox satırının aynı transaction'da yazılıp relay ile Kafka'ya taşındığını gösteren şema

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_events tablosuna 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

  1. Outbox yazımını transaction dışına almak. Deseni tamamen boşa çıkarır; outbox kaydı iş kaydıyla aynı @Transactional içinde olmalıdır.

  2. Dev payload. Olaya tüm belgeyi değil, id + değişen alanları koyun; tüketici gerekirse API'den çeker.

  3. Zaman damgasına göre sıralamaya güvenmek. Çoklu örnekte saatler farklı olabilir; sıralama garantisi key ve partition'dan gelir.

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