Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Architecture Decision'lar — #03

Trade-off Analizi: Seçenekleri Senaryolarla Karşılaştırmak, Sensitivity ve Trade-off Point'ler

Mert Özen 8 Kas 2026 17 dk 4 görüntülenme
Trade-off Analizi: Seçenekleri Senaryolarla Karşılaştırmak, Sensitivity ve Trade-off Point'ler

Seçenekleri birbirleriyle değil, öncelikli quality attribute senaryolarına karşı ve mümkünse bir spike'ın ölçümleriyle karşılaştırırsın; ATAM'ın sensitivity point, trade-off point ve risk kavramları kararın nerede kırılgan olduğunu gösterir. Bookstore kataloğu için cache yok, HybridCache ve Redis seçenekleri gerçek bir spike'la tartılıyor: p95 neden değişmedi, L2 eski fiyatı neden çözmedi ve weighted decision matrix neden yanıltıyor.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Kampanya haftasının planlama toplantısında Grace bir öneri getiriyor: "Katalog aramasının önüne Redis koyalım." Linus "Redis'i kim işletecek?" diye soruyor, Alan "HybridCache zaten framework'te, neden yeni bir container?" diyor. Tartışma yarım saattir dönüyor ve herkes haklı görünüyor. Ada tartışmayı durdurup bir spike öneriyor: üç seçeneği aynı trafik üzerinde, iki uygulama instance'ıyla dene. Veritabanını simüle ediyoruz (her arama sorgusu 40 ms), Redis ise yerel bir Docker container'ında gerçek. Spike'ın ana döngüsü:

using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Bookstore.Catalog;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
string mode = args.FirstOrDefault() ?? "spike";

if (mode == "spike")
{
    // Kampanyaya benzeyen sabit seed'li trafik: önce 2.000 aramayla ısınma, sonra 2.000 aramalık ölçüm
    string[] warmUp = Requests(seed: 42), measured = Requests(seed: 43);
    foreach (string option in new[] { "no-cache", "hybrid", "redis", "hybrid+l2" })
    {
        CatalogDb db = new();
        string run = Guid.NewGuid().ToString("N");
        ICatalogSearch[] instances = [Build(option, db, "localhost:6390", run), Build(option, db, "localhost:6390", run)];
        for (int i = 0; i < warmUp.Length; i++)
            await instances[i % 2].SearchAsync(warmUp[i]); // istekler iki instance'a sırayla dağılıyor

        int before = db.Queries;
        List<double> ms = [];
        for (int i = 0; i < measured.Length; i++)
        {
            Stopwatch watch = Stopwatch.StartNew();
            await instances[i % 2].SearchAsync(measured[i]);
            ms.Add(watch.Elapsed.TotalMilliseconds);
        }
        int queries = db.Queries - before;

        // Fiyat değişti: instance 0 kendi cache'ini temizliyor, instance 1 ne görüyor?
        string hot = measured[0];
        await instances[1].SearchAsync(hot);
        db.ChangePrice(hot, 21.99m);
        await instances[0].InvalidateAsync(hot);
        SearchResult seenByOther = await instances[1].SearchAsync(hot);

        Console.WriteLine($"{option,-9} p50={P(ms, 50),5:F2} ms  p95={P(ms, 95),5:F2} ms  " +
            $"db queries={queries,4}  other instance price={seenByOther.Price}");
    }
}

Seçenekleri kuran yardımcı method'lar ve sabit seed'li Zipf dağılımı:

static string[] Requests(int seed)
{
    Random random = new(seed);
    return [.. Enumerable.Range(0, 2_000).Select(_ => $"term-{Zipf(random, 1_000)}")];
}

static ICatalogSearch Build(string option, CatalogDb db, string redis, string run)
{
    ServiceCollection services = new();
    if (option is "redis" or "hybrid+l2")
        services.AddStackExchangeRedisCache(o => { o.Configuration = redis; o.InstanceName = run; });
    if (option is "hybrid" or "hybrid+l2")
        services.AddHybridCache(o => o.DefaultEntryOptions = new() { Expiration = TimeSpan.FromMinutes(5) });
    ServiceProvider sp = services.BuildServiceProvider();

    return option switch
    {
        "no-cache" => new DbSearch(db),
        "redis" => new RedisSearch(db, sp.GetRequiredService<IDistributedCache>()),
        _ => new HybridSearch(db, sp.GetRequiredService<HybridCache>()),
    };
}

static int Zipf(Random random, int n)
{
    // Basit Zipf (s = 1): popüler terimler çok daha sık aranıyor
    double h = 0;
    for (int k = 1; k <= n; k++) h += 1.0 / k;
    double u = random.NextDouble() * h, sum = 0;
    for (int k = 1; k <= n; k++)
    {
        sum += 1.0 / k;
        if (sum >= u) return k;
    }
    return n;
}

static double P(List<double> values, int percentile)
{
    List<double> sorted = [.. values.Order()];
    return sorted[(int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1];
}

Ve üç arama implementation'ı:

namespace Bookstore.Catalog
{
    public sealed record SearchResult(string Term, int Hits, decimal Price);

    public sealed class CatalogDb
    {
        private readonly Dictionary<string, decimal> _prices = [];
        private int _queries;

        public int Queries => _queries;

        public async Task<SearchResult> SearchAsync(string term, CancellationToken ct = default)
        {
            Interlocked.Increment(ref _queries);
            await Task.Delay(40, ct); // full-text arama sorgusunu temsil eden simülasyon
            return new SearchResult(term, term.Length * 7, _prices.GetValueOrDefault(term, 18.49m));
        }

        public void ChangePrice(string term, decimal price) => _prices[term] = price;
    }

    public interface ICatalogSearch
    {
        Task<SearchResult> SearchAsync(string term, CancellationToken ct = default);
        Task InvalidateAsync(string term, CancellationToken ct = default);
    }

    public sealed class DbSearch(CatalogDb db) : ICatalogSearch
    {
        public Task<SearchResult> SearchAsync(string term, CancellationToken ct = default) => db.SearchAsync(term, ct);
        public Task InvalidateAsync(string term, CancellationToken ct = default) => Task.CompletedTask;
    }

    public sealed class HybridSearch(CatalogDb db, HybridCache cache) : ICatalogSearch
    {
        public async Task<SearchResult> SearchAsync(string term, CancellationToken ct = default) =>
            await cache.GetOrCreateAsync($"search:{term}", async token => await db.SearchAsync(term, token), cancellationToken: ct);

        public async Task InvalidateAsync(string term, CancellationToken ct = default) =>
            await cache.RemoveAsync($"search:{term}", ct);
    }

    public sealed class RedisSearch(CatalogDb db, IDistributedCache cache) : ICatalogSearch
    {
        public async Task<SearchResult> SearchAsync(string term, CancellationToken ct = default)
        {
            byte[]? cached = await cache.GetAsync($"search:{term}", ct);
            if (cached is not null)
                return JsonSerializer.Deserialize<SearchResult>(cached)!;

            SearchResult result = await db.SearchAsync(term, ct);
            await cache.SetAsync($"search:{term}", JsonSerializer.SerializeToUtf8Bytes(result),
                new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct);
            return result;
        }

        public async Task InvalidateAsync(string term, CancellationToken ct = default) =>
            await cache.RemoveAsync($"search:{term}", ct);
    }
}

Dört soru. Cache'li seçeneklerde p95 ne kadar düşer? Hangi seçenek veritabanına en az sorgu gönderir? Fiyat değiştikten ve instance 0 cache'ini temizledikten sonra instance 1 hangi fiyatı gösterir? Son olarak hybrid+l2, yani HybridCache'in arkasına Redis'i ikinci seviye cache olarak koymak, fiyat sorununu çözer mi?

Spike'ın Sonuçları

no-cache  p50=42.06 ms  p95=42.10 ms  db queries=2000  other instance price=21.99
hybrid    p50= 0.00 ms  p95=42.09 ms  db queries= 324  other instance price=18.49
redis     p50= 0.31 ms  p95=42.79 ms  db queries= 192  other instance price=21.99
hybrid+l2 p50= 0.00 ms  p95=42.47 ms  db queries= 193  other instance price=18.49

Birincisi, p95 hiçbir seçenekte düşmedi. Üç seçenekte de 42 ms civarında, yani veritabanı süresi. Sebebi basit: ısınmadan sonra bile HybridCache'te aramaların yüzde 16'sı, Redis'te yüzde 10'u veritabanına gidiyor. Miss oranı yüzde 5'in üstündeyse en yavaş yüzde 5'lik dilim tamamen miss'lerden oluşur ve p95 veritabanının süresine eşit olur. Cache p50'yi neredeyse sıfıra indirdi, ama "kampanyada p95 < 300 ms" senaryosunu kurtaran şey latency değil, veritabanı yükü: 2.000 aramada 2.000 sorgu yerine 324 ya da 192. Mimariye Giriş serisinin üçüncü yazısında ortalamanın neden yanılttığını görmüştük; burada aynı ders p50 için geçerli.

İkincisi, fiyat. Instance 0 kendi cache'ini temizledi, ama instance 1 eski fiyatı (18.49) göstermeye devam ediyor; HybridCache'in L1'i (birinci seviye, process içindeki cache) her instance'ın kendi belleğinde. Asıl sürpriz son satırda: HybridCache'in arkasına Redis'i L2 (ikinci seviye, paylaşılan cache) olarak eklemek veritabanı yükünü Redis seviyesine indirdi (193), ama eski fiyat sorunu aynen kaldı. RemoveAsync çağrıldığı instance'ın L1'ini ve L2'yi temizleyebilir, ama öteki instance'ın belleğine ulaşmıyor. Bunu dokümandan değil, spike'tan öğrendik.

Üçüncüsü, Redis'in kendisi düşerse ne olur? Programın üçüncü modu container durdurulmuşken aynı aramayı deniyor:

else
{
    // Redis container'ı durdurulmuşken: varsayılan ayar ve kısa connectTimeout ile art arda üç arama
    foreach (string redis in new[] { "localhost:6390", "localhost:6390,abortConnect=false,connectTimeout=250,asyncTimeout=250" })
    foreach (string option in new[] { "redis", "hybrid+l2" })
    {
        ICatalogSearch search = Build(option, new CatalogDb(), redis, "down");
        List<string> calls = [];
        for (int i = 1; i <= 3; i++)
        {
            Stopwatch watch = Stopwatch.StartNew();
            try
            {
                await search.SearchAsync($"term-{i}");
                calls.Add($"ok {watch.ElapsedMilliseconds} ms");
            }
            catch (Exception ex)
            {
                calls.Add($"{ex.GetType().Name} {watch.ElapsedMilliseconds} ms");
            }
        }
        Console.WriteLine($"{option,-9} [{redis}] {string.Join(" | ", calls)}");
    }
}
redis     [localhost:6390] RedisConnectionException 6037 ms | RedisConnectionException 5992 ms | RedisConnectionException 6002 ms
hybrid+l2 [localhost:6390] ok 6075 ms | ok 5967 ms | ok 6000 ms
redis     [localhost:6390,abortConnect=false,connectTimeout=250,asyncTimeout=250] RedisConnectionException 5007 ms | RedisConnectionException 5998 ms | RedisConnectionException 5000 ms
hybrid+l2 [localhost:6390,abortConnect=false,connectTimeout=250,asyncTimeout=250] ok 5044 ms | ok 5999 ms | ok 6001 ms

Yalnız Redis kullanan seçenekte her arama yaklaşık 5–6 saniye bekleyip RedisConnectionException ile düşüyor. HybridCache L2 hatasını yutup veritabanından cevap veriyor, ama o da her istekte aynı süreyi bekliyor. Bağlantı ayarlarını kısaltmak bu makinede süreyi değiştirmedi; nedenini bu yazının kapsamında araştırmadık, ama sonuç değişmiyor: Redis seçeneği, kampanya günü Redis'in düşmesini bir arama kesintisine çeviriyor. Aynı spike'ın son modu da instance sayısının etkisini ölçüyor:

else if (mode == "scale")
{
    // Instance sayısı arttıkça in-process cache'in veritabanına bıraktığı yük
    string[] warmUp = Requests(seed: 42), measured = Requests(seed: 43);
    foreach (int count in new[] { 1, 2, 4, 8 })
    {
        CatalogDb db = new();
        ICatalogSearch[] instances = [.. Enumerable.Range(0, count).Select(_ => Build("hybrid", db, "", ""))];
        for (int i = 0; i < warmUp.Length; i++)
            await instances[i % count].SearchAsync(warmUp[i]);
        int before = db.Queries;
        for (int i = 0; i < measured.Length; i++)
            await instances[i % count].SearchAsync(measured[i]);
        Console.WriteLine($"hybrid x{count}  db queries={db.Queries - before,4}");
    }
}
hybrid x1  db queries= 192
hybrid x2  db queries= 324
hybrid x4  db queries= 474
hybrid x8  db queries= 647

Seçenekleri Senaryolara Karşı Tartmak

Mimariye Giriş serisinin üçüncü yazısında quality attribute senaryolarını yazıp utility tree ile önceliklendirmiştik. Bu yazıda onları bir karar aracı olarak kullanıyoruz: her seçeneği, o seçeneğin etkilediği öncelikli senaryolara karşı tek tek değerlendir. "Hangisi daha iyi?" sorusu cevapsız kalır; "kampanyada arama senaryosunu hangisi tutturur, fiyat senaryosunu hangisi bozar?" sorusunun ise ölçülebilir bir cevabı var. Bookstore'un bu karar için dört senaryosu: kampanya zirvesinde veritabanı yükü (utility tree'deki arama senaryosundan), değişen fiyatın 60 saniye içinde her yerde görünmesi (bu karar için ürün ekibiyle yazılan yeni senaryo), cache component'i düştüğünde aramanın çalışmaya devam etmesi ve yarı zamanlı tek ops'un taşıyabileceği işletme yükü.

03-option-scenario-matrix-tr.png

Matris "kazanan"ı söylemiyor; her seçeneğin hangi senaryoyu neye mal olarak karşıladığını söylüyor. Cache yok seçeneği tutarlı ve basit, ama kampanya yükünü taşımıyor. Redis yükü en iyi taşıyor ve tutarlı, ama yeni bir arıza biçimi ve yeni bir işletme yükü getiriyor. HybridCache arıza ve işletme açısından bedava, ama fiyat senaryosunu bozuyor ve instance sayısı arttıkça veritabanına bıraktığı yük büyüyor. Hücrelerdeki S, T ve R harfleri bir sonraki bölümün konusu.

ATAM'dan Dört Kavram

ATAM (Architecture Tradeoff Analysis Method), Rick Kazman, Mark Klein ve Paul Clements'in 2000 tarihli SEI (Software Engineering Institute) teknik raporunda (CMU/SEI-2000-TR-004) tanımlanan, paydaşlarla birkaç gün süren dokuz adımlı bir mimari değerlendirme yöntemi. Tamamını küçük bir ekipte uygulamak gerçekçi değil, ama raporun dört kavramı her trade-off konuşmasına taşınabilir.

Sensitivity point: bir quality attribute cevabının kritik biçimde bağlı olduğu bir mimari özellik. Raporun örneği, bir VPN'in (Virtual Private Network) gizlilik düzeyinin şifreleme bit sayısına duyarlı olması. Bizde HybridCache seçeneğinde veritabanı yükü instance sayısına duyarlı: 1, 2, 4, 8 instance için 192, 324, 474, 647 sorgu. Sensitivity point'ler sarı bayraktır: "bu parametreyi değiştirirken dikkat et."

Trade-off point: birden fazla quality attribute için aynı anda sensitivity point olan ve onları ters yönde etkileyen özellik. Raporun örneği yine şifreleme: seviyesini artırmak güvenliği artırır, işlem süresini de. Bizde cache entry'sinin expiration süresi (TTL, time to live): uzatmak veritabanı yükünü düşürür, eski fiyatın görünme süresini uzatır. Rapor trade-off point'leri bir mimarideki en kritik kararlar olarak tanımlıyor; bu yüzden TTL'in değeri ADR'da (Architecture Decision Record) gerekçesiyle yazılmalı, kodda sihirli bir sayı olarak kalmamalı.

Risk ve non-risk. Risk, potansiyel olarak sorunlu bir mimari karar ya da henüz verilmemiş bir karar. Non-risk ise genellikle örtük varsayımlara dayanan iyi bir karar. Bizde risk: "Redis erişilemezse istemcinin ne kadar bekleyeceği ve ne yapacağı tanımlı değil." Non-risk: "60 saniyelik eski fiyat kabul edilebilir, çünkü checkout fiyatı her zaman veritabanından okuyor." Rapor önemli bir uyarı yapıyor: non-risk, varsayımları değişmediği sürece non-risk. Biri checkout'ta da cache'teki fiyatı kullanmaya başlarsa, aynı karar bir risk olur. Bu yüzden non-risk'i varsayımıyla birlikte yazıyoruz.

Risk theme. ATAM'ın son adımında risk'ler ortak temalar altında toplanır ve her temanın hangi iş hedefini tehdit ettiği gösterilir. SEI'nin 2006 tarihli "Risk Themes Discovered Through Architecture Evaluations" raporu (CMU/SEI-2006-TR-012) 18 değerlendirmeyi incelemiş ve ilginç bir sonuca varmış: bulunan temaların kabaca iki katı "omission" türünden, yani verilmemiş kararlardan geliyor. Bizim Redis risk'imiz tam olarak böyle ve yalnız değil: ödeme sağlayıcısı için de bir timeout ve fallback politikası yazılı değil. İkisi aynı temaya giriyor: "dış bağımlılıkların timeout davranışı tanımsız." Tek tek risk'ler bir karar listesi verir, tema ise bir mimari eksiği gösterir.

İki Saatlik Hafif ATAM

Bookstore ekibi bu kararı bir öğleden sonra, iki saatlik bir oturumda tarttı. Gündem ATAM'ın adımlarından türetildi, ama paydaş atölyesi ve senaryo oylaması yerine ekibin zaten yazılı olan utility tree'si kullanıldı:

  • 0–15 dk: iş hedefi ve öncelikli senaryolar. Utility tree'den yalnız bu kararın dokunduğu (H, *) senaryolar okunur; eksik senaryo varsa (bizde fiyat tazeliği) ürün ekibiyle burada yazılır.
  • 15–35 dk: seçenekler. Her seçenek, öneren kişi tarafından beş dakikada anlatılır; "hiçbir şey yapma" seçeneği her zaman listededir.
  • 35–85 dk: seçenek × senaryo yürüyüşü. Her hücre için "bu seçenek bu senaryoyu karşılar mı, neye dayanarak?" sorulur; cevap ölçümse spike'tan, tahminse "tahmin" olarak işaretlenir. Sensitivity point, trade-off point, risk ve non-risk bu sırada not edilir.
  • 85–105 dk: risk'ler gruplanır ve temalar çıkarılır. Her tema için bir sahip ve bir sonraki adım (spike, ayrı bir ADR, bir test) yazılır.
  • 105–120 dk: karar ya da kararın neden verilemediği. Karar verildiyse ADR'ın taslağını yazacak kişi belirlenir; verilemediyse eksik bilgi ve onu getirecek spike tanımlanır.

Oturumun çıktısı bir rapor değil; doğrudan ADR'ın Decision Drivers, Pros and Cons ve More Information bölümlerine giren notlar. Tam ATAM'ın ayrıntısını, adımların sırasını ve paydaş rollerini merak edenler için SEI raporu iyi bir başlangıç.

Weighted Decision Matrix'in Sahte Kesinliği

Oturumun ortasında Alan klasik bir öneri getirdi: kriterlere ağırlık verelim, seçenekleri 1–5 arası puanlayalım, en yüksek skor kazansın. Ekip puanları spike sonuçlarına bakarak verdi. Sonucun ne kadar sağlam olduğunu görmek için her ağırlığı ve her puanı bir adım oynatan küçük bir program yazdık:

using System.Globalization;

CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;

string[] criteria = ["db load", "p50 latency", "price freshness", "cache outage", "operations"];
int[] weights = [5, 3, 4, 4, 3];

// 1-5 arası puanlar; spike sonuçlarına bakarak ekip toplantısında verildi
Dictionary<string, int[]> scores = new()
{
    ["no-cache"] = [1, 1, 5, 5, 5],
    ["hybrid"] = [4, 5, 2, 5, 4],
    ["redis"] = [5, 4, 5, 2, 2],
};
string baseline = Winner(scores, weights);
Print("baseline", scores, weights);

// Her ağırlığı bir adım oynat: kazanan değişiyor mu?
int weightFlips = 0, weightVariants = 0;
foreach ((int i, int[] w) in OneStep(weights))
{
    weightVariants++;
    if (Winner(scores, w) != baseline) weightFlips++;
}
Console.WriteLine($"weights: winner changed in {weightFlips} of {weightVariants} one-step changes");

// Her puanı bir adım oynat: kazanan değişiyor mu?
int scoreVariants = 0;
foreach (string option in scores.Keys)
{
    foreach ((int i, int[] s) in OneStep(scores[option]))
    {
        scoreVariants++;
        Dictionary<string, int[]> changed = new(scores) { [option] = s };
        if (Winner(changed, weights) != baseline)
            Print($"{option}.{criteria[i]} {scores[option][i]}->{s[i]}", changed, weights);
    }
}
Console.WriteLine($"scores: {scoreVariants} one-step changes tried");

static IEnumerable<(int Index, int[] Values)> OneStep(int[] values)
{
    for (int i = 0; i < values.Length; i++)
        foreach (int delta in new[] { -1, 1 })
        {
            int v = values[i] + delta;
            if (v is < 1 or > 5) continue;
            int[] copy = [.. values];
            copy[i] = v;
            yield return (i, copy);
        }
}

static string Winner(Dictionary<string, int[]> s, int[] w) => s.MaxBy(x => Score(x.Value, w)).Key;

static double Score(int[] s, int[] w) => (double)s.Zip(w, (a, b) => a * b).Sum() / w.Sum();

static void Print(string label, Dictionary<string, int[]> s, int[] w) =>
    Console.WriteLine($"{label,-28} " + string.Join("  ", s.Select(x => $"{x.Key}={Score(x.Value, w):F2}")) + $"  -> {Winner(s, w)}");
baseline                     no-cache=3.32  hybrid=3.95  redis=3.74  -> hybrid
weights: winner changed in 0 of 9 one-step changes
hybrid.db load 4->3          no-cache=3.32  hybrid=3.68  redis=3.74  -> redis
scores: 21 one-step changes tried

HybridCache 3.95 ile kazanıyor, Redis 3.74. İki ondalık hane bir kesinlik izlenimi veriyor, ama puanlar 1–5 arası, yarısı tahmine dayalı ve fark tek bir puan adımından küçük. Ağırlıklar açısından sonuç sağlam: dokuz değişikliğin hiçbiri kazananı değiştirmiyor. Puanlarda ise tek bir hücre yetiyor: HybridCache'in "db load" puanı 4 yerine 3 olsaydı Redis kazanırdı. Bu hücre tam olarak spike'ın ölçtüğü şey: yüzde 84'lük azalma 4 mü, 3 mü? İki instance'la 4 demek kolay; sekiz instance'ta 647 sorgu ile 3 demek de kolay.

Matrisi atmıyoruz, ama ondan sayı değil yer bilgisi alıyoruz: kazananı değiştiren hücre, ölçme emeğinin harcanacağı yer. Skorun kendisi ADR'a girmiyor; ADR'a giren, kararın hangi varsayıma duyarlı olduğu ("instance sayısı 4'ü geçerse yeniden bak").

Sonucu ADR'a Taşımak

Oturumun sonunda karar verildi ve Grace, önerisi seçilmemiş olmasına rağmen, ADR'ı yazmayı üstlendi. Spike ve matris, ADR'ın ilgili bölümlerine şöyle girdi (Pros and Cons'un yalnız Redis kısmını gösteriyoruz):

---
status: accepted
date: 2026-06-10
decision-makers: Grace, Ada
consulted: Linus (ops), Catalog & Search team
informed: customer support
---

# Cache catalog search in-process with HybridCache

## Context and Problem Statement

At campaign peak, catalog search sends about 200 queries per second to the
database and the search scenario (p95 < 300 ms at 200 requests/s) is at risk.
Should we put a cache in front of catalog search, and if so, which one?

## Decision Drivers

* Database load at campaign peak (search scenario, H/M in the utility tree)
* A changed price must not be shown for longer than 60 seconds
* Search keeps working when any cache component fails
* One part-time ops engineer

## Considered Options

* No cache
* HybridCache, in-process only
* Redis through IDistributedCache

## Decision Outcome

Chosen option: "HybridCache, in-process only", because it removed 84% of the
search queries in the spike with no new infrastructure and no new failure mode.
The entry expiration is 60 seconds, because the price freshness driver and the
database load pull it in opposite directions (trade-off point).

### Consequences

* Good, because search no longer depends on the database for popular terms.
* Bad, because each instance has its own cache; database load grows with the
  instance count (192, 324, 474, 647 queries for 1, 2, 4, 8 instances).
* Bad, because a changed price can be shown for up to 60 seconds; checkout
  always reads the price from the database.

### Confirmation

A load test in the nightly pipeline fails when database queries per 1,000
searches exceed 250 with two instances.

## Pros and Cons of the Options

### Redis through IDistributedCache

* Good, because one shared cache: fewest queries (192) and no stale prices.
* Bad, because with Redis down every search failed after about 6 seconds in
  the spike; a timeout and fallback policy has to be designed first.
* Bad, because a new container to monitor, back up and secure.

## More Information

Reopen when we run more than 4 instances, or when price changes must be
visible in less than 60 seconds. Risk recorded: timeout behaviour of external
dependencies is not defined anywhere (same theme as the payment provider).

Dikkat edilecek üç yer var. Decision Outcome trade-off point'i adıyla anıyor; 60 saniye keyfi bir sayı değil, iki driver arasındaki uzlaşma. Consequences, sensitivity point'i ölçülmüş değerleriyle veriyor; sekiz instance'a çıkıldığında kimse "bunu bilmiyorduk" diyemez. More Information ise kararı yeniden açacak iki koşulu ve risk temasını kayda geçiriyor. Redis reddedilmedi, ertelendi: önce dış bağımlılıkların timeout politikası yazılmalı. HybridCache'in kendisini ASP.NET Core Web API (Application Programming Interface) serisinin ".NET 9 Yenilikleri" yazısında, caching seçeneklerini de aynı serinin caching yazısında ayrıntılı görmüştük.

Sık Düşülen Tuzaklar

Seçenekleri senaryolar yerine özelliklerle karşılaştırmak. "Redis daha hızlı", "HybridCache daha basit" cümlelerinin hiçbiri bir senaryoya bağlı değil. Her iddiayı "hangi senaryoyu, hangi ölçümle?" sorusuna bağla.

Spike yapmadan karar vermek. Bu yazıdaki iki önemli bulgu, p95'in değişmemesi ve L2'nin eski fiyatı çözmemesi, tartışmada kimsenin aklına gelmemişti. Belirsizliği azaltmanın en ucuz yolu, kararın döndüğü noktayı birkaç saatlik bir prototiple ölçmek.

Spike sonucunu production gerçeği sanmak. Veritabanı simüle edildi, Redis aynı makinedeydi, trafik sentetikti. Spike seçenekleri birbirine göre kıyaslar; mutlak sayılar için production'a yakın bir load test gerekir. ADR'da bunu açıkça yaz.

Weighted matrix'in skorunu karar sanmak. İki ondalıklı bir skor, 1–5 arası tahminlerden üretilmiş bir sayı. Skoru değil, kazananı değiştiren hücreyi raporla.

Non-risk'i varsayımsız yazmak. "Eski fiyat sorun değil" cümlesi, "çünkü checkout fiyatı veritabanından okuyor" olmadan altı ay sonra bir bug'a dönüşür.

Yalnız verilmiş kararların risk'ine bakmak. SEI raporunun bulgusu net: risk'lerin çoğu verilmemiş kararlardan geliyor. Oturumda "henüz kimsenin karar vermediği ne var?" sorusunu ayrıca sor.

Küçük Bir Deneme

★☆☆ Spike'ta Zipf'in terim sayısını 1.000'den 5.000'e çıkar. HybridCache ile Redis arasındaki sorgu farkı nasıl değişiyor? p95 için miss oranının yüzde 5'in altına inmesi gerekiyor; hangi terim sayısında bu oluyor?

★★☆ HybridSearch'e LocalCacheExpiration ayarı ekle ve fiyat testini, expiration'ı geçecek kadar bekleyip tekrarla. Sonra trade-off point'i ölç: expiration 5, 30 ve 60 saniyeyken, zaman damgalı bir istek dizisinde veritabanı sorgusu kaç olur?

★★★ Kendi sisteminde bekleyen bir karar için iki saatlik hafif ATAM oturumu düzenle. Seçenek × senaryo matrisini doldur, en az bir sensitivity point, bir trade-off point, bir risk ve varsayımıyla bir non-risk bul. Risk'leri gruplayıp bir tema çıkarabiliyor musun?

Bu karar bir ekibin içinde, iki saatte verildi. Bazı kararlar ise birden fazla module'ü ve ekibi ilgilendiriyor, çözümü de baştan belli değil. Bir sonraki yazıda bu tür kararları yazıyla tartışmanın araçlarına geçiyoruz: design doc'un bölümleri ve yaşam döngüsü, RFC (Request for Comments) süreci ve Bookstore'un sipariş servisine outbox eklemek için yazılmış iki sayfalık bir design doc.