İçeriğe geç
Muhammet Şafak
en
Günlük 5 dk okuma

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

Kaybolan event'ler beni transactional outbox'a, at-least-once teslim de idempotent tüketime götürdü; GDPR alanlarını satır seviyesinde şifreledim.

Kapak görseli — ekranda outbox_messages tablosu: payload satırı ENCRYPTED etiketiyle vurgulu, yanda x-gdpr-sensitive kilidi ve RabbitMQ panelinde outbox_delivery kuyruğu

Daha önce aynı e-fatura entegrasyonunda numara çakışmasını çözerken 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:

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. Chris Richardson’ın microservices.io’daki tanımıyla problem “veritabanını atomik olarak güncellerken mesaj broker’ına da mesaj göndermek”, çözüm ise “mesajı gönderen servisin, mesajı önce iş varlıklarını güncelleyen transaction’ın parçası olarak veritabanına yazması; ayrı bir sürecin sonra bu mesajları broker’a göndermesi”. Pratikte: 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.

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 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 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:

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 → (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:

// Ş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 yaptığı şifreleme; tüketici tarafında Crypt::decryptString ile aynı anahtarla açılıyor. Dokümanın ifadesiyle Laravel şifrelemeyi OpenSSL üzerinden AES-256 ve AES-128 ile yapıyor ve şifrelenmiş her değeri bir MAC (message authentication code) ile imzalıyor — yani şifrelendikten sonra değerin içeriği değiştirilemiyor. Bu ikinci kısım burada önemli: outbox satırına ya da broker’a erişebilen biri, şifreli tax_id’yi okuyamadığı gibi sessizce başka bir değerle de değiştiremiyor. 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 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.


Kaynaklar

Bu yazının arkasındaki araştırmalar

Partial index on beş dakikada üç yüz beş kat şişti — ve autovacuum bir kez bile gelmedi

Sürekli churn altındaki bir kuyruk tablosunda partial index'in küçüklüğü kalıcı mı, ve varsayılan autovacuum ayarları ona yetişiyor mu?

Bulgu

Canlı küme beş bin satırda sabit dururken partial index 0,125 MB'dan 38,2 MB'a çıktı — üç yüz beş kat. Küçüklüğü canlı satır sayısından geliyor, şişme hızı iş hacminden, ve ikisi arasında hiçbir bağ yok. Composite index oransal olarak daha az şişti (%42) ama mutlak olarak daha çok (+126 MB) ve şişerken belleğe sığmayı bıraktı: gecikmesi 0,52 ms'den 61 saniyeye çıktı, kuyruğu 126 bine tırmandı. On beş dakikada 1,75 milyon ölü satır birikti ve autovacuum **bir kez bile koşmadı** — varsayılan eşik tablonun tamamına göre ölçekleniyor (50 + 0,2 × 10 milyon ≈ 2 milyon), churn ise küçük canlı kümede oluyor.

25 gün önce ölçüldü

Yüksek güven

Partial index kuyruk tablosunu kırk bir kat küçültüyor — planner onu seçtiği sürece

Milyonlarca ölü satır biriken bir Postgres kuyruk tablosunda partial index ne kazandırıyor, ve planner onu ne zaman seçmiyor?

Bulgu

10 milyon ölü satırda partial index saniyede 11.537 iş çıkarıyor, index'siz tablo 7. Composite index'le hız farkı küçük (%6,9) ama boyut farkı değil: 7,6 MB'a karşı 310,4 MB, ve partial tabloyla birlikte büyümüyor çünkü yalnız 5.000 canlı satırı indeksliyor. Asıl bulgu bunların hiçbiri: planner hazırlanmış bir deyimde generic plana geçtiği anda partial index tamamen devre dışı kalıyor — 11.752 tps 7'ye, 0,68 ms 1,1 saniyeye düşüyor. Bin altı yüz otuz beş kat. Aynı koşulda composite index etkilenmiyor.

25 gün önce ölçüldü

Yüksek güven

Postgres partial index'i generic plana çevirmedi: kırk çalıştırma, kırk custom plan

Hazırlanmış bir deyimde Postgres partial index'li sorguyu kendiliğinden generic plana çeviriyor mu — yoksa bin altı yüz otuz beş katlık uçuruma ancak elle mi düşülüyor?

Bulgu

Postgres geçişi reddediyor. Partial index'te kırk çalıştırmanın kırkı da custom plan — sayaç 40/0. Reddetmesinin sebebi tam olarak felaketin kendisi: generic plan partial index'i kullanamaz, bu yüzden tahmini maliyeti yüksek çıkar ve planner onu seçmez. Composite index ise ders kitabındaki gibi altıncı çalıştırmada geçiyor (5/35) ve hiçbir şey kaybetmiyor. Yani bin altı yüz otuz beş katlık uçurum gerçek ama çitli: ona düşmek için `plan_cache_mode = force_generic_plan` yazmak gerekiyor.

25 gün önce ölçüldü

Yüksek güven

Bu konudaki deneyler

Üretim veritabanına giden ad-hoc SQL'i onaya, maskelemeye ve değiştirilemez bir ize bağlayan self-hosted portal; QueryProxy ürününe dönüştü.

Şu an ne yapıyor

Geliştiricinin yazdığı SQL'i üretim veritabanında doğrudan değil, bir onaydan geçirerek çalıştırıyor; sonuçlar diske yazılırken maskeleniyor ve her istek değiştirilemez bir kayda düşüyor. Prod erişimi tek kişide toplanmış ekipler kurabilir.

Açık kaynak Web uygulaması PHP Laravel Livewire +5 daha
Eylül 2026 — Eylül 2026

Çok hesaplı gelir-gider takibini tek modelde toplayan finans çekirdeği; web + iOS + Android'de çalışan Parantaj'a dönüştü.

Şu an ne yapıyor

Tek hesap/işlem modeli çoklu hesap yönetimini, bütçeyi, hedefleri ve raporlamayı aynı çekirdekte taşıyor; web, iOS ve Android istemcileri yayında. Kişisel ve kurumsal finansını tek yerden takip etmek isteyen kullanabilir.

Açık kaynak Web uygulaması Mobil uygulama PHP Laravel Go +4 daha
Mart 2024 — Ekim 2024
Paylaş:

Son güncelleme:

Yorumlar

Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.

İlgili Yazılar

Sitede Ara

Yazı, proje ve sayfalarda arama yapmak için yazmaya başlayın.

Esc ile kapat Pagefind ile güçlendirildi