.NET Core

Caching-Strategien in .NET: In-Memory, Distributed und Redis

14 Min. Lesezeit9. Februar 2026Aktualisiert 9. März 2026
.NET cachingRedis .NETIn-memory cache C#Distributed cache .NETIMemoryCacheIDistributedCache.NET performanceCache invalidation

Caching gehört zu den wirksamsten Performance-Hebeln im Backend — allerdings nur, wenn Konsistenz- und Invalidierungsregeln bewusst entworfen werden. Bei hochfrequentierten APIs habe ich erlebt, wie Caching die durchschnittliche Antwortzeit von 200ms auf unter 5ms senkte und gleichzeitig die Datenbanklast um über 80% reduzierte. Der entscheidende Punkt ist, auf welcher Ebene gecacht wird und wann man veraltete Daten bewusst in Kauf nimmt.

Dieser Artikel behandelt alle Caching-Mechanismen im modernen .NET — mit praxisnahen Codebeispielen, die sich direkt in Produktionsumgebungen einsetzen lassen.

Cache-Typen und Abwägungen

In-Memory Cache mit IMemoryCache

Die einfachste und schnellste Option. Die Daten liegen im Arbeitsspeicher des Anwendungsprozesses — kein Serialisierungsaufwand, keine Netzwerk-Hops. Der Nachteil: Jede Instanz der Anwendung hat ihren eigenen isolierten Cache. Hinter einem Load Balancer mit mehreren Replicas funktioniert das allein nicht.

csharp
public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly IProductRepository _repository;

    public ProductService(IMemoryCache cache, IProductRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        var cacheKey = $"product:{id}";

        if (_cache.TryGetValue(cacheKey, out Product? cached))
            return cached;

        var product = await _repository.GetByIdAsync(id);

        if (product is not null)
        {
            var options = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
                SlidingExpiration = TimeSpan.FromMinutes(2),
                Size = 1,
                Priority = CacheItemPriority.High
            };

            options.RegisterPostEvictionCallback((key, value, reason, state) =>
            {
                // Eviction für Monitoring protokollieren
            });

            _cache.Set(cacheKey, product, options);
        }

        return product;
    }
}

Registrierung in Program.cs:

csharp
builder.Services.AddMemoryCache(options =>
{
    options.SizeLimit = 1024; // Maximale Anzahl an Cache-Einträgen
});

Ein häufiger Fehler, den ich sehe: SizeLimit wird überhaupt nicht gesetzt. Ohne diese Begrenzung wächst der Cache unbegrenzt, und in der Produktion kommt es zu Memory-Pressure-Problemen. Setzen Sie immer ein Limit und weisen Sie jedem Eintrag eine Size zu.

Distributed Cache mit IDistributedCache und Redis

Wenn mehrere Instanzen hinter einem Load Balancer laufen, braucht man einen gemeinsamen Cache. IDistributedCache ist die .NET-Abstraktion dafür, und Redis ist der am häufigsten verwendete Backing Store.

csharp
// Program.cs
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = builder.Configuration.GetConnectionString("Redis");
    options.InstanceName = "myapp:";
});
csharp
public class CatalogService
{
    private readonly IDistributedCache _cache;
    private readonly ICatalogRepository _repository;
    private readonly ILogger<CatalogService> _logger;

    public CatalogService(
        IDistributedCache cache,
        ICatalogRepository repository,
        ILogger<CatalogService> logger)
    {
        _cache = cache;
        _repository = repository;
        _logger = logger;
    }

    public async Task<List<Category>> GetCategoriesAsync(CancellationToken ct = default)
    {
        var cacheKey = "categories:all";

        var cached = await _cache.GetStringAsync(cacheKey, ct);
        if (cached is not null)
        {
            return JsonSerializer.Deserialize<List<Category>>(cached)!;
        }

        var categories = await _repository.GetAllCategoriesAsync(ct);

        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
            SlidingExpiration = TimeSpan.FromMinutes(15)
        };

        await _cache.SetStringAsync(
            cacheKey,
            JsonSerializer.Serialize(categories),
            options,
            ct);

        return categories;
    }
}

Wichtig zu beachten: IDistributedCache serialisiert alles als byte[] oder Strings. Das bedeutet, bei jedem Lese- und Schreibvorgang fallen Serialisierungskosten an. Für Hot Paths kann es sinnvoll sein, den StackExchange.Redis IConnectionMultiplexer direkt zu verwenden, um den Abstraktions-Overhead zu vermeiden.

Output Caching (.NET 7+)

Output Caching arbeitet auf Middleware-Ebene und speichert komplette HTTP-Antworten zwischen. Besonders leistungsfähig für leseintensive, öffentliche Endpunkte, bei denen die Antwort nicht pro Benutzer variiert.

csharp
// Program.cs
builder.Services.AddOutputCache(options =>
{
    options.AddBasePolicy(builder => builder.Expire(TimeSpan.FromMinutes(5)));

    options.AddPolicy("CatalogPolicy", builder =>
        builder
            .Expire(TimeSpan.FromMinutes(30))
            .Tag("catalog")
            .SetVaryByQuery("page", "sort"));
});

app.UseOutputCache();
csharp
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [OutputCache(PolicyName = "CatalogPolicy")]
    public async Task<IActionResult> GetProducts(
        [FromQuery] int page = 1,
        [FromQuery] string sort = "name")
    {
        var products = await _service.GetProductsAsync(page, sort);
        return Ok(products);
    }

    [HttpPost]
    public async Task<IActionResult> CreateProduct(
        CreateProductDto dto,
        IOutputCacheStore cacheStore)
    {
        var product = await _service.CreateAsync(dto);

        // Alle mit "catalog" getaggten Antworten invalidieren
        await cacheStore.EvictByTagAsync("catalog", default);

        return CreatedAtAction(nameof(GetProducts), new { id = product.Id }, product);
    }
}

HybridCache (.NET 9)

Mit .NET 9 wurde HybridCache eingeführt, das In-Memory und Distributed Cache unter einer einzigen API vereint. Serialisierung, Stampede-Schutz und zweistufiges Caching werden automatisch verwaltet. Für neue Projekte ist das meine klare Empfehlung.

csharp
// Program.cs
builder.Services.AddHybridCache(options =>
{
    options.MaximumPayloadBytes = 1024 * 1024; // 1 MB
    options.MaximumKeyLength = 256;
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(5)
    };
});

// Distributed-Cache-Backend ebenfalls registrieren
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "localhost:6379";
});
csharp
public class OrderService
{
    private readonly HybridCache _cache;
    private readonly IOrderRepository _repository;

    public OrderService(HybridCache cache, IOrderRepository repository)
    {
        _cache = cache;
        _repository = repository;
    }

    public async Task<OrderSummary> GetOrderSummaryAsync(
        int orderId, CancellationToken ct = default)
    {
        return await _cache.GetOrCreateAsync(
            $"order:{orderId}",
            async token => await _repository.GetSummaryAsync(orderId, token),
            new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromMinutes(10),
                LocalCacheExpiration = TimeSpan.FromMinutes(2)
            },
            cancellationToken: ct);
    }

    public async Task InvalidateOrderAsync(int orderId)
    {
        await _cache.RemoveAsync($"order:{orderId}");
    }
}

HybridCache prüft zuerst den lokalen In-Memory Cache, dann den Distributed Cache und ruft den Factory-Delegaten nur auf, wenn beide leer sind. Gleichzeitige Anfragen für denselben Key werden zusammengeführt — das eliminiert Cache Stampede konstruktionsbedingt.

Das Cache-Aside-Pattern im Detail

Cache-Aside (auch Lazy-Loading genannt) ist das am weitesten verbreitete Cache-Pattern. Die Anwendung prüft zuerst den Cache; bei einem Miss wird aus der Quelle geladen und der Cache befüllt.

csharp
public class CacheAsideService<T> where T : class
{
    private readonly IDistributedCache _cache;
    private readonly ILogger _logger;
    private readonly JsonSerializerOptions _jsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public CacheAsideService(IDistributedCache cache, ILogger logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<T?> GetOrSetAsync(
        string key,
        Func<Task<T?>> factory,
        TimeSpan? absoluteExpiration = null,
        TimeSpan? slidingExpiration = null,
        CancellationToken ct = default)
    {
        // 1. Cache prüfen
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
        {
            _logger.LogDebug("Cache HIT für {Key}", key);
            return JsonSerializer.Deserialize<T>(cached, _jsonOptions);
        }

        _logger.LogDebug("Cache MISS für {Key}", key);

        // 2. Aus der Quelle laden
        var value = await factory();
        if (value is null) return null;

        // 3. Cache befüllen
        var options = new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = absoluteExpiration ?? TimeSpan.FromMinutes(10),
            SlidingExpiration = slidingExpiration ?? TimeSpan.FromMinutes(2)
        };

        var serialized = JsonSerializer.Serialize(value, _jsonOptions);
        await _cache.SetStringAsync(key, serialized, options, ct);

        return value;
    }

    public async Task InvalidateAsync(string key, CancellationToken ct = default)
    {
        await _cache.RemoveAsync(key, ct);
        _logger.LogInformation("Cache INVALIDIERT für {Key}", key);
    }
}

Das Risiko bei Cache-Aside ist, dass Datenquelle und Cache auseinanderlaufen können. Wenn ein anderer Dienst oder ein direktes Datenbank-Update die Daten ändert, erfährt der Cache davon nichts. Hier kommen Invalidierungsstrategien ins Spiel.

Strategien zur Cache-Invalidierung

Phil Karlton sagte einmal, es gäbe nur zwei schwere Probleme in der Informatik: Cache-Invalidierung und Namensgebung. Hier sind die Strategien, die sich in der Produktion tatsächlich bewähren.

Zeitbasierter Ablauf (TTL)

Der einfachste Ansatz. Man setzt einen TTL und akzeptiert, dass die Daten für diese Dauer veraltet sein können. Geeignet für Daten, bei denen Eventual Consistency ausreicht.

csharp
// Kurzer TTL für häufig wechselnde Daten
var stockOptions = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30)
};

// Langer TTL für selten wechselnde Daten
var configOptions = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
    SlidingExpiration = TimeSpan.FromHours(6)
};

Event-basierte Invalidierung

Wenn sich Daten ändern, wird ein Event veröffentlicht, das die Cache-Löschung auslöst. Das ist der zuverlässigste Ansatz für verteilte Systeme.

csharp
public class ProductUpdatedHandler : INotificationHandler<ProductUpdatedEvent>
{
    private readonly IDistributedCache _cache;
    private readonly IOutputCacheStore _outputCache;

    public ProductUpdatedHandler(
        IDistributedCache cache,
        IOutputCacheStore outputCache)
    {
        _cache = cache;
        _outputCache = outputCache;
    }

    public async Task Handle(
        ProductUpdatedEvent notification,
        CancellationToken ct)
    {
        // Spezifischen Eintrag löschen
        await _cache.RemoveAsync($"product:{notification.ProductId}", ct);

        // Listen-Caches löschen, die dieses Produkt enthalten könnten
        await _cache.RemoveAsync($"products:category:{notification.CategoryId}", ct);

        // Output-Cache-Antworten invalidieren
        await _outputCache.EvictByTagAsync("catalog", ct);
    }
}

Versionierte Schlüssel

Statt Cache-Einträge zu löschen, ändert man den Schlüssel. Das ist nützlich, wenn ein atomarer Wechsel ohne cache-loses Zeitfenster gewünscht ist.

csharp
public class VersionedCacheService
{
    private readonly IDistributedCache _cache;
    private readonly IMemoryCache _versionCache;

    public async Task<string> GetVersionedKeyAsync(string baseKey)
    {
        var version = await GetCurrentVersionAsync(baseKey);
        return $"{baseKey}:v{version}";
    }

    public async Task InvalidateByVersionAsync(string baseKey)
    {
        var currentVersion = await GetCurrentVersionAsync(baseKey);
        var newVersion = currentVersion + 1;

        await _cache.SetStringAsync(
            $"{baseKey}:version",
            newVersion.ToString(),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(7)
            });

        // Alte versionierte Einträge laufen über TTL natürlich ab
    }

    private async Task<long> GetCurrentVersionAsync(string baseKey)
    {
        var version = await _cache.GetStringAsync($"{baseKey}:version");
        return version is not null ? long.Parse(version) : 1;
    }
}

Vermeidung von Cache Stampede

Ein Cache Stampede entsteht, wenn ein populärer Cache-Eintrag abläuft und Dutzende (oder Tausende) gleichzeitiger Anfragen alle den Cache leer vorfinden und gemeinsam auf die Datenbank losgehen. Ich habe erlebt, wie das während eines Traffic-Spikes eine Datenbank zum Absturz brachte — der Cache lief genau zum ungünstigsten Zeitpunkt ab.

Sperren mit SemaphoreSlim

csharp
public class StampedeProtectedCache<T> where T : class
{
    private readonly IDistributedCache _cache;
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public async Task<T?> GetOrCreateAsync(
        string key,
        Func<Task<T?>> factory,
        TimeSpan expiration,
        CancellationToken ct = default)
    {
        // Zuerst ohne Sperre den Cache prüfen
        var cached = await _cache.GetStringAsync(key, ct);
        if (cached is not null)
            return JsonSerializer.Deserialize<T>(cached);

        // Per-Key-Sperre erwerben
        var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

        await keyLock.WaitAsync(ct);
        try
        {
            // Nach Sperren-Erwerb erneut prüfen
            cached = await _cache.GetStringAsync(key, ct);
            if (cached is not null)
                return JsonSerializer.Deserialize<T>(cached);

            // Nur eine Anfrage gelangt hierher
            var value = await factory();
            if (value is not null)
            {
                await _cache.SetStringAsync(
                    key,
                    JsonSerializer.Serialize(value),
                    new DistributedCacheEntryOptions
                    {
                        AbsoluteExpirationRelativeToNow = expiration
                    },
                    ct);
            }

            return value;
        }
        finally
        {
            keyLock.Release();
        }
    }
}

Jitter beim Ablauf

Fügen Sie den TTL-Werten Zufälligkeit hinzu, damit nicht alle Einträge gleichzeitig ablaufen.

csharp
public static class CacheExpirationExtensions
{
    private static readonly Random _jitter = new();

    public static DistributedCacheEntryOptions WithJitter(
        this DistributedCacheEntryOptions options,
        double jitterPercentage = 0.1)
    {
        if (options.AbsoluteExpirationRelativeToNow.HasValue)
        {
            var baseTtl = options.AbsoluteExpirationRelativeToNow.Value;
            var jitterRange = baseTtl.TotalMilliseconds * jitterPercentage;
            var jitter = _jitter.NextDouble() * jitterRange * 2 - jitterRange;

            options.AbsoluteExpirationRelativeToNow =
                baseTtl + TimeSpan.FromMilliseconds(jitter);
        }

        return options;
    }
}

// Verwendung
var options = new DistributedCacheEntryOptions
{
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
}.WithJitter(0.2); // ±20% Jitter → TTL zwischen 8-12 Minuten

Hintergrund-Aktualisierung (Stale-While-Revalidate)

Den veralteten Wert sofort ausliefern und im Hintergrund aktualisieren. Das eliminiert Latenzspitzen bei Cache Misses für unkritische Daten.

csharp
public class StaleWhileRevalidateCache<T> where T : class
{
    private readonly IMemoryCache _cache;

    public async Task<T?> GetOrCreateAsync(
        string key,
        Func<Task<T>> factory,
        TimeSpan freshDuration,
        TimeSpan staleDuration)
    {
        if (_cache.TryGetValue(key, out CacheWrapper<T>? wrapper))
        {
            if (wrapper!.IsStale && !wrapper.IsRefreshing)
            {
                wrapper.IsRefreshing = true;
                // Im Hintergrund aktualisieren
                _ = Task.Run(async () =>
                {
                    var fresh = await factory();
                    _cache.Set(key, new CacheWrapper<T>(fresh, freshDuration),
                        freshDuration + staleDuration);
                });
            }
            return wrapper.Value;
        }

        var value = await factory();
        _cache.Set(key, new CacheWrapper<T>(value, freshDuration),
            freshDuration + staleDuration);
        return value;
    }
}

public class CacheWrapper<T>
{
    public T Value { get; }
    public DateTime FreshUntil { get; }
    public bool IsStale => DateTime.UtcNow > FreshUntil;
    public bool IsRefreshing { get; set; }

    public CacheWrapper(T value, TimeSpan freshDuration)
    {
        Value = value;
        FreshUntil = DateTime.UtcNow.Add(freshDuration);
    }
}

Überwachung der Cache-Performance

Was man nicht misst, kann man nicht verbessern. Die Cache-Trefferquote ist die wichtigste Metrik — sinkt sie, sind entweder die TTLs zu kurz, der Cache zu klein oder die Invalidierung zu aggressiv.

Eigene Metriken mit IMemoryCache

csharp
public class InstrumentedCacheService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<InstrumentedCacheService> _logger;
    private static long _hits;
    private static long _misses;

    public async Task<T?> GetAsync<T>(string key)
    {
        if (_cache.TryGetValue(key, out T? value))
        {
            Interlocked.Increment(ref _hits);
            return value;
        }

        Interlocked.Increment(ref _misses);
        return default;
    }

    public double GetHitRatio()
    {
        var total = Interlocked.Read(ref _hits) + Interlocked.Read(ref _misses);
        return total == 0 ? 0 : (double)Interlocked.Read(ref _hits) / total;
    }
}

Health Check für Redis

csharp
public class RedisCacheHealthCheck : IHealthCheck
{
    private readonly IConnectionMultiplexer _redis;

    public RedisCacheHealthCheck(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken ct = default)
    {
        try
        {
            var db = _redis.GetDatabase();
            var latency = await db.PingAsync();

            var data = new Dictionary<string, object>
            {
                ["latency_ms"] = latency.TotalMilliseconds,
                ["connected_clients"] = _redis.GetServer(
                    _redis.GetEndPoints().First()).Info("clients")
            };

            return latency.TotalMilliseconds < 100
                ? HealthCheckResult.Healthy("Redis antwortet", data)
                : HealthCheckResult.Degraded("Redis-Latenz ist hoch", null, data);
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Redis ist nicht erreichbar", ex);
        }
    }
}

Metrik-Endpunkt bereitstellen

csharp
app.MapGet("/metrics/cache", (InstrumentedCacheService cache) =>
{
    return Results.Ok(new
    {
        HitRatio = cache.GetHitRatio(),
        Timestamp = DateTime.UtcNow
    });
});

In der Produktion leite ich diese Metriken an Prometheus weiter und setze Alarme, wenn die Trefferquote unter 70% fällt. Ein plötzlicher Abfall deutet in der Regel darauf hin, dass sich die Datenzugriffsmuster geändert haben oder ein Deployment das Key-Format verändert hat.

Häufige Caching-Fehler

Dies sind Fehler, die mir in realen Produktionssystemen begegnet sind:

1. Null-Ergebnisse ohne Schutz cachen

csharp
// SCHLECHT: Fehlendes Produkt gibt null zurück, DB wird jedes Mal angefragt
var product = await _cache.GetAsync<Product>(key);
if (product is null)
{
    product = await _db.FindAsync(id); // Bei jeder Anfrage aufgerufen
    if (product is not null)
        await _cache.SetAsync(key, product);
}

// GUT: Abwesenheit mit kurzem TTL cachen
var product = await _db.FindAsync(id);
if (product is not null)
{
    await _cache.SetAsync(key, product, TimeSpan.FromMinutes(10));
}
else
{
    // "Nicht gefunden" cachen, um wiederholte DB-Anfragen zu vermeiden
    await _cache.SetAsync(key, NullSentinel.Instance, TimeSpan.FromMinutes(1));
}

2. Zu allgemeine Cache-Schlüssel verwenden

csharp
// SCHLECHT: Gleicher Schlüssel unabhängig von Benutzer, Sprache oder Filtern
var key = "products";

// GUT: Schlüssel spiegelt die tatsächlichen Abfrageparameter wider
var key = $"products:cat={categoryId}:page={page}:lang={locale}:sort={sortBy}";

3. Keine graceful Degradation bei Cache-Ausfall

csharp
// SCHLECHT: Wenn Redis ausfällt, scheitert die gesamte API
var cached = await _cache.GetStringAsync(key); // Wirft Exception!

// GUT: Abfangen und auf die Quelle zurückfallen
public async Task<T?> SafeGetAsync<T>(string key) where T : class
{
    try
    {
        var data = await _cache.GetStringAsync(key);
        return data is not null ? JsonSerializer.Deserialize<T>(data) : null;
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "Cache-Lesen fehlgeschlagen für {Key}, Fallback auf DB", key);
        return null; // Graceful Degradation zur Datenbank
    }
}

4. Serialisierungsformat-Mismatch nach Deployment

Wenn sich die Struktur eines gecachten Objekts ändert (Eigenschaften hinzufügen/entfernen), schlägt die Deserialisierung alter Cache-Einträge fehl. Versionieren Sie Ihre Cache-Schlüssel oder verwenden Sie ein Format, das fehlende Eigenschaften toleriert.

5. Keine Speichergrenzen setzen

Ohne SizeLimit bei IMemoryCache oder maxmemory bei Redis wächst der Cache, bis es zu OOM-Kills kommt oder zufällige Schlüssel verdrängt werden. Konfigurieren Sie immer Obergrenzen.

Fazit

Gute Caching-Architektur ist ein Gleichgewicht aus Geschwindigkeit, Konsistenz und betrieblicher Klarheit. Wenn Sie .NET 9 einsetzen, starten Sie mit HybridCache — zweistufiges Caching und Stampede-Schutz sind von Haus aus integriert. Für ältere Versionen kombinieren Sie IMemoryCache für Hot Data mit IDistributedCache und Redis für geteilten Zustand. Behandeln Sie Cache-Policy als Produktentscheidung, nicht als rein technisches Detail: TTL, Invalidierungsstrategie und Monitoring sollten gemeinsam mit dem Feature entworfen werden — nicht nachträglich hinzugefügt.

Ich unterstütze gern beim Entwurf belastbarer Cache-Strategien — lassen Sie uns darüber sprechen.

Verwandte Artikel

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen