Mert Özen Her Satırda Daha İleri
Backend Caching — #07

Cache Invalidation Stratejileri: TTL, Explicit Silme, Event ile Invalidation ve Versioned Key'ler

Mert Özen 8 Kas 2026 15 dk 5 görüntülenme
Cache Invalidation Stratejileri: TTL, Explicit Silme, Event ile Invalidation ve Versioned Key'ler

Doğru invalidation, silmeyi commit'e bağlamak ve kalan yarışı TTL, generation key ya da event ile sınırlamaktır: Bookstore'da "önce yaz, sonra sil" sırası bile eski 12.00 fiyatını 600 saniye cache'te bıraktı, SavedChangesAsync'te silen interceptor açık transaction'da sorunu büyüttü, TransactionCommittedAsync ile düzeldi. Generation key aynı düzenekte yarışı kapattı, Redis pub/sub ikinci instance'ın L1'ini 0,7 ms'de temizledi.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; örnekler Entity Framework Core (EF Core) 10.0.14 (SQLite), StackExchange.Redis 3.4.31 ve Redis 8.2 ile koşturuldu.

Önce Tahmin Et

Bookstore'da fiyat güncelleyen kod, System Design serisinin önerdiği sırayı izliyor: önce veritabanına yaz, sonra Redis'teki book:42 key'ini sil. Fiyat 12.00'den 19.90'a çıkıyor. Tam o sırada bir okuma isteği cache'i kaçırmış, veritabanından 12.00'yi okumuş ve cevabı Redis'e yazmak üzere. Okuma isteğini küçük bir GC (garbage collection) duraklaması birkaç milisaniye geciktiriyor.

İki soru. Yazma bittikten sonra cache'te hangi fiyat var ve ne kadar süre orada kalır? İkincisi: ekip silme işini her endpoint'te unutmamak için EF Core'un SaveChanges interceptor'ına, SavedChangesAsync'e taşıyor. Fiyat güncellemesi aynı transaction'da bir audit satırı da yazdığı için BeginTransaction ile açık bir transaction kullanıyor. Interceptor sorunu çözer mi, büyütür mü?

Seçenekler ve Bedelleri

Invalidation'ın teorisi, yani cache-aside'da "yaz, sonra sil" sırası, silmenin güncellemeden neden daha güvenli olduğu ve okuma ile yazma arasındaki yarış System Design serisinin "Caching Stratejileri: Cache-Aside, Write-Through, Write-Behind ve Cache Stampede" yazısında anlatıldı; Facebook'un bu yarışı lease'lerle çözen makalesi de orada. Bu yazı .NET tarafına iniyor: yarışı kodda deterministik olarak üretip her çözümü aynı düzenekte ölçüyoruz. Önce elimizdeki seçenekler ve bedelleri:

StratejiBayatlık penceresiBedelBookstore'da
Yalnız TTL (Time to Live)TTL kadarKod yok; bayatlık tahmin edilebilirBestseller listesi
Yazınca silYarış penceresi, en kötü TTLVeriyi değiştiren her yol cache'i bilmeliKitap detayı
Write-through (yazınca SET)Eşzamanlı yazmalarda ters sıraYazma yolu yavaşlarKullanmıyoruz
Tag ile grup invalidationTag'in kapsadığı kadarTag takibi; HybridCache'te mantıksalKategori menüsü
Versioned ve generation keySıfır; eski key'ler TTL'e kadar bellekteFazladan bir GET, yetim key'lerKategori sayfaları
Event ile invalidationMesaj gecikmesiBroker, outbox, idempotent consumerBaşka service'in fiyat değişikliği
Redis pub/sub ile L1 (level 1, process içi cache)Milisaniyeler; mesaj kaybolabilirKısa L1 TTL'i yine şartProcess içi kitap cache'i

Tablodaki her satırın bir ortak noktası var: TTL hiçbir zaman kalkmıyor. Explicit silme, event ya da pub/sub bayatlık penceresini kısaltır; ama bir mesaj kaybolduğunda, bir process silmeden önce çöktüğünde ya da aşağıdaki yarışlardan biri yaşandığında son savunma hattı TTL'dir.

Stale Set'i Deterministik Olarak Üretmek

Yarışı "bazen oluyor" diye anlatmak yerine istediğimiz anda üretmek için cache-aside okumasına bir test hook'u koyuyoruz. Hook, veritabanından okuduktan sonra ama cache'e yazmadan önce çağrılıyor:

// klasik cache-aside; afterDbRead, okuyanı okuma ile set arasında donduran test hook'u
public async Task<BookDto> GetAsync(int id, Func<Task>? afterDbRead = null)
{
    string key = $"book:{id}";
    RedisValue cached = await redis.StringGetAsync(key);
    if (cached.HasValue) return JsonSerializer.Deserialize<BookDto>(cached.ToString())!;

    await using CatalogDb db = newDb();
    BookDto book = await db.Books.AsNoTracking().Where(b => b.Id == id)
        .Select(b => new BookDto(b.Id, b.Title, b.Price)).SingleAsync();
    log($"reader: cache miss, read price {book.Price:F2} from db");
    if (afterDbRead is not null) await afterDbRead();

    await redis.StringSetAsync(key, JsonSerializer.Serialize(book), Ttl);
    log($"reader: set {key} = {book.Price:F2}");
    return book;
}

Senaryo iki TaskCompletionSource ile sırayı kilitliyor: okuyan eski fiyatı okuyup bekliyor, yazan "doğru" sırayla işini bitiriyor, sonra okuyan devam ediyor:

// okuyan cache'i kaçırır, eski fiyatı okur ve set'ten önce durur
(Func<Task> hook, Task readDone, TaskCompletionSource resume) = Gate();
Task<BookDto> reader = cache.GetAsync(42, hook);
await readDone;

// yazan "doğru" sırada: önce veritabanı, sonra silme
await UpdatePriceAsync(42, 19.90m);
await redis.KeyDeleteAsync("book:42");
Log("writer: deleted book:42");

resume.SetResult();
await reader;
await ReportAsync();
== stale-set
   71 ms  reader: cache miss, read price 12.00 from db
   94 ms  writer: price 19.90 committed
   96 ms  writer: deleted book:42
  104 ms  reader: set book:42 = 12.00
  110 ms  result: db=19.90 cache=12.00 ttl=600s
== delete-first
    1 ms  writer: deleted book:42
   73 ms  reader: cache miss, read price 12.00 from db
   80 ms  reader: set book:42 = 12.00
  103 ms  writer: price 19.90 committed
  110 ms  result: db=19.90 cache=12.00 ttl=600s

Tahminin ilk cevabı: 12.00, tam 600 saniye boyunca. Silme, okuyanın SET'inden önce yapıldığı için hiçbir şey silmedi; okuyan da elindeki eski değeri taze bir TTL ile yazdı. İkinci senaryo, silmeyi veritabanından önce yapan sıra. Sonuç aynı ama pencere çok daha geniş: yazmanın bütün süresi boyunca gelen her okuma eski değeri cache'e geri koyabilir. "Yaz, sonra sil" yarışı imkânsız kılmıyor; yalnız pencereyi okuyanın okuma ile set arasındaki kısa süreye daraltıyor. Üç durumun zaman çizelgesi:

07-invalidation-race-tr.png

Commit Sonrası Invalidation: SaveChanges Interceptor'ı

Silme kodunu her endpoint'e yazmak, birini unutmayı garantiler. EF Core interceptor'ları değişikliği yapan yoldan bağımsız olarak çalıştığı için doğal bir yer; interceptor'ların temeli EF Core serisinin "EF Core Interceptor'ları ve Event'leri: Audit Field'ları ve Yavaş Sorgu Logu" yazısında. İlk deneme, SavingChangesAsync'te değişen kitapların key'lerini toplayıp SavedChangesAsync'te silen bir interceptor'dı. Tahmindeki gibi açık bir transaction'la çalıştırdım:

== tx-naive
   72 ms  reader: cache miss, read price 12.00 from db
   80 ms  reader: set book:42 = 12.00
  110 ms  invalidator: deleted book:42
  110 ms  writer: SaveChanges done, transaction still open
  112 ms  reader: cache miss, read price 12.00 from db
  113 ms  reader: set book:42 = 12.00
  113 ms  writer: committed
  119 ms  result: db=19.90 cache=12.00 ttl=600s

Tahminin ikinci cevabı: interceptor sorunu büyüttü. SavedChangesAsync, SaveChanges bittiğinde çalışır, transaction commit edildiğinde değil. Silme commit'ten önce yapıldı; aradaki okuma veritabanında hâlâ commit edilmemiş değişikliği göremedi ve 12.00'yi cache'e geri koydu. Transaction'ın ayrıntıları EF Core serisinin "EF Core'da Transaction'lar: SaveChanges, BeginTransaction ve Savepoint'ler" yazısında. Düzeltme, silmeyi commit'e bağlamak:

// key'leri SavingChanges'te toplar, yalnız transaction commit edildikten sonra invalid eder
public sealed class CommitAwareInvalidationInterceptor(ICacheInvalidator invalidator, ILogger<CommitAwareInvalidationInterceptor> logger)
    : SaveChangesInterceptor, IDbTransactionInterceptor
{
    private readonly HashSet<string> _pending = [];

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        _pending.UnionWith(CacheKeys.For(eventData.Context!));
        return ValueTask.FromResult(result);
    }

    public override async ValueTask<int> SavedChangesAsync(
        SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default)
    {
        // açık bir transaction varsa commit'i bekle
        if (eventData.Context!.Database.CurrentTransaction is null)
            await FlushAsync(cancellationToken);
        return result;
    }

    public async Task TransactionCommittedAsync(
        DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default) =>
        await FlushAsync(cancellationToken);

    public Task TransactionRolledBackAsync(
        DbTransaction transaction, TransactionEndEventData eventData, CancellationToken cancellationToken = default)
    {
        _pending.Clear();
        return Task.CompletedTask;
    }

    private async Task FlushAsync(CancellationToken ct)
    {
        if (_pending.Count == 0) return;
        string[] keys = [.. _pending];
        _pending.Clear();
        try
        {
            await invalidator.InvalidateAsync(keys, ct);
        }
        catch (RedisException ex)
        {
            // commit çoktan oldu; entry TTL dolana kadar yaşar (dual write, outbox'a bak)
            logger.LogWarning(ex, "Invalidation failed for {Keys}", string.Join(", ", keys));
        }
    }
}
== tx-commit
   73 ms  reader: cache miss, read price 12.00 from db
   82 ms  reader: set book:42 = 12.00
  107 ms  writer: SaveChanges done, transaction still open
  112 ms  invalidator: deleted book:42
  112 ms  writer: committed
  116 ms  result: db=19.90 cache=(empty)

Commit'ten önceki okuma cache'teki 12.00'yi döndü; bu doğru, çünkü değişiklik henüz yok. Silme TransactionCommittedAsync içinde yapıldı ve cache boş kaldı. Interceptor bir HashSet tuttuğu için state'lidir; bu yüzden singleton değil, her DbContext'e ayrı bir instance verilir:

builder.Services.AddScoped<CommitAwareInvalidationInterceptor>();
builder.Services.AddDbContext<CatalogDb>((sp, o) => o
    .UseSqlite("Data Source=../shop.db")
    .AddInterceptors(sp.GetRequiredService<CommitAwareInvalidationInterceptor>()));

Geriye bir boşluk kalıyor: commit başarılı, ama process silmeden önce çöküyor ya da Redis o an erişilemez. Veritabanı ve cache iki ayrı sistem ve ikisine birden atomik olarak yazamıyorsun; bu, Messaging ve Event-Driven Architecture serisinin "Transactional Outbox ve Inbox: Dual Write Problemini Anlamak ve Doğru Tasarlamak" yazısındaki dual write probleminin ta kendisi. Invalidation'ı kaybetmemek gerekiyorsa key'leri aynı transaction'da bir outbox tablosuna yazar, silmeyi arka plandaki dispatcher'a bırakırsın. Çoğu cache için ise interceptor'daki catch bloğu ve TTL yeterli: kaybedilen bir invalidation, en fazla bir TTL süresi bayatlık demek.

Akla gelen bir soru: bunu neden hazır bir EF Core second-level cache kütüphanesine bırakmıyoruz? Bu kütüphaneler sorgu sonuçlarını SQL (Structured Query Language) metni ve parametrelerle key'leyip, aynı uygulamanın SaveChanges'inde ilgili tabloya dokunan her şeyi geçersiz kılıyor. Bookstore'da üç sebeple tercih etmiyoruz. Invalidation tablo düzeyinde olduğu için tek bir fiyat değişikliği Books tablosundaki bütün cache'li sorguları siler ve sık yazılan tabloda hit oranı çöker. Aynı DbContext'ten geçmeyen yazmaları (başka bir service, import job'ı, elle çalıştırılan SQL) göremezler. En önemlisi, neyin, ne kadar süre ve hangi tutarlılık sözüyle cache'lendiği kararını sorgunun içine gömerler; bu yazıdaki yarışların hiçbiri ortadan kalkmaz, yalnız görünmez olur.

Delayed Double Delete Ne Zaman İşe Yarar

Commit sonrası silme, stale set yarışını kapatmaz; yalnız transaction kaynaklı pencereyi kapatır. Yarışın kalan kısmı için sık önerilen hile, silmeyi bir süre sonra tekrarlamak. Okuyanın duraklamasını önce 100, sonra 800 ms yaptım; ikinci silme 500 ms sonra:

Task<BookDto> reader = cache.GetAsync(42, () => Task.Delay(readerStallMs));
await Task.Delay(20);

await UpdatePriceAsync(42, 19.90m);
await redis.KeyDeleteAsync("book:42");
Log("writer: deleted book:42");
Task second = Task.Run(async () =>
{
    await Task.Delay(500);
    await redis.KeyDeleteAsync("book:42");
    Log("writer: deleted book:42 again (+500 ms)");
});
== double-delete 100
   72 ms  reader: cache miss, read price 12.00 from db
   73 ms  writer: price 19.90 committed
   75 ms  writer: deleted book:42
  185 ms  reader: set book:42 = 12.00
  579 ms  writer: deleted book:42 again (+500 ms)
 1519 ms  result: db=19.90 cache=(empty)
== double-delete 800
   70 ms  reader: cache miss, read price 12.00 from db
   72 ms  writer: price 19.90 committed
   73 ms  writer: deleted book:42
  576 ms  writer: deleted book:42 again (+500 ms)
  894 ms  reader: set book:42 = 12.00
 1523 ms  result: db=19.90 cache=12.00 ttl=599s

100 ms'lik duraklamada ikinci silme bayat SET'i temizledi ve bayatlık yaklaşık 400 ms'ye indi (figürdeki üçüncü panel). 800 ms'de ikinci silme erken geldi ve 12.00 yine 599 saniyelik TTL'le cache'te kaldı. Kural basit: double delete ancak gecikme, okuyanın okuma ile set arasında geçirebileceği en uzun süreden büyükse işe yarar. Bu süreyi bilemezsin; GC duraklaması, yavaş bir Redis bağlantısı ya da sıkışmış bir thread pool onu saniyelere çıkarabilir. Gecikmeyi büyütmek de bir bedel: ikinci silmeden hemen önce cache'i dolduran meşru taze değer de silinir. Double delete'i yarışı yüzde yüz kapatan bir çözüm olarak değil, bayatlık süresini TTL'den gecikmeye indiren ucuz bir ek olarak gör. İkinci silmeyi örnekteki gibi Task.Run ile değil, kalıcı bir yerden (outbox, background job queue'su) çalıştırmak gerekir; aksi hâlde deploy sırasında kaybolur.

Versioned ve Generation Key'ler

Yarışı kökten kaldırmanın yolu, eski değeri yazan okuyanın yazdığı yeri kimsenin okumamasını sağlamak. Generation key bunu yapıyor: key'in içinde bir sayaç var ve invalidation, değeri silmek yerine sayacı artırmak. Kategori sayfaları için:

// generation key: tek bir INCR kategorinin bütün sayfalarını erişilmez yapar
public async Task<List<BookDto>> GetCategoryPageAsync(int categoryId, int page, Func<Task>? afterDbRead = null)
{
    long generation = (long?)await redis.StringGetAsync($"gen:category:{categoryId}") ?? 0;
    string key = $"books:category:{categoryId}:g{generation}:page:{page}";
    RedisValue cached = await redis.StringGetAsync(key);
    if (cached.HasValue) return JsonSerializer.Deserialize<List<BookDto>>(cached.ToString())!;

    await using CatalogDb db = newDb();
    List<BookDto> books = await db.Books.AsNoTracking().Where(b => b.CategoryId == categoryId)
        .OrderBy(b => b.Id).Skip((page - 1) * 20).Take(20)
        .Select(b => new BookDto(b.Id, b.Title, b.Price)).ToListAsync();
    log($"reader: miss on {key}, read from db");
    if (afterDbRead is not null) await afterDbRead();

    await redis.StringSetAsync(key, JsonSerializer.Serialize(books), Ttl);
    log($"reader: set {key}");
    return books;
}

public async Task BumpCategoryAsync(int categoryId)
{
    long generation = await redis.StringIncrementAsync($"gen:category:{categoryId}");
    log($"writer: gen:category:{categoryId} -> {generation}");
}

Aynı stale set düzeneği, bu sefer silme yerine commit'ten sonra BumpCategoryAsync ile:

== generation
   72 ms  reader: miss on books:category:0:g0:page:1, read from db
   95 ms  writer: price 19.90 committed
   97 ms  writer: gen:category:0 -> 1
  106 ms  reader: set books:category:0:g0:page:1
  108 ms  reader: miss on books:category:0:g1:page:1, read from db
  109 ms  reader: set books:category:0:g1:page:1
  109 ms  next reader sees book 42 at 19.90
  113 ms  redis: books:category:0:g1:page:1 book 42 = 19.90
  114 ms  redis: books:category:0:g0:page:1 book 42 = 12.00

Duraklayan okuyan 12.00'yi yine yazdı, ama g0 key'ine; sayaç artık 1 olduğu için o key'i kimse okumuyor. Sonraki okuma g1'i kaçırdı ve 19.90'ı getirdi. Sayacı commit'ten sonra artırdığın sürece yeni sayacı gören her okuyan commit edilmiş veriyi okur. İkinci kazanç gruplar: kategorinin kaç sayfası cache'te olursa olsun tek bir INCR hepsini geçersiz kılar; SCAN ile key aramak ya da tag listesi tutmak gerekmez.

Bedeli üç kalem. Her okumada fazladan bir GET (sayaç). Yetim key'ler: g0 sayfaları TTL dolana kadar Redis belleğinde durur, bu yüzden generation key'ler TTL'siz yazılmaz. Sayacın kendisi de kaybolabilir; Redis eviction ile sayacı silerse sıfırdan başlar ve eski g0 key'leri hâlâ duruyorsa geri canlanır. Sayaç key'ini TTL'siz tutup eviction policy'nin onu atmayacağından emin olmak (serinin üçüncü yazısındaki volatile-* policy'ler) bu yüzden önemli. Key'deki v1: gibi şema sürümü de aynı fikrin deploy'a uygulanmış hâli: JSON (JavaScript Object Notation) şekli değişince sürüm artar ve eski key'ler kendiliğinden ölür.

Event ile Invalidation: Redis Pub/Sub ile L1'i Temizlemek

Buraya kadar silinen şey ortak Redis'teydi. L1 ise her instance'ın belleğinde; serinin dördüncü yazısında HybridCache'in diğer sunuculardaki L1'e dokunmadığını görmüştük. İki instance'lı bir API (Application Programming Interface) kurdum: her biri kitap detayını 5 dakikalık IMemoryCache'te tutuyor, fiyat değişikliği yukarıdaki interceptor'dan geçiyor. Interceptor'ın çağırdığı ICacheInvalidator, yerel L1'i silip bir Redis kanalına mesaj yayınlıyor:

// bu instance'ın L1'inden siler ve diğer instance'lara da aynısını yapmalarını söyler
public sealed class PubSubL1Invalidator(IMemoryCache l1, IConnectionMultiplexer redis, InstanceInfo me,
    ILogger<PubSubL1Invalidator> logger) : ICacheInvalidator
{
    public static readonly RedisChannel Channel = RedisChannel.Literal("bookstore:invalidate");

    public async Task InvalidateAsync(IReadOnlyCollection<string> keys, CancellationToken ct)
    {
        foreach (string key in keys) l1.Remove(key);
        if (!me.PubSub) return;

        string message = JsonSerializer.Serialize(new InvalidationMessage(me.Name, [.. keys], DateTime.UtcNow));
        long receivers = await redis.GetSubscriber().PublishAsync(Channel, message);
        logger.LogInformation("{Instance} published {Keys}, receivers={Receivers}", me.Name, string.Join(",", keys), receivers);
    }
}
await redis.GetSubscriber().SubscribeAsync(PubSubL1Invalidator.Channel, (_, value) =>
{
    InvalidationMessage message = JsonSerializer.Deserialize<InvalidationMessage>(value.ToString())!;
    if (message.Origin == me.Name) return;
    foreach (string key in message.Keys) l1.Remove(key);
    logger.LogInformation("{Instance} removed {Keys} from L1 ({Origin} -> {Instance}: {Ms:F1} ms)",
        me.Name, string.Join(",", message.Keys), message.Origin, me.Name, (DateTime.UtcNow - message.SentAtUtc).TotalMilliseconds);
});

A (17533) ve B (17534) aynı SQLite dosyasını ve aynı Redis'i kullanıyor. Önce pub/sub kapalı, sonra açık:

== pubsub off
A: GET /api/books/42   {"price":12.0,"source":"db","instance":"A"}
B: GET /api/books/42   {"price":12.0,"source":"db","instance":"B"}
B: GET /api/books/42   {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=19.90     204
A: GET /api/books/42   {"price":19.9,"source":"db","instance":"A"}
B: GET /api/books/42   {"price":12.0,"source":"L1","instance":"B"}
B: GET /api/books/42   {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=21.00     204
B: GET /api/books/42   {"price":12.0,"source":"L1","instance":"B"}
== pubsub on
A: GET /api/books/42   {"price":12.0,"source":"db","instance":"A"}
B: GET /api/books/42   {"price":12.0,"source":"db","instance":"B"}
B: GET /api/books/42   {"price":12.0,"source":"L1","instance":"B"}
A: PUT price=19.90     204
A: GET /api/books/42   {"price":19.9,"source":"db","instance":"A"}
B: GET /api/books/42   {"price":19.9,"source":"db","instance":"B"}
B: GET /api/books/42   {"price":19.9,"source":"L1","instance":"B"}
A: PUT price=21.00     204
B: GET /api/books/42   {"price":21.0,"source":"db","instance":"B"}
A published book:42, receivers=2
A published book:42, receivers=2
B removed book:42 from L1 (A -> B: 11.0 ms)
B removed book:42 from L1 (A -> B: 0.7 ms)

Pub/sub kapalıyken B iki fiyat değişikliğini de kaçırdı ve L1'deki 12.00'yi 5 dakika boyunca servis edecekti. Açıkken B'nin L1'i temizlendi; mesaj ilk güncellemede 11 ms'de, ikincisinde 0,7 ms'de ulaştı. receivers=2 A'nın kendisini de sayıyor; A kendi mesajını Origin kontrolüyle atlıyor.

Bu çözümün sınırını Redis dokümanı açıkça yazıyor: pub/sub at-most-once teslimat yapar; bağlantısı o an kopuk olan abone mesajı bir daha hiç almaz. Denemede CLIENT KILL TYPE pubsub ile abonelik bağlantılarını kopardım; StackExchange.Redis bir sonraki güncellemeden önce yeniden bağlanıp aboneliği yeniledi ve mesaj ulaştı, ama kopukluğa denk gelen mesaj kaybolur. Bu yüzden L1 TTL'ini, kaybolan bir mesajın bedelini kabul edeceğin kadar kısa tut. Mesajın kaybolmaması gerekiyorsa invalidation'ı bir domain event olarak outbox üzerinden kalıcı bir broker'a koyarsın; bu, ilerideki Message Queue'lar serisinin konusu. Pub/sub'ın kendisi ve Redis Streams gibi kalıcı alternatifler ise ilerideki Veritabanı › Redis serisinde. Bu mekanizmayı kendin yazmak istemiyorsan FusionCache'in backplane'i aynı işi yapıyor; dördüncü yazıda karşılaştırmıştık.

Üretimde Dikkat

Invalidation'ı da ölç. Yayınlanan ve alınan mesaj sayısını, interceptor'daki catch bloğunun kaç kez çalıştığını ve L1 TTL'inden önce gelen silmelerin oranını metric olarak tut. Bir instance'ın aldığı mesaj sayısı diğerlerinden düşükse o instance bir süre kopuk kalmıştır ve L1'i bayat olabilir.

Bayatlığı örnekle denetle. Arka planda saatte bir, rastgele yüz key için cache'teki değeri veritabanıyla karşılaştıran küçük bir job, hiçbir testin yakalayamayacağı yarışları ortaya çıkarır. Fark oranı sıfır değilse TTL'in o veri için verdiğin tazelik sözünü tuttuğunu doğrula.

Silmeyi idempotent ve toplu yap. Aynı key'i iki kez silmek zararsızdır; interceptor'daki HashSet bir transaction içindeki tekrarları zaten birleştiriyor. Binlerce satırı güncelleyen bir import'ta tek tek silmek yerine generation sayacını bir kez artırmak çok daha ucuz.

Interceptor her yolu görmez. ExecuteUpdateAsync ve ExecuteDeleteAsync change tracker'ı atladığı için SavingChanges'te key toplanmaz; bu method'larla toplu güncelleme yapan kod invalidation'ı kendisi çağırmalı. Veritabanına doğrudan yazan başka sistemler varsa tek gerçek kaynak veritabanının kendisidir: CDC (Change Data Capture) ile değişiklik akışını okuyup invalidation üretmek bu durumda en sağlam yol.

Sık Düşülen Tuzaklar

Silmeyi commit'ten önce yapmak. Bu, SavedChangesAsync'te silen interceptor da olabilir, transaction scope'unun içindeki bir RemoveAsync çağrısı da. Açık transaction varsa silme commit'e bağlanmalı.

Silmek yerine güncellemek. Write-through'da iki eşzamanlı yazma cache'e ters sırayla ulaşırsa eski değer kazanır; silmek bu sırayı umursamaz.

Double delete'i garanti sanmak. Gecikmeden uzun süren bir okuma, ikinci silmeden sonra yine bayat değer yazar; ölçtüğümüz 800 ms'lik senaryo tam olarak bu.

Generation sayacını eviction'a açık bırakmak. Sayaç silinip sıfırlanırsa eski sayfalar geri döner.

Pub/sub'a güvenip L1 TTL'ini uzun tutmak. At-most-once teslimatta kaybolan her mesaj, L1 TTL'i kadar bayatlık demek.

State'li interceptor'ı singleton yapmak. Paylaşılan bir HashSet farklı isteklerin key'lerini karıştırır ve bir isteğin rollback'i diğerinin invalidation'ını siler.

Küçük Bir Deneme

★☆☆ stale-set senaryosunda TTL'i 10 dakikadan 30 saniyeye indir ve sonucu tekrar çalıştır. Bayatlık penceresi nasıl değişti? Bu sayının kitap detayı için kabul edilebilir olup olmadığına karar ver.

★★☆ CommitAwareInvalidationInterceptor'a bir SaveChangesFailedAsync override'ı ekle ve açık transaction olmadan SaveChanges hata verdiğinde _pending'in temizlendiğini bir senaryoyla göster. Eklemezsen bir sonraki başarılı SaveChanges neyi siler?

★★★ Kitap detayını da generation key'e çevir: sayacı ayrı bir key yerine veritabanındaki bir Version kolonundan al (EF Core serisinin "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazısındaki concurrency token gibi). Stale set düzeneğini tekrar çalıştır ve fazladan okuduğun kolonun maliyetini, kapattığın yarışla karşılaştır.

Bu yazıda invalidation'ın doğruluğuyla uğraştık: neyin, ne zaman, hangi sırayla silindiği. Bir sonraki yazıda cache'i üretimde çökerten üç soruna geçiyoruz: stampede, penetration ve avalanche. Request coalescing, negative caching, TTL'e jitter eklemek ve Redis erişilemezken uygulamayı ayakta tutmak.