# Dual-write'tan outbox'a: idempotent tüketim ve alan-seviyesi şifreleme günlüğü

> Aynı projede dual-write yüzünden kaybolan event'ler beni transactional outbox'a, at-least-once teslim de idempotent tüketime götürdü. Bir de GDPR alanlarını x-gdpr-sensitive ile satır seviyesinde şifreledim. Gerçek bir projeden notlar.

- Yayın: 2026-06-22
- Kategori: Günlük
- Etiketler: PHP, Laravel, RabbitMQ, MySQL
- Okuma süresi: 6 dk okuma
- Kaynak: https://www.muhammetsafak.com.tr/blog/outbox-idempotent-tuketim-ve-alan-sifrelemesi/
- Dil: tr-TR
- Yazar: Muhammet Şafak

---
[Daha önce aynı e-fatura entegrasyonunda numara çakışmasını çözerken](/blog/e-fatura-entegrasyonunda-numara-cakismasi) senkron bir akışı RabbitMQ üzerinden asenkrona taşıdığımı anlatmıştım. O göçün ardından, gözden kaçırdığım daha sessiz bir hata kaldı: bazı faturalar veritabanına yazılıyor ama event'i kuyruğa hiç düşmüyordu. Consumer onları hiç görmüyor, kullanıcı da "kestim" diyor ama karşı tarafta hiçbir iz yok. Günde bir-iki tane, hep gece, hep deploy ya da network dalgalanması anında.

Tabloyu netleştirince sebep ortaya çıktı: veritabanına `INSERT`, kuyruğa `publish` — bunlar **iki ayrı sistemdi** ve aralarında hiçbir garanti yoktu. Yazma başarılı oluyor, hemen ardından `publish` ağ takılmasıyla düşüyordu. Yerel transaction commit olmuştu, mesaj ise hiç yola çıkmamıştı. Buna **dual-write problemi** deniyor: tek bir iş adımının iki ayrı veri deposuna atomik olmayan biçimde yazması.

Bu yazı o sorunu **transactional outbox** ile nasıl çözdüğümün, çözünce ortaya çıkan **at-least-once** (en az bir kez) tekrarları idempotent tüketimle nasıl bastırdığımın ve son olarak event'in içinden geçen kişisel veriyi **alan seviyesinde** nasıl şifrelediğimin günlüğü. Derin mimari tarafını ayrı yazdım (aşağıda link var); burada *gerçek bir projede bu kararları hangi sırayla aldım* tarafını anlatmak istiyorum.

## Dual-write problemi: iki yazma, sıfır garanti

Sorunun özü tek cümleyle şu: **bir transaction'ı iki farklı sisteme yayamazsınız.** MySQL'e yazıp RabbitMQ'ya `publish` eden klasik kod şuna benzer:

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

Bu kod *çoğu zaman* çalıştığı için tehlikeli. Ama iki ayrı arıza modu var:

- `save()` başarılı, `publishToRabbit()` ağ hatasıyla düşer → veritabanında fatura var, event yok. **Kayıp event.**
- `publishToRabbit()` başarılı, ardından transaction başka bir sebeple `rollback` olur → event gitti ama veritabanında karşılığı yok. **Hayalet event.**

`DB::transaction` içine almak da kurtarmıyor, çünkü RabbitMQ o transaction'ın parçası değil; commit/rollback yalnızca MySQL tarafını sarıyor. İki sistemi tek bir atomik adımda tutmanın pratik yolu, yazmayı **tek bir sisteme** indirgemek.

## Çözüm: önce veritabanına yaz, sonra ayrı bir süreç yayımlasın

Transactional outbox deseninin fikri sade: mesajı kuyruğa doğrudan basmak yerine, **aynı veritabanı 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.

```php
DB::transaction(function () use ($invoice) {
    $invoice->save();

    OutboxMessage::create([
        'id'         => Str::uuid(),     // event'in idempotency anahtarı
        'topic'      => 'invoice.issued',
        'payload'    => $this->buildPayload($invoice),
        'status'     => 'pending',
        'created_at' => now(),
    ]);
});
```

Kuyruğa basma işini ayrı bir **relay** (yayımlayıcı) yapıyor: `pending` satırları okuyup RabbitMQ'ya `publish` ediyor, başarınca `published` olarak işaretliyor. RabbitMQ kullanımının temeline [daha önce RabbitMQ'yu PHP ile anlattığım yazıda](/blog/php-rabbitmq) girmiştim; buradaki tek fark, `publish` çağrısının artık iş kodunda değil, bu relay'de olması.

Önemli nokta: relay "publish ettim ama `published` damgasını vurmadan çöktüm" durumunda aynı satırı tekrar yayımlayabilir. Yani outbox dual-write'ı çözerken size **bedava bir garanti** vermiyor; **at-least-once** veriyor. Mesaj kaybolmuyor ama **tekrar edebiliyor.** Sıradaki problem bu.

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

At-least-once teslimde her event'in en az bir kez, 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. Bu, [API'de idempotency üzerine yazarken](/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek) anlattığım disiplinin kuyruk tarafındaki yüzü: orada istemci bir `Idempotency-Key` üretiyordu, burada event'in kendi `id`'si o anahtar.

Tüketici, işlediği her event `id`'sini bir `processed_messages` tablosuna yazıyor ve işi **aynı transaction içinde** yapıyor:

```php
public function handle(array $event): void
{
    DB::transaction(function () use ($event) {
        // Daha önce işlendiyse sessizce çık (unique kısıt bunu garanti eder)
        $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 eleniyor ve iş etkisi hiç çalışmıyor. İş etkisini de aynı transaction'a almak şart — yoksa "işledim ama damgalamadan çöktüm" boşluğu açılır ki tam kaçtığımız şey budur.

## Bu kalıbın derinliği sade.dev'de

Outbox'ın çalışan bir günlük kaydından dayanıklı bir sisteme dönüşmesi, bu yazının kapsamı dışında bıraktığım birkaç karara bağlı: relay'i `polling` ile mi yoksa **CDC** (change data capture) ile mi besleyeceğiniz, `pending` satırlarını çoklu relay'de `SELECT ... FOR UPDATE SKIP LOCKED` ile nasıl çakışmasız dağıtacağınız, teslim sırasının (ordering) nereye kadar garanti edilebileceği, `processed_messages` tablosunun nasıl budanacağı ve exactly-once'ın neden çoğu zaman bir yanılsama olduğu. Bunları kod ve diyagramlarıyla ayrı topladım:

> **[Transactional Outbox: Dual-Write Problemi, At-Least-Once ve İdempotent Tüketim →](https://sade.dev/tr/systems/transactional-outbox-dual-write-ve-idempotent-tuketim)** (sade.dev)

Bu blogun şeridi uygulamalı kalıyor; örüntünün sistem-seviyesi tasarımını sade.dev'e bırakıyorum.

## Bir de GDPR: event'in içinden geçen kişisel veri

Outbox yerine oturunca yeni bir soru çıktı: bu event'lerin payload'ında müşteri adı, vergi numarası, adres gibi kişisel veriler var ve bu payload artık **kalıcı** bir tabloda (`outbox`) duruyor, üstelik RabbitMQ broker'ından geçiyor. Yani veriyi yerinde bırakırsam, GDPR/KVKK kapsamındaki alanları şifresiz biçimde birden çok yere kopyalamış oluyorum.

Bütün payload'ı şifrelemek istemedim — çünkü `topic`, `invoice_id` gibi alanları relay ve gözlemleyebilmek için açık görmem gerekiyordu. İhtiyacım olan şey **alan seviyesi** şifrelemeydi: yalnızca hassas alanlar şifreli, gerisi açık. Payload şemasında hassas alanları `x-gdpr-sensitive` ile işaretledim ve serileştirme anında yalnızca onları şifreledim:

```php
// Şema, hangi alanların hassas olduğunu beyan eder
$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(function ($value, $field) use ($schema) {
    return ($schema[$field]['x-gdpr-sensitive'] ?? false)
        ? Crypt::encryptString((string) $value) // yalnızca hassas alan şifreli
        : $value;
})->all();
```

`Crypt`, Laravel'in `APP_KEY` ile AES-256 şifrelemesi; tüketici tarafında `Crypt::decryptString` ile aynı anahtarla açılıyor. Bunun pratikteki üç faydası şuydu:

- **Outbox ve broker'da düz metin kişisel veri durmuyor.** Bir tabloyu ya da kuyruğu yanlışlıkla log'lasam bile `tax_id` şifreli kalıyor.
- **Açık alanlar açık kalıyor.** `invoice_id` ile event'i takip edebiliyor, `total` ile metrik üretebiliyorum; şifreleme gözlemlenebilirliği öldürmüyor.
- **Şema kendini belgeliyor.** `x-gdpr-sensitive` bayrağı, "hangi alan kişisel veri?" sorusunun cevabını koda gömüyor; sonradan bir denetimde tahmin etmek gerekmiyor.

Anahtar yönetimi, rotasyon ve "şifreli alanda nasıl arama yaparsın" gibi sorular bu günlüğün sınırının ötesinde — onlar da sade.dev'in alanı.

## Günlüğe not ettiğim üç şey

**Bir deseni çözmek bir sonrakini açar.** Outbox dual-write'ı kapattı ama yerine at-least-once tekrarlarını koydu; onu idempotent tüketim kapattı. Her garanti bir maliyetle geliyor ve "çözdüm" demeden önce *yerine ne koydum* diye sormak gerekiyor. Tek seferde biten bir şey değil, kararların zinciri.

**Teslim garantisini değil, tüketiciyi tasarlayın.** Uzun süre "mesaj tam bir kez gelsin" diye uğraşmak istedim; oysa dağıtık sistemde ucuz ve dürüst garanti at-least-once. Asıl iş, [idempotency yazısında](/blog/api-de-idempotency-ayni-istegi-guvenle-tekrar-etmek) olduğu gibi, tekrarın *yan etkisiz* olmasını tasarlamak. Tüketici idempotentse, teslim garantisinin gevşekliği sorun olmuyor.

**Uyumluluğu sonradan değil, payload'ı tasarlarken düşünün.** Kişisel veriyi en baştan `x-gdpr-sensitive` ile işaretleyince, şifreleme bir "ek özellik" değil serileştirmenin doğal bir adımı oldu. Bunu en sona bıraksaydım, çoktan birden çok yere kopyalanmış düz metin veriyi geri toplamaya çalışırdım — ki en pahalı iş odur.

Sonuçta bu üç parça tek bir cümlede birleşiyor: **veriyi tek bir sisteme yaz, tekrarı tüketicide bastır, hassas alanı yola çıkmadan mühürle.** E-fatura entegrasyonunda numara çakışmasından çıkardığım dersle aynı kapıya çıktı — state'i dış dünyaya açılmadan tam zamanında ve doğru biçimde mühürlemek.
