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

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

Терминология

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

  • cache miss - "промах кэша". Сценарий, когда запрошенный ключ не был найден в кэше

  • cache hit - "попадание в кэш". Сценарий, когда запрошенный ключ был найден в кэше

  • hit ratio - % попаданий запросов в кэш. Характеризует эффективность кэширования. hitRatio = cacheHit / (cacheMiss + cacheHit)

  • горячий ключ - ключ, на который приходится бОльшая часть запросов

  • прогрев кэша - процесс наполнения кэша данными. Зачастую кэш прогревается либо при запуске системы заранее известными наиболее часто запрашиваемыми данными, либо по мере запросов(при частых или даже при первом cache miss)

  • инвалидация - удаление / обновление информации из кэша. Например, если в кэше находилось значение balance = 100$, а мы получили запрос на запись в базу balance = 400$, то очевидно что после успешной записи нам необходимо инвалидировать это значение в кэше

При необходимости и наличии нужных метрик, можно вычислить математически, будет ли эффективен кэш вовсе в вашем сценарии:

AverageAccessTime = DBAccessTime*CacheMissRate + CacheAccessTime

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

Разновидности кэша

Итак, какие же существуют разновидности кэша? Это зависит от критерия классификации.

По расположению в архитектуре систему кэши разделяют на 2 вида - внутренний и внешний кэш.

Внутренний кэш или in-memory cache находится в оперативной памяти нашего приложения. Внешний же кэш располагается как ни странно на стороннем сервере, представляющим собой такое же back-end приложение, либо более частый сценарий - одно из готовых решений по типу Redis, Tarantool и др.

Преимущества внутреннего кэша:

  1. Высокая скорость записи / чтения данных (из-за отсутствия сетевых запросов)

  2. Вытекает из п1 - отсутствие расходов на сериализацию / десериализацию данных

Недостатки внутреннего кэша:

  1. Сложное, а иногда невозможное горизонтальное масштабирование

  2. При любом рестарте сервиса приходится заново прогревать кэш

Преимущества внешнего кэша:

  1. Простое горизонтальное масштабирование

  2. Вытекает из п1 - возможно хранение большого объема данных

  3. При падении нашего сервиса - данные кэша не теряются

  4. Простой прогрев кэша и инвалидация данных

Недостатки внешнего кэша:

  1. Скорость работы (из-за сетевых запросов)

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

В данной парадигме мы можем вычленить 3 типа кэша - Cache Aside, Cache Through и Cache Ahead. Давайте о каждом по порядку:

1. Cache Aside (Кэш на стороне)

При данной стратегии наше приложение само координирует запросы в кэш и в БД.

Read aside
Read aside
Write aside
Write aside

2. Cache through (Сквозное кэширование)

Все запросы приложения проходят через кэш

Read through
Read through
Write through
Write through

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

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

3. Cache Ahead (Опережающее кэширование)

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

Read ahead
Read ahead

Алгоритмы вытеснения

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

Так уж вышло, что безграничных ресурсов не существует, и проектируя какую-либо систему или отдельный компонент по типу кэша, мы всегда должны учитывать ресурсы, доступные нам в употребление. Сейчас не важен конкретный размер кэша, 10 или 1,000,000 записей, важно то, что он ограничен. То есть на длинном промежутке времени, при использовании нашего приложения, рано или поздно мы неизбежно столкнемся с ситуацией, где весь доступный кэш заполнен, и для записи новой информации в него, нужно удалить какую либо из существующих. Как же решить какая именно запись должна быть удалена? Именно на этот вопрос и дает нам ответ выбранный алгоритм вытеснения.

Для начала давайте взглянем на тривиальные алгоритмы вытеснения

Random

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

FIFO (First-In, First-Out)

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

LIFO (Last In, First Out)

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


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

Итак, 3 наших кита - LRU, MRU и LFU. Давайте по порядку:

LRU (Least Recently Used)

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

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


MRU (Most Recently Used)

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

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


LFU (Least Frequently Used)

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

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


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

Алгоритм Белади (Belady's Optimal Algorithm)

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

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

Advanced алгоритмы

Указанные выше 3 алгоритма довольно просты, и имеют также свои недостатки. В реальных проектах используются намного более сложные алгоритмы, которые являются многократными надстройками над данными китами. Их рассмотрение выходит за рамки данной статьи, но при желании, вы можете найти интересующую вас информацию по каждому из них. Вот наиболее известные алгоритмы: Second Chance, Clock, 2Q, SLRU(Segmented LRU), TLRU(Time aware LRU), LRU-K.

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


  1. cupraer
    18.01.2025 07:39

    Есть золотое правило: если у вас используется алгоритм вытеснения из тройки random/lifo/fifo — значит, вам кэш не нужен в принципе.

    И еще одно: кэш — в подавляющем большинстве проектов — это preliminary optimization, приносит больше зла, чем пользы.

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


    1. MonkAlex
      18.01.2025 07:39

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


      1. cupraer
        18.01.2025 07:39

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

        VoidCache решает проблему decoupling’а, а не несуществующие пока проблемы устаревания. Когда вы решите подключить кэш, вам не придется делать рефакторинг на газиллион строк, достаточно будет заменить одну, а потом воевать с неправильным устареванием, с которым по любому придется воевать.


        1. MonkAlex
          18.01.2025 07:39

          Тогда это не VoidCache некий, а любой другой класс который отвечает за соединение с внешним сервисом\СУБД - репозиторий, сервис, менеджер, на любой вкус.

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


          1. cupraer
            18.01.2025 07:39

            Класс? Серьезно? А если у меня Хаскель — жить мне без кэша?

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

            Я написал ровно то, что имел в виду.


            1. MonkAlex
              18.01.2025 07:39

              и предоставляет интерфейс invalidate

              Окей, операцию R мы выносим в отдельный модуль, модуль ещё и умеет в инвалидацию видимо. Можно где-то почитать как этим пользоваться? Я пока не видел таких примеров кода, мне непонятны плюсы и как пользоваться.


              1. cupraer
                18.01.2025 07:39

                Можно где-то почитать как этим пользоваться?

                Понятия не имею. Я не читаю научно-популярные книжки.


    1. olku
      18.01.2025 07:39

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


      1. cupraer
        18.01.2025 07:39

        Не, кэш не является БД ни в каком смысле, потому что из БД не исчезают внезапно записи.

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

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

        Особенно в современных реалиях, где время расходуется не на точечные, пусть даже и сетевые, вызовы. Смешно видеть, как люди выгружают тонну джаваскрипта клиенту на каждый чих, а рядом прикручивают кэш к постгресу. Так и хочется подойти с шепнуть: «Чувак, постгрес писали люди, которые выборку из миллиарда строк оптимизировали лучше, чем ты свою проверку на cache miss».


        1. olku
          18.01.2025 07:39

          Есть базы данных где записи исчезают, например tsdb, есть и кэши с ACID.


          1. cupraer
            18.01.2025 07:39

            Да, я в курсе, но люди, у которых

            […] представление меняется заменой слова на «базу данных», чем по сути кэш и является […]

            — навряд ли.


      1. chlorine Автор
        18.01.2025 07:39

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


      1. Dadadam999
        18.01.2025 07:39

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

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


  1. nv13
    18.01.2025 07:39

    А заморозка применяется в таких кэшах, как в процессорах?


    1. WASD1
      18.01.2025 07:39

      О какой "заморозке" в процессорных кэшах идёт речь?

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


      1. nv13
        18.01.2025 07:39

        В некоторых процессорах есть команда freeze кэш страницы. Не помню деталей, да и не использовал ни разу


        1. WASD1
          18.01.2025 07:39

          Поскольку кэш работает на уровне кэшлайнов, а не страниц, то "фриз кэш страниицы" - звучит как какая-то ерунда.

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


          1. nv13
            18.01.2025 07:39

            По моему там употреблялось page, а не line, но не уверен. И смысл был в том, чтобы считать массив констант из динамической памяти, потом заморозить этот лайн или пейдж, пройти цикл обработки с их использованием, заморозить кэш инструкций с этой функцией, а потом заниматься чем то другим, чтобы при появлении новых данных это всё повторялось уже из кэша


            1. WASD1
              18.01.2025 07:39

              Честно говоря супер пространное описание.
              Смутно похоже на 1 из 3 вещей:
              - Tight Coupled Memory (программно управляемый кэш)
              - LL SC синхронизацию
              - watch points
              В целом неясно в чём смысл: оставить надолго некоторые данные в кэше или же "разбудить" ожидающий процесс? Если цель "разбудеть" - то обычно так не делают скрыто в аппаратуре, а посылают уснувшему процессу явный сигнал на уровне программной модели.


              В общем случае в микроэлектронике - сложность сделать доступ к памяти быстрой. А в программном кэше сложность - вовремя инвалидировать устаревшие данные (т.к. данные мимо кэша идут в виде "запросов" по которым надо угадать что может инвалидироваться).

              Вот вокруг этого: сложности инвалидации (а ещё и "когерентности", если она есть) - построена основная архитектура программных кэшей.


              1. nv13
                18.01.2025 07:39

                Да, по бенефитам похоже на TCM ARMа но вряд ли. Видел лет 30 или больше назад в сигнальных процессорах TI, а может и ещё где, когда переставали делать статическую память, а динамическая существенно тормозила. Внутренняя память процессора могла работать как статическая, а могла как кэш. И вот в режиме кэша можно было принудительно загнать туда константы и куски кода, которые должны были выполняться постоянно и гарантированно быстро, а свободную часть оставить для кэширования медленной внешней памяти


                1. WASD1
                  18.01.2025 07:39

                  Понял. Ну из общих соображений:

                  1. Вам можно забить на согласованность (*) - просто кладёте эти данные в отдельный кэшик и забываете
                  2. Вам нельзя забивать на согласованность - данные иногда будут инвалидироваться => подолгу будут недоступны. Лучшее что вы можете сделать это повысить приоритет данных при вытеснении (такая возможность наверняка должна быть).


                  *) В терминах CAP-теоремы


  1. IVNSTN
    18.01.2025 07:39

    Джонни Кэш ещё. Из категории, которая тоже много на что влияет в нашей жизни.


  1. anonymous
    18.01.2025 07:39

    НЛО прилетело и опубликовало эту надпись здесь


    1. chlorine Автор
      18.01.2025 07:39

      спасибо за фидбэк. рад если информация оказалась полезной, но это лишь начало. советую углубиться в упомянутые advanced алгоритмы (не зубрить их устройство, а просто почитать для расширения кругозора и возможно попробовать реализовать какой нибудь на практике). + в дополнение почитайте про методы инвалидации и возможные проблемы (e.g thundering herd problem)


  1. Ianalis
    18.01.2025 07:39

    Интересная статья. Только вливаюсь в программирование, поэтому эта информация очень к стати


    1. chlorine Автор
      18.01.2025 07:39

      FYI данная тема является частью раздела System Design, который далеко не первостепенный для изечения новичку. но если интересно - кто запретит читать :)


      1. olku
        18.01.2025 07:39

        100%. С кого сейчас начинают требовать System Design на интервью? По моим наблюдениям на сеньора, техлида и архитекта.


        1. chlorine Автор
          18.01.2025 07:39

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


  1. dph
    18.01.2025 07:39

    А почему "внутренний кэш" мешает горизонтальному масштабированию, а внешний - "легко горизонтально масштабируется"? Это совершенно не верно, тип кэша никак не связан с простотой масштабирования. Более того, все приведенные примеры для внешних кэшей (Тарантул, Редис) не масштабируются горизонтально. А вот вполне себе внутренний Хазелкаст - как раз масштабируется.

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


    1. vlad4kr7
      18.01.2025 07:39

      Redis Cluster вроде горизонтально масштабируется, а внутренний - это думать надо как все сделать!

      джунам пойдет, а по факту - бери, что попроще и не выдумывай.

      На сколько внешний медленнее внутреннего? Никто не переживает, вот и берут, то попроще.


      1. dph
        18.01.2025 07:39

        В теории - да, в Redis Cluster есть шардинг. Но в реальном использовании там все не так просто и масштабирование нужно делать очень аккуратно, при этом еще и справляясь с кучей ограничений кластера. Внутренний на хазелкасте - масштабируется примерно так же, чуть сложнее для программиста, чуть проще для админа, разница не принципиальна.

        Но, в любом случае, горизонтальное масштабирование не зависит от того, внутренний или внешний кэш.

        Медианный latency внутреннего кэша примерно на два-четыре десятичных порядка меньше, чем доступ через сеть, хотя реальные значения зависят от множества параметров (как ни странно, нигде не увидел актуальных данных по latency доступа к современной серверной памяти и сетевых задержек внутри современного ДЦ).
        На надо помнить, что для сети какой-нибудь 99.99 персентиль будет много выше медианы и может легко доходить до десятков ms, а время доступа к локальной памяти гораздо меньше подвержено флуктуациям.


  1. wov1a
    18.01.2025 07:39

    Очень интересно. Сложный материал легким языком. Огонь! А Jitter это из этой серии?