Решаем Проблему Паники в Кэше
В приложении ASP.NET Core API кэшировался довольно ресурсоёмкий отчёт на 60 секунд. При обычной нагрузке всё было нормально: один запрос перестраивал кэш, все остальные читали из него. При пиковой нагрузке десятки запросов поступали одновременно, все видели промах кэша и параллельно выполняли один и тот же ресурсоёмкий запрос. Слой кэширования не помогал. Это называется «паникой в кэше» (cache stampede).
Почему это происходит
IMemoryCache.GetOrCreate (и его асинхронный аналог) сами по себе не добавляют блокировок:
public async Task<Report> GetReportAsync(string key)
{
if (_cache.TryGetValue(key, out Report cached))
return cached;
var report =
await _service.BuildReportAsync(key);
_cache.Set(key, report, TimeSpan.FromSeconds(60));
return report;
}
Если 10 запросов придут одновременно после истечения срока действия кэша, для всех TryGetValue вернёт false, и все они вызовут BuildReportAsync. Это не специфично для IMemoryCache. Распределённые кэши, такие как Redis, имеют ту же проблему. Сам кэш не знает и не заботится о том, что параллельные вызывающие процессы собираются запросить тот же ключ.
Решение 1: блокировка для каждого ключа с помощью SemaphoreSlim
Самое простое решение — заставить одновременные вызывающие процессы для одного и того же ключа ждать завершения первого:
private static readonly
ConcurrentDictionary<string, SemaphoreSlim> _locks = new();
public async Task<Report> GetReportAsync(string key)
{
if (_cache.TryGetValue(key, out Report cached))
return cached;
var keyLock = _locks.GetOrAdd(key,
_ => new SemaphoreSlim(1, 1));
await keyLock.WaitAsync();
try
{
// Перепроверка: другой запрос мог уже
// задать значение, пока мы ждали семафора
if (_cache.TryGetValue(key, out cached))
return cached;
var report = await _service.BuildReportAsync(key);
_cache.Set(key, report, TimeSpan.FromSeconds(60));
return report;
}
finally
{
keyLock.Release();
}
}
Примечание: ConcurrentDictionary<string, SemaphoreSlim> будет бесконечно расти, если его не чистить. Для небольшого набора ключей это нормально. Иначе - удаляйте неиспользуемые семафоры.
Решение 2: HybridCache сделает это за вас
Microsoft.Extensions.Caching.Hybrid.HybridCache координирует одновременные вызовы для одного и того же ключа, так что одновременно выполняется только один вызов:
// регистрация
services.AddHybridCache(options =>
{
opts.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromSeconds(60),
LocalCacheExpiration = TimeSpan.FromSeconds(60)
};
});
// использование
public async Task<Report> GetReportAsync(string key)
{
return await _cache.GetOrCreateAsync(
key,
async ct => await _service.BuildReportAsync(key, ct),
cancellationToken: default);
}
HybridCache также предоставляет двухуровневый кэш. Защита от «паники» применяется на локальном уровне на каждом узле; она не предотвращает одновременную перестройку одного и того же ключа двумя разными узлами в кластере, поскольку отсутствует межмашинная блокировка. Для этого потребуется распределённая блокировка или придётся смириться с периодическим двойным перестроением на разных узлах как с менее масштабной версией той же проблемы.
Решение 3: не допускать одновременного истечения срока действия всего кэша
Даже при блокировке по ключу, проблема может возникать, если срок действия множества разных ключей истекает одновременно. Решение - добавить разброс (jitter):
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 10));
_cache.Set(key, report, TimeSpan.FromSeconds(60) + jitter);
Источник: https://steven-giesel.com/blogPost/605274d2-719b-4b1f-b0d5-3ad9001d9b56/adding-static-getter-thanks-to-extensions