Skip to content
~/sph.sh

Transactional Outbox Pattern: Dağıtık Sistemlerde Güvenilir Event Publishing

Transactional Outbox Pattern'in dağıtık sistemlerdeki dual-write problemini nasıl çözdüğünü, PostgreSQL, DynamoDB ve CDC araçlarıyla pratik implementasyonlarını öğren.

Özet

Dual-write problemi, çalıştığım hemen her event-driven sistemde karşıma çıktı. Database'i güncelleyip aynı anda event yayınlaman gerektiğinde imkansız bir seçimle karşılaşırsın: işler ters gittiğinde hangi operasyon başarısız olacak? Transactional Outbox Pattern, her iki operasyonu da aynı database'e tek bir transaction içinde yazarak kanıtlanmış bir çözüm sunuyor. Ardından ayrı bir process eventleri güvenilir şekilde publish ediyor. Bu yazıda polling publishers, Change Data Capture (CDC) ve AWS serverless pattern'lerini kullanarak pratik implementasyonları inceliyoruz.

Dual-Write Problemi

Sürekli karşılaştığım bir senaryo: bir order servisi, siparişi database'e kaydetmeli ve OrderCreated eventi yayınlamalı. Basit yaklaşım şöyle görünüyor:

typescript
async function createOrder(orderData: Order) {  // Adım 1: Database'e kaydet  await db('orders').insert(orderData);
  // Adım 2: Event yayınla  await messageQueue.publish('OrderCreated', orderData);}

Ne yanlış gidebilir? Her şey.

Hata Senaryosu 1: Database başarılı, event yayını başarısız

  • Message broker'a network timeout
  • Message broker geçici olarak down
  • Servisin database write'dan sonra crash olması
  • Sonuç: Sipariş database'de var ama inventory servisi eventi hiç almıyor. Stok rezerve edilmiyor.

Hata Senaryosu 2: Event yayını başarılı, database başarısız

  • Database write constraint ihlal ediyor
  • Deadlock nedeniyle transaction rollback
  • Database bağlantısı kopuyor
  • Sonuç: Inventory servisi eventi alıyor ve stok rezerve ediyor ama sipariş yok. Data tutarsızlığı.

Neden Two-Phase Commit (2PC) Kullanmıyoruz?

"Distributed transaction'lar kullanamaz mıyız?" diye sorabilirsin. Teknik olarak evet ama trade-off'lar pratikte kullanımı zorlaştırıyor:

  • Performance overhead: Sistemler arası transaction koordinasyonu ciddi latency ekliyor
  • Azaltılmış availability: Herhangi bir katılımcı down olursa tüm operasyon başarısız
  • Complexity: XA transaction'ları doğru implement etmek zor
  • Sınırlı destek: Birçok message broker 2PC desteklemiyor
  • Coupling: Microservices bağımsızlık prensiplerini ihlal ediyor

Dağıtık sistemlerle çalışırken öğrendiğim şey: distributed transaction'lardan kaçınmak, onları güvenilir çalıştırmaya çalışmaktan daha iyi.

Outbox Pattern'i Anlamak

Transactional Outbox Pattern, dual-write problemini basit bir içgörüyle çözüyor: iki ayrı sisteme yazmak yerine (database + message broker), aynı database'deki iki tabloya tek bir ACID transaction içinde yaz.

Temel Bileşenler

  1. Outbox Table: Yayınlanacak eventleri saklıyor, business datanla aynı database'de yaşıyor
  2. Business Transaction: Hem business tablolara hem outbox'a yazan tek ACID transaction
  3. Message Relay: Ayrı bir process outbox'u okuyor ve message broker'a publish ediyor
  4. Idempotent Consumer'lar: Downstream servisler duplicate eventleri doğru handle ediyor

Nasıl Çalışıyor

Anahtar içgörü: ya hem business data hem event commit ediliyor, ya da hiçbiri edilmiyor. Bu, state değişikliklerin ve event yayınının arasında atomicity garanti ediyor.

Implementasyon Yaklaşımı 1: Polling Publisher

En basit yaklaşım outbox table'ı periyodik olarak poll ediyor. Pratikte işe yarayan şey:

Temel Implementasyon

typescript
// 1. Outbox table schema (PostgreSQL)CREATE TABLE outbox (  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),  aggregate_type VARCHAR(100) NOT NULL,  aggregate_id VARCHAR(100) NOT NULL,  event_type VARCHAR(100) NOT NULL,  payload JSONB NOT NULL,  created_at TIMESTAMP DEFAULT NOW(),  published BOOLEAN DEFAULT FALSE);
-- Verimli polling için kritik indexCREATE INDEX idx_outbox_unpublishedON outbox(created_at)WHERE published = false;

Producer: Outbox'a Yaz

typescript
async function createOrder(orderData: Order) {  await db.transaction(async (trx) => {    // Siparişi ekle    const order = await trx('orders').insert({      id: orderData.id,      customer_id: orderData.customerId,      total: orderData.total,      status: 'PENDING'    }).returning('*');
    // Eventi outbox'a ekle AYNI TRANSACTION İÇİNDE    await trx('outbox').insert({      id: uuid(),      aggregate_type: 'Order',      aggregate_id: order[0].id,      event_type: 'OrderCreated',      payload: {        orderId: order[0].id,        customerId: orderData.customerId,        total: orderData.total,        items: orderData.items      },      created_at: new Date()    });
    // Her ikisi de başarılı veya her ikisi de başarısız - atomicity garanti  });}

Publisher: Poll ve Publish

typescript
async function publishOutboxEvents() {  // FOR UPDATE SKIP LOCKED kullanarak concurrent processing'i önle  const events = await db.raw(`    SELECT * FROM outbox    WHERE published = false    ORDER BY created_at    LIMIT 100    FOR UPDATE SKIP LOCKED  `);
  for (const event of events.rows) {    try {      // Message broker'a publish et      await messageQueue.publish(event.event_type, {        messageId: event.id,  // Deduplication için önemli        aggregateId: event.aggregate_id,        payload: event.payload      });
      // Published olarak işaretle      await db('outbox')        .where('id', event.id)        .update({ published: true });
    } catch (error) {      console.error('Event publish başarısız:', error);      // Sonraki poll'da retry yapılacak - at-least-once delivery    }  }}
// Publisher'ı her 5 saniyede bir çalıştırsetInterval(publishOutboxEvents, 5000);

FOR UPDATE SKIP LOCKED clause kritik: birden fazla publisher instance'ının aynı eventleri process etmesini önleyerek horizontal scaling sağlıyor.

Polling Ne Zaman Kullanılmalı

Artıları:

  • Implement etmesi ve anlaması basit
  • Ek infrastructure gerekmez
  • Herhangi bir database ile çalışır
  • SQL sorguları ile debug kolay

Eksileri:

  • Polling database load ekliyor
  • Latency poll interval'e bağlı (tipik 5-10 saniye)
  • Yüksek volume'ler için CDC'den daha az verimli

Polling kullan:

  • Düşük-orta event volume'lerinde (< 1000 event/dakika)
  • Hızlıca başlamak için
  • Basit mimariler için
  • Database'in CDC desteği yoksa

Implementasyon Yaklaşımı 2: Change Data Capture (CDC)

Production sistemlerde scale için CDC, database transaction log'unu doğrudan monitor ederek polling overhead'ini ortadan kaldırıyor.

CDC Nasıl Çalışıyor

Outbox table'ı poll etmek yerine, Debezium gibi CDC araçları database'in Write-Ahead Log'unu (PostgreSQL) veya Binary Log'unu (MySQL) monitor ediyor. Bir outbox eventi yazıldığında, CDC aracı bunu tespit edip otomatik olarak message broker'a publish ediyor.

PostgreSQL + Debezium Setup

sql
-- 1. Logical replication aktif et (PostgreSQL restart gerektirir)ALTER SYSTEM SET wal_level = 'logical';-- Not: wal_level değişikliğinin etkili olması için PostgreSQL restart edilmeli
-- 2. Outbox table için publication oluşturCREATE PUBLICATION outbox_publication FOR TABLE outbox;
-- 3. Debezium kullanıcısına replication hakları verALTER USER debezium_user WITH REPLICATION;

Debezium Konfigürasyonu

json
{  "name": "outbox-connector",  "config": {    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",    "database.hostname": "postgres.example.com",    "database.port": "5432",    "database.user": "debezium_user",    "database.password": "${DB_PASSWORD}",    "database.dbname": "orders_db",    "database.server.name": "orders",    "table.include.list": "public.outbox",    "plugin.name": "pgoutput",    "transforms": "outbox",    "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",    "transforms.outbox.table.field.event.type": "event_type",    "transforms.outbox.table.field.event.key": "aggregate_id",    "transforms.outbox.table.field.payload": "payload"  }}

Producer Kodu (Polling ile Aynı)

CDC'nin güzelliği: uygulama kodun değişmiyor. Hala aynı transaction içinde outbox table'a yazıyorsun. Debezium publishing'i handle ediyor.

typescript
// Polling yaklaşımıyla aynı kod - değişiklik gerekmezasync function createOrder(orderData: Order) {  await db.transaction(async (trx) => {    await trx('orders').insert(orderData);    await trx('outbox').insert({      aggregate_type: 'Order',      aggregate_id: orderData.id,      event_type: 'OrderCreated',      payload: orderData    });  });  // Debezium otomatik olarak yeni outbox satırını tespit edip publish ediyor}

CDC Ne Zaman Kullanılmalı

Artıları:

  • Gerçek zamana yakın event publishing (< 1 saniye)
  • Minimal database overhead (WAL okuyor, table'ları değil)
  • Yüksek volume'lere scale ediyor (100K+ event/saniye)
  • Partition başına event sırasını koruyor

Eksileri:

  • Karmaşık infrastructure (Kafka Connect, Debezium)
  • Operasyonel uzmanlık gerektiriyor
  • Database-specific setup (WAL konfigürasyonu)
  • Serverless seçeneklerden daha pahalı

CDC kullan:

  • Yüksek event volume'lerinde (> 1000 event/dakika)
  • Düşük latency gereksinimleri (< 1 saniye)
  • Scale'deki production sistemlerde
  • Zaten Kafka ecosystem kullanıyorsan

AWS Implementasyonu: DynamoDB + EventBridge Pipes

AWS, DynamoDB Streams ve EventBridge Pipes kullanarak serverless bir outbox implementasyonu sunuyor. AWS-native mimariler için tercih ettiğim yaklaşım bu.

Mimari

Implementasyon

typescript
// 1. Her iki item'ı tek transaction ile yaz// Not: DynamoDB transaction limitleri - max 100 item, 4MB toplam boyutasync function createOrder(orderData: Order) {  await dynamodb.transactWrite({    TransactItems: [      {        Put: {          TableName: 'Orders',          Item: {            orderId: { S: orderData.id },            customerId: { S: orderData.customerId },            total: { N: orderData.total.toString() },            status: { S: 'PENDING' }          }        }      },      {        Put: {          TableName: 'Outbox',          Item: {            eventId: { S: uuid() },            aggregateType: { S: 'Order' },            aggregateId: { S: orderData.id },            eventType: { S: 'OrderCreated' },            payload: { S: JSON.stringify(orderData) },            timestamp: { N: Date.now().toString() }          }        }      }    ]  }).promise();}

Infrastructure as Code (AWS CDK)

typescript
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';import * as pipes from 'aws-cdk-lib/aws-pipes';import * as events from 'aws-cdk-lib/aws-events';
// 1. Stream aktif outbox table oluşturconst outboxTable = new dynamodb.Table(this, 'OutboxTable', {  partitionKey: { name: 'eventId', type: dynamodb.AttributeType.STRING },  stream: dynamodb.StreamViewType.NEW_IMAGE,  // Kritik: yeni item'ları stream et  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,  removalPolicy: cdk.RemovalPolicy.DESTROY});
// 2. Event bus oluşturconst eventBus = new events.EventBus(this, 'OrderEventBus', {  eventBusName: 'order-events'});
// 3. EventBridge Pipe oluştur (Lambda GEREKMİYOR!)new pipes.CfnPipe(this, 'OutboxPipe', {  source: outboxTable.tableStreamArn!,  target: eventBus.eventBusArn,  roleArn: pipeRole.roleArn,  sourceParameters: {    dynamoDbStreamParameters: {      startingPosition: 'LATEST',      batchSize: 10,      maximumRetryAttempts: 3,  // Not: Varsayılan -1 (sonsuz retry)      deadLetterConfig: {        arn: dlqQueue.queueArn      }    }  },  targetParameters: {    eventBridgeEventBusParameters: {      detailType: 'OutboxEvent',      source: 'outbox.publisher'    }  }});

Bu Yaklaşım Neden İşe Yarıyor

Publishing için Lambda kodu yok: EventBridge Pipes otomatik olarak DynamoDB Streams'i okuyup EventBridge'e publish ediyor. Bu şunları ortadan kaldırıyor:

  • Cold start latency
  • Publisher için Lambda billing
  • Relay için maintain edilecek kod

Built-in reliability: Pipes retry logic, dead-letter queue ve monitoring'i out-of-the-box sunuyor.

Cost efficiency: Sadece işlenen eventler için ödeme yapıyorsun, idle publisher infrastructure için değil.

Maliyet Analizi

Ayda 10 milyon event işleyen bir sistem için:

  • DynamoDB Streams: Ücretsiz (DynamoDB'ye dahil)
  • EventBridge Pipes: 0.40/milyonevent=0.40/milyon event = 4.00/ay
  • EventBridge Event Bus: 1.00/milyonevent=1.00/milyon event = 10.00/ay
  • Toplam: 10M event için ~$14/ay

Lambda polling yaklaşımıyla karşılaştır:

  • Lambda invocation'lar: 43,200/ay (her dakika) = ~$0.01
  • Lambda duration: 100ms ortalama × 43,200 = ~$0.50
  • RDS sorguları: Database'e load ekliyor
  • Toplam: Benzer maliyet ama daha yüksek operasyonel complexity

Ordering ve Idempotency Yönetimi

Ordering Garantileri

Outbox pattern partition başına sıralamayı koruyor, tüm eventlerde global sıralama değil.

typescript
// Aynı aggregate için eventlerin sıralandığından emin olawait kafka.producer.send({  topic: 'order-events',  messages: [{    key: event.aggregateId,  // ORDER-123 için tüm eventler aynı partition'a gidiyor    value: JSON.stringify(event.payload)  }]});

DynamoDB Streams için aggregate ID'yi partition key olarak kullan:

typescript
await dynamodb.put({  TableName: 'Outbox',  Item: {    aggregateId: 'ORDER-123',  // Partition key - sıralamayı garanti ediyor    eventId: uuid(),            // Sort key    eventType: 'OrderCreated',    timestamp: Date.now()  }});

Inbox Pattern: Consumer-Side Idempotency

Outbox pattern at-least-once delivery garanti ediyor, yani eventler birden fazla kez deliver edilebilir. Consumer'lar duplicate'leri handle etmeli.

Inbox Pattern idempotent processing sağlıyor:

typescript
async function handleOrderCreatedEvent(event: OrderCreatedEvent) {  await db.transaction(async (trx) => {    // 1. Daha önce işlenmiş mi kontrol et    const existing = await trx('inbox')      .where('message_id', event.messageId)      .first();
    if (existing) {      console.log('Duplicate message, atlanıyor:', event.messageId);      return;  // Idempotent - güvenle atla    }
    // 2. Eventi işle (business logic'in)    await trx('inventory')      .where('product_id', event.productId)      .decrement('quantity', event.quantity);
    // 3. İşlenmiş olarak kaydet AYNI TRANSACTION İÇİNDE    await trx('inbox').insert({      message_id: event.messageId,      event_type: event.type,      processed_at: new Date()    });
    // Ya üç operasyon da başarılı, ya da hepsi başarısız  });
  // Sadece başarılı commit'ten sonra message broker'a ACK  await messageQueue.ack(event.messageId);}

Inbox table schema:

sql
CREATE TABLE inbox (  message_id UUID PRIMARY KEY,  event_type VARCHAR(100),  processed_at TIMESTAMP DEFAULT NOW(),  payload JSONB  -- Opsiyonel: debugging için);
-- Eski işlenmiş mesajları temizle (günlük çalıştır)DELETE FROM inboxWHERE processed_at < NOW() - INTERVAL '7 days';

Tam Pattern: Outbox + Inbox

Performance Değerlendirmeleri

Database Performance

Outbox table büyümesi: Temizlik yapılmazsa outbox table sonsuza kadar büyüyor. Bunun ciddi performance degradation'a neden olduğunu gördüm.

sql
-- Strateji 1: Publish'dan hemen sonra silDELETE FROM outbox WHERE id = $1 AND published = true;
-- Strateji 2: Batch temizlik (cron ile günlük çalıştır)DELETE FROM outboxWHERE published = true  AND created_at < NOW() - INTERVAL '7 days';
-- Strateji 3: Table partitioning (PostgreSQL 10+)CREATE TABLE outbox_2025_12 PARTITION OF outbox  FOR VALUES FROM ('2025-12-01') TO ('2026-01-01');
-- Eski partition'ları drop et (DELETE'den çok daha hızlı)DROP TABLE outbox_2025_11;

Index optimizasyonu: Partial index sadece yayınlanmamış eventleri index'liyor, yer tasarrufu sağlıyor:

sql
CREATE INDEX idx_outbox_unpublishedON outbox(created_at)WHERE published = false;

Polling Publisher Tuning

Poll interval trade-off'ları:

  • 1 saniye: Düşük latency, yüksek database load
  • 5 saniye: Dengeli (çoğu durum için önerilen)
  • 10+ saniye: Düşük overhead, yüksek latency

Batch size:

typescript
// Çok küçük: çok fazla sorgu, verimsizconst batchSize = 10;
// Çok büyük: uzun transaction'lar, lock contentionconst batchSize = 10000;
// Optimal: verimlilik ve transaction uzunluğu dengesiconst batchSize = 100;  // Önerilen başlangıç noktası

CDC Performance

Debezium'un yetiştiğinden emin olmak için replication lag'i monitor et:

sql
-- Replication slot lag'ini kontrol etSELECT slot_name,       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as lagFROM pg_replication_slotsWHERE slot_type = 'logical';

Lag büyürse WAL dosyaların birikirve disk dolabilir. Bununla gerçekten uğraştığım operasyonel bir concern.

Yaygın Tuzaklar ve Çözümler

Tuzak 1: Sınırsız Table Büyümesi

Problem: Outbox table sonsuza kadar büyüyor, sorgular yavaşlıyor.

Çözüm: Publisher'ında otomatik temizlik implement et:

typescript
async function publishAndCleanup() {  // Eventleri publish et  await publishOutboxEvents();
  // Eski published eventleri temizle (her 100 iteration'da bir)  if (cleanupCounter++ % 100 === 0) {    await db('outbox')      .where('published', true)      .where('created_at', '<', db.raw("NOW() - INTERVAL '7 days'"))      .delete();  }}

Tuzak 2: Message Relay Başarısızlığı Fark Edilmiyor

Problem: Publisher crash oluyor, eventler publish edilmeden biribiyor.

Çözüm: Outbox yaş metriklerini monitor et:

typescript
async function checkOutboxHealth() {  const result = await db('outbox')    .where('published', false)    .min('created_at as oldest')    .first();
  if (!result.oldest) return;  // Yayınlanmamış event yok
  const ageMs = Date.now() - new Date(result.oldest).getTime();  const ageMinutes = ageMs / 60000;
  if (ageMinutes > 5) {    alerting.trigger('OUTBOX_LAG_HIGH', {      ageMinutes,      message: 'Outbox eventleri publish edilmiyor'    });  }}
// Health check'i her dakika çalıştırsetInterval(checkOutboxHealth, 60000);

Tuzak 3: CDC Replication Slot Disk Dolduruyor

Problem: Debezium connector down oluyor, PostgreSQL WAL biriyor.

Çözüm: Replication slot'ları monitor et ve retention limit'leri belirle:

sql
-- WAL retention limit belirleALTER SYSTEM SET wal_keep_size = '10GB';
-- Slot durumunu monitor etSELECT slot_name, active,       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) as lagFROM pg_replication_slots;

Bir slot 5 dakikadan fazla inactive ise alert ver, publisher failure göstergesi.

Diğer Pattern'lerle Karşılaştırma

Outbox vs. Event Sourcing

YönOutbox PatternEvent Sourcing
AmaçGüvenilir event publishingEventler truth kaynağı
Event ÖmrüKısa ömürlü (publish'dan sonra siliniyor)Kalıcı append-only log
State SaklamaCurrent state table'lardaEventlerden türetiliyor
ComplexityDüşükYüksek
Query ModelDirekt database sorgularıProjection/CQRS gerektirir
En İyi KullanımE-ticaret siparişleri, workflow'larBankacılık, audit sistemleri

Temel fark: Event sourcing'de eventler kalıcı kayıt. Outbox'ta eventler bir iletişim mekanizması.

Outbox vs. Saga Pattern

Outbox pattern saga pattern'i tamamlıyor. Saga'ya katılan her servis içinde outbox kullan:

typescript
// Order Service OrderCreated'ı outbox ile publish ediyorawait db.transaction(async (trx) => {  await trx('orders').insert(order);  await trx('outbox').insert({ event_type: 'OrderCreated', payload: order });});
// Saga Orchestrator OrderCreated'ı alıyor, kendi outbox'u ile command'ları publish ediyorawait db.transaction(async (trx) => {  await trx('saga_state').insert({ saga_id: orderId, step: 'INVENTORY_PENDING' });  await trx('outbox').insert({ event_type: 'ReserveInventory', payload: { orderId } });});

Karar Çerçevesi

Doğru implementasyonu seçmek için bu çerçeveyi kullan:

Polling seç:

  • Event volume < 1000/dakika
  • Hızlı başlamak için
  • Basit mimari tercih ediliyorsa
  • Database CDC desteklemiyorsa

CDC seç:

  • Event volume > 1000/dakika
  • < 1 saniye latency gerekiyorsa
  • Scale'deki production sistemlerde
  • Zaten Kafka kullanıyorsan

DynamoDB + EventBridge seç:

  • AWS üzerinde geliştiriyorsan
  • Serverless mimari istiyorsan
  • Minimal operasyonel overhead isteniyorsa
  • Orta volume'ler için cost-effective

Production Readiness Checklist

Outbox pattern'i production'a deploy etmeden önce:

  • Cleanup stratejisi: Published eventlerin otomatik silinmesi
  • Monitoring: Outbox age, backlog size, publisher health
  • Alerting: Lag threshold'u aşıyor, publisher failure'ları
  • Idempotency: Inbox pattern veya idempotency key'leri implement edildi
  • Ordering: Event sıralaması için partition key stratejisi
  • Dead Letter Queue: Başarısız eventler inceleme için yönlendiriliyor
  • Schema versioning: Event payload versioning stratejisi
  • Load testing: Beklenen throughput'ta doğrulandı
  • Runbook: Recovery prosedürleri dokümante edildi
  • Backup stratejisi: Outbox ve inbox table'ları için

Ana Çıkarımlar

Birden fazla sistemde outbox pattern ile çalışırken öğrendiğim dersler:

  1. Basit başla: Polling publisher'larla başla. Performance gerektiğinde CDC'ye geç.

  2. Lag'i agresif monitor et: Event oluşturma ve publishing arasındaki süre en önemli metrik. Bu büyüyorsa sistemin bozuluyor.

  3. Idempotency pazarlık konusu değil: At-least-once delivery duplicate'ler olacağı anlamına geliyor. Bunu ilk günden tasarla.

  4. Acımasızca temizle: Sınırsız büyüyen outbox table'lar sonunda production sorunlarına neden oluyor. Otomatik temizlik yap.

  5. Akıllıca partition'la: Partition içinde event sıralaması garanti. Aggregate ID'leri partition key olarak kullan.

  6. AWS kolaylaştırıyor: DynamoDB + EventBridge Pipes minimal kodla production-ready outbox sağlıyor.

Outbox pattern sadece teori değil; güvenilir event-driven sistemler inşa etmek için güvendiğim, savaş testinden geçmiş dual-write probleminin çözümü. Burada gösterilen implementasyonlar, kendi gereksinimlerine uyarlayabileceğin production-ready pattern'ler.

İleri Okuma

İlgili Yazılar