Кэширование — как инструмент управления коммуникациями в микросервисах
Кэширование обычно воспринимают как способ «чтобы работало быстрее». В монолите так оно и есть. В микросервисной архитектуре у кэша появляется вторая, не менее важная роль: он решает, когда сервису вообще стоит разговаривать с "соседом". В этой статье разберёмся, чем кэш помогает микросервисам, где он может находиться, как узнаёт об устаревании данных и как всё это реализовать на ASP.NET Core.
Немного лирики (вместо вступления)
Когда микросервисов было мало, а запросов ещё меньше… Кажется, это слишком лирично, продолжу как обычно. Представьте обычный интернет-магазин. Есть сервис «Заказы» (Orders), и чтобы показать пользователю корзину, ему нужно сходить в «Каталог» за названиями товаров, в «Клиенты» за адресом доставки и в «Цены» за актуальной стоимостью. «Каталог», в свою очередь, спрашивает «Склад» про наличие, а «Цены» — «Скидки» про персональные предложения. Пока сервисов три, всё работает, и никто не задумывается.

Один запрос пользователя превращается в пять сетевых вызовов
А теперь давайте посчитаем. Если сервисы вызываются последовательно, задержки (latency) складываются: 40 + 30 + 35 + 45 + 50 = 200 мс только на сеть и работу соседей. Но хуже другое — доступность (availability) не складывается, а перемножается. Каждый сервис в отдельности доступен 99% времени, что звучит вполне прилично. Однако цепочка из пяти таких сервисов доступна только 0,995 ≈ 95,1% времени. В пересчёте на месяц это около 35 часов, когда корзина не открывается, хотя «Заказы» сами по себе абсолютно здоровы.
Вывод напрашивается сам: часть этих вызовов можно вообще не делать. Названия товаров не меняются каждую секунду, адрес клиента — тем более. Вот здесь на сцену и выходит кэширование.
Чем может помочь кэширование микросервисам?
Итак, что же получает микросервисная система, если в нужных местах появляется кэш? Перечислю основное.
- Снижение задержки. Чтение из памяти процесса занимает микросекунды, из Redis — около миллисекунды, а поход к соседу по HTTP — десятки миллисекунд. Разница в порядки.
- Снижение нагрузки на сервис-поставщик. Если «Каталог» спрашивают 2000 раз в секунду об одних и тех же 300 товарах, то 1990 из этих запросов ему просто не нужны.
- Ослабление временной связанности (temporal coupling). Без кэша «Заказы» работают только тогда, когда одновременно работают все их соседи. С кэшем — даже тогда, когда «Каталог» перезагружается после деплоя.
- Отказоустойчивость. Если сосед недоступен, можно отдать последнюю известную версию данных вместо ошибки. В HTTP для этого даже есть термин «устаревшее при ошибке» (stale-if-error).
- Экономия. Меньше трафика между сервисами, меньше реплик под пиковую нагрузку, меньше счёт за облако.
Ключевой момент статьи: в микросервисах кэш — это не только скорость. Это управляемое решение о том, когда сервису разговаривать с соседом, а когда можно обойтись своими силами. Именно поэтому я называю его инструментом управления коммуникациями.
Кстати, у этой медали есть и обратная сторона. Как только вы положили чужие данные к себе, вы согласились с тем, что какое-то время они могут быть не самыми свежими. Весь остальной текст статьи, по сути, о том, как этим согласием грамотно управлять.
Как и для чего его можно и нужно использовать?
Кэшировать всё подряд — плохая идея. Кэшировать ничего — тоже. Вопрос, который стоит задать себе про каждый тип данных: «Что случится, если пользователь увидит версию этих данных минутной давности?» Если ответ «ничего страшного», это хороший кандидат. Если ответ «мы продадим товар, которого нет», — нет.
Хорошо подходят данные, которые часто читаются, редко меняются и допускают небольшое устаревание: справочники, конфигурация, каталог, профили пользователей. Плохо подходят остатки на складе (если речь о резервировании, а не о надписи «в наличии»), балансы, платежи, права доступа.
| Тип данных | Кэшировать? | Допустимое время устаревания |
|---|---|---|
| Справочники (страны, валюты, единицы измерения) | Да | Часы, сутки |
| Конфигурация, флаги функциональности | Да | Минуты |
| Описание товаров в каталоге | Да | Минуты, часы + сброс по событию |
| Профиль клиента | Да | Минуты |
| Цены | Осторожно | Секунды, минуты + обязательный сброс по событию; при оформлении заказа — перепроверка у владельца |
| Наличие «есть / нет» для витрины | Осторожно | Секунды |
| Остатки для резервирования | Нет | — |
| Балансы, платежи | Нет | — |
| Права доступа, отзыв токенов | Нет | — |
Из этой таблицы вытекают два правила, которые я бы повесил над рабочим столом каждого, кто проектирует межсервисное взаимодействие.
Первое правило кэша: для каждого кэшируемого объекта заранее определите, насколько он может устареть. Это решение бизнеса, а не разработчика, и его нужно записать.
Второе правило кэша: у данных всегда один владелец. Кэш — это копия, а не источник истины. Любое решение, которое нельзя отменить (списание денег, резервирование товара), принимается только по данным владельца.
Какие бывают варианты кэширования коммуникаций?
Теперь о том, где именно кэш может находиться. Вариантов пять, и они не исключают друг друга — в реальной системе обычно работают два-три одновременно.

Пять мест, где может находиться кэш
Вариант первый — на шлюзе (API Gateway). Ответы кэшируются ещё на входе в систему, и повторный запрос вообще не доходит до сервисов. Отлично подходит для публичных GET-запросов: список категорий, карточка товара для анонимного посетителя. Плохо подходит для всего, что зависит от пользователя.
Вариант второй — на стороне потребителя (consumer-side). «Заказы» держат у себя копию ответов «Каталога». Это самый частый случай и самый полезный с точки зрения отказоустойчивости: если «Каталог» лежит, «Заказы» продолжают работать на кэше.
Вариант третий — на стороне поставщика (provider-side). «Каталог» сам кэширует результаты своих тяжёлых запросов к базе данных. Большой плюс — владелец данных точно знает, когда они изменились, а значит, может сбросить кэш вовремя. Минус — сетевой вызов от потребителя всё равно происходит.
Вариант четвёртый — HTTP-кэширование. Используем то, что заложено в сам протокол: заголовки Cache-Control, ETag и условные запросы (conditional requests). Потребитель спрашивает: «У меня версия 17, она ещё актуальна?» — и получает либо новые данные, либо короткий ответ 304 Not Modified без тела.
Вариант пятый — проекция по событиям (event-carried state transfer). Самый радикальный вариант: «Заказы» вообще не спрашивают «Каталог». Вместо этого «Каталог» публикует события об изменениях в шину, а «Заказы» складывают нужные им поля в собственную таблицу. Строго говоря, это уже не кэш в привычном смысле, а локальная копия данных, но решает она ту же задачу — убирает синхронный вызов.
Подробное сравнение всех пяти вариантов собрано в разделе «Сводная таблица» в конце статьи.
Наполнение и инвалидация: как кэш узнаёт, что данные устарели?
Есть известная шутка: в программировании только две по-настоящему трудные вещи — инвалидация кэша и придумывание имён. Положить данные в кэш легко. Вовремя оттуда их убрать — вот где начинается настоящая работа.
Стратегии наполнения
- Ленивое наполнение (cache-aside). Приложение сначала смотрит в кэш, при промахе идёт к источнику и кладёт результат в кэш. Самая распространённая стратегия, на ней построены почти все примеры в этой статье.
- Чтение через кэш (read-through). То же самое, но логику «сходить к источнику» берёт на себя библиотека кэширования. Метод
GetOrCreateAsyncуHybridCache— как раз такой вариант. - Запись через кэш (write-through). При изменении данных владелец сразу обновляет и кэш. Работает только на стороне поставщика — потребитель ничего не пишет в чужие данные.
- Упреждающее обновление (refresh-ahead). Популярные ключи обновляются в фоне незадолго до истечения срока, и пользователь никогда не попадает на промах.
Стратегии инвалидации
По времени (TTL, time to live). Самый простой способ: запись живёт заданное время и исчезает. Не требует никакой связи с владельцем данных. Минус очевиден — всё это время потребитель может видеть устаревшие данные.
По событию. Владелец данных публикует событие об изменении, потребители сбрасывают соответствующие ключи. Устаревание сокращается до времени доставки сообщения. В моём стеке это Rebus поверх RabbitMQ.
Изменение цены в каталоге, событие, сброс кэша и повторное наполнение
Третье правило кэша: инвалидация по событию никогда не заменяет TTL, а дополняет его. Событие может потеряться, и тогда только TTL гарантирует, что устаревшие данные когда-нибудь всё-таки исчезнут.
Кстати, здесь кроется ловушка, на которую натыкаются почти все. Rebus, как и большинство шин, работает по модели конкурирующих потребителей (competing consumers): все экземпляры сервиса «Заказы» читают из одной очереди orders, и каждое сообщение получает только один из них. Распределённый кэш в Redis общий, его сброс увидят все. А вот локальная память (L1) остальных экземпляров об изменении не узнает и будет отдавать старую цену до истечения своего срока. Решений три: держать срок жизни L1 коротким (секунды или минута), разослать сигнал всем экземплярам через Redis pub/sub или выделить каждому экземпляру собственную очередь подписки. Код для второго варианта приведён в разделе «Способы реализации в ASP.NET Core» ниже по тексту.
Способы реализации в ASP.NET Core
Переходим к коду. Начнём с самого простого и будем постепенно добавлять возможности. Все примеры — для .NET 9 и новее, контроллеры заменены на Minimal API ради краткости.
IMemoryCache — локальный кэш
Самый простой вариант, доступный в ASP.NET Core «из коробки». Данные хранятся в памяти процесса, поэтому чтение мгновенное.
public sealed class CatalogClient(HttpClient http, IMemoryCache cache)
{
public async Task<ProductDto?> GetProductAsync(int productId, CancellationToken ct)
{
return await cache.GetOrCreateAsync($"product:{productId}", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await http.GetFromJsonAsync<ProductDto>($"api/products/{productId}", ct);
});
}
}
Работает отлично, пока у вас один экземпляр сервиса. Как только экземпляров становится три, появляются три независимых кэша: каждый со своим набором данных, каждый с промахом после перезапуска, и сбросить их все разом нечем.
IDistributedCache и Redis — общий кэш
Следующий шаг — вынести кэш в Redis. Теперь все экземпляры видят одни и те же данные, а перезапуск сервиса кэш не обнуляет. Платим за это сетевым походом (около миллисекунды) и сериализацией. Интерфейс IDistributedCache работает с массивами байтов, поэтому вручную его используют редко — удобнее сразу перейти к следующему варианту, который использует Redis внутри себя.
HybridCache — лучшее из двух миров
Появившийся в .NET 9 HybridCache объединяет оба уровня: L1 в памяти процесса и L2 в любом зарегистрированном IDistributedCache. Плюс к этому он умеет защищать от «давки» (stampede protection), сериализовать объекты и сбрасывать записи по тегам.

Путь запроса через HybridCache: память процесса, Redis, сервис-владелец
Регистрация выглядит так:
// L2: Redis как реализация IDistributedCache
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "orders:";
});
// L1 + L2: HybridCache сам найдёт зарегистрированный IDistributedCache
builder.Services.AddHybridCache(options =>
{
options.DefaultEntryOptions = new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // сколько живёт в Redis
LocalCacheExpiration = TimeSpan.FromMinutes(1) // сколько живёт в памяти процесса
};
});
builder.Services.AddHttpClient<ICatalogClient, CatalogClient>(client =>
client.BaseAddress = new Uri("http://catalog:8080/"));
Обратите внимание на соотношение сроков. LocalCacheExpiration намеренно сделан коротким — это та самая страховка от проблемы с конкурирующими потребителями, о которой шла речь в разделе «Наполнение и инвалидация: как кэш узнаёт, что данные устарели?».
А теперь клиент «Каталога», который умеет не только кэшировать, но и переживать недоступность соседа. Результат возвращается через Operation<T, TError> из моего пакета Calabonga.Results, чтобы вызывающий код явно обрабатывал оба исхода:
public sealed record ProductDto(int Id, string Name, decimal Price);
public sealed class CatalogClient(
HttpClient http,
HybridCache cache,
ILogger<CatalogClient> logger) : ICatalogClient
{
// «последняя известная версия» живёт долго и без тегов
private static readonly HybridCacheEntryOptions StaleOptions = new()
{
Expiration = TimeSpan.FromHours(24),
LocalCacheExpiration = TimeSpan.FromMinutes(5)
};
// только прочитать из кэша, фабрику не вызывать
private static readonly HybridCacheEntryOptions PeekOnly = new()
{
Flags = HybridCacheEntryFlags.DisableUnderlyingData
};
public async Task<Operation<ProductDto, string>> GetProductAsync(
int productId, CancellationToken ct)
{
var key = $"product:{productId}";
try
{
var product = await cache.GetOrCreateAsync(key, async token =>
{
var fresh = await http.GetFromJsonAsync<ProductDto>(
$"api/products/{productId}", token);
if (fresh is not null)
{
await cache.SetAsync($"{key}:stale", fresh, StaleOptions,
cancellationToken: token);
}
return fresh;
},
tags: [key, "catalog"],
cancellationToken: ct);
return product is null
? Operation.Error($"Товар {productId} не найден")
: Operation.Result(product);
}
catch (HttpRequestException exception)
{
logger.LogWarning(exception,
"Каталог недоступен, берём последнюю версию товара {ProductId}", productId);
var stale = await cache.GetOrCreateAsync<ProductDto?>(
$"{key}:stale",
_ => ValueTask.FromResult<ProductDto?>(null),
PeekOnly,
cancellationToken: ct);
return stale is null
? Operation.Error("Каталог временно недоступен")
: Operation.Result(stale);
}
}
}
Здесь стоит остановиться на нескольких моментах. Ключ product:42:stale записывается без тегов, поэтому RemoveByTagAsync его не трогает — это и есть наш запас на случай аварии. Флаг DisableUnderlyingData позволяет просто «заглянуть» в кэш, не вызывая фабрику. А вызывающий код выглядит так:
var operation = await catalog.GetProductAsync(item.ProductId, ct);
if (!operation.Ok)
{
return Results.Problem(operation.Error,
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var product = operation.Result;
Кстати, сброс по тегу в HybridCache — логическая операция. Записи физически остаются в памяти и в Redis до истечения срока, но все значения, созданные раньше момента сброса, считаются промахом. Для логики приложения разницы нет, но про это полезно помнить, когда смотрите на объём памяти Redis.
Output Caching — кэш на стороне поставщика
Теперь посмотрим на ситуацию со стороны «Каталога». Если запрос карточки товара тяжёлый (несколько таблиц, вычисление итоговой цены), его результат можно кэшировать целиком как HTTP-ответ. Хранилище ответов — снова Redis, чтобы все экземпляры «Каталога» пользовались одним кэшем:
// Каталог: хранилище output cache в Redis — общее для всех экземпляров
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
options.InstanceName = "catalog-output:";
});
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("products", policy => policy
.Expire(TimeSpan.FromMinutes(1))
.SetVaryByRouteValue("id")
.Tag("products"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapGet("/api/products/{id:int}", GetProductAsync)
.CacheOutput("products");
Главное преимущество этого варианта — владелец данных точно знает, когда их нужно сбросить. Например, в обработчике изменения цены:
app.MapPut("/api/products/{id:int}/price", async (
int id, ChangePriceRequest request,
IOutputCacheStore store, IBus bus, CancellationToken ct) =>
{
// ... сохраняем новую цену в БД ...
await store.EvictByTagAsync("products", ct);
await bus.Publish(new ProductPriceChanged(id, request.Price, DateTime.UtcNow));
return Results.NoContent();
});
Здесь сбрасываются все товары сразу — грубо, но надёжно. Если товаров много, а изменения частые, заведите тег на каждый товар и сбрасывайте точечно.
YARP — кэш на шлюзе
Если у вас уже есть шлюз на YARP (Yet Another Reverse Proxy), кэширование на нём включается практически бесплатно. YARP использует стандартный механизм Output Caching из ASP.NET Core, а политика назначается каждому маршруту отдельно прямо в конфигурации:
{
"ReverseProxy": {
"Routes": {
"catalog-route": {
"ClusterId": "catalog",
"OutputCachePolicy": "catalog-public",
"Match": {
"Path": "/catalog/{**catch-all}",
"Methods": [ "GET" ]
},
"Transforms": [
{ "PathRemovePrefix": "/catalog" }
]
},
"orders-route": {
"ClusterId": "orders",
"Match": { "Path": "/orders/{**catch-all}" },
"Transforms": [
{ "PathRemovePrefix": "/orders" }
]
}
},
"Clusters": {
"catalog": {
"Destinations": {
"catalog-1": { "Address": "http://catalog:8080/" }
}
},
"orders": {
"Destinations": {
"orders-1": { "Address": "http://orders:8080/" }
}
}
}
}
}
Маршрут к «Каталогу» получает политику catalog-public, а маршрут к «Заказам» — нет, потому что заказы всегда персональные. Сама политика описывается в Program.cs шлюза:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("catalog-public", policy => policy
.Expire(TimeSpan.FromSeconds(30))
.SetVaryByQuery("page", "size", "category")
.Tag("catalog"));
});
var app = builder.Build();
app.UseOutputCache();
app.MapReverseProxy();
app.Run();
SetVaryByQuery указывает, какие параметры строки запроса участвуют в ключе: ?page=2&size=20 и ?page=3&size=20 — разные записи. Кстати, по умолчанию Output Caching не кэширует ответы на запросы с авторизацией и ответы, которые устанавливают cookie. Это правильное поведение для шлюза: персональные данные одного пользователя никогда не попадут к другому. Если очень нужно кэшировать авторизованные запросы, придётся написать собственную политику (IOutputCachePolicy) и обязательно добавить пользователя в ключ.
Ещё один приятный момент: конфигурация маршрутов в YARP перечитывается на лету, поэтому включить или выключить кэш для маршрута можно без перезапуска шлюза.
HTTP-кэширование через ETag
Этот вариант полезен, когда ответы большие, а меняются редко. Поставщик выставляет заголовок ETag — версию ресурса — и отвечает 304, если версия у потребителя совпадает с текущей:
app.MapGet("/api/products/{id:int}", async (
int id, HttpContext http, CatalogDbContext db, CancellationToken ct) =>
{
var product = await db.Products.FindAsync([id], ct);
if (product is null)
{
return Results.NotFound();
}
var etag = $"\"{product.Version}\"";
if (http.Request.Headers.IfNoneMatch == etag)
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
http.Response.Headers.ETag = etag;
return Results.Ok(product.ToDto());
});
На стороне потребителя удобнее всего спрятать эту логику в DelegatingHandler, чтобы прикладной код вообще ничего о ней не знал:
public sealed class ETagCachingHandler(IMemoryCache cache) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
if (request.Method != HttpMethod.Get)
{
return await base.SendAsync(request, ct);
}
var key = $"etag:{request.RequestUri}";
if (cache.TryGetValue(key, out CachedResponse? cached))
{
request.Headers.IfNoneMatch.Add(cached!.ETag);
}
var response = await base.SendAsync(request, ct);
if (response.StatusCode == HttpStatusCode.NotModified && cached is not null)
{
response.Dispose();
return cached.ToResponse(request);
}
if (response.IsSuccessStatusCode && response.Headers.ETag is { } etag)
{
var body = await response.Content.ReadAsByteArrayAsync(ct);
var mediaType = response.Content.Headers.ContentType?.MediaType
?? "application/json";
cache.Set(key, new CachedResponse(etag, body, mediaType),
TimeSpan.FromHours(1));
}
return response;
}
}
public sealed record CachedResponse(
EntityTagHeaderValue ETag, byte[] Body, string MediaType)
{
public HttpResponseMessage ToResponse(HttpRequestMessage request) =>
new(HttpStatusCode.OK)
{
RequestMessage = request,
Content = new ByteArrayContent(Body)
{
Headers = { ContentType = new MediaTypeHeaderValue(MediaType) }
}
};
}
builder.Services.AddMemoryCache();
builder.Services.AddTransient<ETagCachingHandler>();
builder.Services.AddHttpClient<ICatalogClient, CatalogClient>(client =>
client.BaseAddress = new Uri("http://catalog:8080/"))
.AddHttpMessageHandler<ETagCachingHandler>();
Честно скажу: ETag экономит трафик и сериализацию, но не запрос к базе данных на стороне поставщика — версию всё равно нужно прочитать. Поэтому он хорош в паре с кэшем поставщика из раздела «Output Caching — кэш на стороне поставщика», а не вместо него.
Инвалидация по событию через Rebus
Контракт события лежит в отдельной сборке, на которую ссылаются оба сервиса:
// общий контракт события (отдельная сборка Catalog.Contracts)
public sealed record ProductPriceChanged(
int ProductId, decimal NewPrice, DateTime ChangedAtUtc);
Обработчик в «Заказах» сбрасывает все записи с тегом конкретного товара:
public sealed class ProductPriceChangedHandler(
HybridCache cache,
ILogger<ProductPriceChangedHandler> logger) : IHandleMessages<ProductPriceChanged>
{
public async Task Handle(ProductPriceChanged message)
{
await cache.RemoveByTagAsync($"product:{message.ProductId}");
logger.LogInformation("Кэш товара {ProductId} сброшен", message.ProductId);
}
}
Регистрация Rebus и подписка на событие при старте шины:
builder.Services.AddRebus(
configure => configure
.Transport(t => t.UseRabbitMq(
builder.Configuration.GetConnectionString("RabbitMq"), "orders"))
.Routing(r => r.TypeBased()),
onCreated: async bus => await bus.Subscribe<ProductPriceChanged>());
builder.Services.AutoRegisterHandlersFromAssemblyOf<ProductPriceChangedHandler>();
Как уже говорилось, при нескольких экземплярах «Заказов» событие получит только один из них. Если короткого срока жизни L1 недостаточно, добавляем широковещательный сигнал через Redis pub/sub — его получат все экземпляры:
public sealed class CacheInvalidationListener(
IConnectionMultiplexer redis,
HybridCache cache) : BackgroundService
{
public static readonly RedisChannel Channel =
RedisChannel.Literal("orders:cache:invalidate");
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var queue = await redis.GetSubscriber().SubscribeAsync(Channel);
queue.OnMessage(async message =>
await cache.RemoveByTagAsync(message.Message.ToString()));
}
}
// в обработчике события вместо прямого сброса — широковещательная рассылка
await redis.GetSubscriber().PublishAsync(
CacheInvalidationListener.Channel, $"product:{message.ProductId}");
Не забудьте зарегистрировать IConnectionMultiplexer как синглтон и сам CacheInvalidationListener через AddHostedService.
Кстати, если писать такую синхронизацию самостоятельно не хочется, посмотрите на библиотеку FusionCache. У неё есть «объединительная шина» (backplane) на Redis, которая делает ровно это, а также встроенный режим «отдать устаревшее при ошибке» (fail-safe). В этой статье я намеренно остаюсь на стандартном HybridCache, чтобы показать механику.
Проекция по событиям
Последний вариант из раздела «Какие бывают варианты кэширования коммуникаций?» по коду устроен так же, как обработчик выше, только вместо сброса ключа обработчик выполняет upsert строки в собственной таблице «Заказов» через EntityFrameworkCore. Важно хранить в проекции только те поля, которые действительно нужны потребителю, и номер версии из события — чтобы сообщения, пришедшие не по порядку, не затёрли свежие данные старыми.
Подводные камни
А теперь о том, что обычно не пишут в документации, но обязательно случается в Production. Несколько сценариев из жизни.
- Давка (cache stampede). Популярный ключ истёк, и в тот же момент 500 запросов одновременно идут в «Каталог».
HybridCacheзащищает от этого, но только внутри одного экземпляра: если экземпляров десять, «Каталог» всё равно получит десять одинаковых запросов. Помогает небольшой случайный разброс срока жизни (jitter), чтобы ключи не истекали одновременно, и упреждающее обновление самых горячих ключей. - Холодный старт. После деплоя память процесса пуста, после перезапуска Redis — пусто вообще всё, и вся нагрузка разом обрушивается на поставщиков. Именно поэтому L2 нужен даже тогда, когда L1 справляется: он переживает деплой. А для самых важных ключей можно сделать прогрев (warm-up) в
IHostedServiceпри старте. - Изменился контракт. В
ProductDtoдобавили поле, а в Redis лежит JSON старой версии. Десериализация пройдёт без ошибок, новое поле получит значение по умолчанию — и вы будете долго искать, откуда в заказе цена без скидки. Лечится версией в ключе:v2:product:42. - Мультиарендность/мультитенантность. Если система обслуживает несколько арендаторов (tenants), а в ключе нет идентификатора арендатора, данные одного клиента рано или поздно уйдут другому. Ключи стоит строить в одном месте, через отдельный класс, а не собирать строки по всему коду.
- Персональные данные в общем кэше. Redis — не сейф. Если в кэш попадают персональные данные, нужны TLS, ACL, минимум полей и короткий срок жизни. А лучше задуматься, нужно ли их вообще кэшировать.
- Потерялось событие инвалидации. Поставщик сохранил новую цену в базу, но упал до публикации события. Защита на стороне поставщика — паттерн Outbox (событие пишется в ту же транзакцию, что и данные), защита на стороне потребителя — TTL из третьего правила кэша.
- Событие получил не тот экземпляр. Ловушка с конкурирующими потребителями, подробно описанная в разделе «Наполнение и инвалидация: как кэш узнаёт, что данные устарели?». Упоминаю ещё раз, потому что на ней спотыкаются чаще всего.
А как же мониторинг?
Кэш, работу которого вы не видите, — это просто ещё одно место, где могут прятаться ошибки. Минимальный набор того, что стоит измерять:
- Доля попаданий (hit ratio) — главная метрика. Если она ниже 80%, кэш, скорее всего, настроен неправильно: слишком короткий срок жизни или слишком «разнообразные» ключи.
- Количество вызовов фабрики — фактически число запросов к соседу.
- Задержка L2 — если Redis начал отвечать за 20 мс вместо 1 мс, выигрыш от кэша тает.
- Память и вытеснения (evictions) в Redis — признак того, что кэшу тесно.
- Ошибки фабрики — сколько раз пришлось отдавать устаревшие данные.

Метрики сервисов и Redis собираются в Prometheus и выводятся на дашборд в Grafana
HybridCache не сообщает напрямую, было ли попадание, поэтому проще всего считать два счётчика: общее число обращений к кэшу и число вызовов фабрики. Для этого используем стандартный System.Diagnostics.Metrics:
public sealed class CacheMetrics
{
public const string MeterName = "Orders.Cache";
private readonly Counter<long> _requests;
private readonly Counter<long> _factoryCalls;
public CacheMetrics(IMeterFactory meterFactory)
{
var meter = meterFactory.Create(MeterName);
_requests = meter.CreateCounter<long>("orders.cache.requests");
_factoryCalls = meter.CreateCounter<long>("orders.cache.factory_calls");
}
public void Request(string area) =>
_requests.Add(1, new KeyValuePair<string, object?>("area", area));
public void FactoryCall(string area) =>
_factoryCalls.Add(1, new KeyValuePair<string, object?>("area", area));
}
В CatalogClient вызываем metrics.Request("catalog") перед GetOrCreateAsync и metrics.FactoryCall("catalog") внутри фабрики. Экспорт в Prometheus через OpenTelemetry:
builder.Services.AddSingleton<CacheMetrics>();
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter(CacheMetrics.MeterName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddPrometheusExporter());
var app = builder.Build();
app.MapPrometheusScrapingEndpoint(); // GET /metrics
OpenTelemetry заменит точки в именах на подчёркивания и добавит суффикс _total к счётчикам, поэтому долю попаданий в Prometheus можно посчитать так:
1 - sum(rate(orders_cache_factory_calls_total[5m]))
/ sum(rate(orders_cache_requests_total[5m]))
Метрики самого Redis (попадания и промахи по ключам, память, вытеснения) удобно снимать с помощью redis_exporter — он выставляет их в формате Prometheus без единой строчки кода с вашей стороны.
Сводная таблица
Данная таблица в некотором роде подытоживает всё вышесказанное и сравнивает варианты из раздела «Какие бывают варианты кэширования коммуникаций?».
| Вариант | Где живёт | Что экономит | Свежесть данных | Сложность | Когда применять |
|---|---|---|---|---|---|
| Шлюз (YARP + Output Cache) | На входе в систему | Весь путь запроса | TTL | Низкая | Публичные GET-запросы без авторизации |
| Потребитель (HybridCache) | В сервисе, который читает | Вызовы соседа | TTL + события | Средняя | Чтение чужих данных, отказоустойчивость |
| Поставщик (Output Cache) | В сервисе-владельце | Нагрузку на БД и вычисления | Высокая — владелец знает об изменениях | Низкая | Тяжёлые запросы с повторяющимися параметрами |
| HTTP (ETag / 304) | У потребителя и поставщика | Трафик и сериализацию | Всегда актуальные — проверка при каждом запросе | Средняя | Крупные, редко меняющиеся ответы |
| Проекция по событиям | В БД потребителя | Синхронный вызов целиком | Итоговая согласованность (eventual consistency) | Высокая | Когда независимость от соседа критична |
Заключение
Кэширование в микросервисах — это осознанный компромисс между "свежестью" данных и независимостью сервисов друг от друга. Это не волшебная кнопка «быстрее», а набор решений: что кэшировать, где, насколько долго и как узнать, что пора обновиться. Если для каждого кэшируемого объекта вы можете ответить на эти четыре вопроса, ваш кэш будет работать на вас, а не против вас. Начните с HybridCache на стороне потребителя и короткого TTL, добавьте сброс по событиям там, где устаревание критично, и не забудьте про метрики — без них вы не узнаете, работает ли всё это вообще.
Как говорится, всё в наших руках! Пишите правильный код!
Используемые пакеты
Сборки и пакеты NuGet, которые использовались в примерах кода этой статьи:
| Пакет | Для чего |
|---|---|
Microsoft.Extensions.Caching.Memory |
IMemoryCache, входит в ASP.NET Core |
Microsoft.Extensions.Caching.Hybrid |
HybridCache (L1 + L2, теги, защита от давки) |
Microsoft.Extensions.Caching.StackExchangeRedis |
Redis как IDistributedCache (уровень L2) |
Microsoft.AspNetCore.OutputCaching.StackExchangeRedis |
Хранилище Output Cache в Redis |
Yarp.ReverseProxy |
API Gateway с кэшированием маршрутов |
Rebus, Rebus.RabbitMq, Rebus.ServiceProvider |
Публикация событий и подписка через RabbitMQ |
StackExchange.Redis |
Redis pub/sub для сброса L1 на всех экземплярах |
Calabonga.Results |
Operation<T, TError> для явного возврата результата или ошибки |
OpenTelemetry.Extensions.Hosting, OpenTelemetry.Instrumentation.AspNetCore, OpenTelemetry.Instrumentation.Http |
Сбор метрик |
OpenTelemetry.Exporter.Prometheus.AspNetCore |
Эндпоинт /metrics для Prometheus |