Как повысить производительность приложений ASP.NET в веб-ферме с помощью кэширования
Опубликовано: 2022-03-11В компьютерных науках есть только две сложные вещи: инвалидация кеша и присвоение имен вещам.
- Автор: Фил Карлтон
Краткое введение в кэширование
Кэширование — это мощный метод повышения производительности с помощью простого трюка: вместо выполнения дорогостоящей работы (например, сложных вычислений или сложных запросов к базе данных) каждый раз, когда нам нужен результат, система может сохранять — или кэшировать — результат этой работы и просто предоставлять его в следующий раз, когда он запрашивается без необходимости повторного выполнения этой работы (и, следовательно, может реагировать намного быстрее).
Конечно, вся идея кэширования работает только до тех пор, пока результат, который мы кэшировали, остается действительным. И здесь мы подходим к самой сложной части проблемы: как определить, что кэшированный элемент стал недействительным и его необходимо создать заново?
и идеально подходит для решения проблемы кэширования распределенной веб-фермы.
Обычно обычному веб-приложению приходится обрабатывать гораздо больший объем запросов на чтение, чем запросов на запись. Вот почему типичное веб-приложение, предназначенное для обработки высокой нагрузки, спроектировано таким образом, чтобы быть масштабируемым и распределенным, развернутым в виде набора узлов веб-уровня, обычно называемых фермой. Все эти факты влияют на применимость кэширования.
В этой статье мы сосредоточимся на роли кэширования в обеспечении высокой пропускной способности и производительности веб-приложений, предназначенных для обработки высокой нагрузки, и я собираюсь использовать опыт одного из своих проектов и предоставить решение на основе ASP.NET. в качестве иллюстрации.
Проблема обработки высокой нагрузки
Фактическая проблема, которую я должен был решить, не была оригинальной. Моей задачей было сделать прототип монолитного веб-приложения ASP.NET MVC способным выдерживать высокую нагрузку.
Необходимые шаги для улучшения пропускной способности монолитного веб-приложения:
- Включите его для параллельного запуска нескольких копий веб-приложения за балансировщиком нагрузки и эффективно обслуживайте все параллельные запросы (т. е. сделайте его масштабируемым).
- Профилируйте приложение, чтобы выявить текущие узкие места в производительности и оптимизировать их.
- Используйте кэширование для увеличения пропускной способности запросов на чтение, так как это обычно составляет значительную часть общей нагрузки приложений.
Стратегии кэширования часто включают использование некоторого сервера кэширования промежуточного программного обеспечения, такого как Memcached или Redis, для хранения кэшированных значений. Несмотря на их широкое распространение и доказанную применимость, у этих подходов есть некоторые недостатки, в том числе:
- Сетевые задержки, возникающие при доступе к отдельным кэш-серверам, могут быть сравнимы с задержками доступа к самой базе данных.
- Структуры данных веб-уровня могут быть непригодны для сериализации и десериализации по умолчанию. Чтобы использовать кэш-серверы, эти структуры данных должны поддерживать сериализацию и десериализацию, что требует постоянных дополнительных усилий по разработке.
- Сериализация и десериализация увеличивают нагрузку во время выполнения, что отрицательно сказывается на производительности.
Все эти вопросы были актуальны в моем случае, поэтому пришлось изучать альтернативные варианты.
Встроенный кэш в памяти ASP.NET ( System.Web.Caching.Cache
) работает очень быстро и может использоваться без дополнительных затрат на сериализацию и десериализацию как во время разработки, так и во время выполнения. Однако кэш в памяти ASP.NET имеет и свои недостатки:
- Каждому узлу веб-уровня требуется собственная копия кэшированных значений. Это может привести к более высокому потреблению уровня базы данных при холодном запуске или перезапуске узла.
- Каждый узел веб-уровня должен быть уведомлен, когда другой узел делает какую-либо часть кэша недействительной, записывая обновленные значения. Поскольку кеш распределен и без надлежащей синхронизации, большинство узлов будут возвращать старые значения, что обычно неприемлемо.
Если дополнительная нагрузка на уровень базы данных сама по себе не приведет к узким местам, то реализация правильно распределенного кэша кажется простой задачей, верно? Что ж, это непростая задача, но она возможна . В моем случае тесты показали, что уровень базы данных не должен быть проблемой, так как большая часть работы выполнялась на веб-уровне. Итак, я решил использовать кэш в памяти ASP.NET и сосредоточиться на реализации правильной синхронизации.
Представляем решение на основе ASP.NET
Как уже объяснялось, мое решение состояло в том, чтобы использовать кэш ASP.NET в памяти вместо выделенного сервера кэширования. Это означает, что каждый узел веб-фермы имеет собственный кеш, напрямую обращается к базе данных, выполняет все необходимые вычисления и сохраняет результаты в кеше. Таким образом, все операции с кэшем будут выполняться молниеносно благодаря тому, что кэш находится в оперативной памяти. Как правило, кэшированные элементы имеют четкое время жизни и устаревают при изменении или записи новых данных. Таким образом, из логики веб-приложения обычно ясно, когда элемент кеша должен быть признан недействительным.
Единственная оставшаяся здесь проблема заключается в том, что когда один из узлов делает недействительным элемент кеша в своем собственном кеше, ни один другой узел не узнает об этом обновлении. Таким образом, последующие запросы, обслуживаемые другими узлами, будут давать устаревшие результаты. Чтобы решить эту проблему, каждый узел должен делиться своими инвалидациями кеша с другими узлами. Получив такое аннулирование, другие узлы могут просто удалить свое кешированное значение и получить новое при следующем запросе.
Здесь Redis может вступить в игру. Сила Redis по сравнению с другими решениями заключается в его возможностях Pub/Sub. Каждый клиент сервера Redis может создать канал и опубликовать на нем некоторые данные. Любой другой клиент может прослушивать этот канал и получать соответствующие данные, что очень похоже на любую систему, управляемую событиями. Эту функцию можно использовать для обмена сообщениями о недействительности кеша между узлами, чтобы все узлы могли аннулировать свой кеш, когда это необходимо.
Кэш в памяти ASP.NET в некоторых отношениях прост, а в других сложен. В частности, он прост в том смысле, что работает как карта пар ключ/значение, но в нем много сложностей, связанных со стратегиями аннулирования и зависимостями.
К счастью, типичные варианты использования достаточно просты, и можно использовать стратегию аннулирования по умолчанию для всех элементов, позволяя каждому элементу кэша иметь не более одной зависимости. В моем случае я закончил следующим кодом ASP.NET для интерфейса службы кэширования. (Обратите внимание, что это не настоящий код, так как я опустил некоторые детали для простоты и частной лицензии.)
public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }
Здесь служба кеша в основном допускает две вещи. Во-первых, он позволяет сохранять результат некоторой функции получения значений потокобезопасным способом. Во-вторых, это гарантирует, что при запросе всегда возвращается текущее значение. Как только элемент кэша устаревает или явно удаляется из кэша, метод получения значения вызывается снова для извлечения текущего значения. Ключ кэша был абстрагирован интерфейсом ICacheKey
, главным образом для того, чтобы избежать жесткого кодирования строк ключей кэша во всем приложении.
Для инвалидации элементов кеша я ввел отдельный сервис, который выглядел так:
public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }
Помимо основных методов перетаскивания элементов с данными и прикосновения к клавишам, которые имели только зависимые элементы данных, есть несколько методов, связанных с каким-то «сеансом».

Наше веб-приложение использовало Autofac для внедрения зависимостей, который является реализацией шаблона проектирования инверсии управления (IoC) для управления зависимостями. Эта функция позволяет разработчикам создавать свои классы, не беспокоясь о зависимостях, поскольку контейнер IoC берет на себя это бремя.
Служба кеша и инвалидатор кеша имеют совершенно разные жизненные циклы в отношении IoC. Служба кеша была зарегистрирована как singleton (один экземпляр, общий для всех клиентов), а инвалидатор кеша был зарегистрирован как экземпляр для каждого запроса (для каждого входящего запроса создавался отдельный экземпляр). Почему?
Ответ связан с дополнительной тонкостью, с которой нам нужно было справиться. Веб-приложение использует архитектуру Модель-Представление-Контроллер (MVC), которая помогает в основном разделить пользовательский интерфейс и логические задачи. Итак, типичное действие контроллера заключено в подкласс ActionFilterAttribute
. В среде ASP.NET MVC такие C#-атрибуты используются для того, чтобы каким-то образом украсить логику действий контроллера. Этот конкретный атрибут отвечал за открытие нового соединения с базой данных и запуск транзакции в начале действия. Кроме того, в конце действия подкласс атрибута фильтра отвечал за фиксацию транзакции в случае успеха и ее откат в случае неудачи.
Если инвалидация кеша произошла прямо в середине транзакции, может возникнуть состояние гонки, при котором следующий запрос к этому узлу успешно поместит старое (все еще видимое для других транзакций) значение обратно в кеш. Чтобы избежать этого, все инвалидации откладываются до тех пор, пока транзакция не будет зафиксирована. После этого элементы кеша можно безопасно удалить, а в случае сбоя транзакции вообще нет необходимости в модификации кеша.
Это и было целью связанных с «сеансом» частей инвалидатора кеша. Кроме того, это цель его жизни, связанная с запросом. Код ASP.NET выглядел так:
class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }
Здесь метод PublishRedisMessageSafe
отвечает за отправку сообщения (второй аргумент) в определенный канал (первый аргумент). На самом деле существуют отдельные каналы для сброса и касания, поэтому обработчик сообщений для каждого из них точно знал, что делать — бросать/касаться клавиши, равной полезной нагрузке полученного сообщения.
Одной из сложных частей было правильное управление подключением к серверу Redis. В случае выхода из строя сервера по какой-либо причине приложение должно продолжать работать корректно. Когда Redis снова подключится к сети, приложение должно снова беспрепятственно начать использовать его и снова обмениваться сообщениями с другими узлами. Для этого я использовал библиотеку StackExchange.Redis, и полученная логика управления соединениями была реализована следующим образом:
class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }
Здесь ConnectionMultiplexer
— это тип из библиотеки StackExchange.Redis, отвечающий за прозрачную работу с базовым Redis. Важной частью здесь является то, что когда конкретный узел теряет соединение с Redis, он возвращается в режим без кэширования, чтобы убедиться, что ни один запрос не получит устаревшие данные. После восстановления соединения узел снова начинает использовать кэш в памяти.
Вот примеры действия без использования службы кеша ( SomeActionWithoutCaching
) и идентичной операции с его использованием ( SomeActionUsingCache
):
class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }
Фрагмент кода из реализации ISomeService
может выглядеть так:
class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }
Сравнительный анализ и результаты
После того, как кэширующий код ASP.NET был настроен, пришло время использовать его в логике существующего веб-приложения, и бенчмаркинг может оказаться удобным, чтобы решить, куда приложить больше всего усилий по переписыванию кода для использования кэширования. Крайне важно выбрать несколько наиболее распространенных или важных с точки зрения эксплуатации вариантов использования для сравнительного анализа. После этого такой инструмент, как Apache jMeter, можно было использовать для двух целей:
- Чтобы сравнить эти ключевые варианты использования с помощью HTTP-запросов.
- Для имитации высокой нагрузки на тестируемый веб-узел.
Чтобы получить профиль производительности, можно использовать любой профилировщик, способный подключаться к рабочему процессу IIS. В моем случае я использовал JetBrains dotTrace Performance. После некоторого времени, потраченного на эксперименты с определением правильных параметров jMeter (таких как количество одновременных запросов и количество запросов), становится возможным начать сбор моментальных снимков производительности, которые очень полезны для выявления горячих точек и узких мест.
В моем случае некоторые варианты использования показали, что около 15%-45% общего времени выполнения кода было потрачено на чтение базы данных с очевидными узкими местами. После того, как я применил кеширование, производительность большинства из них почти удвоилась (то есть стала вдвое быстрее).
Заключение
Как видите, мой случай может показаться примером того, что обычно называют «изобретением велосипеда»: зачем пытаться создавать что-то новое, когда уже есть широко применяемые лучшие практики? Просто настройте Memcached или Redis и не останавливайтесь.
Я определенно согласен с тем, что использование лучших практик обычно является лучшим вариантом. Но прежде чем слепо применять любую передовую практику, следует спросить себя: насколько применима эта «лучшая практика»? Подходит ли он для моего случая?
На мой взгляд, правильные варианты и анализ компромиссов являются обязательными при принятии любого важного решения, и я выбрал именно этот подход, потому что проблема была не такой уж простой. В моем случае нужно было учитывать множество факторов, и я не хотел принимать универсальное решение, когда оно могло оказаться неправильным подходом к рассматриваемой проблеме.
В конце концов, при правильном кэшировании я получил почти 50% прирост производительности по сравнению с первоначальным решением.