У вас есть запрос к базе данных или к платному 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 (количество логических процессоров).

Измеряемые параметры:

  1. Median execution time — медианное время выполнения бенчмарка.

  2. Allocated memory — сколько выделилось памяти.

  3. Operations executed — сколько раз фактически была вызвана IO-bound или CPU-bound операция (метод ExecuteOperation).

Полный код бенчмарков и результаты лежат в репозитории.

Проверяем ConcurrentDictionary

ConcurrentDictionary с натяжкой можно назвать кэшем. Обычно его используют как in-memory кэш, в котором значения хранятся до рестарта процесса или до явной очистки через Clear или Remove. Давка кэша тут наблюдается, когда словарь пустой.

Типичное использование ConcurrentDictionary выглядит так:

  1. Пробуем прочитать значение по ключу используя TryGetValue. Если значение есть, то возвращаем.

  2. Если нет, то выполняем «тяжёлую» операцию, сохраняем результат через 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 синхронизации потоков нет.

Результаты бенчмарка для ConcurrentDictionary
Результаты бенчмарка для ConcurrentDictionary

В результате мы видим:

  • Для 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, видно, что защиты от давки кэша там нет. Это подтверждается и результатами бенчмарков.

Результаты бенчмарка для MemoryCache
Результаты бенчмарка для MemoryCache

На графиках почти то же самое, что и у 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.

Результаты бенчмарка для HybridCache с L1
Результаты бенчмарка для HybridCache с L1

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

Результаты бенчмарка для HybridCache с L1 + L2
Результаты бенчмарка для HybridCache с L1 + L2

Наличие уровня L2 не поможет от давки кэша: в HybridCache нет механизма распределённой блокировки (distributed lock). Хотя разработчики .NET согласны, что было бы неплохо добавить такой функционал когда-нибудь в будущем.

Заключение

  1. Не используйте ConcurrentDictionary и MemoryCache без защиты от давки кэша. В высоконагруженных приложениях это гарантированно приведёт к излишнему выполнению «тяжёлых» операций.

  2. Если у вас одна реплика, то просто используйте HybridCache – его функционала будет достаточно.

  3. Если реплик несколько и вам допустимо выполнить «тяжёлую» операцию несколько раз, то HybridCache всё так же подходит. 

  4. Если реплик несколько и выполнение лишних «тяжёлых» операций недопустимо, то

    1. Применить Cache TTL Jittering (дрожание TTL кэша) – добавление к значению  TTL ключа кэша случайного времени, чтобы TTL не был одинаковым. Это не гарантирует защиту от давки кэша, но заметно уменьшает вероятность синхронного обновления на всех репликах. В некоторых библиотеках, как, например FusionCache, эта функциональность есть из коробки.

    2. Применить паттерн Single Flight на уровне L2. В таком случае, только одна реплика будет выполнять «тяжёлую» операцию. Остальные будут ждать и прочитают уже обновлённое значение из L2. Я, к сожалению, не нашёл готовых библиотек для распределённых кэшей, реализующих этот функционал. Если вы знаете о таких, то напишите в комментариях.

Комментарии (1)


  1. antonb73
    17.12.2025 07:01

    Спасибо за статью. Оказывается если класс использовать для задач, для которых он не предназначен, то он не сработает! Не знал, что такое бывает. :)

    Проблема конечно в стремлении понадобавлять разных методов во вполне себе хорошие классы, при этом не объяснив джунам, что лучше этими методами не пользоватся либо пользоватся понимая ограничения. Но, кто будет разбиратся и вдумыватся о возможных проблемах? Таких единицы.

    Описанная вами проблема не решается ни одним классом, так как само решение нетревиальное и ненадёжное.

    Решение - при отсутсвии ключа в кэше, первый вызов, записывается ключ и статус "не готово", затем запускается метод получения данных, после того как метод завершён, данные помещаются в кэш и статус меняется на "готово". Во время получения данных все вызовы из других потоков с этим ключём встают в ожидание - в идеале подписываются на событие и ожидают завершения получения данных.

    Это основной, успешный сценарий. но есть и альтернативные - когда данные не удалось получить. Что делать в этом случае? :)

    Вот тут и начинается самое интересное, и сценариев может быть множество. Эти сценарии сильно зависят от вашей задачи и пихать их в библиотеку .NET никто не будет - ибо дело неблагодарное :)

    Как минимум наличие встроенных альтернативных сценариев подразумевает наличия настроек для них, например - количество попыток получить данные, время ожидания получения данных, и наконец просто флага включения сценария по умолчанию, который в данном случае мало кому пригодится - так как давка кэша проблема специфичная для сложных приложений, а её негативное проявление будет только при большой нагрузке.

    Вывод - если этих настроек нет, значит в классе нет реализации альтернативных сценариев. Если нет альтернативных сценариев, то нет и никакого кода для решения проблемы.

    Как видите, не нужно проводить исследования, чтобы по косвенным признакам определять границы сложности класса. Достаточно просто посмотреть на класс и сказать "он слишком прост для решения этой сложной проблемы".