Опыт кэширования данных eCommerce в Azure Cloud на примере платформы Virto Commerce (ASP.NET Core)

Эта статья — про опыт кэширования приложения eCommerce, написанного на ASP.NET Core и работающего в облаке Azure Cloud. Общеизвестно, что кэширование может значительно улучшить производительность и масштабируемость тяжелых приложений, в том числе платформ eCommerce, за счет за счет ускорения доступа к данным из бэкенда. При этом кэширование лучше всего работает с данными, которые меняются относительно редко и/или создание которых требует больших затрат.    

«В компьютерных науках есть только две сложные вещи: инвалидация кэша и присвоение имен», — Фил Карлтон, разработчик Netscape.

Команда Virto DevLabs протестировала несколько различных способов кэширования данных в приложении, чтобы снизить нагрузку на внешние службы, базу данных и минимизировать задержку приложения при обработке запросов API.

Далее — про технические детали о методах кэширования, которые мы оценили как лучшие и которые используем на платформе электронной В2В торговли. Вот список тем, которые затронуты в данной статье:

  • Проблемы при использовании distributed кэша;

  • Эксклюзивное “потокобезопасное” получение данных для добавления в кэш, при многопоточном доступе;

  • Оригинальный подход к генерации ключей для кэширования, особенно для объектов комплексных типов;

  • Управление временем жизни объектов в кэше через глобальные настройки и логические объединения в регионы;

  • Кэширование нулевых значений;

  • Синхронизация локальных кэшей для разных экземпляров приложения через сторонний сервис (backplane).

Шаблон Cache-Aside

Был выбран Cache-Aside в качестве основного шаблона Azure Cloud Design Patterns для всей логики кэширования, потому что он очень прост и понятен для реализации и тестирования. Шаблон позволяет приложениям загружать данные по запросу:

https://github.com/VirtoCommerce/vc-platform/blob/master/docs/media/essential-caching-1.png
https://github.com/VirtoCommerce/vc-platform/blob/master/docs/media/essential-caching-1.png

Механизм Cache-Aside работает по классике жанра: когда нужны конкретные данные, мы сначала пытаемся получить их из кэша. Если данных нет в кэше, мы получаем их из источника, добавляем в кэш и возвращаем. В следующий раз эти данные будут возвращены из кэша. Этот шаблон повышает производительность, а также помогает поддерживать согласованность между данными, хранящимися в кэше, и данными в базовом хранилище данных. Дополнительную информацию о шаблоне Cache-Aside см. в документации Microsoft.

О проблемах реализация шаблона Cache-Aside с использованием ASP.NET Core in-memory cache

Мы не использовали распределенный (distributed) кэш в коде платформы, т.к. хотели сохранить гибкость и простоту конфигурации платформы и предпочитаем решать потенциальные проблемы масштабируемости другими способами (см. раздел «Масштабируемость» ниже).

У распределенного (distributed) кэша есть три существенных недостатка, которые повлияли на наше решение не использовать его в нашем продукте:

  • Все кэшированные данные должны поддерживать сериализацию и десериализацию, что не всегда возможно сделать прозрачно для всех сущностей в приложении;

  • Возможно снижение производительности по сравнению со встроенной кэш-памятью из-за сетевых вызовов для кэшированных данных (network latency);

  • Так как отказаться от in-memory режима не всегда возможно, то при использовании смешанного кэширования (in-memory + distributed) в приложении будет сложно определять, какой тип использовать в конкретной ситуации. Это важно особенно для продуктов с открытым кодом, и для таких продуктов как Virto Commerce B2B eCommerce platform не будет плюсом.

Сериализация преобразует объект в поток байтов, поэтому его можно вывести из процесса либо для сохранения, либо для отправки другому процессу. Десериализация — это обратный процесс, который преобразует поток байтов обратно в объект.

Механизм сериализации, предоставляемый платформой .NET, имеет две основные проблемы:

— Медленно: сериализация .NET использует отражение (Reflection) для проверки информации о типе во время выполнения. Отражение — медленный процесс по сравнению с предварительно скомпилированным кодом.

— Громоздко: .NET Serialization хранит полное имя класса, культуру, детали сборки и ссылки на другие экземпляры в переменных-членах, и все это делает сериализованный поток байтов много больше исходного объекта по размеру.

Поэтому для кэширования платформы мы выбрали работу с In-memory cache. Следует сказать, что ASP.NET Core поддерживает Distributed caching, но мы отказались от его использования в виду вышеперечисленных проблем.

Простая Cache-Aside реализация шаблона с использованием абстракции IMemoryCache выглядит так:

public object GetDataById(string objectId) {     object data;     if (!this._memoryCache.TryGetValue($"cache-key-{objectId}", out data))     {         data = this.GetObjectFromDatabase(objectId);         this._memoryCache.Set($"cache-key-{objectId}", data, new TimeSpan(0, 5, 0));     }     return data; }

У этой стандартной реализации кода есть несколько недостатков (и поэтому ниже мы представим свое решение):

  • Требуется ручное создание ключа кэша и знание правил построения ключей, плюс забота об их уникальности;

  • Это не является «потокобезопасным», т.е. несколько потоков будут пытаться получить доступ к одному и тому же ключу кэша одновременно, что может привести к избыточному обращению к источнику данных. Хотя это может и не быть проблемой, если ваше приложение не имеет высокой одновременной нагрузки и дорогостоящих запросов к серверной части,  

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

Относительно новые методы MemoryCache, — GetOrCreate/GetOrCreateAsync, — также страдают от этих проблем, а это значит, что мы не можем использовать их в том виде, в каком они есть. Вот в этой статье проблема описывается более подробно: ASP.NET Core Memory Cache — Is the GetOrCreate method thread-safe.

Что мы сделали для улучшения кода

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

Вот вариант предыдущего примера кода с новым расширением:

public object GetDataById(string objectId) 2   { 3       object data; 4       var cacheKey = CacheKey.With(GetType(), nameof(GetDataById), id); 5       var data = _memoryCache.GetOrCreateExclusive(cacheKey, cacheEntry => 6           { 7             cacheEntry.AddExpirationToken(MyCacheRegion.CreateChangeToken());  8             return this.GetObjectFromDatabase(objectId); 9           }); 10      return data; 11  }

Теперь можно остановиться на разборе сделанных изменений в коде.

Генерация ключей

Специальный статический класс CacheKey (строка 4 в примере выше) предоставляет метод для создания уникального ключа строкового кэша в соответствии с переданными аргументами и информацией о типе / методе.

CacheKey.With(GetType(), nameof(GetDataById), "123"); /* =>  "TypeName:GetDataById-123" */

CacheKey также можно использовать для генерации ключей кэша для объектов сложных типов. Большинство типов платформ являются производными от классов Entity или ValueObject, каждый из этих типов реализует интерфейс ICacheKey, содержащий метод GetCacheKey (), который можно использовать для генерации ключей кэша.

class ComplexValueObject : ValueObject {     public string Prop1 { get; set; }     public string Prop2 { get; set; } } var valueObj = new ComplexValueObject { Prop1 = "Prop1Value", Prop2 = "Prop2Value" }; var data = CacheKey.With(valueObj.GetCacheKey()); //cacheKey will take the value "Prop1Value-Prop2Value"

Конкурентный доступ

Теперь скажем про потокобезопасное кэширование:

В строке 5 (в примере выше) метод _memoryCache.GetOrCreateExclusive () вызывает расширение потокобезопасного кэширования, которое гарантирует, что кэшируемый делегат (вызываемый при отсутствии данных в кэше) выполняется только один раз при параллельном вызове из нескольких потоков.

Также доступна асинхронная версия этого метода расширения: _memoryCache.GetOrCreateExclusiveAsync ().

Следующий код демонстрирует как работает этот монопольный доступ к кэшируемому делегату:

public void GetOrCreateExclusive()         {             var sut = new MemoryCache();             int counter = 0;             Parallel.ForEach(Enumerable.Range(1, 10), i =>             {                 var item = sut.GetOrCreateExclusive("test-key", cacheEntry =>                 {                     cacheEntry.SlidingExpiration = TimeSpan.FromSeconds(10);                     return Interlocked.Increment(ref counter);                 });                Console.Write($"{item} ");             });         }

Получаем вывод на консоль:
1 1 1 1 1 1 1 1 1 1

Управление временем жизни

Управление временем жизни объектов в кэше происходит в строке 7,

cacheEntry.AddExpirationToken(MyCacheRegion.CreateChangeToken()); 

где создается объект CancellationTokenSource. Он связан с кэшированными данными и строго типизированным регионом MyCacheRegion, что позволяет объединять кэшированные данные в логические группы (регионы), через которые можно управлять временем жизни этих объектов в кэше. Например, удалить все связанные данные из кэша одним простым вызовом метода  MyCacheRegion.ExpireRegion(). Более подробно о том, как работают зависимости в кэше в статье ASP.NET Core Memory Cache dependencies.

Для In-memory cache в cache-dependencies есть один механизм, позволяющий неявно связывать кэшированные данные в одну группу. Вот выдержка из документации:

using (var entry = _cache.CreateEntry(CacheKeys.Parent))     { …  } With the using pattern in the code above, cache entries created inside  the using block will inherit triggers and expiration settings.

Таким образом, все данные, которые будут добавлены в кэш внутри секции using, унаследуют настройки по времени жизни от родителя. В этом проекте мы категорически отказались от такой скрытой возможности, так как она приводила к непредсказуемому поведению по управлению времени жизни объектов в кэше, и понадобилось много времени, чтобы отловить порожденные таким «счастьем» баги.

Если говорить об управлении временем жизни объектов в кэше, то в нашем продукте мы избегали ручного управления временем жизни кэшированных данных в коде. Платформа Virto Commerce имеет специальный объект CachingOptions, который содержит настройки абсолютного или относительного  времени жизни для всех кэшированных данных (см. ниже).

Строго типизированные регионы кэша

Платформа поддерживает конструкцию, называемую строго типизированными регионами кэша (Cache regions), которая используется для управления временем жизни сгруппированных / связанных объектов в кэше.

Для этих целей мы определили generic класс  CancellableCacheRegion <>. У этого класса есть методы AddExpirationToken и ExpireRegion, которые можно использовать для добавления либо удаления всех данных из кэша для данного региона:

//Region definition public static class MyCacheRegion : CancellableCacheRegion<MyCacheRegion> {     }  //Usage cacheEntry.AddExpirationToken(MyCacheRegion.CreateChangeToken());   //Expire all data associated with the region MyCacheRegion.ExpireRegion();

Также существует специальная область (регион) GlobalCacheRegion, которую можно использовать для истечения срока действия всех кэшированных данных всего приложения:

//Expire all cached data for entire application GlobalCacheRegion.ExpireRegion();

С удивлением обнаружили для себя что в стандартной реализации InMemoryCaching отсутствует встроенный механизм по очистки всего кэша, так что способ с глобальным регионом пришлось реализовать самим, как рекомендовалось в этой статье https://stackoverflow.com/questions/34406737/how-to-remove-all-objects-reset-from-imemorycache-in-asp-net-core/.

Впоследствии мы еще хлебнули с этим подходом ввиду того, что токены, ассоциированные с глобальным регионом, не вычищались сборщиком мусора и  приводили к огромным утечкам памяти, вот мой Gist который демонстрирует проблему: https://gist.github.com/tatarincev/4c942a7603a061d41deb393e0aa66545. В итоге проблему решили, хотя, как говорится, осадок остался.

Кэширование пустых значений

По умолчанию, платформа кэширует нулевые значения. Если отрицательное кэширование является выбором дизайна, это дефотное поведение по умолчанию можно изменить, передав false в cacheNullValue в методе GetOrCreateExclusive, например:

 var data = _memoryCache.GetOrCreateExclusive(cacheKey, cacheEntry => {},  cacheNullValue: false);

Параметры кэша и масштабирование

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

 "Caching": {         //Позволяет отключить кэширование на уровне всего приложения. Удобно для отладки и траблшутинга.         "CacheEnabled": true,           //Относительное – относительно последнего обращения, глобальное время жизни для обьектов в кэше,           "CacheSlidingExpiration": "0:15:00",          //Глобальное абсолютное время жизни  для всех обьектов в кэше. Имеет приоритет над CacheSlidingExpiration         //"CacheAbsoluteExpiration": "0:15:00"     }

Теперь пара слов про обещанное ранее масштабирование. Запуск нескольких экземпляров платформы, каждый из которых имеет свой собственный локальный кэш, и который, в свою очередь, должен согласовываться с кэшем других экземпляров, может быть сложной задачей. Без решения этой задачи каждый экземпляр приложения будет иметь несогласованные данные, что точно не понравится клиентам наших сервисов.

Для решения этой проблемы нам был нужен некий внешний сервис, который бы выступал в качестве ретранслятора (backplane) всех операций по удалению объектов из кэша, и к которому были бы подключены все экземпляры приложения. Через pub/sub экземпляры сообщали бы или узнавали обо всех изменениях с кэшем в других инстансах, тем самым поддерживая закэшированные данные в памяти в согласованном состоянии с актуальными значениями в источнике данных.

Службу Redis в качестве ретранслятора (backplane) синхронизации локальных кэшей
Службу Redis в качестве ретранслятора (backplane) синхронизации локальных кэшей

В статье How to scale out platform on Azure более подробно описано, как масштабировать платформу в Azure и как настроить службу Redis в качестве ретранслятора (backplane) синхронизации локальных кэшей в случае нескольких экземпляров платформы электронной коммерции Virto Commerce.

Заключение

В этой статье мы постарались познакомить вас с некоторыми наиболее часто встречающими проблемами реализации и работы с кэшированием. Заодно рассказать, как мы справились с этим проблемами в проекте Virto Commerce .NET B2B eCommerce-платформы.

Еще раз — краткий список того, что было затронуто в статье:

  • Проблемы при использовании distributed-кэша;

  • Эксклюзивное “потокобезопасное” получение данных для добавления в кэш, при многопоточном доступе;

  • Оригинальный подход к генерации ключей для кеширования, особенно для объектов комплексных типов;

  • Управление временем жизни объектов в кэше через глобальные настройки и логические объединения в регионы;

  • Кэширование нулевых значений;

  • Синхронизация локальных кэшей для разных экземпляров приложения через сторонний сервис (backplane).

Хочу поблагодарить коллег Евгения Татаринцева и Олега Жука за помощь в работе над этим постом.

Спасибо, что дочитали до конца, — если будут вопросы или захотите поделиться своим опытом кэширования тяжелых приложений ASP.NET Core — ждем откликов в комментариях.

ссылка на оригинал статьи https://habr.com/ru/post/568756/

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *