HybridCache против IMemoryCache против IDistributedCache в .NET 11: что выбрать?
Для нового кода кеширования в .NET 11 по умолчанию используйте HybridCache. Берите IMemoryCache, только если нужна скорость на одном сервере без сериализации, а IDistributedCache, только как хранилище-бэкенд. Вот матрица принятия решений.
Для нового кода кеширования в .NET 11 по умолчанию используйте HybridCache. Он даёт внутрипроцессную скорость IMemoryCache, межсерверный охват IDistributedCache, а также защиту от лавины запросов плюс инвалидацию по тегам, которой нет ни у одной из старых API, и всё это за одним вызовом GetOrCreateAsync. Берите чистый IMemoryCache, только если нужна задержка одного сервера без сериализации и тонкий контроль над вытеснением, а к чистому IDistributedCache обращайтесь в основном тогда, когда нужно распределённое хранилище без уровня L1 (или как опорный уровень для HybridCache). Эта статья подкрепляет данную рекомендацию полной матрицей возможностей, реально важными различиями API и деталью, которая делает выбор за вас.
Всё здесь нацелено на .NET 11, ASP.NET Core 11 и C# 14. HybridCache поставляется в пакете Microsoft.Extensions.Caching.Hybrid, который вышел в GA вместе с .NET 9 и является тем же пакетом, что вы используете в .NET 11. Он поддерживает среды выполнения вплоть до .NET Framework 4.7.2 и .NET Standard 2.0, поэтому сравнение ниже не ограничивается новейшим TFM.
Матрица возможностей
| Возможность | IMemoryCache | IDistributedCache | HybridCache |
|---|---|---|---|
| Уровень | L1 (в процессе) | L2 (вне процесса) | L1 + опциональный L2 |
| Общий между серверами | Нет | Да | Да (через L2) |
| Переживает перезапуск процесса | Нет | Да | L2 переживает, L1 нет |
| Хранится как | живой объект | byte[] | объект в L1, сериализован в L2 |
| Сериализация | нет | пишете сами | встроена (System.Text.Json и др.) |
| Защита от лавины запросов | нет | нет | да |
| Инвалидация по тегам | нет | нет | да (RemoveByTagAsync) |
| Получить-или-создать за один вызов | только расширение, без защиты | нет | да (GetOrCreateAsync) |
| Контроль истечения на запись | полный | абсолютное + скользящее | общее + локальное (LocalCacheExpiration) |
| Встроенные метрики OpenTelemetry | да (.NET 11) | зависит от бэкенда | да |
| В коробке (без NuGet) | да | абстракция да, бэкенды нет | нет (один пакет) |
| Минимальная среда выполнения | широкая | широкая | .NET Framework 4.7.2 / netstandard2.0 |
Все три регистрируются через внедрение зависимостей и разрешаются по интерфейсу (или, для HybridCache, по абстрактному классу). Важные различия не в регистрации, а в том, что каждая делает при промахе кеша и при конкурентном доступе.
Что на самом деле представляет собой каждая API
IMemoryCache хранит ссылки на живые объекты в хранилище на базе ConcurrentDictionary внутри вашего процесса. Сериализации нет: вы кладёте Customer, и получаете ту же ссылку Customer. Это делает его самым быстрым из трёх и единственным, где попадание в кеш стоит, по сути, поиска в словаре. Цена в том, что он привязан к процессу: два экземпляра за балансировщиком нагрузки имеют два независимых кеша, а перезапуск его опустошает.
// .NET 11, C# 14
builder.Services.AddMemoryCache();
public class ProductService(IMemoryCache cache, ProductDb db)
{
public Task<Product> GetAsync(int id) =>
cache.GetOrCreateAsync($"product:{id}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return db.LoadProductAsync(id);
})!;
}
IDistributedCache — это намеренно низкоуровневая абстракция над хранилищем вне процесса. Его поверхность — это GetAsync, SetAsync, RefreshAsync и RemoveAsync (плюс синхронные варианты), и каждое значение — это byte[]. Здесь нет GetOrCreate, нет объектной модели и нет контроля конкурентного доступа. Вы сами отвечаете за сериализацию, именование ключей, политику истечения и шаблон сквозного чтения.
// .NET 11, C# 14
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = builder.Configuration.GetConnectionString("Redis"));
public class ProductService(IDistributedCache cache, ProductDb db)
{
public async Task<Product> GetAsync(int id)
{
var key = $"product:{id}";
var bytes = await cache.GetAsync(key);
if (bytes is not null)
return JsonSerializer.Deserialize<Product>(bytes)!;
var product = await db.LoadProductAsync(id);
await cache.SetAsync(key, JsonSerializer.SerializeToUtf8Bytes(product),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
return product;
}
}
Это примерно пятнадцать строк шаблонного кода на каждое кешируемое значение, и каждая копия — это шанс забыть про истечение, неправильно обработать null или выбрать слегка иной сериализатор. Встроенные реализации включают в памяти (AddDistributedMemoryCache, только для разработки и тестов, так как на самом деле он не распределённый), Redis (AddStackExchangeRedisCache), SQL Server (AddDistributedSqlServerCache), Azure Cache for Redis и сторонние хранилища, такие как NCache.
HybridCache — это абстракция, которую Microsoft добавила, чтобы свести два приведённых выше шаблона в один. Он держит внутрипроцессный L1 (по умолчанию MemoryCache) и, если вы зарегистрировали IDistributedCache, автоматически использует его как L2. Вызов GetOrCreateAsync проверяет L1, затем L2, затем выполняет вашу фабрику и записывает обратно на оба уровня. Вы никогда не касаетесь сериализации, если сами не захотите.
// .NET 11, C# 14
builder.Services.AddHybridCache();
// If an IDistributedCache is also registered, it becomes the L2 automatically.
public class ProductService(HybridCache cache, ProductDb db)
{
public ValueTask<Product> GetAsync(int id, CancellationToken ct = default) =>
cache.GetOrCreateAsync(
$"product:{id}",
async token => await db.LoadProductAsync(id, token),
cancellationToken: ct);
}
Тот же результат, что и в блоке IDistributedCache, три строки вместо пятнадцати, с защитой от лавины запросов и уровнем L1, который вам не пришлось подключать вручную.
Когда выбирать IMemoryCache напрямую
- Кеши на одном сервере или на узел, где данные дёшево пересчитать после перезапуска. Таблица поиска, загружаемая один раз на процесс, разобранная конфигурация, корзина ограничителя частоты. Нет смысла сериализовать их вне процесса. Сочетайте его с новым встроенным измерителем OpenTelemetry из .NET 11, чтобы по-прежнему получать метрики коэффициента попаданий и вытеснений без собственного опросника, как описано в статье о метриках MemoryCache в .NET 11.
- Горячие пути, где даже один цикл сериализации — это слишком много. Поскольку
IMemoryCacheвозвращает живой объект, попадание — это чтение из словаря. Если вы кешируете значение, читаемое тысячи раз в секунду на одной машине, это важно. Это то же рассуждение, что и за тем, чтобы держать план запроса в памяти, как в скомпилированных запросах для горячих путей EF Core. - Вам нужны возможности вытеснения, которые
HybridCacheне предоставляет. Лимиты по размеру (SizeLimitплюсSizeна запись), приоритет вытеснения иPostEvictionCallbacks— это понятияIMemoryCache.HybridCacheне предоставляет их в своём API.
Подвох, на который вы соглашаетесь, идя напрямую: GetOrCreateAsync у IMemoryCache — это метод расширения без защиты от лавины. При всплеске на холодном кеше каждый конкурентный вызывающий выполняет фабрику.
Когда выбирать IDistributedCache напрямую
- Вам нужно общее хранилище, но вы явно не хотите уровень L1. Если корректность зависит от того, что каждый узел видит одно и то же значение в момент его изменения, внутрипроцессный L1 со своим собственным истечением — это риск, потому что инвалидация ключа не доходит до L1 других серверов (подробнее об этом ниже). Прямой переход к Redis убирает окно устаревания, которое вносит L1.
- Вы на самом деле не кешируете.
IDistributedCacheлежит в основе состояния сессии ASP.NET Core и может хранить ключи Data Protection. Это сценарии хранения, а не сквозные кеши, иHybridCacheимеет неподходящую для них форму. - Вам нужен полный контроль над сериализованными байтами. Собственные бинарные форматы, сжатие, которым управляете вы, или взаимодействие с другой системой, читающей те же ключи Redis.
HybridCacheможет принять собственный сериализатор, но если байты являются контрактом, более низкоуровневая API честнее.
Когда выбирать HybridCache (вариант по умолчанию)
- Любой новый сквозной кеш в приложении, которое может масштабироваться за пределы одного экземпляра. Вы получаете скорость L1 уже сегодня и корректность L2 в тот момент, когда регистрируете кеш Redis, без изменения кода в точке вызова. Это в точности конфигурация, описанная в использовании HybridCache с Redis в качестве кеша L2.
- Везде, где лавина запросов навредила бы.
HybridCacheгарантирует, что для данного ключа только один конкурентный вызывающий выполняет фабрику, пока остальные ждут этот единственный результат. Холодный кеш, в который попадают сто запросов, выполняет один опорный запрос, а не сто, что является той же проблемой, с которой вы боретесь, гоняясь за запросами N+1 в EF Core 11. - Вам нужна групповая инвалидация. Пометьте набор записей тегами (
tags: ["product", $"category:{categoryId}"]) и удалите их вместе черезRemoveByTagAsync("category:42"). Ни у одной из старых API нет понятия тега.
Бенчмарк лавины, конкретно
Это различие, которое проявляется в продакшене, поэтому стоит его измерить, а не утверждать. Возьмите фабрику, которая имитирует чтение из базы данных длительностью 200 мс, и запустите 100 конкурентных вызовов GetOrCreateAsync для одного и того же ключа на холодном кеше.
// .NET 11, BenchmarkDotNet 0.15.x style harness (simplified)
async Task<int> Factory(CancellationToken _)
{
Interlocked.Increment(ref _factoryCalls);
await Task.Delay(200); // stand-in for a DB / HTTP round trip
return 42;
}
var tasks = Enumerable.Range(0, 100)
.Select(_ => hybrid.GetOrCreateAsync("k", Factory).AsTask());
await Task.WhenAll(tasks);
С HybridCache _factoryCalls равно 1: один вызывающий выполняет фабрику в 200 мс, а остальные 99 ждут её результата, поэтому весь всплеск разрешается примерно за 200 мс одним опорным вызовом. Замените на метод расширения GetOrCreateAsync у IMemoryCache, и _factoryCalls поднимается до 100, потому что ничто не сериализует вызывающих, промахнувшихся по холодному кешу. Против реальной базы данных это разница между одним запросом и стопкой из ста в пуле соединений. Точное число для случая IMemoryCache варьируется в зависимости от тайминга (некоторые вызывающие могут попасть после завершения первой записи), что как раз и есть суть: оно не ограничено и недетерминировано, тогда как HybridCache фиксирует его на единице. Цифры измерены на .NET 11 (11.0.x), Windows 11, только со встроенным L1 и без настроенного L2.
Истечение: имена опций различаются так, что это кусается
Все три API называют истечение по-разному, и их путаница — самая частая ошибка конфигурации.
IMemoryCache использует MemoryCacheEntryOptions с AbsoluteExpiration, AbsoluteExpirationRelativeToNow и SlidingExpiration. IDistributedCache использует DistributedCacheEntryOptions с теми же тремя именами. HybridCache использует HybridCacheEntryOptions с двумя свойствами, которые означают нечто иное:
// .NET 11, C# 14
var options = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5), // overall lifetime (drives L2)
LocalCacheExpiration = TimeSpan.FromMinutes(1) // how long the L1 copy is trusted
};
Expiration — это общее время жизни записи, и оно управляет копией в L2. LocalCacheExpiration — это то, как долго внутрипроцессная копия L1 считается действительной, прежде чем запись будет повторно извлечена из L2. Установка LocalCacheExpiration короче, чем Expiration, — это то, как вы ограничиваете устаревание L1 в многосерверном развёртывании: каждый узел доверяет своей локальной копии не более минуты, затем повторно проверяет её относительно общего L2. В HybridCache нет понятия скользящего истечения; если вы полагаетесь на скользящие окна, это причина остаться на более низкоуровневой API.
Другие значения по умолчанию, которые стоит знать: HybridCacheOptions.MaximumPayloadBytes по умолчанию равно 1 МБ, а MaximumKeyLength — 1024 символа. Значения или ключи сверх лимита записываются в журнал и молча не кешируются, что является тихим режимом отказа, если вы кешируете большие блобы.
Деталь, которая делает выбор за вас
Инвалидация по тегам и по ключу в HybridCache не доходит до L1 других серверов. Когда вы вызываете RemoveByTagAsync или RemoveAsync, запись удаляется из локального L1 и общего L2, но каждый другой узел продолжает отдавать свою собственную копию L1, пока эта копия не истечёт по своему собственному LocalCacheExpiration. Документация прямо об этом говорит: инвалидация по тегам — это логическая операция, которая помечает будущие чтения как промахи, она не вычищает другие узлы активно.
Это единственное поведение определяет несколько решений:
- Если вы можете терпеть ограниченное окно устаревания (установите
LocalCacheExpirationна приемлемое для вас окно),HybridCacheидеален, и вы сохраняете скорость L1. - Если вы не можете терпеть никакого окна, потому что устаревшее значение авторизации или цены — это ошибка корректности, то уровень L1 — неподходящий инструмент, и вам следует идти напрямую к
IDistributedCache(или установитьLocalCacheExpirationв ноль, что в значительной степени сводит на нет смысл L1).
Другая принуждающая функция — это безопасность сериализации. HybridCache по умолчанию десериализует свежий объект для каждого вызывающего, чтобы сохранить гарантии потокобезопасности IDistributedCache. Если ваш кешируемый тип неизменяем, вы можете включить повторное использование экземпляров, пометив тип как sealed и применив [ImmutableObject(true)], что устраняет накладные расходы на десериализацию на каждый вызов. Если ваши кешируемые объекты изменяемы и разделяемы, не применяйте этот атрибут, иначе вы внесёте состояния гонки.
Рекомендация, повторно
В .NET 11 пишите новый код кеширования под HybridCache, если только у вас нет конкретной причины этого не делать. Он является почти прямой заменой обеим старым API, устраняет шаблонный код cache-aside, который навязывает вам IDistributedCache, и закрывает дыру лавины, которую оставляет открытой незащищённый IMemoryCache.GetOrCreateAsync. Спускайтесь к чистому IMemoryCache, когда вам нужна скорость одного сервера, нулевая сериализация или возможности вытеснения (лимиты размера, приоритет, колбэки вытеснения), которые HybridCache не предоставляет. Спускайтесь к чистому IDistributedCache, когда вам нужно общее хранилище без окна устаревания L1, когда сериализованные байты являются контрактом с другой системой, или когда вы используете его для хранения сессий и ключей, а не для кеширования. Для всего остального, а это большинство кеширования, HybridCache — это ответ.
Связанное
- Как использовать HybridCache в ASP.NET Core 11 с Redis в качестве кеша L2
- .NET 11 даёт MemoryCache первоклассные метрики OpenTelemetry
- Как обнаружить запросы N+1 в EF Core 11
- Как использовать скомпилированные запросы с EF Core для горячих путей
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.