Pull to refresh
0
Инфопульс Украина
Creating Value, Delivering Excellence

Что нам стоит Cache построить?

Reading time11 min
Views25K

Написано не мало хороших статей на тему «Что, как и где кешировать». Так почему же ещё раз мусолить эту тему? А потому что тема достаточно важная, а многие, пока не столкнуться с конкретными проблемами, не считают нужным с ней разбираться. Так что аудитория, на которую я рассчитываю, это те, кто к моменту выхода уже существующих статей были в них не заинтересованы, но сейчас интерес есть, и они не пройдут мимо.

Я постараюсь кратко осветить основные моменты организации кеширования, после чего рассмотрю новшества .Net Framework 4.0, которые должны упростить жизнь разработчиков (речь пойдёт о In-memory кеше вне ASP.NET инфраструктуры).

Вступление


Часто, когда речь заходит о производительности, довольно сложно обойтись без применения техники кеширования. Но прежде, чем мы сможем её эффективно применить, нам надо ответить на следующие вопросы:
  • Что кешировать: какие именно данные должны сохраняться в кеше;
  • Как кешировать: какой максимальный объём мы можем выделить для работы кеша; будет ли установлено максимально допустимое время, в течении которого элемент не будет считаться устаревшим; будет ли актуальность наших элементов в кеше зависеть от каких-то внешних факторов или будут зависимости между самими элементами внутри кеша; будет ли важен порядок, в котором мы будем удалять элементы из кеша при достижении лимита памяти; и так далее…
  • Где кешировать: что будет выступать в роли кеша – в устройствах, это может быть аппаратный кеш, в программах, как правило мы прибегаем к готовой или самописной реализации кеша, которая способна удовлетворить требованиям в вопросе «как»;

Интересно то, что ответы необходимо давать именно в том порядке, как эти вопросы перечислены. Ибо сложно сказать «где кешировать», не понимая, «что и как» мы кешируем. Ещё, крайне желательно позаботиться о кешировании на ранних этапах проектирования системы. Так как вопреки бытующему мнению, что «Кеш всегда можно добавить в последний момент», это зачастую не так. Не думая о кешировании на начальном этапе, потом его добавление и тестирование может быть крайне затруднительным. Давайте попробуем найти ответы на вопросы, заданные выше, но сразу хочу уточнить, что большинство размышлений ниже будут приведены касательно кеша общего назначения внутри .Net приложения хранимого в оперативной памяти, т.е. это не кеш процессора и не кеш браузера. Кроме того, в рамках одной статьи, будет крайне сложно подробно и доходчиво изложить всю возможную теорию кеширования, поэтому я приведу базовые рекомендации и советы, которые надеюсь помогут избежать распространённых ошибок.

Что? Как? Где?


Когда мы решаем, что мы будем кешировать, нужно понимать, что кеш не бесплатный и будет полезен далеко не для всех типов данных. Независимо от выбранной стратегии кеширования и реализации кеша, у нас так или иначе будут проявляться проблемы устаревания данных и занимаемого ими объёма памяти. А в виду того, что кеширование добавляет сложность во время создания /тестирования/сопровождения нашего приложения, то однозначно не нужно кешировать всё подряд. Нужно взвешенно подходить к выбору тех мест, где добавление кеша кроме проблем принесёт ещё и выгоду.

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

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

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

Думая о том, как правильно хранить наши данные в кеше, нам следует обратить внимание на следующие моменты:
  • Своевременное устаревание данных
  • Правильная очерёдность удаления элементов при достижении максимально доступного объёма памяти
  • Когерентность данных (если кеш распределённый, то один и тот же объект может отличаться в различных экземплярах кеша и тем самым приводить к негативным последствиям)

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

Итак, получив ответы на вопросы «Что» и «Как», может оказаться, что нашим ответом на вопрос где будет Dictionary<T,T> созданный в нашем приложении. Если так, то нам очень повезло. Но, как правило, всё чуточку сложнее и нам всё-таки придётся писать полноценную реализацию кеша либо выбирать что-то из уже готовых решений.
Примечание: нет единого мнения касательно того, будет ли реализация на основе Dictionary считаться кешем или нет. Лично я предпочитаю считать это частным случаем, который обособленно «стоит в сторонке». При этом мне даже встречался термин описывающий такой кеш как «статический», т.е. кеш в котором данные не удаляются и считаются бесконечно актуальными.

Рукописный кеш


Я не стану рассказывать, как написать свой кеш. Я, наоборот, постараюсь уберечь вас от ложного впечатления, что это сделать легко и просто. За исключением случая, когда Dictionary-like реализация отлично покрывает наши потребности, написание полноценного кеша является достаточно сложной задачей.

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

Если подумать о том, как происходит устаревание данных, то в голову может прийти ряд далеко не самых простых сценариев, которые должен поддерживать наш кеш. Взяв декартовое произведение всех возможных вариантов, мы получим то множество состояний, в которых сможет оказаться наша система. Это станет достаточным основанием, чтобы сделать задачу отладки и тестирования самописного кеша просто неподъёмной.

Многие примеры, реализующие кеширование, которые можно найти в интернете, используют механизм слабых ссылок (Weak references). Может появиться неудержимое желание применить их в своей реализации. Но без достаточного опыта в соответствующей области, мы не слабо увеличиваем шансы получить код, который мало того, что большинство команды не будет понимать, так он ещё навряд ли заработает после первых 5 раз переписывания.
Я думаю, что мог бы ещё долго продолжать этот список, но надеюсь, что даже уже перечисленных причин достаточно, чтобы у вас пропало желание проверять себя на прочность и усидчивость. Если же нет, то я могу лишь пожелать «Силы, ума и терпения (С)».

Теперь, поняв, что свой кеш это далеко не так просто, я предлагаю перейти к завершающей части статьи, которая расскажет, что уже есть готовое в .NET Framework для упрощения нашей жизни.

Жизнь до появления .Net Framework 4.0


Кеширование всегда было неотъемлемой частью ASP.NET веб приложений и .Net Framework предлагал отличные инструменты для ASP.NET приложений. Поэтому, исторически сложилось так, что все классы для работы с кешом располагались в сборке System.Web. Когда же кеш требовался вне веба (например Windows сервис), то многие разработчики жертвовали красотой своих решений и добавляли ссылку на сборку System.Web. Это позволяло воспользоваться преимуществами кеша, но тянуло за собой огромное количество ненужного кода. Данная проблема оставалась нерешённой достаточно долго, но к счастью, в .NET Framework 4.0 к ней всё-таки вернулись. В итоге мы получили пространство имён System.Runtime.Caching, в котором среди прочего, есть абстрактный класс ObjectCache и его единственная реализация — MemoryCache. Именно с ними я бы и хотел вас познакомить.

ObjectCache


ObjectCache — абстрактный класс, который даёт нам возможность стандартизировать подходы при работе с различными реализациями кеша. Имея одинаковый интерфейс (API) для работы с кешом, нам не придётся детально изучать каждую новую реализацию кеша. Ведь реализации с точки зрения пользователя должны будут выглядеть одинаково и вести себя согласно общеизвестным ожиданиями, выраженными в виде API данного класса. Основные методы, свойства и их назначение приведены ниже.

Свойства:
  • DefaultCacheCapabilities – битовыe флаги (enum, с атрибутом Flags), определяющие какие возможности предоставляет конкретная реализация (удаление элемента в определённое время, поддержка регионов, наличие механизма обратного вызова и т.п.)
  • Name – имя экземпляра кеша; в случае использования MemoryCache, может быть полезно, если, мы захотим сохранять данные в изолированных участках памяти и будем создавать более одного экземпляра кеша (данная возможность называется «регионы» и в MemoryCache не реализована)
  • this – индексер для доступа элементов по ключу

Методы:
  • Add(…), AddOrGetExisting(…), Set(…) – добавляют данные в кеш
  • Get(…), GetCacheItem(…), GetValues(…) – возвращают данные из кеша
  • GetCount() – возвращает текущий размер кеша
  • Contains(…) – проверяет существование элемента по ключу
  • Remove(...) – удаляет элемент по ключу

Надеюсь теперь, в общих чертах уже понятно, как можно создать свою реализацию кеша. Но есть ещё пару моментов, которые я бы хотел рассмотреть чуть подробней, а именно методы добавления данных в кеш. Предлагаю это сделать, на примере методов в уже существующей реализации кеша – класс MemoryCache.

MemoryCache


Как следует из названия, MemoryCache является реализацией, которая хранит данные в оперативной памяти. На данный момент это единственный класс в .Net Framework, который наследует ObjectCache, но существуют Nuget пакеты, которые предлагают другие реализации (например для хранения данных в Sql сервере можно воспользоваться SqlCache Nuget пакетом). Ниже будут рассмотрены только те методы, работа которых может быть не сразу очевидна. В качестве демонстрации работы методов будут приведены листинги юнит тестов, написанные с использованием xUnit.

Метод AddOrGetExisting(…)

Добавляет элемент, только в случае, если ключ ещё не был использован, иначе игнорирует новое значение и возвращает существующее значение.


Метод Add(…)

Является обвёрткой над AddOrGetExisting(…) и работает практически идентично, с тем лишь различием, что он возвращает True, если элемент успешно добавлен, и False, если ключ уже существует (т.е. добавление значения не происходит).


Метод Set(…)

Добавляет новый или замещает существующий элемент, не производя проверку существующих ключей. Т.е. в отличии от методов Add и AddOrGetExisting, переданное значение в метод Set всегда появится в кеше.


Регионы в MemoryCache

Все методы добавления данных в MemoryCache имеют перегрузки, которые принимают параметр region (пример1, пример2 и пример3). Но при попытке передать в него любое не NULL значение мы получим NotSupportedException. Кто-то может сказать, что это нарушает принцип подстановки Лисков (soLid), но это не так. Ведь прежде чем воспользоваться возможностью регионов, клиентский код должен убедится, что они реализованы в конкретной реализации. Делается это проверкой свойства DefaultCacheCapabilities на наличие соответствующего битового флага (DefaultCacheCapabilities.CacheRegions), а он как раз не задан для MemoryCache.

CacheItemPolicy



Для демонстрации всех методов добавления данных были выбраны наиболее простые версии перегрузок, принимающие ключ, значение и время, до которого значение будет считаться актуальным. Но все они так же имеют версию, которая принимает параметр типа CacheItemPolicy. Именно благодаря этому параметру у нас есть достаточно богатые возможности по управлению временем жизни элемента в кеше, что делает реализацию MemoryCache крайне полезной.

Большинство свойств данного типа выглядят понятно, но на практике мы столкнёмся с множеством неожиданных сюрпризов. Строго говоря, многие из них будут не в самом классе CacheItemPolicy, а в логике методов MemoryCache, которые принимают этот тип. Но так как используются эти типы часто вместе, то предлагаю их рассмотреть тоже вместе.

Свойства AbsoluteExpiration и SlidingExpiration

Из названия понятно за что отвечают эти свойства. Но любопытные могут задаться следующим вопросом: «Как себя поведёт кеш, если одновременно задать значения для обоих свойств»? Кто-то может предположить, что AbsoluteExpiration имеет более высокий приоритет и объект удалится в момент AbsoluteExpiration, даже если его регулярно запрашивать из кеша (чаще чем SlidingExpiration). Кто-то наоборот, предположит, что значение SlidingExpiration позволит объекту пережить AbsoluteExpiration. Но разработчики Микрософта, посчитали, что истинно правильного ответа нет и поступили по-другому – они генерируют ArgumentException на этапе добавления элемента в кеш. Поэтому мы можем выбрать только одну временнУю (зависящую от времени) стратегию инвалидации для каждого элемента.

Второй сюрприз нас ожидает, если мы решим написать тесты на функционал, использующий кеш. Наверняка, для ускорения прогона тестов, мы захотим задать достаточно маленькое значение для SlidingExpiration (менее 1 секунды). В этом случае наши тесты будут вести себя не стабильно и часто будут падать. Это всё по тому, что для оптимизации работы кеша, в момент вычитывания элемента (метод Get и его производные), новое значение Expires будет устанавливаться только, если оно отличается от старого, не менее чем на одну секунду. Я не смог найти подтверждение этому в документации, но убедиться в этом можно декомпилировав класс MemoryCache и изучив метод UpdateSlidingExp(…) внутреннего класса MemoryCacheEntry.



Свойство Priority

Увидев это свойство, я ожидал, что оно может иметь значения «низкий/средний/высокий», чтобы задать порядок удаления элементов из кеша при достижении максимального объёма. Но у него может быть только 2 значения: CacheItemPriority.Default или CacheItemPriority.NotRemovable.
MSDN гласит, что установка значения CacheItemPriority.NotRemovable приведёт к тому, что элемент никогда не будет удалён из кеша. Лично я воспринял этот факт, как то, что, добавив все элементы с таким приоритетом, мы получим Dictionary-like реализацию, но это далеко не так. Элементы всё же будут удалены, если они «протухнут» (наступит AbsoluteExpiration или пройдет SlidingExpiration), но в отличии от режима по умолчанию, они не будут удаляться из памяти, при достижении лимита по объёму занимаемой памяти. Кстати лимит можно задать через свойство CacheMemoryLimit (в байтах) или через свойство PhysicalMemoryLimit (проценты от общего объёма памяти в системе).

RemovedCallback и UpdateCallback

Очередной сюрприз. Оба свойства принимают делегаты, которые будут вызваны, как в случае обновления, так и в случае удаления элемента из кеша.

Если задуматься, то обновление, это по сути операция удаления, после которой сразу же следует операция добавления нового значения. Это объясняет, почему RemovedCallback срабатывает при обновлении элемента. А то что UpdateCallback срабатывает при удалении – просто факт из MSDN.

Различие же свойств заключается в том, что RemovedCallback должен вызываться после, а UpdateCallback – до фактического удаления элемента из кеша. Делегаты, хранимые в этих свойствах, принимают параметр, в котором содержится ссылка на кеш, ссылка на удаляемый элемент и причина удаления элемента.

Ещё один подарок хранится в реализации MemoryCache. В этом классе есть немного странная логика валидации переданного CacheItemPolicy параметра. Сначала она проверяет, что бы оба делегата не были заданы одновременно, иначе мы получим ArgumentException на этапе добавления элемента в кеш.



И всё бы ничего, если б для корректной работы свойства UpdateCallback было бы достаточно убедиться в отсутствии значения в свойстве RemovedCallback. Но по факту, мы всегда получаем ArgumentException на этапе добавления элемента при установке не пустого значения в UpdateCallback.


В итоге, единственно допустимое свойство для установки делегата сигнализирующего об изменениях в кеше является RemovedCallback (справедливо только для реализации MemoryCache).

Свойство ChangeMonitors

Данное свойство может хранить коллекцию объектов типа ChangeMonitor, каждый из которых может добавлять условия, при которых элемент будет удалён из кеша.

Кроме того, что мы можем создавать свои реализации абстрактного класса ChangeMonitor, в .Net Framework существуют следующие классы:
  • CacheEntryChangeMonitor – следит за изменениями других элементов в том же экземпляре кеша
  • FileChangeMonitor (HostFileChangeMonitor) – следит за изменениями файлов и папок в файловой системе
  • SqlChangeMonitor – следит за изменениями в базе данных (достаточно медленный и редко применим на практике).

Важно помнить, что данное свойство в объекте CacheItemPolicy нужно задать до добавления элемента в кеш. Установка или изменение его для уже добавленного элемента не имеет никакого эффекта.

Заключение

Несмотря на ряд не самых очевидных особенностей в реализации MemoryCache, данный класс всё же является крайне полезным инструментом в арсенале разработчика, так как позволяет получить потоко-безопасную «железобетонно» работающую реализацию кеша с неплохими возможностями управления политиками инвалидации элементов. Уверен, что попытка написать свой аналог будет достаточно время затратной и наверняка не будет столь же эффективной.
Tags:
Hubs:
+11
Comments9

Articles

Information

Website
www.infopulse.com
Registered
Founded
1992
Employees
1,001–5,000 employees
Location
Украина