IMemoryCache: кеш в оперативній пам'яті
IMemoryCache: кеш в оперативній пам'яті
IMemoryCache — найпростіший і найшвидший механізм кешування в ASP.NET Core. Він зберігає дані прямо в RAM поточного серверного процесу: жодного мережевого запиту, жодної серіалізації. Час доступу — наносекунди.
Це відправна точка для більшості застосунків. Додати його — один рядок у Program.cs, використати — виклик GetOrCreateAsync. Але за цією простотою ховається багато нюансів: правила про час закінчення, стратегії витіснення при нестачі пам'яті, тонкощі конкурентного доступу.
CatalogApi — API каталогу товарів, покроково додаючи кешування від базового Get/Set до повноцінного production-патерну з Cache Stampede захистом. Кожен крок — відповідь на конкретний біль.Реєстрація
var builder = WebApplication.CreateBuilder(args);
// Мінімальний варіант — нічого не налаштовано
builder.Services.AddMemoryCache();
// Або з деталями (більш production-ready)
builder.Services.AddMemoryCache(options =>
{
// SizeLimit: максимальний розмір кешу в умовних одиницях.
// Самостійно обираєте одиницю виміру (байти, кількість записів, тощо).
// Кожен запис вказує свій .Size — разом вони не мають перевищувати SizeLimit.
options.SizeLimit = 1024;
// CompactionPercentage: який відсоток кешу видалити при досягненні SizeLimit.
// 0.25 = видалити 25% кешу (найстаріші/найнижчого пріоритету записи)
options.CompactionPercentage = 0.25;
// ExpirationScanFrequency: як часто перевіряти закінчені записи
// За замовчуванням — 1 хвилина, але для часто змінюваних кешів — можна зменшити
options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);
// TrackStatistics (за замовчуванням false): вмикає детальну статистику кешу
// Використовуйте в dev/staging для аналізу hit rate
options.TrackStatistics = builder.Environment.IsDevelopment();
});
Після реєстрації IMemoryCache доступний через DI у будь-якому сервісі чи ендпоінті.
Базові операції: Set, Get, TryGetValue, Remove
using Microsoft.Extensions.Caching.Memory;
// Ін'єкція через конструктор
public class ProductRepository
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
public ProductRepository(IMemoryCache cache, AppDbContext db)
{
_cache = cache;
_db = db;
}
// ─── Set: записати в кеш ──────────────────────────────────
public void CacheProduct(Product product)
{
var key = $"product:{product.Id}";
// Простий Set — зберігає без TTL (живе до перезапуску або eviction)
_cache.Set(key, product);
// Set з TTL
_cache.Set(key, product, TimeSpan.FromMinutes(15));
// Set з DateTimeOffset — абсолютний час закінчення
_cache.Set(key, product, DateTimeOffset.UtcNow.AddMinutes(15));
}
// ─── Get: отримати з кешу (повертає null якщо відсутній) ──
public Product? GetCached(int id)
{
return _cache.Get<Product>($"product:{id}");
// Повертає null якщо ключ не знайдено або закінчився
}
// ─── TryGetValue: безпечна перевірка без allocations ──────
public Product? GetCachedSafe(int id)
{
var key = $"product:{id}";
if (_cache.TryGetValue(key, out Product? cached))
{
// Cache HIT: повертаємо закешований об'єкт
return cached;
}
// Cache MISS: читаємо з БД
var product = _db.Products.Find(id);
if (product is not null)
_cache.Set(key, product, TimeSpan.FromMinutes(15));
return product;
}
// ─── Remove: примусово видалити ───────────────────────────
public void InvalidateProduct(int id)
{
_cache.Remove($"product:{id}");
_cache.Remove("products:all"); // Також інвалідуємо список
}
}
TryGetValue — кращий вибір за Get, оскільки не потребує повторного пошуку при cache miss. Get = TryGetValue + повернення null, але з одним TryGetValue ви уникаєте подвійного пошуку в внутрішньому словнику кешу.
GetOrCreateAsync: головний патерн
Ручне TryGetValue + Set — це боilerplate. GetOrCreateAsync (і синхронний GetOrCreate) — скорочений запис цього патерну:
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
private readonly ILogger<ProductService> _logger;
// Ключі кешу — константи, щоб уникнути typos і magic strings
private const string AllProductsKey = "products:all";
private const string ProductKeyPrefix = "products:id:";
public ProductService(
IMemoryCache cache,
AppDbContext db,
ILogger<ProductService> logger)
{
_cache = cache;
_db = db;
_logger = logger;
}
public async Task<List<Product>> GetAllAsync(CancellationToken ct = default)
{
// GetOrCreateAsync:
// 1. Якщо ключ є в кеші → повертає закешоване значення (factory НЕ викликається)
// 2. Якщо ключа немає → викликає factory, зберігає результат, повертає його
var result = await _cache.GetOrCreateAsync(
key: AllProductsKey,
factory: async entry =>
{
// entry — це MemoryCacheEntryOptions, де налаштовуємо TTL
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
_logger.LogInformation(
"Cache MISS [{Key}] — hitting database", AllProductsKey);
// Дорогий запит — виконується тільки при cache miss
return await _db.Products
.AsNoTracking()
.ToListAsync(ct);
});
return result ?? [];
}
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
{
var key = $"{ProductKeyPrefix}{id}";
return await _cache.GetOrCreateAsync(key, async entry =>
{
// Комбінований TTL: absolute + sliding
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
entry.SlidingExpiration = TimeSpan.FromMinutes(15);
return await _db.Products
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Id == id, ct);
});
}
}
Час закінчення: AbsoluteExpiration vs SlidingExpiration
Це один із найважливіших вибірів при кешуванні — коли запис вважається застарілим?
AbsoluteExpiration
Запис завжди видаляється рівно через N часу після створення, незалежно від того, скільки разів до нього зверталися.
// Варіант 1: відносний час від моменту створення
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
// Запис створено о 10:00 → видаляється о 10:30, незалежно від доступів
// Варіант 2: абсолютний DateTimeOffset
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(1);
// Видалити рівно опівночі (корисно для кешу, що "скидається" щодня)
entry.AbsoluteExpiration = new DateTimeOffset(
DateTime.Today.AddDays(1), TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time").GetUtcOffset(DateTime.Today));
// Видалити опівночі за Київським часом
Коли використовувати: для даних, що мають гарантовано оновлюватись через певний час, навіть якщо запитуються постійно. Наприклад: прайс-лист оновлюється раз на годину — кеш 60 хвилин.
SlidingExpiration
Запис видаляється якщо до нього не зверталися протягом N часу. Кожен доступ "скидає годинник".
entry.SlidingExpiration = TimeSpan.FromMinutes(10);
// Запис створено о 10:00
// Запитано о 10:08 → TTL скидається: видаляється не о 10:10, а о 10:18
// Не запитували 10 хвилин → видаляється
// Проблема: якщо запис запитується постійно — він НЕ видаляється НІКОЛИ
// (доки є місце в кеші)
Коли використовувати: для підтримки "гарячих" записів у кеші (популярні товари) і автоматичного видалення неактивних (рідко запитувані). Але без Absolute обмеження — записи можуть жити вічно.
Комбінований підхід (найкращий)
// Абсолютне обмеження: не більше 1 години
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
// Sliding: видалити раніше якщо неактивний 15 хвилин
entry.SlidingExpiration = TimeSpan.FromMinutes(15);
// Результат:
// → Популярні записи живуть до 1 години
// → Непопулярні видаляються через 15 хвилин неактивності
// → Жоден запис не живе більше 1 години (гарантія свіжості)
MemoryCacheEntryOptions: повний контроль
public async Task<Product?> GetProductWithFullOptionsAsync(int id)
{
var key = $"product:{id}";
using var cacheEntry = _cache.CreateEntry(key);
// ─── Час життя ───────────────────────────────────────────
cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
cacheEntry.SlidingExpiration = TimeSpan.FromMinutes(15);
// ─── Розмір (для SizeLimit eviction) ────────────────────
// Умовний розмір: наприклад, 1 одиниця = 1 запис (незалежно від фактичного розміру)
// Або: фактичний розмір у байтах (потребує серіалізації для оцінки)
cacheEntry.Size = 1;
// ─── Пріоритет при eviction ──────────────────────────────
// Коли кеш переповнений і треба щось видалити — видаляється з нижчим пріоритетом
cacheEntry.Priority = CacheItemPriority.Normal;
// Варіанти:
// CacheItemPriority.Low — видаляється першим
// CacheItemPriority.Normal — за замовчуванням
// CacheItemPriority.High — видаляється в останню чергу
// CacheItemPriority.NeverRemove — НІКОЛИ не видаляється автоматично (обережно!)
// ─── ExpirationTokens — зовнішні тригери інвалідації ────
// CancellationChangeToken: при скасуванні токена — запис видаляється
var cts = new CancellationTokenSource();
cacheEntry.ExpirationTokens.Add(
new CancellationChangeToken(cts.Token));
// Пізніше: cts.Cancel() → запис видаляється з кешу
// ─── PostEvictionCallbacks — реакція на видалення ───────
cacheEntry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
{
EvictionCallback = (evictedKey, value, reason, state) =>
{
_logger.LogInformation(
"Cache entry '{Key}' evicted. Reason: {Reason}. " +
"Value type: {Type}",
evictedKey, reason, value?.GetType().Name);
// reason може бути:
// EvictionReason.Expired — закінчився TTL
// EvictionReason.Removed — явно видалено через Remove()
// EvictionReason.Replaced — перезаписано через Set()
// EvictionReason.Capacity — видалено через нестачу пам'яті (SizeLimit)
// EvictionReason.TokenExpired — спрацював ExpirationToken
}
});
// Завантажуємо значення
var product = await _db.Products.FindAsync(id);
cacheEntry.Value = product;
// CreateEntry + SetValue = аналог Set(), але з повним контролем options
return product;
}
ExpirationTokens: залежні кеші
ExpirationToken — потужний механізм: один запис кешу може "слідкувати" за іншим. Коли батьківський запис видаляється — дочірній видаляється автоматично.
// Приклад: кеш категорії і кеш товарів категорії — пов'язані
public void CacheCategoryWithProducts(Category category, List<Product> products)
{
// 1. Створюємо запис категорії з CancellationTokenSource
var categoryCts = new CancellationTokenSource();
var categoryEntry = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1));
_cache.Set($"category:{category.Id}", category, categoryEntry);
// 2. Запис товарів слідкує за токеном категорії
var productsEntry = new MemoryCacheEntryOptions()
.SetAbsoluteExpiration(TimeSpan.FromHours(1))
.AddExpirationToken(new CancellationChangeToken(categoryCts.Token));
_cache.Set($"category:{category.Id}:products", products, productsEntry);
// 3. При видаленні категорії — товари видаляються автоматично
// categoryCts.Cancel() → обидва записи видаляються
}
SizeLimit та інтелектуальне витіснення (Eviction)
Без SizeLimit кеш може необмежено рости. SizeLimit встановлює максимальну суму Size всіх записів:
builder.Services.AddMemoryCache(opts =>
{
// Максимум 500 умовних одиниць (наприклад, 500 записів по 1 одиниці кожен)
opts.SizeLimit = 500;
opts.CompactionPercentage = 0.25; // При досягненні ліміту — видалити 25%
});
// Кожен запис вказує свій Size
_cache.Set("tiny-config", config, new MemoryCacheEntryOptions
{
Size = 1, // 1 умовна одиниця
Priority = CacheItemPriority.High
});
_cache.Set("large-product-list", products, new MemoryCacheEntryOptions
{
Size = 50, // 50 умовних одиниць (великий об'єкт)
Priority = CacheItemPriority.Normal
});
SizeLimit задано, але для запису НЕ задано Size — .NET кине InvalidOperationException при спробі додати запис. Або можна задати MemoryCacheOptions.SizeLimit = null (за замовчуванням).Порядок витіснення при переповненні
Коли сума Size досягає SizeLimit, IMemoryCache видаляє записи у порядку:
CacheItemPriority.Low— першимиCacheItemPriority.Normal— другимиCacheItemPriority.High— третімиCacheItemPriority.NeverRemove— ніколи (але займають місце!)
Серед одного пріоритету — спочатку видаляються найстаріші (Least Recently Used, LRU).
Моніторинг: MemoryCacheStatistics
builder.Services.AddMemoryCache(opts =>
{
opts.TrackStatistics = true; // Лише у Development — має overhead
});
// GET /admin/cache/stats
app.MapGet("/admin/cache/stats", (IMemoryCache cache) =>
{
if (cache is not MemoryCache memCache)
return Results.BadRequest("Statistics not available");
var stats = memCache.GetCurrentStatistics();
if (stats is null)
return Results.BadRequest("TrackStatistics is disabled");
return Results.Ok(new
{
TotalHits = stats.TotalHits,
TotalMisses = stats.TotalMisses,
HitRate = stats.TotalHits + stats.TotalMisses == 0 ? 0 :
(double)stats.TotalHits / (stats.TotalHits + stats.TotalMisses) * 100,
TotalEntries = stats.CurrentEntryCount,
EstimatedSize = stats.CurrentEstimatedSize
});
});
Cache Stampede: проблема та рішення
Cache Stampede (або Thundering Herd) — катастрофічна ситуація при паралельному навантаженні:
// ❌ Небезпечно при 1000 рівночасних запитів:
var products = await _cache.GetOrCreateAsync("products:all", async entry => {
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
return await _db.Products.ToListAsync(); // 1000 паралельних SQL-запитів!
});
Коли кеш закінчується, всі 1000 запитів одночасно виявляють cache miss і виконують дорогий SQL-запит паралельно. База даних отримує 1000 одночасних запитів замість 1.
Рішення: SemaphoreSlim per-key
using System.Collections.Concurrent;
public class SafeCacheService
{
private readonly IMemoryCache _cache;
private readonly ILogger<SafeCacheService> _logger;
// Словник семафорів: кожен ключ кешу — окремий SemaphoreSlim(1,1)
// ConcurrentDictionary для thread-safe доступу
private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public SafeCacheService(IMemoryCache cache, ILogger<SafeCacheService> logger)
{
_cache = cache;
_logger = logger;
}
public async Task<T?> GetOrCreateSafeAsync<T>(
string key,
Func<CancellationToken, Task<T?>> factory,
TimeSpan absoluteExpiration,
TimeSpan? slidingExpiration = null,
CancellationToken ct = default)
{
// КРОК 1: Перша перевірка без блокування (fast path)
// 99% запитів при cache hit завершаться тут — без overhead семафора
if (_cache.TryGetValue(key, out T? cached))
{
_logger.LogDebug("Cache HIT (fast path): {Key}", key);
return cached;
}
// КРОК 2: Cache miss — захоплюємо семафор для цього ключа
// GetOrAdd гарантує thread-safe створення семафора
var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
// WaitAsync — якщо семафор зайнятий, чекаємо (без блокування потоку)
await semaphore.WaitAsync(ct);
try
{
// КРОК 3: Друга перевірка після захоплення семафора
// Поки ми чекали — інший потік міг вже заповнити кеш
if (_cache.TryGetValue(key, out cached))
{
_logger.LogDebug("Cache HIT (after acquiring lock): {Key}", key);
return cached;
}
_logger.LogInformation("Cache MISS — executing factory for: {Key}", key);
// КРОК 4: Тільки ОДИН потік виконує дорогу операцію
var result = await factory(ct);
if (result is null) return default;
// КРОК 5: Записуємо в кеш
var options = new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = absoluteExpiration
};
if (slidingExpiration.HasValue)
options.SlidingExpiration = slidingExpiration.Value;
_cache.Set(key, result, options);
return result;
}
finally
{
// ЗАВЖДИ звільняємо семафор — навіть при винятку
semaphore.Release();
// Видаляємо семафор якщо він більше не потрібен
// (нова задача — новий семафор, нема memory leak)
_locks.TryRemove(key, out _);
}
}
}
Реєстрація і використання:
builder.Services.AddSingleton<SafeCacheService>();
// Singleton важливий: словник семафорів має бути shared між запитами
public async Task<List<Product>> GetAllSafeAsync(CancellationToken ct)
{
return await _cacheService.GetOrCreateSafeAsync(
key: "products:all",
factory: async cancelToken =>
await _db.Products.AsNoTracking().ToListAsync(cancelToken),
absoluteExpiration: TimeSpan.FromMinutes(30),
slidingExpiration: TimeSpan.FromMinutes(10),
ct: ct) ?? [];
}
Інвалідація: стратегії видалення
Явна інвалідація
public async Task<Product> CreateAsync(CreateProductRequest request, CancellationToken ct)
{
var product = new Product { Name = request.Name, Price = request.Price };
_db.Products.Add(product);
await _db.SaveChangesAsync(ct);
// Список товарів тепер застарілий — видаляємо
_cache.Remove("products:all");
return product;
}
public async Task<bool> UpdateAsync(int id, UpdateProductRequest req, CancellationToken ct)
{
var product = await _db.Products.FindAsync([id], ct);
if (product is null) return false;
product.Name = req.Name;
product.Price = req.Price;
await _db.SaveChangesAsync(ct);
// Видаляємо і список, і конкретний товар
_cache.Remove("products:all");
_cache.Remove($"products:id:{id}");
return true;
}
Групова інвалідація через CancellationToken
// Для групи пов'язаних записів — один CancellationTokenSource
private CancellationTokenSource _categoriesCts = new();
public void CacheCategory(int categoryId, Category category)
{
_cache.Set($"category:{categoryId}", category, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
// Прив'язуємо до спільного токена
ExpirationTokens = { new CancellationChangeToken(_categoriesCts.Token) }
});
}
// При зміні будь-якої категорії — видаляємо ВСІ закешовані категорії
public void InvalidateAllCategories()
{
// Cancel() тригерить видалення всіх записів з цим токеном
_categoriesCts.Cancel();
// Створюємо новий CTS для майбутніх записів
_categoriesCts = new CancellationTokenSource();
}
Типові помилки
IMemoryCache зберігає посилання — не копії. Зміна об'єкта після кешування = зміна у кеші.
// ❌ Небезпечно
var product = _cache.Get<Product>("product:1")!;
product.Price = 999; // Змінює і кеш!
// ✅ Клонуємо через record with-expression (shallow copy)
var product = _cache.Get<Product>("product:1")!;
return product with { }; // Копія
// ✅ Кешуємо NeverMutate DTO, не EF-entity
_cache.Set("product:1", new ProductDto(product.Id, product.Name, product.Price));
// ❌ Конфлікт при наявності Product і Category з однаковим Id
_cache.Set("1", product);
_cache.Set("1", category); // Перезаписує product!
// ✅ Завжди включайте тип у ключ
_cache.Set("products:1", product);
_cache.Set("categories:1", category);
// ❌ NeverRemove + великий об'єкт = memory leak
_cache.Set("huge-report", reportData, new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.NeverRemove // Не видалиться НІКОЛИ
// Якщо reportData — 100 МБ і таких записів сотні — OOM
});
// ✅ Завжди ставте AbsoluteExpiration навіть для "постійних" даних
_cache.Set("config", config, new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.High,
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24) // Оновлюємо раз на день
});
// ❌ Паролі, токени, платіжні дані — не кешувати!
_cache.Set("user:1", new { user.Email, user.HashedPassword, user.PaymentToken });
// ✅ Кешуємо лише безпечний публічний DTO
_cache.Set("user:1:public", new { user.Id, user.DisplayName, user.AvatarUrl });
Практичний приклад: повний ProductService
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly AppDbContext _db;
private readonly SafeCacheService _safeCache;
private readonly ILogger<ProductService> _logger;
private const string AllKey = "products:all";
private const string IdPrefix = "products:id:";
public ProductService(
IMemoryCache cache,
AppDbContext db,
SafeCacheService safeCache,
ILogger<ProductService> logger)
{
_cache = cache;
_db = db;
_safeCache = safeCache;
_logger = logger;
}
// Список — захищений від Stampede, 30 хвилин кеш
public Task<List<Product>?> GetAllAsync(CancellationToken ct) =>
_safeCache.GetOrCreateSafeAsync(
AllKey,
async cancelToken => await _db.Products.AsNoTracking().ToListAsync(cancelToken),
absoluteExpiration: TimeSpan.FromMinutes(30),
slidingExpiration: TimeSpan.FromMinutes(10),
ct: ct);
// Конкретний товар — 1 година, sliding 15 хвилин
public Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
_safeCache.GetOrCreateSafeAsync(
$"{IdPrefix}{id}",
async cancelToken => await _db.Products.FindAsync([id], cancelToken),
absoluteExpiration: TimeSpan.FromHours(1),
slidingExpiration: TimeSpan.FromMinutes(15),
ct: ct);
// Запис — інвалідація всіх пов'язаних ключів
public async Task<Product> CreateAsync(CreateProductRequest req, CancellationToken ct)
{
var p = new Product { Name = req.Name, Price = req.Price };
_db.Products.Add(p);
await _db.SaveChangesAsync(ct);
_cache.Remove(AllKey);
return p;
}
public async Task<bool> UpdateAsync(int id, UpdateProductRequest req, CancellationToken ct)
{
var p = await _db.Products.FindAsync([id], ct);
if (p is null) return false;
p.Name = req.Name; p.Price = req.Price;
await _db.SaveChangesAsync(ct);
_cache.Remove(AllKey);
_cache.Remove($"{IdPrefix}{id}");
return true;
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct)
{
var p = await _db.Products.FindAsync([id], ct);
if (p is null) return false;
_db.Products.Remove(p);
await _db.SaveChangesAsync(ct);
_cache.Remove(AllKey);
_cache.Remove($"{IdPrefix}{id}");
return true;
}
}
Практичні завдання
Рівень 1 — Базовий
Завдання 1.1. Реалізуйте endpoint GET /admin/cache/entries що за допомогою MemoryCache GetCurrentStatistics() повертає кількість записів, загальний розмір, кількість hits та misses. Endpoint має бути доступний тільки при builder.Environment.IsDevelopment().
Завдання 1.2. Додайте заголовок X-Cache: HIT або X-Cache: MISS до HTTP-відповіді використовуючи IMemoryCache.TryGetValue перед GetOrCreateAsync. Передавайте HttpContext у ProductService або реалізуйте окремий CacheHeaderMiddleware.
Рівень 2 — Логіка
Завдання 2.1. Реалізуйте Cache Warming: IHostedService що при старті завантажує в кеш перші 50 найпопулярніших товарів (за полем ViewCount). Додайте endpoint POST /admin/cache/warm для ручного прогріву. Логуйте час виконання через Stopwatch.
Завдання 2.2. Реалізуйте CacheInvalidationService з методами InvalidateProduct(int id), InvalidateAllProducts(), InvalidateCategory(int id), InvalidateAll(). Використовуйте CancellationTokenSource для групової інвалідації. Зареєструйте як Singleton і впровадьте у ProductService та CategoryService.
Рівень 3 — Архітектура
Завдання 3.1. Реалізуйте Generic Cache Decorator (CachedProductRepository) що обгортає IProductRepository і автоматично кешує результати методів GetAll, GetById. Використовуйте атрибут [Cacheable(Key = "...", Duration = 60)] для маркування методів і Reflection для зчитування атрибута у декораторі. Зареєструйте через decorator pattern у DI.
Огляд кешування: чотири рівні і коли що обирати
Огляд чотирьох механізмів кешування в ASP.NET Core: IMemoryCache, IDistributedCache, Response Cache, Output Cache. Порівняльна таблиця, стратегічна схема production-стека, правило вибору для кожного сценарію.
IDistributedCache і Redis: розподілений кеш
Детальний розгляд IDistributedCache в ASP.NET Core з Redis через StackExchange.Redis: реєстрація, серіалізація, GetOrCreateAsync-обгортка, теги через Redis Sets, Lua-скрипти, двох'ярусний кеш L1+L2, паблікація повідомлень (Pub/Sub) для інвалідації, rate limiting.