В серверной разработке кэш влияет не только на скорость ответа. От него напрямую зависят стоимость обработки запроса, нагрузка на базу, поведение системы под пиком и предсказуемость масштабирования. Именно поэтому практика кэширования имеет сегодня столько нюансов и разновидностей, если вы хотите реализовать что-то особенное или применить сложную схему кэширования, от ваших специалистов потребуются специальные навыки и умения. И сегодня я расскажу о том, как мы ушли от кастомного кода к использованию гибридного кэша из .Net Framework, с какими сложностями столкнулись на этапе масштабирования системы, и как решали вопросы инвалидации кэша в процессе развития нашего проекта API Hub.

Привет, Хабр! Меня зовут Михаил Сенцов, и я — главный разработчик в департаменте информационных технологий компании Cloud X. Сегодня я хочу поделиться нашим опытом внедрения гибридного кэша на базе Redis для API Hub — платформы-агрегатора, позволяющей разработчикам получать доступ к различным API и интегрировать их в свои приложения без лишних сложностей.
С самого начала разработки API Hub, мы активно используем кэширование при написании серверных приложений. И это очевидный плюс, ведь кэширование позволяет снижать нагрузку на базы данных, уменьшать количество сетевых запросов и ускорять получение результатов для пользователя.
Если обратиться к документации по .Net core, вы найдёте четыре типа кэширования:
-
локальное кэширование;
-
распределенное кэширование;
-
гибридное кэширование;
-
кэширование ответов.
Корректно ли работает браузер с кэшем?
Со стороны браузера кэшированные ответы должны возвращать 304 код ответа, а также использовать ETag-хэддеры. Код 304 возвращается в тех случаях, когда ответ на запрос не менялся и совпадает с предыдущим. При правильной реализации браузер корректно обрабатывает подобные ситуации и не выполняет лишних действий.
Для тестирования корректности работы с кэшем в Swagger можно добавить соответствующие фильтры.
public class CachingOperationFilter : IOperationFilter{public void Apply(OpenApiOperation operation, OperationFilterContext context){ var isGet = context.MethodInfo.GetCustomAttribute() != null || operation.OperationId?.StartsWith("get", StringComparison.OrdinalIgnoreCase) == true; if (isGet) { var response304 = new OpenApiResponse { Description = "Not Modified - ответ берётся из кэша", Headers = CreateCachingHeaders() }; operation.Responses[((int)HttpStatusCode.NotModified).ToString()] = response304; operation.Parameters ??= []; operation.Parameters.Add(new OpenApiParameter { Name = "If-None-Match", In = ParameterLocation.Header, Required = false, Schema = new OpenApiSchema { Type = "string" }, Description = "ETag из предшествующего ответа для валидации кэша" }); if (operation.Responses.TryGetValue(((int)HttpStatusCode.OK).ToString(), out var response200)) { response200.Headers ??= new Dictionary(); var cachingHeaders = CreateCachingHeaders(); foreach (var header in cachingHeaders) { response200.Headers[header.Key] = header.Value; } } }} private Dictionary CreateCachingHeaders(){ return new Dictionary { ["ETag"] = new OpenApiHeader { Description = "Тег сущности для проверки кэша", Schema = new OpenApiSchema { Type = "string" } }, ["X-Cache-Status"] = new OpenApiHeader { Description = "Статус кэша (HIT, MISS, HIT-ETag)", Schema = new OpenApiSchema { Type = "string" } } };}}
Если вы так же используете Redis, то можете добавить использование кэша на уровне бэкенда, используя подобный код:
public static IServiceCollection AddBackendCache(this IServiceCollection services,IConfiguration configuration,string applicationName){services.AddMemoryCache(); var redisConnectionString = configuration[EnvironmentVariablesNames.RedisHosts]; if (!string.IsNullOrEmpty(redisConnectionString)){ var redisOptions = GetRedisConfiguration(configuration); var instancePrefix = $"{applicationName}:"; services.AddSingleton(sp => { var options = GetRedisConfiguration(configuration); try { return ConnectionMultiplexer.Connect(options); } catch (Exception ex) { options.AbortOnConnectFail = false; Console.WriteLine($"[CRITICAL] Redis Connection Failed: {ex.Message}"); return ConnectionMultiplexer.Connect(options); } }); services.AddDataProtection() .SetApplicationName(applicationName); services.AddSingleton>(sp => { return new ConfigureOptions(options => { var muxer = sp.GetRequiredService(); options.XmlRepository = new RedisXmlRepository(() => muxer.GetDatabase(), "DataProtection-Keys"); }); }); services.AddStackExchangeRedisCache(options => { options.ConnectionMultiplexerFactory = () => { var muxer = services.BuildServiceProvider().GetRequiredService(); return Task.FromResult(muxer); }; options.InstanceName = instancePrefix; }); services.AddStackExchangeRedisOutputCache(options => { options.ConnectionMultiplexerFactory = () => Task.FromResult(services.BuildServiceProvider().GetRequiredService()); options.InstanceName = instancePrefix; });} ConfigureCachePolicies(services); return services;}
Переход к гибридному кэшу
Но, как я уже говорил, кэш бывает разных типов. И при развитии практик кэширования постепенно хочется сделать его лучше. В начале мы использовали локальный кэш, который неплохо справлялся и позволял нам быстро давать ответы на запросы клиентов. Позже был разработан самописный гибридный кэш, который сочетал плюсы локального и распределенного кэша. Мы приняли решение использовать гибридный кэш, чтобы активнее всего использовать наиболее быстрый локальный кэш. И только в тех случаях, когда локальный кэш экспарится, чтение происходит из распределенного кэша. По сути, гибридный кэш представляется собой L1/L2 уровень подобно архитектуре современных компьютеров. И распределенный кэш (в нашем случае это Redis) работает медленнее, так как время тратится как минимум на сетевые запросы
Но, как показала практика, в большинстве случаев самописный код уступает фреймворку — по уровню функциональности, стабильности, производительности и так далее. В нашем случае, например, оказалось, что мы не предусмотрели особую схему работы кэша при конкуренции потоков, которая возникала при автотестировании.
Если говорить более конкретно, то запись происходила после чтения. Автотесты очень быстро читали данные…в отличие от UI приложения. А в ответах возникали ошибки с отсутствующими данными в кэше.
Блокировки и критические секции в большинстве случаев помогали решить эту проблему, но добавляли задержки и приводили к дедлокам.
Еще одна проблема была связана с самой стратегией кэширования. Принцип “L1 First” привел к тому, что в течение периода действия локального кэша данные могли устареть в распределенном кэше L2. И даже явная инвалидация кэша на одном инстансе не могла очистить кэш на других узлах.
И вот тут стал очевиден плюс, который дает фреймворк: при использовании библиотеки Microsoft.Extensions.Caching.Hybrid для .NET 9 локальный кэш L1 инвалидируется при изменении кэша в L2. Впрочем, на тот момент мы использовали .NET 8, где данная функциональность еще не была реализована. Пришлось решать проблему с помощью workaround-решения — короткого TTL для L1 и явной инвалидации данных для всех инстансов.
Но именно в этот момент был запущен переход на новую схему работы с кэшем. Выполняя отладки очередного запроса с ошибкой, я случайно обнаружил, что вышла “preview”-версия библиотеки для гибридной работы с кэшем в Redis. Я установил ее за несколько минут, и Уже после первых тестов стало понятно, что новинка обещала быть лучшим решением в условиях многопоточности, тем более, что гарантированные механизмы защиты данных уже были реализованы.
Масштабирование
Когда встал вопрос масштабирования приложения для улучшения скорости предоставления ответов пользователям при большей нагрузке, хорошо показала практика добавления новых инстансов приложения с помощью kubernetes. Ведь даже если приложение имеет архитектуру модульного монолита, работа с базой данных при появлении новых pod не нарушается.
Но первое, с чем мы столкнулись на этом пути — инвалидация токенов при аутентификации. На фронтенде стали появляться ответы с ошибкой 401, которые выскакивали как только пользователи успевали ввести логин и пароль.
Как выяснилось, проблема была в том, что ключи шифрования и куки на разных инстансах не были синхронизированы. В результате мы фактически инвалидировали куки аутентификации.
Тогда было принято решение сохранять ключи шифрования и cookies в распределенном кэше, также повысилось удобство работы для пользователей. При релизе и перезапуске приложения ключи сохраняются в распределенном кэше и продолжают действовать, добавляя удобство в сохранении аутентифицированных сессий.
Практики инвалидации
Следующий кейс, который отнял много сил при разработке — это, конечно же, инвалидация кэша. На уровне бизнес-логики кэширование возможно как для отдельных зарегистрированных учетных записей, так и для неаутентифицированных пользователей. Чтобы упорядочить этот процесс, пришлось придумывать специализированные тэг-метки, по которым происходит инвалидизация кэша.
public class AutoTagPathPolicy : IOutputCachePolicy{private readonly string[] RequestQueries =[ "page", "size", "limit", "offset", "perpage", "sort", "order", "orderby", "sortby", "filter", "q", "search", "query", "keyword", "categoryid","selectionid","providerid", "status", "type", "state", "tag", "fromutc", "toutc", "field", "publish", "interval", "start", "end", "date", "datetime"]; public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken ct){ var request = context.HttpContext.Request; if (!HttpMethods.IsGet(request.Method)) { context.EnableOutputCaching = false; return ValueTask.CompletedTask; } context.AllowCacheLookup = true; context.AllowCacheStorage = true; context.ResponseExpirationTimeSpan = TimeSpan.FromMinutes(HybridCacheDefaults.CacheExpirationTimeInMinutes); var userValue = context.HttpContext.GetCacheUser() ?? "anonymous"; var tags = request.Path.ToString().GetPathTags(); foreach (var tag in tags) { context.Tags.Add(tag); context.Tags.Add(string.Format(CacheUserPaths.UserPathsTemplate, tag, userValue)); } context.CacheVaryByRules.VaryByValues.Add("path", request.Path.ToString()); context.CacheVaryByRules.VaryByValues.Add("user", userValue); if (!context.HttpContext.Response.HasStarted) { context.HttpContext.Response.Headers[HeaderNames.ETag] = GenerateETag(context); } context.CacheVaryByRules.HeaderNames = new[] { TestHeaders.IncludeTestData }; context.CacheVaryByRules.QueryKeys = RequestQueries; return ValueTask.CompletedTask;} public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken ct){ AddCustomHeaders(context, "HIT"); return ValueTask.CompletedTask;} private static string GenerateETag(OutputCacheContext context){ var user = context.HttpContext.GetCacheUser() ?? "anon"; var path = context.HttpContext.Request.Path; var query = context.HttpContext.Request.QueryString; return $"\"{Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{path}{query}{user}"))[..16]}\"";} public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken ct){ AddCustomHeaders(context, "MISS"); return ValueTask.CompletedTask; }}
Мы сделали так, чтобы практически каждое управляющее действие вызывало инвалидацию кэша для определенной группы. Кроме этого, найденный подход позволяет сохранять не только ключи хэширования cookies, но и ответы на запросы пользователей.
Заключение
Конечно, этим преимущества использования Redis не ограничиваются. В других наших проектах Redis широко применяется, например, как шина оповещений Pub/Sub. Но об этом я подробнее расскажу уже в следующих статьях.
ссылка на оригинал статьи https://habr.com/ru/articles/1024574/