Помогаю зарабатывать (или не тратить) с помощью ИТ
0,0
рейтинг
8 февраля 2013 в 10:13

Разработка → Стратегия кеширования в приложении из песочницы

Когда заходит разговор о кешировании складывается парадоксальная ситуация. С одной стороны все понимают важность и нужность кеширования в архитектуре приложений. С другой стороны мало кто может внятно объяснить что и как надо кешировать.

Обычно люди сходу начинают предлагать готовые реализации кеша, вроде memcached или HTTP-кеша, но это лишь ответ на вопрос где кешировать.

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


Зачем нужен кеш


Кеш приближает данные к месту их использования. В современном мире, состоящим на 98% из интернета, данные обычно лежат очень далеко от пользователя. На всем пути от хранилища к пользователю есть кеши, которые служат только одной цели – чтобы пользователь как можно быстрее получил свои данные.

Если рассмотреть внимательнее, то видно, что драгоценное время тратится на обработку данных в поставщике и передачу данных от поставщика клиенту, время обработки данных на клиенте тут не учитываем.

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

Кеш нельзя просто включить


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

На заре своей карьеры программиста я один раз просто так включил кеширование, буквально через час пришлось его выключить. Тогда я нарвался на основную проблему при кешировании – устаревание данных. Пользователь после изменения данных не видел результата 15 минут.

Очень важно понимать что и как вы собираетесь кешировать, чтобы не нарушать логику работы приложения. И первый вопрос, на который вам необходимо ответить – насколько устаревшие данные можно отдавать клиенту. Естественно можно сделать свой кеш для каждого клиента, это упростит решение вопроса об актуальности данных, но принесет много других проблем.

Типы кеширования


Есть три основных типа кеширования по механике работы:
  • Lazy cache, он же ленивый кеш, он же тупой кеш – самый простой в реализации тип кеширования, зачастую встроен в фреймворки. Кеш просто сохраняет данные и отдает их пока не устареет.
  • Synchronized cache, синхронизированный кеш – клиент вместе с данными получается метку последнего изменения и может спросить у поставщика не изменились ли данные, чтобы повторно из не запрашивать. Такой тип кеширования позволяет всегда иметь свежие данные, но очень сложен в реализации.
  • Write-through cache, или кеш сквозной записи – любое изменение данных выполняется сразу в хранилище и в кеше. Этот тип кеша может никогда не устаревать, но возникают проблемы с так называемой “когерентностью”.


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

Устаревание и когерентность кеша


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

Если одни и те же данные попадают в разные кеши, то возникает проблема когерентности кеша. Например одни и те же данные используются для формирования разных страниц и кешируются страницы. Страницы сформированные позже будут содержать обновленные данные, а страницы, закешированные раньше, будут содержать устаревшие данные. Таким образом будет нарушена согласованность поведения.

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

Эффективность кеша


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

Частые сбросы кеша, кеширование редко запрашиваемых данных, недостаточный объем кеша – все это ведет к пустой трате оперативной (обычно) памяти, не повышая эффективность работы.

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

Применение разных типов кеширования


Ленивый кеш

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

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

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

Синхронизированный кеш

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

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

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

Кеш сквозной записи

Если есть система распределенного кеширования (memcached, Windows Sever App Fabric, Azure Cache), то можно использовать кеш сквозной записи. Рукопашная реализация синхронизации кешей между узлами сама по себе отдельный большой проект, потому не стоит заниматься ей в рамках разработки приложения.

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

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

Что еще нужно учитывать в стратегии кеширования


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

Кешируйте данные как можно позже, непосредственно перед отдачей во внешнюю систему. Кешировать данные, полученные извне, необходимо только в случае проблем с производительностью на этом этапе. Внешние хранилища, такие как СУБД и файловые системы, сами реализуют кеширование, поэтому обычно нет смысла кешировать результаты запросов.

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

Заключение


Надеюсь статья была интересной и полезной для вас. Комментируйте, оценивайте, буду рад любым предложениям.
Стас Выщепан @gandjustas
карма
28,5
рейтинг 0,0
Помогаю зарабатывать (или не тратить) с помощью ИТ
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (47)

  • +5
    Например кеширование данных для каждого пользователя скорее всего будет неэффективно при большом количестве пользователей. Если кешировать данные для всех пользователей разом, то возникнут проблемы с устареванием данных и когерентностью кеша.


    Еще с правами доступа можно получить массу удовольствия. Если кэш общий для всех — пользователь может получить «закрытые» для него данные. Но даже если кэш отдельный для каждого пользователя, то можно нарваться, например, на получение данных из кэша, к которым доступ был закрыт позже (если, например, права доступа администрируются отдельно, и на «актуальность» собственно данных влияния не оказывают).
  • +1
    Кошерно про кеши
  • +5
    Статья получилась для тех, кто все эти схемы попробовал и знает их плюсы и минусы. Чтобы охватить более широкий круг пользователей, хорошо бы добавить простые примеры реализации с описанием получающихся граблей.
    Опять же, наверное стоило упомянуть о проблеме конкурентной генерации кэша…
    • +1
      К сожалению простой пример синхронизированного кеша по объему равен всей статье. Позже напишу про кеширование в asp.net mvc и sharepoint.
  • 0
    Спасибо за интересную статью!
    Как мне кажется, для начинающих хорошее подспорье.

    Кстати, если уж говорить о кеше, то можно упомянуть, что не только в сетевых технологиях он акутален. Для тех же игр кеширование текстур — нормальное явление, для программ со сложным и навороченным UI — тоже. Если программа занимается обработкой большого количества данных, хранящихся в файловой системе (ну допустим около терабайта), без кеша опять же не обойтись.
  • +3
    адский псевдокод:

    get_something
        if cached
            return cached
        else
            cached = ...
            return cached
    
    set_something:
        ...
        drop cached
    


    Такая логика, по-моему, работает в 95+ процентах случаев, когда чтений данных больше, чем изменений.
    Когда не так — уже приходится думать.

    А что сложного в кешах для каждого юзера? Почему не сделать для его закешированного профиля ключ в кеше, включающий его идентификатор?
    • 0
      но не работает в случае если данные были изменены 3й стороной
      • 0
        Ок. Мне всегда сопутствовала роскошь работать на проектах, где с данными общалась только одна codebase. Как бы это по-русски? :)
        • –1
          Одна codebase еще не означает автоматической синхронизации кешей между разными серверами.
      • +2
        Так для третей стороны есть API.
        Нечего ей прямой доступ к данным давать.
        • –1
          мы говорим о разных вещах.
          представьте, у нас горизонтальный кластер из 10 серверов приложений. пользователь имеет сессию на одном из серверов и соответственно на этом же сервере у него есть кеш.
          3й стороной в данном случае может быть даже сам пользователь, открывший сессию с другим сервером (например с другого компьютера). я уже молчу про администратора и апи.
          • +1
            Ну, кеш на сервере с одной стороны это хорошо, близко.

            С другой стороны, разумно сделать кластер memcached-ов на этих 10 серверах и не париться.

            Если становятся важны единицы миллисекунд — то ок, можно перенести кеши на каждый сервер в отдельности; но тогда надо придумывать способы, чтобы пользователь всегда приходил на один сервер.
    • 0
      Этот аццкий псевдокод реализует lazy кеш. Зачастую его даже писать не надо, он во многие фреймворки встроен. Но, по причинам описанным в посте, мало полезен.

      А в кешах для каждого юзера сложности нет, но процент попадай в кеш получается низкий, когда юзеров много.
      • 0
        Так кеш генерится только тогда, когда он каким-то юзером запрошен. Нормальный будет процент)
        • 0
          Можно посчитать. Предположим 1000 пользователей, каждый держит в кеше по 100кб. Объем кеша 50МБ — то есть примерно 50% пользовательских кешей будет влезать. Если все 1000 пользователей запрашивают равновероятно, то матожидание попадания в кеш будет 50%. Это довольно низкий процент. При увеличении количества пользователей эффективность падает. При всплесках нагрузки кеш окажется почти бесполезным.
          • 0
            Решение — сделать 100 МБ. И следить за наполняемостью кеша, не стоит доводить выше, чем до 80% (в случае memcache).

            Потом, 100кБ на пользователя — это очень много.
            • 0
              Ну сделаешь 100МБ, а потом придет 10,000 пользователей…

              100кб может быть много для одного запроса, но для нескольких — очень даже мало.
              • 0
                Если у вас одновременно работают десять тысяч пользователей, то, наверно, популярность вашего приложения позволит вам и (целый!) гигабайт выделить на кеш.
      • 0
        Насчет «устаревших данных» и «затрат на реализацию» я категорически не согласен — таких проблем у приведенного псевдокода не будет, и написать его, ей-богу, не трудно. Даже когда он не «псевдо».
        Какой-то expire добавить, конечно, надо — но это на случай, если кеш перестал быть кому-то нужен, а не чтобы он по нему обновлялся.
    • 0
      По псевдокоду:
      про «адский» увидел, но не смог удержаться, простите.

      def get_something(id):
          if not self.__cached:
              self.__cached = self._get_something()
          return self.__cached
      
      
      def set_something(id):
          self.__cached = None
          ....
      


      Тут хорошо подходит идеология обращения через property

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

      При изменениях, id записей накапливаются.
      Есть один или несколько (нужен механизм распределения записей по обработчикам) обработчиков.
      На cron вешать эти обработчики, чтоб они пачками обновляли кэш не по каждому объекту, а по списку накопившихся.

      Конкретика:
      Использовал в поддержании актуальности (задержки 10 минут) таблицы по сущности, которая собирается из множества других таблиц в oracle,
      когда запрос с join-ами этих таблиц для web-интерфейса отчетности не давал приемлимых результатов.
      На все эти таблицы вешались триггеры, которые вычисляли и записывали в таблицу-лог id объекта и время изменения, то есть, накапливали объекты, по которым кэш устарел
      Была процедура, которая запускалась раз в 10 минут.
      — Сделать view, в которой используются таблицы, по набору колонок равную таблице
      — Запомнить время старта
      — Выбрать все объекты, которые имеют запись в логе до времени старта
      — Накатить все данные из view, которые есть в логе (merge into table using(select * from view where id in (select id from log))… )
      — Удалить записи из лога, которые до времени старта (чтобы удалить только гарантированно отработанные, так как при обновлении, успевают налететь еще данные)

      На выходе получали таблицу с 50-60 столбцами (все нужные параметры сущности), по которой очень удобно и быстро строить отчеты почти online.
      Время инкрементного обновления кэша 40 сек. Если выбирать все записи из представления, то 3 часа.
      • 0
        Ну да, такой get, конечно, лучше :)
  • 0
    Почему не будет? Как приложение узнает что cached пора обновить? Иначе получится что cached заполнится один раз на все время жизни приложения и не будет меняться. Такого, конечно, не бывает.

    «Какой-то expire добавить» выливается в объем кода, сравнимый с объемом кода логики приложения.
    • 0
      Ну обратите внимание на set_something — когда мы закешированную сущность меняем в базе, сбрасываем её кеш.

      В случае memcached «добавить expire» выливается в добавление ", $expire".

      Если в вашей системе кеширования нет встроенного expire, то я надеюсь, что там есть хотя бы вытеснение старых данных новыми — сойдет и это.
  • 0
    Одна и та же сущность может быть закеширована много раз, например в персональных кешах пользователей или просто для разных представлений. Как выяснить какие кеши нужно экспайрить?
    • 0
      Надо немного конкретизировать, чтобы мы оставались в общем контексте.
      Я так понимаю, вы имеете в виду ситуацию, когда, например, у нас есть некоторая сводная страница, где надо показать не одну сущность — пользователь, сайт, etc — а некую их группу или группы. Вероятно, в каком-то определенном порядке.
      При этом информация о каждом отдельном элементе каждой группы в кеше есть.

      Как бы я поступал в таком случае.

      Во-первых, надо определиться, допустимы ли тут устаревшие данные и если да, то насколько.

      Для начала, пусть допустимы.

      Тогда я бы получал список групп для показа, в нужном порядке, и кешировал бы эти группы — в виде id сущностей. На какое-то время, определяемое задачей. Скажем, 5 минут.
      Потом на каждый запрос я бы обращался к этому кешу, собирал все id, и забирал отдельный кеш каждой сущности.
      В memcached есть get_multi, кстати — экономия на накладных расходах существенная. Да и запросы в базу для cache miss тоже можно группировать, так что, конечно, групповые lookup-ы надо для каждой сущности реализовать.
      Рендерил страницу, отдавал.

      Теперь пусть неактуальность недопустима.

      Снова развилка — как часто потенциально может обновляться информация на сводной странице?

      Скажем, редко. Это маловероятная ситуация, сейчас станет понятно, почему.
      Тогда можно дропать сводный кеш каждый раз, когда происходит что-то, что может повлиять на сводную страницу — то есть на каждый апдейт каждой сущности, которая теоретически может там присутствовать.
      Скорее всего, так ключ будет дропаться чуть ли не чаще, чем запрашиваться.

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

      Либо это грамотно огранизованная таблица/таблицы в РСУБД с правильными индексами, либо это, скажем, Redis или что-то ещё, работающее в памяти.

      В итоге мы все равно получим список id сущностей, которые будут лукапиться вторым шагом.

      В общем, весь этот очень длинный текст сводится к тому, что не надо дважды одну информацию класть в два места, если вам важна когерентность этих мест. Просто вот не надо и всё. Придумывайте по-другому.
      • 0
        Сводную страницу из разных данных можно разбить на несколько блоков, каждый из которых кешируется и обновляется отдельно. И собирать это всё при отдаче клиенту.
      • 0
        Давно придумали тэгирование. И очистку кеша по тэгу.
        • 0
          Можно подробнее?
          • 0
            Это скорее ответ не Вам, а gandjustas.

            Пример, кеширование запросов в БД через ORM. В большей части ORM есть модели, к модели можно привязать определенные префикс. Модель выполняет разнообразные запросы с различными параметрами и все отправляет в кэш со своим префиксом.

            Когда наступает момент изменения данных в БД, касающихся определенной модели, мы точно не знаем, какие именно кеши эти данные затронули и сбрасываем кеши объединенные префиксом или тэгом. Многие системы позволяют искать по префиксу (главное, чтобы он был уникальный и не затрагивал лишних данных).

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

            Реализацию с БД привел только как абстрактный пример.
            • 0
              Я такое делал когда еще Velocity Cache не стал Windows Server App Fabric. Там это довольно легко сделать через паттерн publish\subscribe, ну и кеш когерентный получается. Но изобретать такое с нуля — проблематично.
            • 0
              То есть, например, когда в таблице пользователей появляется новое поле, надо все старые кеши инвалидировать?
              Ну или просто по какой-то причине мы знаем, что у нас половина кеша сломана?

              Искать по префиксу в мемкеше вроде и не надо, просто меняем префикс в коде и оп, все пошли за свежим кешем.

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

              Вообще ключи я привык формировать так: NAMESPACE-PREFIX-id, где NAMESPACE отделяет друг от друга виды сущностей, а PREFIX отдельные кеши внутри сущности.

              А именно про теги в мемкеше я нагуглил, но у меня сразу возникли вопросы «а как это работает, когда у меня ключи на десяти серверах» и «а где они хранят информацию о тегах, ведь memcached не персистентное хранилище и может выкинуть из кеша что угодно когда угодно».
              • 0
                Тут уже встает вопрос насколько такой кэш подходит Вашему приложению. Если select/total > 90%, я считаю эту стратегию верной и самой простой.

                Поясните, чем плох дроп кеша, даже массовый (в пределах тега)?

                Менять префиксы не самый лучший способ. Их необходимо где-то хранить. Также некоторые системы при перезаполнении пула, могут просто отказаться работать (старые версии APC, к примеру).

                Другой вариант, это бэкграундое перестроение кеша. Когда Вы заранее знаете, что Вы храните и перестраиваете по затронутым тэгам, а пока кеш не перестроен, отдаете старый контент.
                • 0
                  Массовый дроп может быть плох, если у Вас после этого приложение ломанется в базу так активно, что ляжет :)
                  Речь только о массовом, теги так вряд ли используются, с ними должно быть ок.

                  Менять префиксы не самый лучший способ.

                  Ну, я все это рассказываю в применении к memcached. Там старые данные рано или поздно уплывут, риска переполнения кеша вообще нет.
  • –2
    Просто оставлю это здесь
    ru.wikipedia.org/wiki/Теорема_CAP
  • 0
    Synchronized cache, синхронизированный кеш – клиент вместе с данными получается метку последнего изменения и может спросить у поставщика не изменились ли данные, чтобы повторно из не запрашивать. Такой тип кеширования позволяет всегда иметь свежие данные, но очень сложен в реализации.

    Не понимаю. Мы про бекенд говорим или про фронтенд?
    Если про бекенд, то не вижу никакой проблемы в вводе CacheDependency.
    Если про фронтенд, то опять же нет никакой проблемы — опрашиваем сервер по таймеру или через постоянное соединение отправляем диффы/обьекты клиенту.
  • 0
    CacheDependency можно повесить на произвольную СУБД\веб-сервис? CacheDependency не везде существует.
    Если говорим про фронтэнд на HTTP, то там уже все есть. А если нет, то реализация очень нетривиальная. Мне, к сожалению, пришлось прикручивать такой кеш к SOAP вебсервису. Код кеширования (клиент+сервер) в итоге оказался больше, чем код всего веб-сервиса.
    • 0
      Есть запрос к источнику данных, есть зависимость, которая определяет актуальность этих данных.
      CacheDependency в моем понимании может быть чем угодно и реализуется программистом.
      Например:
      Storage.cache(dependency).get(criteria)
      

      Если dependency не обновился с момента последнего сохранения результата в кеш, то кеш и возвращаем.
      Может вы про случай, когда зависимость нетривиальна и определение актуальности информации действительно сложная задача? Приведите утрированный пример, пожалуйста.
  • 0
    У нас на проекте используется синхронизированный кеш с инвалидацией по тегам. Запросы (чтение) и команды (запись) имеют теги. При исполнении команды происходит сброс всех ключей с указанными тегами. Основная сложность этого подхода состоит в правильном выборе тегов, чтобы они были максимально точными и не сбрасывали лишние ключи.

    Write-through cache похож на подход CQRS, когда при обновлении нормализованного хранилища обновляется так же хранилище для чтения (проекции). Это весьма сложно в реализации, плюс есть шанс прочитать устарвешие данные, т.к. денормализация занимает какое-то время и происходит в фоне.
    • 0
      На чем реализован такой кэш? Я имею в виду тэги.
      • 0
        В качестве кеш-сервера используется memcached (Couchbase). Сам по себе мемкеш не поддерживает теги ключей и сброс по ним, потому пришлось использовать одно известное решение:

        — теги (строки) сами являются ключами, а значениями являются даты их последнего сброса
        — вместе с ключами кеша мы храним дату их записи
        — при получении ключа мы проверяем, что дата его записи больше, чем дата сброса любого из его тегов
        — если какой-то тег был сброшен позже, чем мы записали ключ в кеш, такой ключ считаем протухшим.

        Минус данного подхода в том, что при каждом запросе ключа требуется также запросить даты сброса всех его тегов (можно использовать мульти-гет). Плюс — в актуальности кеша и удобстве управления инвалидацией ключей.
        • –1
          А если memcached решить выкинуть слаб с частью тегов?
          • +1
            Не понял вопроса…
            • 0
              memcached, когда ему начинает не хватать места, начинает выкидывать данные, причем выкидывает он их целыми кусками, блоками.

              То есть любой ключ может пропасть раньше времени наступления expire-а.

              Одна из нод кластера может ребутнуться, наконец.
              • 0
                Интересное замечение. В таком случае при отсутствии тега можно считать, что он инвалидирован. Еще можно дублировать теги в памяти самого приложения (они ничего не весят). Но, поскольку теги довольно часто обновляются и занимают очень малый объем памяти, вероятность того, что мемкеш их выкинет, довольно мала.
  • 0
    С одной стороны все понимают важность и нужность кеширования в архитектуре приложений.


    Computer Science has only three ideas: cache, hash, trash. © Greg Ganger, CMU
  • 0
    Чтобы избежать проблем с кэшем, нужно его использовать в первую очередь с данными на чтение…

    Идея кэшировать редактируемые данные, компрометирует слой (делает его лишним и это обычно БД), отвечающий за хранение данных. Тогда уж лучше отказаться совсем от БД.
  • +3
    Это чей-то курсовик?

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