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.
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 sebeplerollbackolur → 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.
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 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_idile event’i takip edebiliyor,totalile metrik üretebiliyorum; şifreleme gözlemlenebilirliği öldürmüyor. - Şema kendini belgeliyor.
x-gdpr-sensitivebayrağı, “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.
Yorumlar
Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.