Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Yazılım geliştirici

ASP.NET Core Output Cache ile Etiket Tabanlı Önbellek Temizleme

Yazar: Oğuzhan KARAGÜZEL ·

Bir web sitesini hızlandırmanın en etkili yolu, sayfayı her istekte veritabanından okuyup yeniden oluşturmak yerine bir kez oluşturup saklamaktır. İşin zor tarafı saklamak değil, saklananı ne zaman silmeniz gerektiğini bilmek. Bu sitenin altyapısı olan Elevare'de uzun süre en kolayını yaptık: CMS'te bir şey kaydedilince sitenin bütün önbelleğini sildik. Bu yazıda bunun neden pahalıya patladığını ve ASP.NET Core'un Output Cache özelliğiyle sadece değişen sayfaları silen, etiket tabanlı bir yapıyı nasıl kurduğumuzu anlatacağım.

Sorun: her kayıt bütün siteyi soğutuyordu

Elevare iki uygulamadan oluşuyor: içeriği düzenlediğim CMS ve ziyaretçinin gördüğü site. İkisi aynı veritabanını kullanıyor ama ayrı süreçlerde çalışıyor. Site, oluşturduğu sayfaları bir saat boyunca önbellekte tutuyordu. CMS'te herhangi bir şey kaydedildiğinde sitenin /api/cache/clear adresi çağrılıyor ve önbelleğin tamamı siliniyordu.

Sonuç şu: bir yazıdaki tek bir harf hatasını düzeltsem ana sayfa, bütün makaleler ve projeler de önbellekten gidiyordu. Üstelik site haritasını iki saatte bir yenileyen arka plan işi de her çalıştığında aynı temizliği yapıyordu. Trafiği az olan bir sitede bu, ziyaretçilerin çoğunun önbelleği yeni boşalmış bir sayfaya denk gelmesi demek. Yerelde ölçtüğümde soğuk bir sayfa yaklaşık 600 ms'de, önbellekten gelen bir sayfa ise yaklaşık 50 ms'de hazırlanıyordu.

ASP.NET Core Output Cache'e kısaca bakalım

Output Cache, .NET 7 ile gelen ve bir uç noktanın ürettiği cevabı olduğu gibi saklayan bir ara katman (middleware). Bir politika tanımlıyorsunuz, uç noktaya bağlıyorsunuz ve ara katmanı devreye alıyorsunuz:

C#
builder.Services.AddOutputCache(options =>
{
    options.AddPolicy("Pages", policy => policy
        .Expire(TimeSpan.FromHours(24))
        .Tag("pages")
        .SetVaryByQuery(Array.Empty<string>()));
});

var app = builder.Build();
app.UseRouting();
app.UseOutputCache();
C#
public class PageController : Controller
{
    [OutputCache(PolicyName = "Pages")]
    public async Task<IActionResult> Index(string slug) { /* ... */ }
}

Buradaki SetVaryByQuery(Array.Empty<string>()) satırını atlamayın. Varsayılan ayarda sorgu dizesinin tamamı önbellek anahtarına giriyor. Yani ?utm_source=... ya da ?fbclid=... ile paylaşılan her bağlantı için ayrı bir önbellek kaydı oluşuyor. Sayfanın içeriğini gerçekten değiştiren parametreler varsa (bizde liste sayfalarındaki sayfa numarası ve arama metni gibi), anahtara sadece onları eklemek gerekiyor.

Benim için Output Cache'in asıl önemli özelliği etiketler. Her önbellek kaydına bir ya da birkaç etiket verebiliyorsunuz. IOutputCacheStore.EvictByTagAsync ile de o etiketi taşıyan bütün kayıtları tek seferde silebiliyorsunuz.

Etiketleri tasarlamak: sayfa neyden oluştu?

Etiketleri politikada sabit yazmak yetmiyor, çünkü bir sayfanın hangi verilerden oluştuğu ancak sayfa oluşturulurken belli oluyor. Bu yüzden her istek için küçük bir nesne kullandık. Sayfayı oluşturan servisler, okudukları şeyleri bu nesneye yazıyor:

C#
public sealed class PageCacheDependencies
{
    private readonly HashSet<string> _tags = new(StringComparer.Ordinal) { "pages" };

    public IReadOnlyCollection<string> Tags => _tags;

    public void Add(string tag) => _tags.Add(tag);
}

// Program.cs: her istek için bir tane
builder.Services.AddScoped<PageCacheDependencies>();

Sayfa oluşurken her servis kendi payını ekliyor: sayfanın kendisi page:42, kullandığı bağlı şablon (üst menü, alt bilgi) tpl:3, sayfa yolunda (breadcrumb) adı geçen sayfalar info:11, sayfada bir liste varsa listing. Cevap dönmeden hemen önce de bu etiketler önbellek kaydına ekleniyor:

C#
private void TagCachedResponse()
{
    if (HttpContext.Features.Get<IOutputCacheFeature>()?.Context is not { } cache)
        return;

    foreach (string tag in cacheDependencies.Tags)
        cache.Tags.Add(tag);
}

Kullandığımız etiketler ve ne işe yaradıkları:

EtiketHangi kayıtlarda?Ne zaman silinir?
pagesBütün sayfalar, 404, RSS ve site haritalarıNe olduğu anlaşılamayan her değişiklikte
page:{id}Sayfanın kendisiSayfanın içeriği ya da ayarları değişince
info:{id}Başlığı ya da adresi başka bir sayfada görünen sayfalarSayfanın başlığı ya da adresi değişince
tpl:{id}Bağlı bir şablonu kullanan sayfalarŞablon düzenlenince
listingSayfa listesi, önceki/sonraki yazı gösteren sayfalar, RSSBir sayfa eklenince, yayından kalkınca ya da etiketi değişince
notfoundÖnbellekteki 404 cevaplarıO adreste yeni bir sayfa yayımlanabilecek her değişiklikte

Son satır gözden kaçabiliyor. 404 cevaplarını da kısa süreliğine önbelleğe alıyorsanız, o adreste bir sayfa yayımlandığı anda eski 404'ü silmeniz gerekir.

CMS tarafı: değişikliği etiketlere çevirmek

Hangi etiketlerin silineceğine karar vermek için en sağlam yer, veritabanına yazılan değişikliğin kendisi. CMS'te EF Core'un ChangeTracker'ı, kaydedilecek her satırı ve hangi sütunların değiştiğini biliyor. Biz de SaveChanges sırasında bu kayıtları tek tek gezip her birini etiketlere çeviren bir sınıflandırıcı yazdık:

C#
foreach (EntityEntry entry in changeTracker.Entries())
{
    if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted))
        continue;

    switch (entry.Entity)
    {
        case PageContent content:
            if (OnlyInvisibleColumns(entry))   // ör. yalnızca önizleme kopyası değişti
                continue;
            change.AddTag($"page:{content.PageInfoId}");
            change.AddTag("listing");
            break;

        case PageTemplate template:
            change.AddTag($"tpl:{template.Id}");
            break;

        case UserNote or AuditLog:
            break;                             // site bu tabloları hiç okumuyor

        default:
            change.MarkEverything();           // bilinmeyen değişiklik: hepsini sil
            break;
    }
}

Buradaki en önemli karar, varsayılan davranışın "her şeyi sil" olması. İleride yeni bir tablo eklenir de sınıflandırıcıya tanıtılmazsa, sistem eski ve güvenli davranışa döner. Hız için yaptığımız bir iyileştirme, sitede eski içerik göstermek gibi çok daha kötü bir hataya dönüşmemeli. O yüzden daraltmayı her tablo için tek tek, bilerek ekledik.

Bir de ziyaretçiye görünmeyen sütunlar var. CMS her kayıtta "son güncelleme tarihi" gibi alanları da yazıyor, editörün kaydedilmemiş önizleme kopyası da ayrı bir sütunda duruyor. Sadece bu sütunlar değiştiyse ziyaretçinin gördüğü hiçbir şey değişmemiştir, hiçbir etiket silinmiyor.

Site tarafı: etiketle silen uç nokta

Kayıt bitince CMS topladığı etiketleri sitedeki bir uç noktaya gönderiyor. İki uygulama ayrı süreçler olduğu için bu bir HTTP çağrısı. Bu çağrıyı kullanıcı girişiyle değil, iki tarafın da bildiği ortak bir gizli anahtarla koruyoruz:

C#
app.MapPost("/api/cache/evict", async (
    HttpContext http, EvictRequest body, IOutputCacheStore outputCache,
    PageCacheWarmer warmer, CancellationToken ct) =>
{
    if (!InternalAdminAuthorization.IsAuthorized(http, clearSecret))
        return Results.Unauthorized();
    if (body.Tags is not { Count: > 0 and <= 2000 })
        return Results.BadRequest();

    foreach (string tag in body.Tags.Distinct(StringComparer.Ordinal))
        await outputCache.EvictByTagAsync(tag, ct);

    warmer.RequestWarmup();
    return Results.NoContent();
});

Sıralamaya dikkat edin. Sayfalar başka önbelleklerdeki verilerden (dil listesi, site ayarları gibi) oluşuyorsa önce o veri önbelleklerini, en son sayfa önbelleğini temizleyin. Ters sırada yaparsanız araya giren bir istek sayfayı eski veriyle yeniden oluşturur ve o sayfa bir gün boyunca önbellekte kalır.

Önbelleği ziyaretçiden önce ısıtmak

Hedefli temizlik silinen sayfa sayısını azaltıyor ama sıfıra indirmiyor. Silinen sayfayı ilk açan ziyaretçi yine soğuk sayfanın oluşmasını bekliyor. Bunu da ortadan kaldırmak için sitenin içinde, temizlikten sonra yayındaki sayfaları kendisi isteyen bir arka plan servisi (BackgroundService) çalıştırıyoruz:

C#
public sealed class PageCacheWarmer : BackgroundService
{
    // Tek elemanlı kanal: bekleyen bir ısıtma varken gelen istekler ona katılır.
    private readonly Channel<bool> _signal = Channel.CreateBounded<bool>(
        new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });

    public void RequestWarmup() => _signal.Writer.TryWrite(true);

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (await _signal.Reader.WaitToReadAsync(stoppingToken))
        {
            await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); // art arda gelen temizlikleri birleştir
            _signal.Reader.TryRead(out _);
            await WarmAsync(stoppingToken);
        }
    }
}

WarmAsync, yayındaki sayfaların adreslerini veritabanından okuyup bu sayfaları aynı uygulamaya, yerel adres (loopback) üzerinden ve en fazla iki istek paralel olacak şekilde soruyor. Önbellekte duran sayfalar zaten önbellekten cevap verdiği için, küçük bir temizlikten sonra bütün siteyi ısıtmak aslında sadece silinen sayfaları yeniden oluşturmak demek.

Burada iki ayrıntı önemli. Birincisi, önbellek anahtarı ve sayfadaki tam adresli bağlantılar ziyaretçinin kullandığı alan adına göre değişiyor. Isıtma istekleri 127.0.0.1'e gitse bile Host ve X-Forwarded-Proto başlıklarında gerçek ziyaretçinin adresini taşımalı. Yoksa önbelleğe hiç kimsenin istemeyeceği bir kopya yazılır. İkincisi, ısıtma isteklerini özel bir başlıkla işaretliyoruz ki istatistiklerde ziyaretçi gibi görünmesinler. Uygulama yeni açıldığında da aynı ısıtma çalışıyor, böylece deploy'dan sonraki ilk ziyaretçiler de soğuk sayfayla karşılaşmıyor.

Sonuç: ne kazandık?

Bu değişikliklerden sonra:

  • Bir yazıyı kaydettiğimde sadece o yazı, onu listeleyen sayfalar ve RSS önbellekten siliniyor. Not, görev ya da kullanıcı kaydı gibi sitenin hiç okumadığı değişiklikler hiçbir sayfaya dokunmuyor.
  • Değişiklikler kaydettiğim anda siteye yansıdığı için sayfaları önbellekte bir saat yerine 24 saat tutabiliyoruz.
  • Silinen sayfalar birkaç saniye içinde arka planda yeniden oluşuyor. Ziyaretçi neredeyse her zaman önbellekten gelen, 50 ms civarında hazırlanan sayfayı görüyor.

Siteniz birden fazla sunucuda çalışıyorsa varsayılan bellek içi depo yetmez; bütün sunucuların aynı önbelleği görmesi için Output Cache'i Redis gibi ortak bir depoyla kullanmanız gerekir. Etiket mantığı aynen geçerli. Elevare açık kaynak, kodun tamamına GitHub'dan bakabilirsiniz. Deploy'dan sonra ilk isteklerin neden yavaş geldiğini de ReadyToRun ile Soğuk Başlangıç yazısında anlattım.