Neden Messaging? Senkron Çağrı Zincirinden Asenkron İletişime
Senkron HTTP zinciri her bağımlılığın aynı anda ayakta olmasını şart koşar; işi bir queue'ya bırakmak bu temporal coupling'i kırar ve checkout'u e-posta sağlayıcısının çöküşünden korur. Availability çarpımı, Channel<T> ile process içi queue, broker'ın buffer ve load leveling rolü, Web-Queue-Worker ve asenkronluğun bedeli gerçek çıktılarla.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un checkout'u bugün dört adımı arka arkaya yapıyor: Inventory'den stok ayırıyor, ödeme sağlayıcısından parayı çekiyor, siparişi veritabanına yazıyor ve onay e-postasını gönderiyor. Stok, ödeme ve e-posta HTTP (Hypertext Transfer Protocol) üzerinden çağrılan ayrı servisler. Kod, deneyde kullanacağımız sahte client'larla birlikte şöyle:
namespace Bookstore.Ordering;
public sealed class ServiceUnavailableException(string service) : Exception($"{service} unavailable");
// Dış servisleri taklit eden sahte HTTP client'lar
public sealed class InventoryClient
{
public Task ReserveAsync(Guid orderId) => Task.CompletedTask;
}
public sealed class PaymentClient
{
public int Captured { get; private set; }
public Task CaptureAsync(Guid orderId, decimal amount) { Captured++; return Task.CompletedTask; }
}
public sealed class EmailClient
{
public bool IsDown { get; set; }
public int Sent { get; private set; }
public Task SendConfirmationAsync(Guid orderId)
{
if (IsDown) throw new ServiceUnavailableException("Email provider");
Sent++;
return Task.CompletedTask;
}
}
public sealed class OrderStore
{
public List<Guid> Saved { get; } = [];
public Task SaveAsync(Guid orderId) { Saved.Add(orderId); return Task.CompletedTask; }
}
namespace Bookstore.Ordering;
public sealed class SyncCheckoutService(
InventoryClient inventory, PaymentClient payment, OrderStore store, EmailClient email)
{
public async Task<int> PlaceOrderAsync(Guid orderId, decimal total)
{
try
{
await inventory.ReserveAsync(orderId); // HTTP
await payment.CaptureAsync(orderId, total); // HTTP
await store.SaveAsync(orderId); // veritabanı
await email.SendConfirmationAsync(orderId); // HTTP
return 201;
}
catch (ServiceUnavailableException)
{
return 500;
}
}
}
Okul açılışı kampanyasının ortasında e-posta sağlayıcısı üç siparişlik bir süre boyunca cevap vermiyor. On siparişin kaçı 201 döner? Dönmeyenlerde müşterinin kartından para çekilmiş midir, sipariş veritabanında var mıdır? Müşteri "hata aldım" deyip butona tekrar basarsa ne olur?
Senkron Zincirin Gizli Sözleşmesi: Temporal Coupling
Deneyi çalıştıralım. Sağlayıcı 4., 5. ve 6. siparişlerde kapalı:
// Senkron zincir: e-posta sağlayıcısı 4-6. siparişler sırasında çöküyor
{
InventoryClient inventory = new();
PaymentClient payment = new();
OrderStore store = new();
EmailClient email = new();
SyncCheckoutService checkout = new(inventory, payment, store, email);
int ok = 0;
for (int i = 1; i <= 10; i++)
{
email.IsDown = i is >= 4 and <= 6;
int status = await checkout.PlaceOrderAsync(Guid.CreateVersion7(), 30m);
if (status == 201) ok++;
}
Console.WriteLine($"[sync] 201 responses: {ok}/10, orders saved: {store.Saved.Count}, " +
$"payments captured: {payment.Captured}, emails sent: {email.Sent}");
}
[sync] 201 responses: 7/10, orders saved: 10, payments captured: 10, emails sent: 7
On siparişin yedisi 201 aldı, ama on siparişin onu da kaydedildi ve onunun da parası çekildi. Üç müşteri ekranda 500 gördü. Sipariş aslında oluştu, para da çekildi; eksik olan tek şey bir e-postaydı. O üç müşteriden biri butona tekrar basarsa ikinci bir sipariş ve ikinci bir ödeme oluşur. Çözüm Desenleri serisinin "Idempotency Key: Çift Tıklama ve Retry'da Aynı Siparişi İki Kez Oluşturmamak" yazısı tam olarak bu ikinci tıklamayı ele alıyordu; burada sorun daha önce başlıyor: e-postanın başarısız olması, siparişin başarısız olduğu anlamına gelmemeliydi.
Bu kodun yazılmamış bir sözleşmesi var: checkout'un çalışması için stok servisi, ödeme sağlayıcısı ve e-posta sağlayıcısı aynı anda ayakta olmalı ve cevap vermeli. Buna temporal coupling deniyor: iki parçanın birbirine zaman üzerinden bağlı olması. Kodda bir interface'in arkasına saklanmış olabilirler, ayrı ekipler tarafından deploy ediliyor olabilirler; çağrı anında biri yoksa diğeri de çalışmaz. Gregor Hohpe ve Bobby Woolf'un 2003 tarihli Enterprise Integration Patterns kitabı (EIP: message tabanlı entegrasyonun 65 pattern'lik kataloğu) messaging'in ilk faydası olarak tam bunu sayıyor: gönderenle alıcının aynı anda çalışıyor olması gerekmez.
Zincirin bedelini sayıyla görmek kolay. Her halkanın availability'si %99,9 olsun; bu, ayda yaklaşık 43 dakika kesinti demek. Halkalar bağımsız çöküyorsa zincirin availability'si çarpımdır:
using System.Threading.Channels;
using Bookstore.Ordering;
// Zincirin availability'si: her halka %99,9
double[] links = [0.999, 0.999, 0.999];
double chain = links.Aggregate(1.0, (product, a) => product * a);
const double minutesPerMonth = 30 * 24 * 60;
Console.WriteLine($"One service: {links[0]:P2} -> {(1 - links[0]) * minutesPerMonth:F0} min down per month");
Console.WriteLine($"Chain of {links.Length}: {chain:P2} -> {(1 - chain) * minutesPerMonth:F0} min down per month");
double[] five = [0.999, 0.999, 0.999, 0.999, 0.999];
double chain5 = five.Aggregate(1.0, (product, a) => product * a);
Console.WriteLine($"Chain of 5: {chain5:P2} -> {(1 - chain5) * minutesPerMonth:F0} min down per month");
Console.WriteLine();
One service: 99.90% -> 43 min down per month
Chain of 3: 99.70% -> 129 min down per month
Chain of 5: 99.50% -> 216 min down per month
Üç bağımlılık, kesintiyi üçe katlıyor; checkout'un kendisi de bir halka olduğu için gerçek tablo bundan biraz kötü. Mimariye Giriş serisinin "Quality Attribute Senaryoları" yazısında checkout için aylık %99,9 hedef koymuştuk. Bu zincirle o hedef, matematik olarak tutturulamaz. Latency de aynı şekilde birikir: her çağrının gecikmesi toplanır ve en yavaş halkanın kötü günü bütün isteğin kötü günü olur. Ödeme sağlayıcısının 5 saniyelik bir takılması, sepetteki müşteriye 5 saniyelik bir bekleme olarak yansır.
Queue'ya Bırak, Hemen Dön
E-postayı checkout'un bekleyeceği bir iş olmaktan çıkaralım. Checkout yalnızca "bu sipariş için onay e-postası gönderilsin" diye bir message bırakır ve döner; e-postayı arka planda bir worker gönderir. Process içinde bunu yapmanın en kısa yolu System.Threading.Channels:
using System.Threading.Channels;
namespace Bookstore.Ordering;
public sealed record OrderConfirmationRequested(Guid OrderId);
public sealed class QueuedCheckoutService(
InventoryClient inventory, PaymentClient payment, OrderStore store,
ChannelWriter<OrderConfirmationRequested> queue)
{
public async Task<int> PlaceOrderAsync(Guid orderId, decimal total)
{
await inventory.ReserveAsync(orderId);
await payment.CaptureAsync(orderId, total);
await store.SaveAsync(orderId);
// E-postayı beklemiyoruz: queue'ya bırakıp dönüyoruz
await queue.WriteAsync(new OrderConfirmationRequested(orderId));
return 201;
}
}
public sealed class ConfirmationWorker(ChannelReader<OrderConfirmationRequested> queue, EmailClient email)
{
public int Retries { get; private set; }
public async Task RunAsync()
{
await foreach (OrderConfirmationRequested message in queue.ReadAllAsync())
{
while (true)
{
try
{
await email.SendConfirmationAsync(message.OrderId);
break;
}
catch (ServiceUnavailableException)
{
Retries++;
await Task.Delay(TimeSpan.FromMilliseconds(50)); // basit bekle ve tekrar dene
}
}
}
}
}
Aynı deney, bu kez sağlayıcı baştan kapalı ve ancak bütün siparişler alındıktan sonra geri geliyor:
// Queue üzerinden: e-posta işini arka plandaki worker yapıyor
{
InventoryClient inventory = new();
PaymentClient payment = new();
OrderStore store = new();
EmailClient email = new();
Channel<OrderConfirmationRequested> queue = Channel.CreateUnbounded<OrderConfirmationRequested>();
QueuedCheckoutService checkout = new(inventory, payment, store, queue.Writer);
ConfirmationWorker worker = new(queue.Reader, email);
email.IsDown = true; // sağlayıcı baştan kapalı
Task running = worker.RunAsync();
int ok = 0;
for (int i = 1; i <= 10; i++)
{
if (await checkout.PlaceOrderAsync(Guid.CreateVersion7(), 30m) == 201) ok++;
}
Console.WriteLine($"[queued] 201 responses: {ok}/10, emails sent so far: {email.Sent}");
await Task.Delay(TimeSpan.FromMilliseconds(300));
email.IsDown = false; // sağlayıcı geri geldi
queue.Writer.Complete();
await running;
Console.WriteLine($"[queued] after recovery: emails sent: {email.Sent}, worker retried: {worker.Retries > 0}");
}
Console.WriteLine();
[queued] 201 responses: 10/10, emails sent so far: 0
[queued] after recovery: emails sent: 10, worker retried: True
On siparişin onu 201 aldı; o anda tek bir e-posta bile gitmemişti. Sağlayıcı geri geldiğinde worker bekleyen on message'ı tekrar deneyerek gönderdi. Müşteri açısından fark büyük: sipariş ekranı artık e-posta sağlayıcısının sağlığına bağlı değil. Bedeli de aynı çıktıda görünüyor: "emails sent so far: 0". Sipariş oluştu ama onayı henüz yok. Sistem bir süre tutarsız, sonra tutarlı hâle geliyor. Buna eventual consistency deniyor ve bu serinin geri kalanı büyük ölçüde bu bedeli yönetmekle ilgili.
Dikkat edersen stok ve ödeme hâlâ senkron. Bilinçli bir seçim: müşteri, ödemesinin alınıp alınmadığını sayfadan çıkmadan öğrenmek istiyor. Bookstore'un quality attribute'larında bunun da bir cevabı var: ödeme sağlayıcısı 5 saniye içinde cevap vermezse sipariş PaymentPending olur ve kullanıcıya hata dönmez. Yani ödeme yarı senkron: önce kısa bir bekleme, olmazsa asenkron devam. Stok ayırmanın ve ödemenin de event'lerle birbirine bağlandığı tam asenkron sipariş akışı, serinin saga yazılarının konusu.

Message Broker Ne İşe Yarar?
Yukarıdaki Channel<T> bir queue, ama bir message broker değil. Broker, message'ları kendi process'inde ve diskinde tutan ayrı bir sunucu: RabbitMQ, Azure Service Bus, Apache Kafka gibi. Producer message'ı broker'a bırakır, consumer ondan alır; ikisi birbirinin adresini bile bilmez. Bu aracılığın üç somut faydası var.
Buffer. Consumer kapalıyken ya da yavaşken message'lar broker'da bekler. Producer'ın hızı consumer'ın hızına bağlı olmaktan çıkar. Az önceki e-posta senaryosu bunun küçük bir hâliydi.
Load leveling. Microsoft'un Azure Architecture Center'daki Queue-Based Load Leveling pattern'i bunu şöyle özetliyor: queue, düzensiz gelen yükü servisin kendi hızında işleyebileceği düzgün bir akışa çevirir. Bookstore'un kampanya zirvesinde dakikada 120 sipariş geliyor. E-posta tarafının dakikada 50 message işleyebildiğini varsayalım:
// Load leveling: kampanyada dakikada 120 sipariş, e-posta tarafı dakikada 50 işleyebiliyor
int backlog = 0, peak = 0, peakMinute = 0;
for (int minute = 1; minute <= 40; minute++)
{
int arrived = minute <= 10 ? 120 : 10; // 10 dakikalık zirve, sonra sakin saat
backlog = Math.Max(0, backlog + arrived - 50);
if (backlog > peak) (peak, peakMinute) = (backlog, minute);
if (minute is 1 or 5 or 10 or 15 or 20 or 25 or 30)
Console.WriteLine($"minute {minute,2}: arrived {arrived,3}, queue depth {backlog,3}");
if (minute > 10 && backlog == 0)
{
Console.WriteLine($"queue drained at minute {minute} (peak {peak} at minute {peakMinute})");
break;
}
}
minute 1: arrived 120, queue depth 70
minute 5: arrived 120, queue depth 350
minute 10: arrived 120, queue depth 700
minute 15: arrived 10, queue depth 500
minute 20: arrived 10, queue depth 300
minute 25: arrived 10, queue depth 100
queue drained at minute 28 (peak 700 at minute 10)
On dakikalık zirvede queue 700 message'a kadar büyüyor ve sakin saatte 28. dakikada boşalıyor. En son e-posta, siparişten yaklaşık 18 dakika sonra gidiyor. Bu kabul edilebilir mi? E-posta için büyük ihtimalle evet. Ödeme onayı için hayır. Load leveling hiçbir şeyi hızlandırmaz, yalnızca beklemeyi çağıranın üstünden alıp queue'ya taşır. Microsoft'un aynı sayfadaki uyarısı da bu: ortalama giriş hızı işleme hızından büyükse queue büyümeye devam eder; queue derinliğini izlemek ve gerekirse consumer sayısını artırmak gerekir.
Decoupling. Checkout, e-postayı kimin, kaç instance'la, hangi dille gönderdiğini bilmez. Yarın aynı message'ı dinleyen ikinci bir consumer (örneğin müşteriye SMS (Short Message Service) atan bir Notification özelliği) checkout'a dokunmadan eklenebilir. Bu, serinin üçüncü yazısında publish/subscribe başlığıyla geri gelecek.
Web-Queue-Worker: Bu Fikrin Mimari Stil Hâli
Azure Architecture Center bu düzene bir ad veriyor: Web-Queue-Worker. Bir web front end isteği alır ve hızlı cevap verir; uzun ya da kaynak tüketen işler bir queue üzerinden arka plandaki worker'a gider. Mimariye Giriş serisinin "Mimari Stillerin Haritası" yazısında Bookstore'un e-postayı queue'ya bırakma kararını bu stilin küçük, process içi bir hâli olarak görmüştük; bu yazıdaki kod o kararın kendisi. Stilin önerdiği sınır da aynı: web tarafı kullanıcının beklemesi gereken işi yapar, beklemesi gerekmeyen her şeyi worker'a bırakır.
Stil, microservices'e geçmeyi gerektirmiyor. Web ve worker aynı monolith'in iki host'u, hatta aynı process'in iki parçası olabilir. Önemli olan iki tarafın message üzerinden konuşması ve birinin yavaşlığının diğerine sızmaması.
Channel<T> mi, Broker mı?
Mimariye Giriş serisinin "Trade-off Düşüncesi" yazısında Bookstore bir karar almıştı: sipariş onay e-postası process içi bir queue ile gönderilecek, deploy ya da çökme anında queue'daki message'ların kaybolabileceği kabul edildi. Bu yazıdaki Channel<T> aynı kararın kodu. Ne zaman yetmez?
| Senkron HTTP zinciri | Process içi Channel<T> | Message broker | |
|---|---|---|---|
| Bağımlılık çökerse | İstek başarısız olur | Message bellekte bekler | Message broker'da (diskte) bekler |
| Uygulama yeniden başlarsa | Yarım kalan istek kaybolur | Queue'daki her şey kaybolur | Message'lar korunur |
| Consumer nerede çalışır | Karşı serviste | Aynı process'te | Herhangi bir process'te, birden çok instance |
| Ölçeklemek | Karşı servisi büyütmek | Tek makineyle sınırlı | Consumer instance'ı eklemek |
| Operasyon yükü | Yok | Yok | Broker cluster'ı, izleme, yükseltme |
Channel<T>'in yeri, kaybı tolere edilebilen, aynı process içinde kalan ve yalnızca arka plana itilmek istenen işler. Backpressure, bounded channel ve pipeline ayrıntıları için C# Asenkron serisinin "C# Channels ile Producer/Consumer: Backpressure, Bounded Channel ve Pipeline" yazısına bakabilirsin. Message kaybedilemeyecekse, işi başka bir process yapacaksa ya da birden fazla instance message'ları paylaşacaksa sıra broker'a gelir. Mimariye Giriş'teki kararın iki yönlü kapı olduğunu söylemiştik: checkout yalnızca bir ChannelWriter<T> görüyor, yarın onu broker'a yazan bir implementation ile değiştirmek tek bir class'ı ilgilendirir. Kalıcılığın asıl zor kısmı (veritabanı commit'i ile message yayınını birlikte güvenilir kılmak) serinin outbox yazısında.
Bedeli: Asenkronluk Bedava Değil
Hohpe ve Woolf, messaging'in faydalarıyla birlikte zorluklarını da sayıyor: programlama modeli karmaşıklaşır, message'ların sırası garanti olmaz, bazı senaryolar doğası gereği senkrondur. Bookstore ölçeğinde bunlar somut olarak şunlar:
- Eventual consistency: sipariş sayfası "onay e-postası yolda" gibi ara durumları göstermek zorunda; destek ekibi "sipariş var ama e-posta yok" sorusuna bir cevap bulmalı.
- Hata ayıklama: bir hata artık tek bir stack trace'te görünmüyor. Checkout başarılı, worker üç dakika sonra başarısız; ikisini birbirine bağlayan bir id olmadan log'larda kaybolursun. Correlation id'yi bir sonraki yazıda message'ın zarfına koyacağız.
- Operasyon yükü: broker da bir sunucu. Yükseltilmesi, izlenmesi, diskinin dolmaması, dead-letter queue'sunun boşaltılması gerekiyor. Bookstore'un yarı zamanlı tek ops kişisi için bu hafife alınacak bir maliyet değil.
- Duplicate'ler: broker'lar message'ı çoğunlukla "en az bir kez" teslim eder. Worker aynı e-postayı iki kez gönderebilir. Serinin 04. ve 06. yazıları bunun üzerine.
Ne Zaman HTTP Yeter?
Her çağrıyı message'a çevirmek, temporal coupling'den kurtulurken başka bir karmaşıklık satın almak demek. Şu durumlarda senkron çağrı doğru seçim:
Çağıran cevaba hemen ihtiyaç duyuyorsa. Kitap sayfasında "stokta var mı?" sorusu bir query'dir; cevabı message ile göndermenin anlamı yok. Kullanıcı ekranda bekliyor.
İşlem kısa ve bağımlılık güvenilirse. Aynı veri merkezindeki, aynı ekibin işlettiği bir servise 20 milisaniyelik bir çağrı için broker kurmak gereksiz.
Sonucun tutarlı olması kullanıcının kararını etkiliyorsa. Ödeme bu sınırda duruyor; Bookstore'un çözümü yukarıda gördüğümüz hibrit: kısa bir senkron deneme, zaman aşımında asenkron devam.
Akılda tutulacak soru şu: "Bu işi çağıran, sonucunu beklemek zorunda mı?" Cevap hayırsa ve iş başarısız olduğunda tekrar denenebiliyorsa, queue'ya bırakmak için iyi bir aday.
Sık Düşülen Tuzaklar
Queue üzerinden senkron bekleme
Request/reply'ı message ile kurup cevabı aynı HTTP isteğinin içinde beklemek, temporal coupling'i geri getirir; üstüne broker'ın gecikmesini ve timeout yönetimini ekler. Cevabı beklemen gerekiyorsa ya HTTP kullan ya da cevabı bekleme: isteği kabul et (202 Accepted) ve sonucu sonra bildir.
Task.Run ile "arka plana atmak"
E-postayı _ = Task.Run(...) ile göndermek asenkron görünür ama bir queue değildir: retry yok, exception kaybolur, process kapanınca iş yarıda kalır. C# Asenkron serisinin "C# async void ve Fire-and-Forget: Kaybolan Exception'lar" yazısı bu tuzağın ayrıntısı.
Queue derinliğini izlememek
Load leveling örneğinde gördüğün gibi queue, sorunu sessizce biriktirebilir. Consumer bir hata yüzünden durmuşsa kullanıcılar bunu ancak saatler sonra, gelmeyen e-postalardan fark eder. Queue derinliği ve en eski message'ın yaşı, ilk günden izlenmesi gereken iki metrik.
Broker'ı her şeyi çözen bir kutu sanmak
Broker message'ı taşır; iş kurallarını, tutarlılığı ya da sırayı kendiliğinden çözmez. Senkron zincirdeki "ödeme alındı ama 500 döndü" sorunu, aynı kod broker'la yazıldığında "message yayınlandı ama veritabanı commit'i başarısız oldu" sorununa dönüşebilir. Serinin ilerleyen yazıları tam olarak bu boşlukları kapatmak için var.
Küçük Bir Deneme
★☆☆ Load leveling simülasyonunda e-posta tarafının kapasitesini dakikada 50'den 100'e çıkar. Queue en fazla kaç message'a ulaşıyor, kaçıncı dakikada boşalıyor? Kapasite 130 olduğunda ne değişiyor?
★★☆ Channel.CreateUnbounded yerine Channel.CreateBounded<OrderConfirmationRequested>(5) kullan ve sağlayıcı kapalıyken on sipariş vermeyi dene. Checkout neden yavaşlıyor? BoundedChannelFullMode.DropOldest seçersen hangi müşterinin e-postası kaybolur?
★★★ SyncCheckoutService'i e-postayı hiç beklemeyecek şekilde değiştir ama yine de "500 döndü ama para çekildi" sorununu ödeme adımı için düşün: ödeme sağlayıcısı 5 saniyede cevap vermezse sipariş PaymentPending durumuna geçsin. Bu durumdaki bir siparişin ödemesinin alınıp alınmadığını sonradan kim, nasıl öğrenecek?
Bir sonraki yazıda queue'ya bıraktığımız şeyin kendisine bakacağız: OrderConfirmationRequested bir command mı, bir event mi? Fark neden önemli ve bir message'ın zarfında hangi bilgiler olmalı?