---
title: "Transactional Outbox: Dual-Write, İdempotent Tüketim"
description: "Dual-write'ı outbox'la kapatmak, doğan at-least-once tekrarını idempotent tüketimle bastırmak, event'in içindeki kişisel veriyi alanda şifrelemek."
url: https://sade.dev/tr/systems/transactional-outbox-dual-write-ve-idempotent-tuketim/
lang: tr
author: "Muhammet Şafak"
published: 2026-06-22
updated: 2026-09-06
section: System
tags: ["architecture","messaging","idempotency","reliability","security"]
summary: "Outbox dual-write'ı tek write'a indirir ama bedava garanti vermez, at-least-once verir. Relay polling mi CDC mi, SKIP LOCKED ile çoklu relay, ordering, processed_messages budama, exactly-once yanılsaması ve alan-seviyesi şifreleme."
---

# Transactional Outbox: Dual-Write, İdempotent Tüketim

> Outbox dual-write'ı tek write'a indirir ama bedava garanti vermez, at-least-once verir. Relay polling mi CDC mi, SKIP LOCKED ile çoklu relay, ordering, processed_messages budama, exactly-once yanılsaması ve alan-seviyesi şifreleme.

Belirti küçüktü: günde bir-iki fatura veritabanında duruyor ama consumer onları hiç görmüyordu. Kullanıcı "kestim" diyor, karşı tarafta hiçbir iz yok. Hep gece, hep bir deploy ya da network dalgalanması anında. Tabloyu netleştirince sebep ortaya çıktı — veritabanına `INSERT` ile kuyruğa `publish` iki ayrı sistemdi ve aralarında hiçbir garanti yoktu.

Bu yazı o boşluğu kapatan **transactional outbox** kalıbının, kapatınca açılan **at-least-once** tekrarının ve son olarak event'in içinden geçen kişisel veriyi **alan seviyesinde** şifrelemenin sistem tarafı. Karar zincirinin günlük tarafını [muhammetsafak.com.tr'de ayrı yazdım](https://muhammetsafak.com.tr/blog/outbox-idempotent-tuketim-ve-alan-sifrelemesi); burada kalıbın production'da dayanıklı olması için verilmesi gereken kararları topluyorum.

## Dual-write: bir transaction'ı iki sisteme yayamazsınız

Sorunun özü tek cümle: yerel veritabanı transaction'ınız RabbitMQ'yu kapsamaz. Klasik kod *çoğu zaman* çalıştığı için tehlikeli:

```php
DB::transaction(function () use ($invoice) {
    $invoice->save();                 // 1) MySQL'e yaz
    $this->publishToRabbit($invoice); // 2) kuyruğa publish
});
```

İki ayrı arıza modu var:

- `save()` başarılı, `publish` network hatasıyla düşer → DB'de fatura var, event yok. **Kayıp event.**
- `publish` başarılı, ardından transaction başka sebeple `rollback` olur → event gitti, DB'de karşılığı yok. **Hayalet event.**

`DB::transaction` kurtarmaz, çünkü commit/rollback yalnızca MySQL tarafını sarar; broker o transaction'ın parçası değildir. İki sistemi tek atomik adımda tutmanın pratik yolu, yazmayı **tek bir sisteme** indirmek.

## Outbox: önce tek sisteme yaz, sonra ayrı bir süreç yayımlasın

Kalıbın fikri sade: mesajı kuyruğa doğrudan basmak yerine, **aynı transaction içinde** bir `outbox` tablosuna satır olarak yaz. Fatura ile event aynı commit'te ya birlikte var olur ya da birlikte yok olur — dual-write tek write'a iner.

```sql
CREATE TABLE outbox (
    id            BINARY(16)   NOT NULL,        -- event id = idempotency anahtarı
    aggregate_id  BIGINT       NOT NULL,        -- ordering ve partition anahtarı
    topic         VARCHAR(120) NOT NULL,
    payload       JSON         NOT NULL,
    status        ENUM('pending','published') NOT NULL DEFAULT 'pending',
    created_at    DATETIME(6)  NOT NULL,
    published_at  DATETIME(6)  NULL,
    PRIMARY KEY (id),
    KEY idx_dispatch (status, created_at)        -- relay taraması bu indeksten gider
);
```

Yazma artık iş kodunun derdi değil:

```php
DB::transaction(function () use ($invoice) {
    $invoice->save();
    Outbox::write('invoice.issued', $invoice->id, $this->payload($invoice));
});
```

Kuyruğa basmayı ayrı bir **relay** yapar: `pending` satırları okur, RabbitMQ'ya `publish` eder, başarınca `published` damgalar. Burada kritik gerçek şu: relay "publish ettim ama `published` damgasını vuramadan çöktüm" durumuna düşebilir. Yani outbox dual-write'ı çözerken size bedava bir garanti **vermez** — **at-least-once** verir. Mesaj kaybolmaz ama tekrar edebilir. Geri kalan her şey bu gerçeğin etrafına dizilir.

## Relay'i nasıl beslersiniz: polling mi, CDC mi?

Relay `pending` satırları iki yoldan görebilir.

**Polling** — relay tabloyu periyodik tarar:

```sql
SELECT id, topic, payload
FROM outbox
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100;
```

Basit, her veritabanında çalışır, operasyonel yükü düşük. Bedeli iki şey: tarama aralığı kadar **gecikme** (1 sn'lik poll = ~1 sn'lik kuyruk gecikmesi) ve boşta dönen sorgu. `idx_dispatch` indeksi olmadan bu tarama tablo büyüdükçe pahalılaşır; indeksle bile poll sıklığı ile DB yükü arasında pazarlık yaparsınız. Küçük-orta hacimde polling **doğru** cevap — gecikmeyi kabul edilebilir tutacak kadar sık, DB'yi yormayacak kadar seyrek (pratikte 200 ms–1 sn) ayarlayın.

**CDC** (change data capture) — relay tabloyu değil, veritabanının **WAL/binlog**'unu dinler (Debezium tipik araç). `outbox`'a düşen her `INSERT` neredeyse anında bir event'e döner; polling gecikmesi ve boşa tarama ortadan kalkar. Bedeli operasyonel: binlog'a erişim, bir connector süreci, ek bir hareketli parça. CDC'yi gecikme gerçekten önemliyse (saniye altı) ya da hacim polling'i zorluyorsa açarım; aksi halde polling'in sadeliğini bir avantaj sayarım.

> Kural: gecikme bütçeniz ile operasyonel bütçeniz çatışır. CDC gecikmeyi satın alır, karşılığında bir altyapı parçası ödetir. Önce polling'le başlayın; CDC'ye ölçülmüş bir sebep çıkınca geçin.

## Çoklu relay: `SKIP LOCKED` ile çakışmasız dağıtım

Tek relay bir bottleneck ve tek arıza noktasıdır. Birden çok relay aynı tabloyu tarayınca yeni bir risk doğar: ikisi aynı satırı kapıp aynı mesajı iki kez yayımlar. Naif çözüm tabloyu kilitlemektir — ki bu paralelliği öldürür.

Doğru araç `SELECT ... FOR UPDATE SKIP LOCKED`. Her relay bir batch satırı **kilitleyerek** claim eder; başka bir relay'in kilitlediği satırları sıraya girip beklemek yerine **atlar**:

```sql
BEGIN;
  SELECT id, topic, payload
  FROM outbox
  WHERE status = 'pending'
  ORDER BY created_at
  LIMIT 100
  FOR UPDATE SKIP LOCKED;     -- başka relay'in tuttuğu satırları atla, bekleme

  -- bu batch'i publish et, sonra:
  UPDATE outbox SET status = 'published', published_at = NOW(6)
  WHERE id IN (...);
COMMIT;
```

`SKIP LOCKED` olmadan relay'ler aynı satırlar için kuyruğa girer ve paralellik fiilen seri yürür. `SKIP LOCKED` ile her relay disjoint bir küme çeker, hepsi birden ilerler. PostgreSQL ve MySQL 8+ bunu destekler.

Bir incelik: `publish` ile `UPDATE ... published` arasında relay çökerse satır `pending` kalır ve **yeniden yayımlanır**. Bu kabul edilebilir — zaten at-least-once'tayız; çözüm tüketici tarafında, aşağıda. Tehlikeli olan tersidir: önce `published` damgalayıp sonra `publish` etmek. O zaman çökme **kayıp event** üretir ki tam kaçtığımız şey. Yani sıra **değişmez**: önce `publish`, sonra damga.

## Ordering: neyi garanti edebilirsiniz, neyi edemezsiniz

"Event'ler yazıldığı sırada gelsin" sezgisel bir beklenti ama global ordering pahalı ve çoğu zaman gereksiz. Gerçekte ihtiyaç duyduğunuz şey **aggregate başına** sıradır: aynı faturanın `created` event'i `updated`'dan önce gelsin; iki farklı faturanın birbirine göre sırası kimsenin umurunda değil.

Bunu `aggregate_id` ile partition'layarak alırsınız: aynı `aggregate_id`'ye sahip event'ler aynı kuyruk partition'ına (ya da RabbitMQ'da consistent hash exchange ile aynı queue'ya) gider; o partition'ı **tek consumer** sırayla işler. Farklı aggregate'ler paralel akar.

```
outbox (created_at sırasıyla)
   │  hash(aggregate_id) % N
   ├── partition 0 ──► consumer-0   (aggregate A,D olayları — sıralı)
   ├── partition 1 ──► consumer-1   (aggregate B olayları   — sıralı)
   └── partition 2 ──► consumer-2   (aggregate C olayları   — sıralı)
```

İki şey ordering'i yine de bozar ve tasarımın bunlara hazır olması gerekir: çoklu relay `SKIP LOCKED` ile satırları farklı sırada yayımlayabilir ve at-least-once tekrarlar araya girebilir. Bu yüzden global sıraya bel bağlamak yerine, tüketiciyi **sırasız ve tekrarlı** event'e dayanıklı yazmak gerekir. Pratik kalkan: event'e monotonik bir `version`/`sequence` koyup tüketicide eskiyi (daha düşük versiyonu) düşürmek. Sıkı global ordering gerekiyorsa tek partition + tek consumer'a inersiniz — ki bu da throughput'u o tek consumer'a bağlar; çoğu sistem bu bedeli ödemek istemez.

## At-least-once gelince: tüketici idempotent olmalı

Relay tekrar yayımlayabildiği için her event'in en az bir, bazen birden çok kez geleceğini varsaymak gerekir. Çözüm event'i tekil yapmaya çalışmak değil — **tüketiciyi** aynı event'i iki kez işlese de tek kez işlemiş gibi davranacak şekilde kurmak. İşlenen her event `id`'sini bir dedupe tablosuna yazıp işi **aynı transaction içinde** yapmak:

```php
DB::transaction(function () use ($event) {
    $inserted = DB::table('processed_messages')->insertOrIgnore([
        'message_id'   => $event['id'],
        'processed_at' => now(),
    ]);

    if ($inserted === 0) {
        return; // tekrar gelmiş; hiçbir yan etki üretme
    }

    $this->applyBusinessEffect($event); // asıl iş — yalnızca bir kez
});
```

`processed_messages.message_id` üstündeki **unique** kısıt işin kalbi: ikinci kez gelen aynı `id`, `insertOrIgnore` ile sessizce elenir. İş etkisini aynı transaction'a almak şart — yoksa "işledim ama damgalamadan çöktüm" boşluğu açılır. Dedupe anahtarının nereden geldiği ve çökmeden sağ çıkan dedupe tasarımı başlı başına bir konu; oraya [ayrı bir notta](/tr/notes/idempotency-ayni-mesaj-iki-kez) girdim.

Yan etki bir dış sistemse (e-fatura entegratörü, ödeme API'si) dedupe tek başına yetmez — çünkü yan etki transaction'ın dışındadır. Orada anahtarı **dış servise taşımak** gerekir: aynı `id`'yi entegratöre idempotency-key olarak geçmek, ya da göndermeden önce "bu zaten var mı?" diye sormak. Bu sınırı, numarayı dış çağrıdan önce mühürleyip recovery'yi bir lookup'a indiren [JIT rezervasyon yazısında](/tr/systems/ardisik-numara-uretiminde-race-condition-ve-gap) ayrıntılı işledim.

## `processed_messages` sonsuza kadar büyüyemez

Dedupe tablosu her event için bir satır biriktirir; budanmazsa kendisi bir performans sorununa döner. Anahtar gözlem: bir event `id`'sini sonsuza kadar tutmanız gerekmez — yalnızca **bir tekrarın gelebileceği pencere** kadar tutmanız yeter. Relay retry'ları ve broker redelivery'leri saatler mertebesindedir, günler değil.

Pratikte sabit bir **retention penceresi** belirleyip (örneğin 7 gün — en uzun olası redelivery'nin rahatça üstünde) eskisini budarım:

```sql
DELETE FROM processed_messages
WHERE processed_at < NOW() - INTERVAL 7 DAY
LIMIT 10000;   -- tek seferde küçük batch'ler, replica lag'i ve kilidi kontrol altında
```

`message_id` UUID ise tablo ve indeks büyür; budama bunu sınırlı tutar. Pencereyi seçerken kuralı tersinden kurun: *en uzun gecikmeli redelivery ne kadar sonra gelebilir?* Cevabın güvenli üstünü retention yapın. Pencereyi çok kısa tutarsanız geç gelen bir tekrar dedupe'a takılmaz ve yan etki ikinci kez çalışır.

## Exactly-once neden çoğu zaman bir yanılsama

Dağıtık sistemde "tam bir kez teslim" cazip ama uçtan uca garanti edilemez. Sebep aynı dual-write'ın broker sınırındaki hali: tüketici işi yaptı ama `ack`'i broker'a ulaştıramadan çöktü mü, broker mesajı yeniden teslim eder — çünkü `ack` görmemiştir. "İşledim" ile "işlediğimi bildirdim" iki ayrı adımdır ve aralarına çökme girebilir. Bu, iki general probleminin pratikteki yüzü.

Bu yüzden hedef exactly-once **delivery** değil, exactly-once **effect**. Bunu da iki ucuz garantiyi birleştirerek alırsınız:

```
at-least-once delivery  +  idempotent consumer  =  effectively-once
```

Yani teslimi tam bir kez yapmaya çalışmayı bırakıp **tekrarı zararsız** kılarsınız. "Exactly-once" pazarlayan sistemlerin altında çoğu zaman tam olarak bu vardır: at-least-once + bir dedupe katmanı. Garantiyi teslimde değil, etkide arayın.

## Event'in içinden geçen kişisel veri: alan seviyesi şifreleme

Outbox oturunca yeni bir yüzey çıkar: event'lerin payload'ında müşteri adı, vergi numarası, adres gibi kişisel veri var ve bu payload artık **kalıcı** bir tabloda (`outbox`) duruyor, üstelik broker'dan geçiyor. Veriyi yerinde bırakırsam, GDPR/KVKK kapsamındaki alanları şifresiz biçimde birden çok yere kopyalamış olurum.

Bütün payload'ı şifrelemek istemedim — `topic`, `aggregate_id`, `invoice_id` gibi alanları relay yönlendirmesi ve gözlemlenebilirlik için açık görmem gerekiyordu. İhtiyaç **alan seviyesi** şifreleme: yalnızca hassas alanlar şifreli, gerisi açık. Hassas alanları şemada `x-gdpr-sensitive` ile işaretleyip serileştirmede yalnızca onları şifreledim:

```php
$schema = [
    'invoice_id'    => ['x-gdpr-sensitive' => false],
    'customer_name' => ['x-gdpr-sensitive' => true],
    'tax_id'        => ['x-gdpr-sensitive' => true],
    'total'         => ['x-gdpr-sensitive' => false],
];

$payload = collect($raw)->map(fn ($value, $field) =>
    ($schema[$field]['x-gdpr-sensitive'] ?? false)
        ? Crypt::encryptString((string) $value)  // yalnızca hassas alan şifreli
        : $value
)->all();
```

Şemanın hassas alanı beyan etmesi tesadüf değil — bunu kuyruğun kenarında şema doğrulamasıyla birleştirince, "hangi alan kişisel veri ve doğru tipte mi?" sorularının ikisi de [kenarda](/tr/notes/semayi-kuyrugun-kenarinda-dogrulamak) cevaplanır. Asıl mühendislik kararı şifrelemenin kendisinde değil, çevresindeki üç soruda:

**Anahtar yönetimi ve rotasyon.** Laravel `Crypt`, `APP_KEY` ile AES-256 kullanır. Tek bir anahtara bağlanırsanız rotasyon kabusa döner: eski anahtarla yazılmış outbox satırlarını yeni anahtar açamaz. Çözüm, şifreli değerin yanına **anahtar versiyonu** (key id) yazmak ve birden çok anahtarı aynı anda tanıyan bir keyring tutmak — yeni yazımlar güncel anahtarı kullanır, eski değerler kendi versiyonuyla açılır. Laravel'de `config/app.php`'deki `key` + `previous_keys` tam bunun içindir; decrypt sırasıyla eski anahtarları da dener. Rotasyonu bu yüzden veri taşımadan yapabilirsiniz.

**Şifreli alanda arama.** `Crypt::encryptString` her çağrıda farklı ciphertext üretir (rastgele IV), ki bu güvenlik için doğru ama `WHERE tax_id = ?` ile aramayı imkânsız kılar. Eşitlik araması gerekiyorsa **blind index** eklersiniz: aranabilir alanın deterministik bir HMAC'ini ayrı bir kolonda tutup onu sorgularsınız. Şifreli kolon gizliliği, blind index aranabilirliği taşır; ikisi ayrı kolondur çünkü iki ayrı işi vardır.

**Anahtarı kaybetmek veriyi kaybetmektir.** Alan şifrelemesi `APP_KEY`'i bir **availability** bağımlılığına çevirir: anahtar giderse şifreli alanlar kalıcı çöptür. Anahtar yedeği ve erişim kontrolü, şifrelemenin kendisi kadar tasarımın parçasıdır.

## Ne zaman bu kalıbı kurmazsınız?

Outbox + idempotent tüketim + alan şifrelemesi ucuz değil: bir tablo, bir relay süreci (ya da CDC connector), bir dedupe tablosu ve onun budama işi, bir de anahtar yönetimi getirir. Karşılığında ne aldığınızı bilmiyorsanız ödemeyin. Şu durumlarda kurmuyorum:

- **Event kaybı tolere edilebiliyorsa.** Yayımladığınız şey best-effort bir bildirimse (cache invalidation, "yeni içerik var" sinyali) ve bir-iki kayıp önemsizse, doğrudan `publish` yeterli. Outbox'ı kayıp önemli olduğu için kurarsınız.
- **Yazma ile yayım aynı sistemdeyse.** Hedefiniz de aynı veritabanıysa (ayrı bir broker yoksa), dual-write zaten yok; outbox'a gerek kalmaz.
- **Hacim tek consumer'a sığıyorsa ve ordering kritikse.** Düşük hacimde tek tüketicili bir kuyruk hem sırayı hem tekilliği basitçe verir; çoklu relay ve partition makinesini kurmak olmayan bir problemi çözmektir.

Kalıba ihtiyaç üç koşul **birlikte** doğunca başlar: event kaybı kabul edilemez, üretim/tüketim paralel ve veri ayrı bir sisteme (broker) gidiyor. Üçü birden yoksa daha sade bir çözüm vardır.

---

Üç parça tek bir disipline iner: **veriyi tek bir sisteme yaz, tekrarı tüketicide bastır, hassas alanı yola çıkmadan mühürle.** Outbox kuyruğa olan güveni, idempotent tüketim teslime olan güveni, alan şifrelemesi de payload'a olan güveni gereksiz kılar — her biri bir garantiyi koddan dışarı, tasarımın içine taşır.

## Sık sorulanlar

**Relay polling ile mi CDC ile mi beslenmeli?**

Polling her veritabanında çalışır ve operasyonel maliyeti neredeyse yoktur, ama tarama aralığı kadar gecikme ekler — pratikte 200 ms ile 1 s arası — ve boşa dönen bir sorgu bırakır. CDC bunun yerine WAL ya da binlog'u dinler, gecikme neredeyse kaybolur; bedeli binlog erişimi ve fazladan bir hareketli parçadır. Polling ile başlayın, ölçülmüş bir sebep çıkınca CDC'ye geçin.

**processed_messages satırları ne kadar süre saklanmalı?**

Yalnızca bir tekrarın hâlâ gelebileceği süre kadar. Relay retry'ları ve broker redelivery'leri günler değil saatler mertebesindedir; mümkün en geç redelivery'nin rahatça üstünde sabit bir saklama penceresi — örneğin yedi gün — yeterlidir ve küçük partiler hâlinde budanır. Pencere kısa kalırsa geç gelen tekrar dedupe'a yakalanmaz ve yan etki ikinci kez çalışır.


## Kaynaklar

- [Pattern: Transactional outbox](https://microservices.io/patterns/data/transactional-outbox.html) — microservices.io
- [SELECT: the locking clause, FOR UPDATE and SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html) — PostgreSQL
- [InnoDB locking reads: SKIP LOCKED](https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-reads.html) — MySQL
- [Encryption: gracefully rotating encryption keys](https://laravel.com/docs/12.x/encryption) — Laravel
