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; 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:
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ı,publishnetwork hatasıyla düşer → DB’de fatura var, event yok. Kayıp event.publishbaşarılı, ardından transaction başka sebeplerollbackolur → 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.
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:
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:
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 200ms–1sn) 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:
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 çek, üçü 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:
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 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 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:
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 generaller 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önlendirme ve gözlemleyebilmek 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:
$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?” sorusunun ikisi de kenarda 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
publishyeterli. 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.
Yorumlar
Yorum yapmak için GitHub hesabınızla giriş yapmanız yeterli. Yorumlar GitHub Discussions üzerinde saklanır.