Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Messaging ve Event-Driven Architecture — #11

Event-Driven Architecture'ın Bedeli: Gözlemlenebilirlik, Hata Ayıklama ve Ne Zaman Kullanmamalı

Mert Özen 7 Kas 2026 12 dk 5 görüntülenme
Event-Driven Architecture'ın Bedeli: Gözlemlenebilirlik, Hata Ayıklama ve Ne Zaman Kullanmamalı

Event-driven bir sistemde "ne oldu?" sorusunu cevaplamak için W3C traceparent'ı message header'larında taşımak, her message'da correlation ve causation id tutmak ve lag, DLQ, duplicate oranı, takılan saga gibi sinyalleri izlemek gerekir; kullanıcının cevap beklediği akışlar senkron kalmalıdır. Trace propagation ve outbox'ın trace'i koparması gerçek çıktılarla, DLQ runbook'u, distributed monolith ve Bookstore'un final mimarisi.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir; trace örnekleri yalnız System.Diagnostics kullanıyor, ek paket yok.

Önce Tahmin Et

Destek ekibine bir bilet geliyor: "Ada'nın siparişi iki saattir 'işleniyor' görünüyor." Sipariş checkout'ta kabul edildi, outbox'a yazıldı, relay tarafından birkaç saniye sonra yayınlandı. Relay'in yayın kodu, her satır için bir send span'i açıyor:

using (Activity? relay = MessagingTelemetry.Source.StartActivity("outbox relay", ActivityKind.Internal, default(ActivityContext)))
{
    Envelope lost = MessagingTelemetry.Send("bookstore.ordering.order-placed", "OrderPlaced", "Ordering", Guid.CreateVersion7(), null);
    Envelope kept = MessagingTelemetry.Send("bookstore.ordering.order-placed", "OrderPlaced", "Ordering", Guid.CreateVersion7(), null,
        storedContext: requestContext);
    Console.WriteLine($"  request trace {requestContext.TraceId.ToHexString()[..8]}: " +
                      $"without stored context {lost.Headers["traceparent"][3..11]}, with stored context {kept.Headers["traceparent"][3..11]}");
}

İki Send çağrısı aynı event'i yayınlıyor, tek farkları ikincisinin storedContext alması. Tracing aracında Ada'nın checkout isteğini açtığında Inventory'nin ve Payments'ın span'lerini görebilecek misin? Hangi çağrıda?

Asenkron Akışta "Ne Oldu?" Sorusu

Senkron bir zincirde hata ayıklamak, ne kadar uzun olursa olsun, bir call stack'i okumaktır: istek bir yerden girer, bir yerde patlar, exception izi aradaki her adımı gösterir. Serinin "Neden Messaging?" yazısında bu zinciri kırmanın faydalarını saydık; bu yazı faturası. Asenkron akışta tek bir call stack yok. OrderPlaced Ordering'in process'inde doğuyor, outbox'ta birkaç saniye bekliyor, broker'da bir queue'da sıraya giriyor, Inventory'nin üç instance'ından birinde işleniyor, ortaya çıkan StockReserved başka bir queue'dan Payments'a gidiyor. Adımlar arasında milisaniyeler değil bazen dakikalar var, her adımın log'u başka bir yerde. "Ada'nın siparişi nerede takıldı?" sorusunu cevaplamak için üç araç birlikte gerekiyor: message'ların birbirine nasıl yol açtığını kaydeden correlation ve causation id'leri, aynı akışın adımlarını zaman ekseninde gösteren distributed trace'ler ve tek tek siparişlere değil sistemin bütününe bakan metrikler.

Trace Context'i Message ile Taşımak

HTTP (Hypertext Transfer Protocol) dünyasında trace context'i taşımanın standardı W3C (World Wide Web Consortium) Trace Context: Şubat 2020'de Recommendation olan (güncel metni Kasım 2021 tarihli) Level 1 spec'i, traceparent header'ını version-traceid-parentid-flags biçiminde tanımlıyor (örneğin 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01). ASP.NET Core ve HttpClient bunu kendiliğinden taşıyor. Message'larda ise bu iş senin: producer kendi span'inin context'ini message'ın header'ına yazmalı, consumer da onu okuyup kendi span'ine parent yapmalı. .NET'te bunun için ek paket gerekmiyor; System.Diagnostics içindeki Activity ve DistributedContextPropagator yeterli:

public static class MessagingTelemetry
{
    public static readonly ActivitySource Source = new("Bookstore.Messaging");

    // Producer: send span'ini aç, onun context'ini W3C traceparent olarak header'lara yaz
    public static Envelope Send(string destination, string type, string service, Guid correlationId, Guid? causationId,
        ActivityContext? storedContext = null)
    {
        using Activity? send = Source.StartActivity($"send {destination}", ActivityKind.Producer,
            storedContext ?? Activity.Current?.Context ?? default);
        Guid messageId = Guid.CreateVersion7();
        send?.SetTag("messaging.system.name", "rabbitmq");
        send?.SetTag("messaging.operation.type", "send");
        send?.SetTag("messaging.destination.name", destination);
        send?.SetTag("messaging.message.id", messageId.ToString());

        Dictionary<string, string> headers = [];
        DistributedContextPropagator.Current.Inject(send, headers,
            static (carrier, key, value) => ((Dictionary<string, string>)carrier!)[key] = value);
        return new Envelope(messageId, correlationId, causationId, type, service, headers);
    }

    // Consumer: header'daki context'i parent yaparak process span'ini aç
    public static Activity? StartProcess(string destination, Envelope envelope)
    {
        DistributedContextPropagator.Current.ExtractTraceIdAndState(envelope.Headers,
            static (object? carrier, string name, out string? value, out IEnumerable<string>? values) =>
            {
                values = null;
                ((Dictionary<string, string>)carrier!).TryGetValue(name, out value);
            },
            out string? traceParent, out string? traceState);
        ActivityContext.TryParse(traceParent, traceState, isRemote: true, out ActivityContext parent);

        Activity? process = Source.StartActivity($"process {destination}", ActivityKind.Consumer, parent);
        process?.SetTag("messaging.operation.type", "process");
        process?.SetTag("messaging.message.id", envelope.MessageId.ToString());
        process?.SetTag("bookstore.correlation_id", envelope.CorrelationId.ToString());
        return process;
    }
}

Bir checkout isteğini, Inventory'nin ve Payments'ın tepkileriyle birlikte aynı process'te taklit ettim; ActivityListener her span bittiğinde türünü, adını, trace id'sini ve parent span id'sini yazıyor:

1) Direct publish inside the request
  Producer send bookstore.ordering.order-placed           trace dbe26313 parent a3a04f02
  Producer send bookstore.inventory.stock-reserved        trace dbe26313 parent df9e5637
  Producer send bookstore.payments.payment-failed         trace dbe26313 parent 5e0ae8f0
  Consumer process bookstore.inventory.stock-reserved     trace dbe26313 parent 5e40a5e5
  Consumer process bookstore.ordering.order-placed        trace dbe26313 parent 71d74beb
  Server   POST /orders                                   trace dbe26313 parent -

Altı span'in hepsi aynı trace'te (dbe26313): HTTP isteği, OrderPlaced'in gönderilmesi ve işlenmesi, Inventory'nin StockReserved'ı, Payments'ın PaymentFailed'i. Tracing aracında bu, broker'ı aşan tek bir zaman çizelgesi demek. Span adları ve attribute'lar OpenTelemetry'nin messaging semantic convention'larından: span adı {operation} {destination}, operation türleri create, send, receive, process ve settle. Bu convention'lar hâlâ "Development" durumunda ve attribute adları değişebiliyor (örneğin örnekteki messaging.system.name); bu yüzden kendi koduna gömmek yerine kullandığın client library'nin ya da messaging framework'ünün instrumentation'ına bırakmak daha az bakım ister. Aynı spec, consumer span'inin producer context'ini parent yapmasının yalnız tek message'lık işlemede uygun olduğunu, batch işlemede her message için parent yerine link kullanılması gerektiğini söylüyor; bir span'in tek parent'ı olabilir. OpenTelemetry SDK'sının (Software Development Kit) kurulumu ve exporter'lar ilerideki DevOps › Observability serisinin, message client'larındaki instrumentation ise Backend serisinin "Mesajlaşmada Observability" yazısının konusu.

Outbox ve Trace: Context'i Satıra Yaz

Açılıştaki soru, outbox'ın gizli bir yan etkisi. Relay message'ı checkout isteği bittikten saniyeler sonra, kendi döngüsünde yayınlıyor; o anda Activity.Current artık isteğin değil, relay'in span'i:

2) Outbox relay publishes later, without and with the stored traceparent
  Server   POST /orders                                   trace f1b3e59f parent -
  Producer send bookstore.ordering.order-placed           trace 1dfd7818 parent fee3988e
  Producer send bookstore.ordering.order-placed           trace f1b3e59f parent cb6f7198
  request trace f1b3e59f: without stored context 1dfd7818, with stored context f1b3e59f
  Internal outbox relay                                   trace 1dfd7818 parent -

storedContext almayan çağrı message'ı relay'in trace'ine (1dfd7818) bağladı; Ada'nın checkout isteğinin trace'inde (f1b3e59f) bu message ve sonrasındaki her şey görünmez. İkinci çağrı, istek sırasında saklanan context'i parent yaptı ve zincir korundu. Serinin "Transactional Outbox ve Inbox" yazısında outbox satırına traceparent koymayı önermemizin sebebi buydu: context, message'ın diğer üst bilgileri gibi commit anında satıra yazılmalı. Alternatif olarak relay kendi span'ini açıp saklanan context'e link verebilir; trace aracında iki ayrı trace olarak görünür ama aralarında gezinilebilir. CloudEvents kullanıyorsan aynı bilgi Distributed Tracing extension'ının traceparent attribute'unda taşınabilir; extension spec'i, bunun transport header'larının yerine geçmediğini, çok adımlı akışta ilk trace'i taşıdığını belirtiyor.

Correlation ve Causation Zinciri

Trace'ler güçlü ama iki zayıflıkları var: çoğu sistemde sampling yapılır, yani her akışın trace'i saklanmaz, ve trace'ler genelde birkaç gün tutulur. Destek bileti üç hafta sonra gelirse trace çoktan silinmiştir. Bu yüzden her message'ın envelope'unda, serinin "Message Türleri" yazısında tanıttığımız iki alan her zaman taşınır ve log'a yazılır: aynı iş akışının bütün message'larında aynı kalan correlation id ve bu message'a doğrudan yol açan message'ın id'si olan causation id. Log'daki envelope'lardan akışın ağacını kurmak birkaç satır:

public static class CausationTree
{
    // Log'a yazılmış envelope'lardan "ne neye yol açtı" ağacını kurar
    public static void Print(IReadOnlyList<Envelope> log, Guid correlationId, TimeSpan quietFor)
    {
        Envelope[] flow = [.. log.Where(e => e.CorrelationId == correlationId)];
        ILookup<Guid?, Envelope> children = flow.ToLookup(e => e.CausationId);
        foreach (Envelope root in children[null])
            Print(root, children, "", quietFor);
    }

    private static void Print(Envelope e, ILookup<Guid?, Envelope> children, string indent, TimeSpan quietFor)
    {
        Envelope[] next = [.. children[e.MessageId]];
        string note = next.Length == 0 ? $"   <- nothing happened after this for {quietFor.TotalMinutes:0} min" : "";
        Console.WriteLine($"  {indent}{e.Type} ({e.Service}){note}");
        foreach (Envelope child in next)
            Print(child, children, indent + "  ", quietFor);
    }
}
3) Causation tree for the first checkout
  OrderPlaced (Ordering)
    StockReserved (Inventory)
      PaymentFailed (Payments)   <- nothing happened after this for 10 min

Ağaç bileti saniyeler içinde cevaplıyor: ödeme reddedilmiş ve ondan sonra hiçbir şey olmamış. PaymentFailed'e tepki vermesi gereken kimse (Inventory'nin stoğu bırakması, Ordering'in siparişi iptal etmesi) tepki vermemiş; bakılacak yer o consumer'ların DLQ'su (dead-letter queue) ve log'ları. Orchestration kullanan bir saga'da aynı cevap daha da hızlı: saga tablosunda sipariş hangi adımda ve ne zamandan beri orada. Serinin "Saga Pattern" yazısındaki tabloda orchestration'ın lehine yazdığımız satır tam olarak buydu. Correlation id'yi trace id ile karıştırmamak gerekiyor: trace teknik ve sampling'e tabi bir kayıttır, correlation id ise iş akışının kalıcı kimliği; ASP.NET Core Web API (Application Programming Interface) serisinin "Logging" yazısındaki structured logging ile her log satırına scope olarak eklenmesi, log aramasını bir sorguya indirger.

Mimari Sinyal Olan Metrikler ve DLQ Runbook'u

Trace ve log tek bir siparişin hikâyesini anlatır; sistemin sağlığını ise birkaç metrik anlatır. Bunlar teknik metrikler gibi görünse de aslında mimarinin varsayımlarını ölçer. Consumer lag ya da queue'daki en eski message'ın yaşı, "tepki sonra gelebilir" vaadinin ne kadar "sonra" olduğunu söyler; Bookstore'da onay e-postası için bir dakika kabul edilebilir, on dakika değil. Outbox'taki en eski işlenmemiş satırın yaşı, broker'a ulaşamadığımızı kullanıcıdan önce gösterir. DLQ'ya düşen message sayısı ve hızı contract'ın ya da consumer'ın bozulduğunu söyler; bir deploy'dan hemen sonra artıyorsa sebep büyük ihtimalle bir schema değişikliğidir. Inbox'ın yakaladığı duplicate oranı relay'in ya da broker'ın ne kadar tekrar ürettiğini gösterir; birden yükselmesi bir çökme döngüsünün işareti olabilir. Saga'ların adım başına sayısı ve o adımdaki en uzun süre, takılan iş akışlarını gösterir; Failed adımındaki her saga bir insanın işidir. Son olarak, uçtan uca gecikme (event'in OccurredAt'i ile son tepkinin işlenme anı arasındaki süre) eventual consistency'nin gerçek boyunu ölçer. Bu metriklerin toplanması ve dashboard'u Backend serisinin observability yazısında; mimari karar açısından önemli olan, her birine bir eşik ve bir sahip atanması.

DLQ'daki bir message'ı yeniden işlemek de bir mimari karar kadar disiplin ister. Bookstore'da üzerinde anlaşılan sıra şöyle:

  • Sınıflandır: hata türüne göre grupla (deserialization, iş kuralı, dış sistem, bug); aynı sebepten düşen yüzlerce message tek bir olaydır.
  • Kök sebebi düzelt ve deploy et; düzeltmeden replay yapmak message'ları DLQ'ya geri gönderir.
  • Replay'i orijinal MessageId ile yap; yeni id üretmek inbox'ın duplicate korumasını devre dışı bırakır.
  • Hızı sınırla ve küçük bir grupla başla; binlerce message'ı aynı anda bırakmak consumer'ı ve dış sistemleri ezer.
  • Sıraya duyarlı message'larda version alanına güven; replay edilen eski bir message daha yeni bir state'in üstüne yazmamalı.
  • Sonucu doğrula ve kaydet: DLQ boşaldı mı, ilgili saga'lar ilerledi mi, ve olay bir ADR (Architecture Decision Record) ya da postmortem notu gerektiriyor mu?

DLQ'nun broker'lardaki configuration'ı ve yeniden işleme araçları Backend serisinin "Dead-Letter Queue Yönetimi" yazısında.

Event-Driven Architecture'ın Bilinen Hastalıkları

Martin Fowler'ın 2017'deki "What do you mean by Event-Driven?" yazısı, event notification'ın en büyük riskini açıkça söyler: sistemi güzelce ayrıştırırken daha büyük ölçekteki akışı gözden kaybetmek kolaydır. Akış kodda hiçbir yerde yazılı değilse, onu bilen tek yer kişilerin kafası olur; ekip değiştikçe o bilgi de gider. Buna sık rastlanan adıyla event spaghetti deniyor: her servis birkaç event'i dinliyor, birkaçını yayınlıyor ve "sipariş iptal edilince ne olur?" sorusunun cevabı ancak bütün servislerin subscription'larını okuyarak bulunuyor. Serinin "Saga Pattern" yazısında choreography'nin bedeli olarak gördüğümüz şey buydu. İlacı, önemli iş akışlarını bir orchestrator'da görünür kılmak ve AsyncAPI ile kimin neyi dinlediğini bir katalogda tutmak.

İkinci hastalık, event'lerle kurulmuş distributed monolith. Servisler ayrı deploy ediliyor, aralarında broker var, ama Mimariye Giriş serisinin "Monolith'i Doğru Anlamak" yazısında tarif ettiğimiz distributed monolith'in bütün belirtileri duruyor: contract'lar paylaşılan bir DLL'de (dynamic-link library) ve her değişiklik herkesi birlikte deploy etmeye zorluyor; bir servis, cevabını aynı istek içinde beklediği bir "request" message'ı gönderip kullanıcıyı bekletiyor, yani senkron çağrıyı queue'nun arkasına saklıyor; bir event'in işlenmesi, zincirdeki bütün servislerin aynı anda ayakta olmasını gerektiriyor. Bu durumda asenkron iletişimin bütün bedelini ödeyip faydalarının hiçbirini almıyorsun. Üçüncüsü, serinin "Message Türleri" yazısında anlatılan event kılığında command: SendEmailEvent gibi tek alıcısı olan ve alıcıya ne yapacağını söyleyen bir "event", aslında bir command'dır ve yayınlayanı alıcıya bağlar.

Sık Düşülen Tuzaklar

Trace'i log ve correlation id'nin yerine koymak

Sampling'e tabi olan ve birkaç gün saklanan trace'ler, haftalar sonra gelen bir müşteri şikâyetini cevaplayamaz. Correlation ve causation id'ler her message'da ve her log satırında olmalı; trace onların üstüne gelen bir kolaylık.

Outbox'ta trace context'i kaybetmek

Yukarıdaki çıktı gösterdi: relay'in yayınladığı message, saklanan bir context yoksa isteğin trace'inden kopar. Context outbox satırına yazılmalı.

Replay'de yeni MessageId üretmek

DLQ'dan yeniden gönderirken yeni bir message oluşturmak, inbox'ın aynı message'ı tanımasını engeller ve consumer daha önce kısmen işlediği bir message'ı ikinci kez uygular.

Metrikleri broker'ın varsayılan dashboard'una bırakmak

Queue derinliği tek başına bir şey söylemez; 10.000 message bir kampanya sabahında normal, gece üçte alarm sebebidir. Eşikler iş beklentilerinden türetilmeli: "onay e-postası bir dakika içinde" bir eşiktir, "queue'da 1.000 message" değildir.

Her şeyi event yapmak

Bir sonraki bölümün konusu: event-driven bir araç, bir hedef değil.

Ne Zaman Kullanmamalı: Bookstore'un Final Mimarisi

Serinin "Neden Messaging?" yazısında senkron zinciri kırmanın gerekçelerini, sonraki yazılarda bunun bedelini parça parça gördük: eventual consistency, duplicate'ler, sıra, outbox, saga, contract evrimi ve observability. Karar artık akış akış verilebilir. Kullanıcının cevabı beklediği ve cevabın başka bir sistemin tepkisine bağlı olmadığı her şey senkron kalmalı; bir olgunun birden çok, sayısı zamanla artabilecek tepkisi varsa ve bu tepkilerin gecikmesi kabul edilebiliyorsa event uygun; birden çok sistemin değiştiği ve yarıda kalırsa telafi gereken iş akışı saga'dır. Bookstore için:

AkışSeçimGerekçe
Katalog aramasıSenkron HTTP, aynı processKullanıcı cevabı bekliyor; p95 < 300 ms hedefi; salt okuma
Sepette fiyat ve kampanya hesabıSenkron, in-processSonuç anında gösterilmeli; tek module'ün kuralı
CheckoutSenkron kabul + asenkron sagaKullanıcıya "alındı" hemen, sonuç saga bitince
Stok, ödeme, kargoOrchestration (OrderSaga)Pivot, timeout ve compensation var; akış görünür olmalı
Ödeme sağlayıcısı çağrısıPayments içinde senkron HTTP + timeoutDış API senkron; sonucu saga'ya event olarak döner
Onay ve kargo e-postasıEvent + outbox (choreography)Akışı etkilemeyen tepki; birkaç saniye gecikme sorun değil
Fiyat değişikliği (BookPriceChanged)Event-carried state transferOrdering kendi kopyasını tutar, Catalog'a sorgu atmaz
Kargo takip webhook'uHTTP ile al, inbox ile işleKargo firması tekrar gönderebilir; idempotent olmalı
Yönetim raporlarıRead model ve sorguRaporu message ile "istemek" yerine veriyi okumak

11-final-architecture-tr.png

Figürdeki en önemli ayrıntı, hâlâ tek bir deployment olması. Event-driven architecture microservices demek değil; Bookstore'un module'leri aynı process'te çalışırken de outbox, broker, inbox ve saga'dan faydalanıyor, çünkü asıl ihtiyaç dış sistemlerin yavaşlığından ve hatalarından checkout'u korumaktı. Independent deployability, Mimariye Giriş serisinde bilinçli olarak "şimdilik değil" dediğimiz bir characteristic; ekip sayısı dörde çıktığında bu karar yeniden açılacak ve module'ler arasındaki bu event'ler, servis sınırlarının hazır dikiş yerleri olacak. Event-driven yaklaşımı hiç kullanmaman gereken durumlar da belli: tek ekibin yönettiği, birkaç tablolu bir CRUD (Create, Read, Update, Delete) uygulaması; her işlemin anında ve kesin tutarlılık gerektirdiği bir para transferi; kullanıcının her adımda bir sonuç beklediği bir sihirbaz ekranı. Bu durumlarda bir broker eklemek, yönetilecek bir sistem, ayıklanacak bir akış ve açıklanacak bir gecikme ekler; karşılığında bir şey vermez.

Küçük Bir Deneme

★☆☆ CausationTree.Print'i, her message'ın log'a yazıldığı zamanı da gösterecek ve iki adım arasında beş dakikadan uzun süre geçmişse uyaracak şekilde genişlet. Ada'nın siparişi hangi adımda bekledi?

★★☆ MessagingTelemetry.StartProcess'i batch işleme için yeniden yaz: on message'ı tek bir process span'inde işle ve her message'ın context'ini parent yerine ActivityLink olarak ekle. Tracing çıktısı nasıl değişiyor?

★★★ Bookstore'un tablodaki her akışı için bir sinyal (metrik ve eşik) ve bir sahip yaz. Hangisinin eşiğini kampanya dönemindeki on kat trafik bozuyor ve bunu nasıl ifade edersin?

Bu yazıyla seri tamamlandı. Senkron çağrı zincirinin neden kırıldığından başladık; message türlerini, channel'ları, delivery guarantee'lerini, sırayı ve idempotent consumer'ı, outbox ile inbox'ı, saga'yı kavram ve kod olarak, contract evrimini ve son olarak bütün bunların bedelini gördük. Kavramlar broker'dan bağımsızdı; bir sonraki adım bunları gerçek client'larla kurmak. Backend kategorisindeki Message Queue'larla Çalışmak serisi RabbitMQ, Azure Service Bus ve Kafka client'larıyla, MassTransit ve Wolverine ile, bu serideki outbox ve inbox'ın tam Entity Framework Core (EF Core) implementasyonuyla buradan devam ediyor.