İçeriğe geç
Hüseyin Bozdemir
Tüm yazılar

Outbox pattern nedir? .NET ve EF Core ile

Ödeme sistemleri rehberinin 3. bölümü

Outbox pattern nedir? RabbitMQ yokken EF Core tablosu ve .NET BackgroundService ile transactional outbox: çift işleme, retry, para çıkışı.

12 dk okumaGüncellendi:

'Kuyruk olmadan da outbox çalışır.' başlığı; sağda OutboxJobs tablosu: bildirim satırları Done, payout satırı turuncu vurguyla Failed; altında Sunucu A (key: notif, hata: tekrar dene) ve turuncu çerçeveli Sunucu B (key: payment, hata: ekibe bildir).
İçindekiler
  1. Outbox pattern nedir, hangi problemi çözer?
  2. Broker yokken tablo kuyruk olur: EF Core ve BackgroundService
  3. Aynı satırı iki sunucu almasın
  4. Her iş tekrar denenmez
  5. Kendi sisteminde nasıl izlersin
  6. Ne zaman RabbitMQ’ya geçmeli?
  7. Okura kalan
  8. Sık sorulanlar
  9. Outbox pattern ile mesaj kuyruğu arasındaki fark ne?
  10. Outbox tablosu “tam bir kez” işleme garantisi verir mi?
  11. Hangi durumda RabbitMQ gibi bir broker’a geçmek gerekir?

Kullanıcılara mail ve bildirim göndermek gibi işleri isteğin içinde, senkron yapıyorduk. Kullanıcı, işlemi bitse bile mail sunucusunun cevabını da bekliyordu. Bu işleri arka plana almanın klasik cevabı bir mesaj kuyruğudur. Bizim kuyruk altyapımız yoktu ve ilk aşamada RabbitMQ istenmedi. Elimizde bir veritabanı ve birkaç sunucu vardı.

Kısa cevap: outbox pattern, bir işlemin yan etkisini (mesaj yayınlamak, mail göndermek) hemen yapmak yerine işlemle aynı transaction’da bir outbox tablosuna yazmak ve ayrı bir işçiyle sonradan işlemektir. İş kaydı ve outbox satırı ya birlikte kaydedilir ya hiç; yan etki kaybolmaz.

Çözüm, kuyruğu veritabanının içine koymak oldu. Yapılacak işi, işlemin kendisiyle aynı transaction’da bir tabloya satır olarak yazdık, isteği bitirdik. Arkada çalışan BackgroundService’ler o tabloyu okuyup işi yaptı.

Bu yazıda outbox’ı genelde anlatıldığı gibi broker’ın önünde bir ara durak olarak değil, broker yokken kuyruğun kendisi olarak kullanmayı anlatacağım. Tablo kuyruk gibi davranmaya başlayınca kuyruğun dertleri de geliyor: aynı satırı iki işçinin alması, başarısız işler ve hiç tekrar denenmemesi gereken işler.

Outbox pattern nedir, hangi problemi çözer?

Outbox pattern (transactional outbox), bir işlemin yan etkisini (mesaj yayınlamak, mail göndermek, başka bir servisi çağırmak) hemen yapmak yerine, işlemle aynı veritabanı transaction’ında bir outbox tablosuna satır olarak yazmaktır. Ayrı bir işçi bu tabloyu sonradan okuyup işi yapar. İş kaydı ve outbox satırı ya birlikte kaydedilir ya hiç kaydedilmez; yan etki kaybolmaz.

Outbox’ın çözdüğü problemin adı dual write: bir işlemde iki ayrı sisteme yazman gerektiğinde, ikisinin birlikte başarılı olacağının garantisi yok. Klasik örnek veritabanı ve RabbitMQ:

db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await bus.PublishAsync(new OrderCreated(order.Id), ct); // burada çökersen event kaybolur

SaveChangesAsync başarılı oldu, publish’ten hemen önce süreç çöktü. Sipariş veritabanında var, ama onu bekleyen hiçbir servis bundan haberdar değil. Sırayı tersine çevirirsen bu sefer veritabanında olmayan bir siparişin event’i yayılır.

Broker olmadan da aynı problem var. Mail göndermek de veritabanının dışında bir yan etki:

db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await mailer.SendOrderConfirmationAsync(order, ct); // mail sunucusu yanıt vermezse?

Mail sunucusu yavaşsa kullanıcı onu bekler. Hata verirse ya isteği başarısız sayarsın (oysa sipariş kaydedildi) ya da hatayı yutarsın (mail hiç gitmez ve kimse bilmez).

Klasik transactional outbox bunu şöyle çözer: event’i doğrudan broker’a değil, işlemle aynı transaction’da bir outbox tablosuna yazarsın. Ayrı bir süreç (relay) tabloyu okur ve mesajı broker’a iletir. Tablo yalnızca broker’a giden yolda bir ara duraktır.

Bizde broker yoktu. O yüzden relay’in işi mesajı bir yere iletmek değil, işin kendisini yapmaktı.

Broker yokken tablo kuyruk olur: EF Core ve BackgroundService

Outbox tablosundaki her satır yapılacak bir iş. Bizim kurduğumuz yapıyı jenerik alan adlarıyla sadeleştirirsem şöyle bir şeye benziyor:

public sealed class OutboxJob
{
    public long Id { get; set; }
    public required string Type { get; set; }        // "notification.email", "payout" ...
    public required string WorkerKey { get; set; }   // bu satırı hangi işçi alacak
    public required string Payload { get; set; }     // işin JSON gövdesi
    public bool Retryable { get; set; }               // başarısız olursa tekrar denensin mi
    public OutboxStatus Status { get; set; }
    public int Attempts { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? NextAttemptAt { get; set; }
}

public enum OutboxStatus { Pending, Done, Failed }

İşi yazan taraf, iş kaydıyla outbox satırını aynı SaveChangesAsync içinde kaydediyor. EF Core ilişkisel veritabanlarında tek bir SaveChanges çağrısını zaten bir transaction içinde çalıştırır. Yani sipariş ve “bu siparişin mailini gönder” satırı ya birlikte yazılıyor ya hiç yazılmıyor:

db.Orders.Add(order);
db.OutboxJobs.Add(outbox.Create(
    type: "notification.email",
    payload: new OrderConfirmationEmail(order.Id, order.CustomerEmail),
    retryable: true));

await db.SaveChangesAsync(ct); // ikisi tek transaction
return Created($"/orders/{order.Id}", order.Id);

İstek burada bitiyor. Kullanıcı mail sunucusunu beklemiyor.

Tabloyu okuyan taraf bir BackgroundService. Belirli aralıklarla bekleyen satırları çekiyor, her birini türüne göre ilgili handler’a veriyor ve durumunu güncelliyor:

public sealed class OutboxWorker(
    IServiceScopeFactory scopes,
    IOptions<OutboxOptions> options) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        var opt = options.Value;
        using var timer = new PeriodicTimer(opt.PollInterval);

        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var processor = scope.ServiceProvider.GetRequiredService<OutboxProcessor>();
            var now = DateTime.UtcNow;

            var jobs = await db.OutboxJobs
                .Where(j => j.WorkerKey == opt.WorkerKey
                         && j.Status == OutboxStatus.Pending
                         && (j.NextAttemptAt == null || j.NextAttemptAt <= now))
                .OrderBy(j => j.Id)
                .Take(opt.BatchSize)
                .ToListAsync(ct);

            foreach (var job in jobs)
                await processor.ProcessAsync(job, ct);

            await db.SaveChangesAsync(ct);
        }
    }
}

BackgroundService ASP.NET Core uygulamasının içinde, uygulamayla birlikte başlayıp duran bir işçi. Ayrı bir servis kurmana, ayrı bir altyapı işletmene gerek yok. Kuyruk altyapısı olmayan bir ekip için outbox’ı cazip yapan şey de bu: bizim için kurulumu RabbitMQ’dan daha kolaydı. Yeni bir tablo ve uygulamanın içinde birkaç sınıf.

Sorgudaki WorkerKey filtresi ise bir sonraki bölümün konusu.

Aynı satırı iki sunucu almasın

Uygulama tek sunucuda çalışıyorsa yukarıdaki döngü yeter. Birden fazla sunucuda çalışıyorsa her sunucunun BackgroundService’i aynı tabloyu okur. İki sunucu aynı anda aynı Pending satırı çekerse kullanıcıya iki mail gider.

Bu problemin yaygın çözümü satır kilidi. SQL Server’da UPDLOCK, READPAST ipuçlarıyla her işçi bekleyen satırları kilitleyerek alır, başka bir işçinin kilitlediği satırları da beklemeden atlar. PostgreSQL’de karşılığı FOR UPDATE SKIP LOCKED. İngilizce kaynakların çoğu bu yolu anlatıyor ve doğru bir yol.

Biz başka bir yol seçtik: satırları sunuculara paylaştırdık. Her outbox satırının bir WorkerKey’i var ve bu key genelde işin türüne göre belirleniyor. Her sunucunun appsettings’inde de hangi key’i okuyup işleyeceği yazıyor:

{
  "Outbox": {
    "WorkerKey": "notifications",
    "PollInterval": "00:00:05",
    "BatchSize": 50
  }
}

Bir sunucu yalnızca kendi key’ine sahip satırları görüyor. İki sunucu aynı satıra hiç bakmadığı için kilide de ihtiyaç kalmıyor. Bir sunucuya bir şey olduğunda yedek sunucuya aynı key’i veriyorduk ve onun satırlarını o devralıyordu.

Bu yapının tek bir kuralı var: aynı key aynı anda iki sunucuya asla verilmez. Biz hiç vermedik, o yüzden çift işleme yaşamadık. Ama bu garantiyi veritabanı değil, bir operasyon kuralı veriyor. Yanlış kopyalanmış bir appsettings dosyası, kilitli yaklaşımın hiç izin vermeyeceği bir çift işlemeye yol açar.

İki yaklaşımı yan yana koyarsak:

Satır kilidi (UPDLOCK, READPAST) Key ile paylaştırma
Çift işlemeyi engelleyen Veritabanı Yapılandırma kuralı
Bir sunucu düşerse Diğerleri satırları alır Yedeğe key elle verilir
Bir iş türü yoğunlaşırsa Tüm sunucular paylaşır O key’in sunucusu tek başına taşır
Karmaşıklık Kilit, timeout, takılan satır Bir Where koşulu

Key yaklaşımının asıl bedeli üçüncü satırda. Bir iş türü tek bir sunucuya bağlı olduğu için o türün işleri yatayda ölçeklenmiyor. Bildirim gibi hacmi öngörülebilir işlerde bu sorun olmayabilir; ani yük gelen bir iş türünde o sunucunun kuyruğu uzar ve diğer sunucular boşta bekler.

Karşılığında aldığımız şey sadelik. Kilitli yaklaşımda işçi satırı aldıktan sonra çökerse o satırın kilidinin ya da “işleniyor” durumunun nasıl çözüleceğini de tasarlaman gerekir. Key yaklaşımında böyle bir ara durum yok.

Her iş tekrar denenmez

Outbox satırının işi başarısız olabilir: mail sunucusu yanıt vermez, karşı servis hata döner. Burada en kolay karar “başarısız olanı birkaç kez daha dene” olur. Biz bunu her iş için yapmadık.

Her satırın bir Retryable bayrağı var. Tekrar denenebilecek işler bu bayrakla işaretleniyor ve hata aldıklarında bir süre sonra yeniden deneniyor. Riskli gördüğümüz para çıkışı işlemleri ise hiç tekrar denenmiyor: başarısız olunca satır Failed durumuna geçiyor ve incelenmesi için ilgili ekiplere mail gidiyor.

public async Task ProcessAsync(OutboxJob job, CancellationToken ct)
{
    try
    {
        await handlers.For(job.Type).HandleAsync(job.Payload, ct);
        job.Status = OutboxStatus.Done;
    }
    catch (Exception ex)
    {
        job.Attempts++;

        if (job.Retryable && job.Attempts < options.MaxAttempts)
        {
            job.NextAttemptAt = DateTime.UtcNow.Add(Backoff(job.Attempts));
            return; // Pending kalır, sonra tekrar alınır
        }

        job.Status = OutboxStatus.Failed;
        await alerts.NotifyTeamAsync(job, ex, ct); // bir insan baksın
    }
}

Bu ayrım bence yazının en önemli kararı. Bir bildirimi iki kez göndermenin bedeli küçük: kullanıcı aynı maili iki kez görür. Para çıkışında “başarısız” çoğu zaman “olmadı” değil, “ne olduğunu bilmiyorum” demektir. Karşı taraf işlemi yapmış ama cevabı sana ulaşmamış olabilir. Böyle bir işi otomatik olarak tekrar denemek, paranın iki kez çıkması riskini sisteme kendi elinle yazmak olur. Idempotency yazısında banka timeout’u için anlattığım durum bunun aynısı.

Bir ayrıntı daha: outbox işi “en az bir kez” yapar, “tam bir kez” değil. İşçi maili gönderip satırı Done olarak kaydetmeden önce çökerse, sunucu ayağa kalktığında satır hâlâ Pending görünür ve mail tekrar gider. Tekrar denenecek her iş, iki kez çalışmaya dayanıklı olmalı. Dayanıklı değilse ya tüketici tarafında idempotency kurarsın ya da bizim para çıkışında yaptığımız gibi hiç otomatik denemezsin.

Kendi sisteminde nasıl izlersin

Tablo kuyruğa dönüşünce kuyruğun metrikleri de sana kalıyor. RabbitMQ’nun yönetim panelinde hazır gördüğün şeyleri burada kendin sorgulaman gerekiyor. Üç sayı çoğu sorunu erkenden gösterir:

SELECT WorkerKey,
       COUNT(*)       AS Bekleyen,
       MIN(CreatedAt) AS EnEskiBekleyen
FROM OutboxJobs
WHERE Status = 0 -- Pending
GROUP BY WorkerKey;

SELECT COUNT(*) AS Basarisiz
FROM OutboxJobs
WHERE Status = 2; -- Failed
  • Bekleyen satır sayısı sürekli artıyorsa işçi yetişemiyor ya da hiç çalışmıyor.
  • En eski bekleyen satırın yaşı tek başına en iyi alarm. Polling aralığın 5 saniyeyken 10 dakikalık bir satır varsa o key’in sunucusunda bir sorun var. Key ile paylaştırmada bu özellikle önemli, çünkü bir sunucu durduğunda onun satırlarını başka kimse almıyor.
  • Başarısız satır sayısı artıyorsa giden mailler de artıyor demektir; o mailler gözden kaçmaya başlamadan önce sayıya bakmak gerekiyor.

İki bakım işi de unutulmamalı. Birincisi index: işçinin sorgusu her birkaç saniyede bir çalışıyor, (WorkerKey, Status, Id) gibi bir index olmazsa tablo büyüdükçe bu sorgu yavaşlar. İkincisi temizlik: Done satırlar sonsuza kadar tabloda kalırsa tablo şişer. Belirli bir süreden eski tamamlanmış satırları silen ya da arşive taşıyan bir iş gerekir.

Ne zaman RabbitMQ’ya geçmeli?

Biz sonradan hibrit bir yapıya geçtik: bazı yerlerde RabbitMQ, bazı yerlerde outbox kaldı. Outbox tablosu bir kuyruğun yerini tamamen tutmuyor, ama her iş için de kuyruk gerekmiyor.

Genel bir çerçeve çizmem gerekirse outbox tablosu şu durumlarda yetiyor: iş aynı uygulamanın içinde yapılıyor, hacim öngörülebilir ve birkaç saniyelik gecikme sorun değil. Bir event’i birden fazla servisin dinlemesi gerekiyorsa, aynı iş türünü birçok işçiye dağıtman gerekiyorsa ya da tüketicileri bağımsız ölçeklemek istiyorsan broker’ın sağladığı şeyleri tabloyla yeniden yazmaya başlarsın. Orada RabbitMQ daha ucuz.

Broker’a geçince outbox’ı atman da gerekmiyor. Klasik kullanımı tam olarak bu: veritabanı ile broker arasındaki dual write’ı kapatmak. MassTransit ve CAP gibi kütüphaneler EF Core ile çalışan hazır bir transactional outbox sunuyor. Broker’ı kurduğunda elle yazılmış relay’i bırakıp bunlardan birine geçmek çoğu zaman daha az kod demek.

Okura kalan

  • Arka plan işi için mesaj kuyruğu şart değil. İşi iş kaydıyla aynı transaction’da bir tabloya yazan outbox ve bir BackgroundService birçok durumda yeter.
  • Tablo kuyruğa dönüşünce çift işleme senin sorunun olur. Ya satır kilidiyle (UPDLOCK, READPAST / SKIP LOCKED) ya da satırları key ile sunuculara paylaştırarak çözersin. İkincisi basit ama garantisi bir yapılandırma kuralına dayanıyor.
  • Her işi tekrar deneme. İki kez yapılması zararsız olan işi dene. Sonucu belirsiz kalabilen para hareketini başarısız say ve bir insana göster.
  • Kuyruğun metriklerini artık sen tutuyorsun: bekleyen satır sayısı, en eski satırın yaşı ve başarısız satırlar.

Sık sorulanlar

Outbox pattern ile mesaj kuyruğu arasındaki fark ne?

Outbox, yapılacak işi iş verisiyle aynı transaction’da bir tabloya yazmaktır; kuyruk ise işi taşıyan ayrı bir altyapıdır. Klasik kullanımda outbox tablosu broker’a giden yolda bir ara duraktır; broker yokken tablo kuyruğun kendisi olur ve kuyruğun dertleri (çift işleme, başarısız işler, metrikler) sana kalır.

Outbox tablosu “tam bir kez” işleme garantisi verir mi?

Vermez; outbox işi “en az bir kez” yapar. İşçi maili gönderip satırı Done olarak kaydetmeden çökerse satır Pending görünür ve iş tekrar çalışır. Bu yüzden tekrar denenecek her iş iki kez çalışmaya dayanıklı olmalı; dayanıklı değilse ya tüketici tarafında idempotency kurulur ya da hiç otomatik denenmez.

Hangi durumda RabbitMQ gibi bir broker’a geçmek gerekir?

Outbox tablosu iş aynı uygulamanın içinde yapılıyorsa, hacim öngörülebilirse ve birkaç saniyelik gecikme sorun değilse yeter. Bir event’i birden fazla servisin dinlemesi, aynı iş türünü birçok işçiye dağıtmak ya da tüketicileri bağımsız ölçeklemek gerektiğinde broker’ın sağladıklarını tabloyla yeniden yazmaya başlarsın; orada RabbitMQ daha ucuz.