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


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

    Я постараюсь кратко осветить основные моменты организации кеширования, после чего рассмотрю новшества .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, данный класс всё же является крайне полезным инструментом в арсенале разработчика, так как позволяет получить потоко-безопасную «железобетонно» работающую реализацию кеша с неплохими возможностями управления политиками инвалидации элементов. Уверен, что попытка написать свой аналог будет достаточно время затратной и наверняка не будет столь же эффективной.
    • +11
    • 16,9k
    • 9
    Инфопульс Украина 223,83
    Creating Value, Delivering Excellence
    Поделиться публикацией
    Комментарии 9
    • +2
      Может быть я невнимательно читал и статья совсем про другое, но я так и не увидел — что происходит с инвалидированными объектами, выкинутыми из кеша, но все еще остающимися в памяти? Висят во втором поколении мертвым грузом, ожидая своего stop the world секунд на несколько?

      Имхо это одна из основных проблем для реализации кешей в средах с автоматическим управлением памятью. В недавней статье на хабре на эту тему предлагалось решение через сериализацию в длинные массивы. Ожидал увидеть тут какую-то альтернативу.
      • +1
        stop-the world на 2-3 минуты если 300,000,000 in Gen 2
        смотрите мои коммент ниже
      • +2
        Может быть я невнимательно читал и статья совсем про другое

        Статья действительно немного о другом. Я не ставил за цель открыть все подробности реализации MemoryCache. У меня было две цели:
        1) дать вводную информацию про что такое кеш и некоторые советы по тому как правильней организовать кеширование
        2) отрыть некоторые грабли, на которые обязательно наткнёшься при начале использования MemoryCache.

        что происходит с инвалидированными объектами

        Попробую ответить, основываясь на представлений о работе CLR и на беглом исследовании исходного кода (т. к. документация об этих вещах умалчивает).
        Сам класс не старается управлять памятью напрямую, используя неуправляемый (unmanaged) код. Его основной задачей связанной с памятью является гарантирование, что ссылки на элементы будут удалены из кеша согласно «политикам протухания (expiration)», а так же в случае достижения лимитов по разрешённой памяти. На этом его работа считается оконченной и в игру вступает Garbage Collector. А тот уже удаляет объекты согласно алгоритму, основанном на поколениях. И получается, что если элемент в кеше действительно пережил две сборки, то он будет во втором поколении и может залипнуть там на некоторое время. Ведь как вы правильно заметили, то мы работаем в среде с автоматическим управлением памяти, и получается что это уже особенность среды (как она управляет памятью), а не конкретной реализации кеша.
        Вообще, если задуматься, как это можно было бы реализовать иначе, то мне в голову приходят только крайне сложные решения:
        — либо всё на unmanaged коде
        — либо как-то так, что бы класс управлял логикой работы GC. Например, оставлял объекты в специальном поколении, которое можно было бы проверить, после того, как кеш удалил объекты. Но насколько я знаю, пока GC не даёт такой возможности и специально для класса MemoryCache его не будут наделять такими возможностями.
        По итогу, если хотим избежать задержки, то мы должны перенимать на себя заботу о своевременной очистке памяти. Но это уже попахивает написанием своего GC. Поэтому я бы просто смерился, что будет некая задержка, прежде, чем ненужные значения из кеша фактически удалятся из памяти.
        И последнее. Я думаю, что перерасход памяти и задержка не должны быть очень большими. Если у нас в приложении активно что-то кешиться, то наши 0-е и 1-е поколения будут всегда быстро заполняться и приводить к сборке во 2-м поколении. Так что по факту overhead должен быть минимальным.
        • +1
          Спасибо. Я имел в виду достаточно распространенные сценарии когда в кеш подгружается много объектов периодически (раз в Х минут). И как правило переживают несколько сборок мусора прежде чем «протухнут». В итоге у нас второе поколение забито миллионами объектов что радикально увеличивает время сборки в этом поколении.

          Хорошего универсального способа обойти эти грабли я к сожалению не нашел.
          • 0
            Походу я не сразу понял проблему, которую вы описывали. Но тепрь надеюсь смогу ответить на заданный вопрос :).
            У MemoryCache будет проблема, о которой вы спрашиваете. Внутри данные хранятся во множестве объектов типа Hashtable. Т.е. никаких трюков с сериализацией и утаивания существующих объектов от GC не происходит.
        • +2
          описанные решения не позволяют хранить сотни миллионов объектов долго — они убивают ГЦ
          мы решили ети проблемы с помощию Пиле — который описан тут:

          habrahabr.ru/post/257091

          youtu.be/Dz_7hukyejQ

          в наших преложениях спокоино можно держать 200-300 миллионов объектов хоть 3 месяца и сборшик мусора < 50 мс
          Code:

          github.com/aumcode/nfx/blob/master/Source/NFX/ApplicationModel/Pile/ICache.cs
          • +2
            Ваше решение я и имел ввиду в первом комментарии. Выглядит громоздко, но ничего лучше я пока не нашел.
            • +1
              в C# был допушен просчет — отсутствие гибрдинои памяти. они ето сделали для упрошения
              платформы, но им ничего не стоило хранитb те-же манагед objects в unmanaged heap через например
              оператор unmanaged new

              var x = unmanaged new Person();

              именно это мы делаем в нашем языке Аум:

              var pool1 = new Pool() at Area1;

              var obj1 = new Person() at pool1;

              pool1.Delete(); //deallocates eberything instantly

              ========================================
              coming back to C#:

              это бы опять подорвало 3ашиты от дурака — ибо вернулись бы все прелести ц++, но дело не в этом.

              етого нет и не будет скорее всего никогда в ЦЛР.

              поетому Пиле — это самое елегантное что вы сможете найти для работы с ЛЮБыми типами (а не только рукописними в быте [])
              • 0
                Рискну предположить что это был не прощёт, а тщательно продуманное решение. Ведь за гибкостью и большим количеством возможностей обычно таится сложность.
                При разработке языка / платформы не пытались сделать что-то, подходящее на все случаи жизни. Они старались сделать то, что подойдёт для большинства и будет максимально удобным.
                Возможно когда-то это будет добавлено, но до сегоднешнего дня находились более интересные направления развития. Например, TPL, async/await, Roslyn и т.п.
                Также хочу вспомнить старую проблему нулевых ссылок (подробней здесь). Не буду утверждать, что сравнение 100% адекватное, но это пример того, как дополнительная возможность может порождать больше зла, чем добра. Вроде уже даже активно обсуждается добавление в C# инструментов, которые позволять создавать ссылочные типы, но не иметь возможности хранить NULL в ссылочной переменной. Т.е. я намекаю на то, что выбирая между добавлением описанной вами фичи и её отсутсвием, Microsoft, взвесив все за и против, решила её не реализовывать, боясь породить больше зла. Верни время назад, они бы скорее всего не реализовывали возможность хранения NULL значний в ссылочных переменных.
                Мысли такие потому, что часто читал статьи Эрика (Eric Lippert), в которых он рассказывал, как MS скурпулёзно подходит к выбору фич и возможностей языка и платформы. Он многократно на пальцах объяснял, как добавление какой-то «всеми долго желанной» фичи на самом деле не имеет смысла, по причине «большего зла».

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое