Transactional Outbox: Veritabanına Yazmak, Mesajın İletildiği Anlamına Gelmez
Dağıtık sistemlerde bir işlemin veritabanında tamamlanması ile diğer sistemlere duyurulması farklı başarısızlık noktalarına sahiptir.
Bir uygulamada iş verisini güncellemek ve ardından başka bir sisteme mesaj göndermek oldukça sıradan bir ihtiyaçtır.
Sipariş oluşturulur, ardından stok sistemine bildirim gönderilir. Bir iş emri kaydedilir, ardından ilgili servislerin haberdar olması beklenir. Bir cihazdan gelen olay veritabanına yazılır ve başka bir bileşenin bu olayı işlemesi gerekir.
İlk bakışta basit bir akış:
- Veritabanına kaydet.
- Mesajı yayımla.
- İşlemi tamamla.
Ancak bu iki işlem farklı sistemlerde gerçekleşiyorsa aralarında önemli bir güvenilirlik boşluğu vardır.
Veritabanı işlemi başarılı, mesaj başarısız
Bir servis iş emrini veritabanına kaydetmiş olsun.
Veritabanı transaction’ı başarıyla tamamlanır. Ardından servis bir mesaj kuyruğuna WorkOrderCreated olayını göndermeye çalışır.
Tam bu noktada bağlantı kesilir veya uygulama çöker.
Artık veritabanında bir iş emri vardır, fakat diğer sistemler bu iş emrinden haberdar olmayabilir.
Mesajı önce göndermek de sorunu ortadan kaldırmaz. Bu kez mesaj başarıyla yayımlanabilir, ancak veritabanı transaction’ı başarısız olabilir.
Sonuçta diğer sistemlere gerçekte tamamlanmamış bir işlem duyurulmuş olur.
Buradaki temel problem, iki farklı sistemde gerçekleşen işlemin tek bir yerel transaction içinde atomik olarak yönetilememesidir.
Bir işlemin veritabanında başarılı olması, onunla ilişkili mesajın da başarıyla yayımlandığını garanti etmez.
Transactional Outbox neyi değiştirir?
Transactional Outbox yaklaşımı, iş verisi değişikliği ile mesaj yayımlama niyetini aynı veritabanı transaction’ında kaydeder.
Servis, iş emrini oluştururken gönderilmesi gereken olayı da bir outbox tablosuna yazar.
Örneğin:
BEGIN;
INSERT INTO work_orders (id, status)
VALUES ('WO-1042', 'Created');
INSERT INTO outbox_messages
(id, event_type, aggregate_id, payload, created_at)
VALUES
(
gen_random_uuid(),
'WorkOrderCreated',
'WO-1042',
'{"workOrderId":"WO-1042"}'::jsonb,
NOW()
);
COMMIT;
Her iki kayıt da aynı transaction içinde yer aldığı için birlikte başarılı olur veya birlikte geri alınır.
Bu aşamada mesaj henüz RabbitMQ’ya, Kafka’ya veya başka bir sisteme gönderilmiş değildir.
Garanti edilen şey farklıdır:
İş verisi başarıyla kaydedildiyse, yayımlanması gereken olayın kalıcı kaydı da vardır.
Bu küçük ayrım, tasarımın temelidir.
Mesajı kim gönderecek?
Outbox tablosunda bekleyen kayıtlar ayrı bir süreç tarafından okunur ve mesaj altyapısına yayımlanır.
Bu süreç bir background worker, ayrı bir servis veya değişiklik verilerini izleyen CDC tabanlı bir mekanizma olabilir.
Basit bir akış şöyledir:
Business Operation
|
v
Database Transaction
| |
v v
Business Data Outbox Event
| |
+-----+------+
|
COMMIT
|
v
Outbox Worker
|
v
Message Broker
|
v
Consumer
Worker mesajı yayımlamayı başardığında outbox kaydını gönderildi olarak işaretleyebilir.
Ama burada önemli bir problem daha vardır.
Mesaj gönderildi, kayıt güncellenemedi
Worker mesajı broker’a gönderir.
Broker mesajı kabul eder ve başarılı yanıt döner.
Tam bu sırada worker çöker.
Outbox kaydı henüz gönderildi olarak işaretlenmemiştir.
Worker yeniden başladığında aynı mesajı tekrar yayımlayabilir.
Dolayısıyla Transactional Outbox, mesaj kaybına yol açabilecek bir arıza penceresini kapatırken tekrarlı mesaj yayımlanması olasılığını ortadan kaldırmaz.
Bu bir uygulama hatası değil, yaklaşımın bilinmesi gereken davranışıdır.
Outbox üzerinden yapılan teslimat, uygun tasarım ve çalışma koşullarında at-least-once niteliğindedir.
Bu nedenle mesajı tüketen sistemin de tekrarlara dayanıklı olması gerekir.
İlk yazıda ele aldığım idempotency konusu burada yeniden karşımıza çıkar.
İdempotent tüketici neden önemli?
Bir tüketici aynı olayı ikinci kez aldığında aynı iş etkisini yeniden oluşturmamalıdır.
Bunun için mesajların sabit ve benzersiz kimlikleri olmalı; tekrar yayımlanan aynı mantıksal olay yeni bir kimlikle sunulmamalıdır.
Tüketici, işlediği mesajın kimliğini iş verisi değişikliğiyle aynı transaction içinde saklayabilir.
Bu yaklaşım çoğu zaman Inbox veya Processed Messages deseniyle uygulanır.
Ancak tüketicinin bağımsız bir dış servise çağrı yapması ya da fiziksel bir komut çalıştırması söz konusuysa kendi veritabanındaki kayıt tek başına yeterli olmayabilir.
Outbox ile idempotency birbirinin alternatifi değildir. Birlikte ele alınması gereken iki ayrı güvenilirlik mekanizmasıdır.
Outbox tablosu da operasyonel bir bileşendir
Transactional Outbox yalnızca bir tablo eklemekten ibaret değildir.
Sistemi üretime taşırken bazı soruların yanıtlanması gerekir:
- Worker uzun süre çalışamazsa kayıtlar ne kadar birikebilir?
- Aynı anda çalışan birden fazla worker aynı mesajı nasıl sahiplenir?
- Başarısız gönderimler hangi aralıklarla yeniden denenir?
- Sürekli hata veren bir mesaj diğer mesajların ilerlemesini engeller mi?
- Mesajların sırası gerçekten önemli mi ve hangi kapsamda korunacak?
- Gönderilmiş kayıtlar nasıl temizlenir veya arşivlenir?
- Bir mesajın ne kadar süredir beklediği nasıl izlenir?
Örneğin PostgreSQL üzerinde birden fazla worker ile çalışırken FOR UPDATE SKIP LOCKED gibi yöntemler kullanılabilir. Ancak kayıt sahipliği, işlem süreleri ve çökme sonrası toparlanma birlikte tasarlanmalıdır.
Benzer şekilde mesajların sırasını garanti etmek istiyorsak yalnızca created_at alanına göre sıralama yapmak her durumda yeterli değildir. Eşzamanlı transaction’lar, birden fazla worker ve yeniden denemeler teslimat sırasını etkileyebilir.
Bunlar bir desenin uygulanmasından çok, bir sistemin işletilmesiyle ilgili kararlardır.
Her sistem için Outbox gerekli mi?
Hayır.
Bazı uygulamalarda gecikmiş ya da kaybolmuş bir bildirimin etkisi sınırlı olabilir. Bazılarında ise bir olayın kaybolması iş süreçlerinin kalıcı biçimde tutarsızlaşmasına yol açabilir.
Örneğin yalnızca kullanıcı arayüzünde geçici bir bilgilendirme gösteren olayla, finansal bir işlemin sonraki aşamasını başlatan olay aynı güvenilirlik seviyesini gerektirmez.
Transactional Outbox ek karmaşıklık getirir: kalıcı olay kayıtları, worker süreçleri, izleme, temizleme ve toparlanma mekanizmaları gerekir.
Bu nedenle kullanım kararı, teknoloji tercihinden önce olayın iş açısından önemine ve kaybolması durumunda ortaya çıkacak sonuca dayanmalıdır.
Sonuç: Mesaj gönderimini değil, mesajın kaybolmamasını tasarlamak
Dağıtık sistemlerde güvenilirlik yalnızca başarılı senaryonun doğru çalışması değildir.
Asıl fark, işlem adımlarından biri başarısız olduğunda sistemin ne yapabildiğinde ortaya çıkar.
Transactional Outbox bize her mesajın tam olarak bir kez işleneceğini vaat etmez.
Bunun yerine, iş verisiyle birlikte mesaj yayımlama niyetini kalıcı hale getirerek daha güvenilir bir toparlanma zemini oluşturur.
Mesaj yine gecikebilir. Aynı mesaj yeniden yayımlanabilir. Tüketici işlemi başarısız olabilir.
Fakat sistemin elinde hangi olayların yayımlanması gerektiğini gösteren kalıcı bir kayıt bulunur.
Güvenilirlik, hataların hiç yaşanmamasından değil, hatalardan sonra sistemin tutarlı biçimde ilerleyebilmesinden doğar.
Uygulamalı deney: Outbox neden tek başına yeterli değil?
Distributed Reliability Lab kapsamındaki E02 — Duplicate Delivery deneyi, Transactional Outbox yaklaşımının önemli bir sınırını gösteriyor.
Deneyde Outbox Worker, mesajı RabbitMQ’ya yayımlıyor ve broker onayı alıyor. Ancak mesajın yayımlandığı bilgisini veritabanına kaydedemeden çöküyor. Yeniden başlatıldığında aynı olay kimliğine sahip mesaj tekrar yayımlanıyor.
Başka bir senaryoda ise consumer, iş verisini başarıyla kaydettikten sonra ACK gönderemeden çöküyor. RabbitMQ mesajı yeniden teslim ediyor.
Her iki durumda da sorun yalnızca tekrarlı teslimat değil. Tekrarın iş mantığı üzerindeki etkisi belirleyici.
Deney, Transactional Outbox ile idempotent consumer tasarımının neden birbirini tamamlayan iki ayrı güvenilirlik mekanizması olduğunu gösteriyor.
E02 — Duplicate Delivery deneyini GitHub’da inceleyin
Outbox’ın çözdüğü asıl problemi ise E01 — Lost Event deneyinde gösteriyorum: Doğrudan dual write kullanan servis commit ile yayın arasında çöktüğünde olay kayboluyor. Outbox kullanan servis ise aynı çökmeden sonra yeniden başladığında bekleyen olayı yayımlıyor.