В .NET 9 появилась новая библиотека для кэширования — HybridCache. В статье расскажу, что это такое, какие задачи решает, разберу примеры использования и особенности внутреннего устройства.

Как и зачем появился HybridCache

Библиотека HybridCache задумана как новый инструмент кэширования в ASP.NET Core. До её появления было два стандартных подхода:

  • IMemoryCache для кэширования в памяти. Записи кэша хранятся в процессах. Каждый экземпляр приложения имеет отдельный кэш, который теряется при перезапуске.

  • IDistributedCache для работы с внешним хранилищем, например с Redis. Для обмена данными используется сериализация. Приложение перезапускается без потери кэша.

Основная идея HybridCache — объединение этих способов кэширования. Часто используемые объекты хранятся в памяти, и это сокращает задержку. Кэшированные данные из внешнего хранилища можно использовать в распределенных средах — это важно для приложений с балансировкой нагрузки. А внутренний механизм синхронизации гарантирует, что изменения в распределенном кэше автоматически обновляют кэш в памяти.

Для .NET уже есть аналогичные решения, например, FusionCache. Он существует не первый год, имеет схожее внутреннее устройство и сигнатуру методов. Даже программисты Майкрософт используют его при разработке своих продуктов. Учитывая схожесть конструкций, я убеждён, что разработчики HybridCache ориентировались на FusionCache.

Интересно, что HybridCache уже используется в некоторых крупных фреймворках, основанных на ASP.NET Core. Например, Volosoft с октября 2024 использует его в своём продукте.

Использование HybridCache в приложении

Для работы нужно подключить nuget-пакет Microsoft.Extensions.Caching.Hybrid. Важно, что библиотека поддерживает старые среды выполнения .NET Framework 4.7.2 и .NET Standard 2.0. В январе 2025 доступна версия prerelease 9.0.0-preview.9.24556.5. Разработчики обещают выпустить стабильную версию вместе с одним из обновлений .NET 9, но сроки пока неизвестны.

Регистрация сервиса

Для регистрации сервиса HybridCache в DI-контейнере необходимо добавить вызов соответствующей функции:

var builder = WebApplication.CreateBuilder(args);

//Добавление HybridCache и сопутствующих сервисов
builder.Services.AddHybridCache();

Под капотом AddHybridCache вызывается AddMemoryCache и регистрируются сериализаторы — они используются при обмене данными с внешним хранилищем. По умолчанию добавляются сериализаторы для строки и массива байтов, а для остальных объектов используется стандартная сериализация System.Text.Json. При желании можно использовать и другие, например, для работы с XML или кастомные. Для этого необходимо добавить:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHybridCache()
  
  //Добавление кастомного сериализатора с определенным типом
  .AddSerializer<CustomObject, CustomSerializer<CustomObject>>()
  
  //Добавление фабрики сериализаторов для работы со множеством типов
  .AddSerializerFactory<CustomSerializerFactory>();

Больше информации о настройке сериализации можно узнать из документации или посмотреть в примере приложения.

При вызове AddHybridCache регистрируется дефолтный наследник абстрактного класса HybridCacheDefaultHybridCache. Важно понимать, что он не реализует интерфейсы IMemoryCache и IDistributedCache, хотя под капотом агрегирует их. Это означает, что не получится перевести существующий проект на гибридное кэширование через замену одного сервиса на другой. Код придётся переписывать, выпиливая существующие обращения к кэшам и заменяя их на новые конструкции.

DefaultHybridCache по умолчанию использует кэширование в памяти. Если в приложении настроено кэширование с использованием внешнего хранилища, то оно будет использоваться тоже.

Ещё HybridCache умеет в повторное использование объектов. При десериализации лишние экземпляры не будут создаваться для одинаковых объектов, если их классы sealed и помечены атрибутом [ImmutableObject(true)].

Настройки и ограничения

С AddHybridCache можно менять некоторые настройки и задавать ограничения:

var builder = WebApplication.CreateBuilder(args);

//Пример настройки доступных параметров
builder.Services.AddHybridCache(options =>
{
  options.MaximumPayloadBytes = 1024 * 1024;
  options.MaximumKeyLength = 1024;
  options.ReportTagMetrics = true;
  options.DisableCompression = true;
  options.DefaultEntryOptions = new HybridCacheEntryOptions
  {
    Expiration = TimeSpan.FromMinutes(5),
    LocalCacheExpiration = TimeSpan.FromMinutes(5),
    Flags = HybridCacheEntryFlags.DisableDistributedCache
  };
});

Единственное более-менее внятное описание настроек я нашёл в виде комментариев к исходному коду классов HybridCacheOptions, HybridCacheEntryOptions и HybridCacheEntryFlags. Из полезного:

  • MaximumPayloadBytes позволяет настроить максимальный размер записи кэша. Значение по умолчанию — 1 МБ.

  • MaximumKeyLength позволяет настроить максимальную длину ключа, по которому хранится и извлекается объект. Значение по умолчанию — 1024 символов.

  • Expiration и LocalCacheExpiration позволяют задавать общий срок хранения для внутрипроцессного и распределенного кэшей.

  • Флаги позволяют настраивать, какие типы кэширования используются в приложении. Есть отдельные флаги для чтения и записи.

Работа с HybridCache

Сейчас абстрактный класс HybridCache имеет несколько методов создания, получения и удаления объектов в кэше. При выходе стабильной версии методы могут измениться — стоит это учитывать.

GetOrCreateAsync

По задумке метод GetOrCreateAsync является основным, а для большинства сценариев единственным необходимым. Он принимает ключ для получения объекта из кэша. Если элемент не найден в кэше процесса, проверяется кэш внешнего хранилища — если он настроен. Если и там данных нет — вызывается метод получения объекта из источника данных. Затем полученный объект сохраняется в обоих кэшах.

using Microsoft.Extensions.Caching.Hybrid;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHybridCache();
app.MapGet("/hybrid-cache/{key}",
  async (string key, HybridCache cache, CancellationToken cancellationToken) =>
{
  //Получение записей из кэша или из источника данных
  return await cache.GetOrCreateAsync(
    key, //Уникальный ключ для объекта
    async ct => await SomeFuncAsync(key, ct), //Метод получения объекта из источника
    cancellationToken: cancellationToken);
});

static async ValueTask<SomeObj> SomeFuncAsync(string key, CancellationToken token)
{
  if (token.IsCancellationRequested)
  {
    await ValueTask.FromCanceled(token);
  }
  
  return await ValueTask.FromResult(new SomeObj(key));
}

app.Run();

file record SomeObj(string Key);

HybridCache гарантирует, что при параллельных вызовах GetOrCreateAsync с одинаковым ключом, объект будет запрошен из источника только один раз. Остальные вызовы будут ожидать результата и получат значение уже из кэша.

Важно понимать, как HybridCache работает с объектами разных типов, но одинаковым ключом. Допустим, в кэше по определённому ключу хранится объект одного типа. При попытке получить по этому же ключу объект другого типа, новый объект будет запрошен из источника данных и сохранён в кэше. Первый объект при этом будет удалён из кэша.

Также у метода есть интересная перегрузка. Дело в том, что приведённая в примере версия захватывает переменную key. Получается, все аргументы делегата, создающего объект из источника, можно передать только с помощью замыкания. Такой вариант может устроить не всех, так что существует синтаксическая возможность передать все аргументы делегата в виде отдельного объекта:

app.MapGet("/hybrid-cache/{key}",
  async (string key, HybridCache cache, CancellationToken cancellationToken) =>
{
  return await cache.GetOrCreateAsync(
    key,
    (key),
    static async (key, cancellationToken) => await SomeFuncAsync(key, cancellationToken),
    cancellationToken: cancellationToken);
});

При таком варианте читаемость кода ухудшается, зато нет захвата локальных переменных. Какой вариант выбрать — решать тому, кто будет пользоваться данным методом.

Обе перегрузки имеют необязательные параметры options и tags:

app.MapGet("/hybrid-cache/{key}",
  async (string key, HybridCache cache, CancellationToken cancellationToken) =>
{
  HybridCacheEntryOptions options = new()
  {
    Expiration = TimeSpan.FromMinutes(2),
    LocalCacheExpiration = TimeSpan.FromMinutes(2)
  };
  return await cache.GetOrCreateAsync(
    key,
    async ct => await SomeFuncAsync(key, ct),
    options,
    ["tag1", "tag2", "tag3"],
    cancellationToken);
});

Параметр options принимает объект HybridCacheEntryOptions. Позволяет переопределить глобальные значения только для текущего вызова.

Параметр tags принимает IEnumerable<string>. Позволяет группировать различные записи в кэше по тегам.

SetAsync

Метод SetAsync сохраняет объект в кэше по ключу без попытки сначала получить его. Пример:

app.MapPut("/hybrid-cache/{key}",
  async (string key, string[]? tags, HybridCache cache, CancellationToken cancellationToken) =>
{
  HybridCacheEntryOptions options = new()
  {
    Expiration = TimeSpan.FromMinutes(2),
    LocalCacheExpiration = TimeSpan.FromMinutes(2)
  };

  var someObj = await SomeFuncAsync(key, cancellationToken);
  await cache.SetAsync(
    key,
    someObj,
    options,
    tags,
    cancellationToken);
});

Можно передать необязательные аргументы options и tags по аналогии с GetOrCreateAsync.

RemoveAsync

Метод RemoveAsync удаляет объект из кэша по ключу:

app.MapDelete("/hybrid-cache/{key}",
  async (string key, HybridCache cache, CancellationToken cancellationToken) =>
{
  await cache.RemoveAsync(key, cancellationToken);
});

Есть перегрузка для удаления коллекции объектов:

app.MapDelete("/hybrid-cache",
  async (string[] keys, HybridCache cache, CancellationToken cancellationToken) =>
{
  await cache.RemoveAsync(keys, cancellationToken);
});

RemoveByTagAsync

Метод RemoveByTagAsync задуман для удаления объектов из кэша по тегу:

app.MapDelete("/hybrid-cache/{tag}/by-tag",
    async (string tag, HybridCache cache, CancellationToken cancellationToken) =>
{
    await cache.RemoveByTagAsync(tag, cancellationToken);
});

Есть перегрузка для удаления объектов по коллекции тегов:

app.MapDelete("/hybrid-cache/by-tags",
    async (string[] tags, HybridCache cache, CancellationToken cancellationToken) =>
{
    await cache.RemoveByTagAsync(tags, cancellationToken);
});

Важно, что в версии prerelease 9.0.0-preview.9.24556.5 реализация удаления объектов по тегам всё ещё отсутствует, а вызов любой из перегрузок не имеет никакого эффекта.

Итог

  • Библиотека HybridCache — интересное и удобное решение, чтобы объединить существующие подходы к кэшированию.

  • Простой интерфейс скрывает много подкапотной логики. За плохое понимание внутреннего устройства можно поплатиться неочевидным поведением программы.

  • К сожалению, не получится малой кровью добавить HybridCache в проект на существующих механизмах кэширования.

  • Пока не вышла стабильная версия, нужно осторожнее пользоваться библиотекой — состав и сигнатуры методов могут измениться.

Мой проект с примерами использования всех методов

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


  1. tsvettsih
    13.01.2025 10:17

    Пока не вышла стабильная версия, нужно осторожнее пользоваться библиотекой

    Уже есть стабильный FusionCache, так что можно не ждать )


    1. m3ta10ph0b Автор
      13.01.2025 10:17

      Да, согласен, он и по функционалу пока превосходит HybridCache. Но раз майкрософтовские разработчики взяли курс на создание собственной аналогичной либы - надо однозначно за этим следить