У вас есть запрос к базе данных или к платному API, и вы кэшируете результат? Для кэша используете ConcurrentDictionary или MemoryCache?
У кэша, построенного на этих классах, есть одна неприятная проблема: отсутствие защиты от давки кэша (cache stampede). При определённой нагрузке кэш будет многократно выполнять один и тот же запрос из-за отсутствия координации между потоками и репликами. В этой статье я наглядно покажу, как давка кэша влияет на C# приложение и что с этим делать.
Что такое давка кэша
Представим, что мы кэшируем результат долгого запроса в базу. К этому результату десятки раз в секунду обращаются клиенты. Пока значение лежит в кэше, всё хорошо: каждый запрос просто читает его из памяти.
Проблема начинается, когда кэш протухает. Если кэш не умеет координировать параллельные запросы к одному и тому же ключу, то десятки потоков одновременно идут в базу за одним и тем же значением. Аналогичная ситуация может возникнуть после рестарта приложения, когда кэш ещё не прогрет и пустой.

В итоге, вместо одной «тяжёлой» операции вы получаете десятки. Это и называется cache stampede (давка кэша).
Бенчмарки
Я сравнивал поведение ConcurrentDictionary, MemoryCache, а также относительно новый HybridCache в двух сценариях:
IO-bound – имитация запроса к базе данных или внешнему API.
async Task<int> IOBoundOperation(CancellationToken ct)
{
// Имитируем IO-операцию
await Task.Delay(200, ct);
// Имитируем аллокацию 1 КБ памяти
var result = new byte[1024].Length;
return result;
}
CPU-bound – имитация «тяжёлых» вычислений.
Task<int> CPUBoundOperation(CancellationToken _)
{
// Имитируем CPU-операцию
var result = 0;
for (int i = 0; i < 200_000_000; i++)
{
result += i % 7;
}
return Task.FromResult(result);
}
Количество параллельных запросов изменяется от 1 до Environment.ProcessorCount × 2 (количество логических процессоров).
Измеряемые параметры:
Median execution time — медианное время выполнения бенчмарка.
Allocated memory — сколько выделилось памяти.
Operations executed — сколько раз фактически была вызвана IO-bound или CPU-bound операция (метод
ExecuteOperation).
Полный код бенчмарков и результаты лежат в репозитории.
Проверяем ConcurrentDictionary
ConcurrentDictionary с натяжкой можно назвать кэшем. Обычно его используют как in-memory кэш, в котором значения хранятся до рестарта процесса или до явной очистки через Clear или Remove. Давка кэша тут наблюдается, когда словарь пустой.
Типичное использование ConcurrentDictionary выглядит так:
Пробуем прочитать значение по ключу используя
TryGetValue. Если значение есть, то возвращаем.Если нет, то выполняем «тяжёлую» операцию, сохраняем результат через
TryAddи возвращаем его.
if (!_dictionary.TryGetValue(CacheKey, out var existing))
{
var value = await ExecuteOperation(Operation, CancellationToken.None);
_dictionary.TryAdd(CacheKey, value);
return value;
}
return existing;
Важно понимать, что методы TryGetValue и TryAdd потокобезопасны, но они защищают нас только от гонок внутри отдельных операций. Между TryGetValue и TryAdd синхронизации потоков нет.

В результате мы видим:
Для CPU-bound операций с ростом числа параллельных запросов медианное время увеличивается примерно в 2 – 3 раза. Значение Operations executed вырастает до 15.
Для IO-bound операций количество выполнений растёт линейно: сколько параллельных запросов, столько раз и выполняется ExecuteOperation.
В ConcurrentDictionary также есть метод GetOrAdd который кажется атомарным. Код бенчмарка можно было бы переписать вот так:
private readonly ConcurrentDictionary<string, Task<int>> _dictionary = new();
return await _dictionary.GetOrAdd(CacheKey, _ =>
{
return ExecuteOperation(Operation, CancellationToken.None);
});
Но если смотреть на реализацию метода GetOrAdd, то он ничем не отличается от примера с TryGetValue и TryAdd выше. То есть он также не гарантирует однократный вызов передаваемого делегата. Обсуждению этого неочевидного поведения посвящено вот это ишью в GitHub.
Проверяем MemoryCache
В MemoryCache тоже есть удобный метод GetOrCreateAsync:
return await _memoryCache.GetOrCreateAsync(CacheKey, async entry =>
{
return await ExecuteOperation(Operation, CancellationToken.None);
});
И, как в ConcurrentDictionary, на первый взгляд может показаться, что при его использовании, метод ExecuteOperation будет вызван всего один раз. Но если посмотреть исходники GetOrCreateAsync, видно, что защиты от давки кэша там нет. Это подтверждается и результатами бенчмарков.

На графиках почти то же самое, что и у ConcurrentDictionary:
Для CPU-bound операций с ростом числа параллельных запросов медианное время увеличивается до 3 – 4 раз.
Для IO-bound операций количество выполнений также растёт линейно: сколько параллельных запросов, столько раз и выполняется метод
ExecuteOperation.
Проверяем HybridCache
Следующий кандидат – HybridCache. Эта библиотека появилась с выходом .NET 9 и объединяет L1 (локальный, IMemoryCache) и L2 (персистентный, IDistributedCache). На уровне API всё выглядит так же, как в IMemoryCache:
return await hybridCache.GetOrCreateAsync(CacheKey, async (cancellationToken) =>
{
return await ExecuteOperation(Operation, cancellationToken);
});
Ключевое отличие в том, что в HybridCache встроена защита от давки кэша, но только в рамках одного процесса. Если вам интересно как именно она реализована, то смотрите методы GetOrCreateAsync и GetOrCreateStampedeState.
При использовании только L1, мы наконец-то получаем защиту от давки кэша, что подтверждается результатами бенчмарка – количество вызовов ExecuteOperation всегда равно 1.

Но, как я уже говорил, эта защита только на уровне процесса. Если у сервиса несколько реплик, то каждая реплика выполнит метод ExecuteOperation.

Наличие уровня L2 не поможет от давки кэша: в HybridCache нет механизма распределённой блокировки (distributed lock). Хотя разработчики .NET согласны, что было бы неплохо добавить такой функционал когда-нибудь в будущем.
Заключение
Не используйте ConcurrentDictionary и MemoryCache без защиты от давки кэша. В высоконагруженных приложениях это гарантированно приведёт к излишнему выполнению «тяжёлых» операций.
Если у вас одна реплика, то просто используйте HybridCache – его функционала будет достаточно.
Если реплик несколько и вам допустимо выполнить «тяжёлую» операцию несколько раз, то HybridCache всё так же подходит.
-
Если реплик несколько и выполнение лишних «тяжёлых» операций недопустимо, то
Применить Cache TTL Jittering (дрожание TTL кэша) – добавление к значению TTL ключа кэша случайного времени, чтобы TTL не был одинаковым. Это не гарантирует защиту от давки кэша, но заметно уменьшает вероятность синхронного обновления на всех репликах. В некоторых библиотеках, как, например FusionCache, эта функциональность есть из коробки.
Применить паттерн Single Flight на уровне L2. В таком случае, только одна реплика будет выполнять «тяжёлую» операцию. Остальные будут ждать и прочитают уже обновлённое значение из L2. Я, к сожалению, не нашёл готовых библиотек для распределённых кэшей, реализующих этот функционал. Если вы знаете о таких, то напишите в комментариях.
antonb73
Спасибо за статью. Оказывается если класс использовать для задач, для которых он не предназначен, то он не сработает! Не знал, что такое бывает. :)
Проблема конечно в стремлении понадобавлять разных методов во вполне себе хорошие классы, при этом не объяснив джунам, что лучше этими методами не пользоватся либо пользоватся понимая ограничения. Но, кто будет разбиратся и вдумыватся о возможных проблемах? Таких единицы.
Описанная вами проблема не решается ни одним классом, так как само решение нетревиальное и ненадёжное.
Решение - при отсутсвии ключа в кэше, первый вызов, записывается ключ и статус "не готово", затем запускается метод получения данных, после того как метод завершён, данные помещаются в кэш и статус меняется на "готово". Во время получения данных все вызовы из других потоков с этим ключём встают в ожидание - в идеале подписываются на событие и ожидают завершения получения данных.
Это основной, успешный сценарий. но есть и альтернативные - когда данные не удалось получить. Что делать в этом случае? :)
Вот тут и начинается самое интересное, и сценариев может быть множество. Эти сценарии сильно зависят от вашей задачи и пихать их в библиотеку .NET никто не будет - ибо дело неблагодарное :)
Как минимум наличие встроенных альтернативных сценариев подразумевает наличия настроек для них, например - количество попыток получить данные, время ожидания получения данных, и наконец просто флага включения сценария по умолчанию, который в данном случае мало кому пригодится - так как давка кэша проблема специфичная для сложных приложений, а её негативное проявление будет только при большой нагрузке.
Вывод - если этих настроек нет, значит в классе нет реализации альтернативных сценариев. Если нет альтернативных сценариев, то нет и никакого кода для решения проблемы.
Как видите, не нужно проводить исследования, чтобы по косвенным признакам определять границы сложности класса. Достаточно просто посмотреть на класс и сказать "он слишком прост для решения этой сложной проблемы".