Привет, я Федор Володин, бэкэнд-разработчик, представляю IT сообщество Работяги. В этом сообществе ты можешь поделиться своими проблемами в разработке и найти ответы на интересующие тебя вопросы из сферы IT. Ссылки на наши другие ресурсы вы можете найти в профиле нашего канала или в конце этой статьи.
Производительность всегда была ключевой характеристикой технических систем. Сегодня в Интернете нормой являются миллисекундные задержки. Компании несут убытки, если их страницы загружаются медленно, потому что потенциальные клиенты не будут ждать дольше этого времени. С другой стороны, появляется все больше и больше данных из разных источников, которые должны быть загружены в богатый пользовательский опыт.
Учитывая все это, как нам построить сверхбыстрые системы?
Что такое кэширование?
Кэширование - это общий термин, используемый для временного хранения некоторых часто читаемых данных в месте, откуда они могут быть прочитаны гораздо быстрее, чем из источника (базы данных, файловой системы, сервиса). Такое сокращение времени чтения данных уменьшает задержку системы. Это также увеличивает общую пропускную способность системы, поскольку запросы обслуживаются быстрее, а значит, за единицу времени может быть обслужено больше запросов.
В архитектурах микропроцессоров уже давно используется эта техника для ускорения работы программ. Вместо того чтобы постоянно считывать все данные из файловой системы, микропроцессоры используют несколько уровней кэша, где считывание с нижних уровней происходит медленнее, чем с верхних. Теперь задача состоит в том, чтобы убедиться, что данные, считываемые чаще всего, находятся в кэше самого высокого уровня, и так далее. Если все сделать правильно, это может существенно повлиять на скорость работы программы.
Обычно кэш хранит копию исходных данных в течение некоторого времени (называемого временем истечения срока действия или временем жизни (TTL)), после чего данные «вытесняются» из кэша. Поскольку в кэш конечной емкости загружается все больше данных, можно применить некоторые стратегии, чтобы решить, какие данные сохраняются, а какие вытесняются.
Где мы можем использовать кэширование
- Система должна быть перегружена чтением - Как уже должно быть понятно, кэширование - это решение только для масштабирования чтения данных. Поэтому если вы создаете систему, которая считывает данные значительно чаще, чем записывает, кэширование может стать для вас мощной техникой. Системы с большим объемом записи или вычислений получают от кэширования относительно меньше пользы.
- Толерантность к устаревшим данным - кэширование можно применять только в том случае, если мы можем смириться с чтением устаревших данных (по крайней мере, в течение некоторого времени). Поскольку кэш - это копия исходных данных, может случиться так, что исходные данные изменятся, а кэш об этом не узнает. Некоторые системы могут с этим мириться, например, счетчик лайков на вашем ролике в Instagram может некоторое время не соответствовать реальному счету. Другие системы, например бухгалтерские, не могут мириться с работой с несвежими данными. Если работа с несвежими данными неприемлема для нашей системы, то кэширование не является жизнеспособным вариантом для масштабирования.
- Ограниченность объема данных, которые может хранить кэш - в большинстве крупномасштабных сценариев использования невозможно сохранить в кэше все используемые данные. Например, вы не можете загрузить в кэш данные профиля всех своих клиентов, потому что тогда кэш пользовательских данных будет таким же большим, как база данных пользователей, а это может оказаться дорого. В таких ситуациях необходимо грамотно подходить к выбору того, что кэшируется, а что извлекается из источника.
Уровни кэширования
Как я уже говорил, кэширование - это общая концепция, не ограничивающаяся использованием только в веб-архитектурах. Мы также можем использовать его на различных уровнях системной архитектуры. Некоторые из них выполняются разработчиками приложений явно, другие - за экранами с помощью используемых инструментов и фреймворков.
- Базы данных - В большинстве баз данных используется тот или иной механизм внутреннего кэширования для сохранения «горячих» данных в памяти. Например, MySQL загружает небольшие, но часто используемые таблицы полностью в память. Это также обычно скрыто от разработчиков, но понимание этих механизмов может быть полезным при отладке проблем производительности базы данных при высокой нагрузке.
- Приложение - Приложения обычно кэшируют данные, которыми они владеют, в выбранных ими инструментах кэширования. Эта деятельность зависит от разработчика, и именно здесь мы можем оказать наибольшее влияние.
Стратегии кэширования
В зависимости от типа приложения, типа данных и вероятности отказа существует несколько стратегий кэширования, которые можно применить для кэширования.
Read through
Это самая простая и наиболее часто используемая стратегия. Приложение пытается прочитать данные из кэша. Если оно находит данные (так называемое "попадание в кэш"), все в порядке. Если оно не находит данные в кэше (так называемый "промах по кэшу"), оно обращается к источнику, чтобы получить их, и загружает их в кэш. Если кэш заполнен, то для определения данных, которые должны быть удалены из кэша, чтобы освободить место для поступающих данных, используется определенная политика, основанная на характере данных (например, наименее часто используемые, наиболее недавно использованные).
При такой стратегии задачи, столкнувшиеся с пропуском кэша, будут иметь более высокую задержку, чем те, которые получают попадание в кэш.
Write through
В приложениях, где кэш может вместить все данные и ожидается, что они всегда будут свежими, мы можем использовать сквозную схему записи. В этом случае каждая запись сначала выполняется в кэш, а затем в источник. Это означает, что кэш всегда синхронизирован с источником. Кэш становится источником истины для приложения, и оно никогда не считывает данные из источника.
С другой стороны, это требует полной загрузки данных в кэш с самого начала. Это также увеличивает задержки при операциях записи и нагрузку на систему кэша, что, в свою очередь, может повлиять на производительность чтения.
Write Behind
Как и в случае со стратегией записи, приложение сначала помещает новые данные в кэш. Но после этого процесс приложения возвращается к своим основным обязанностям. Сам кэш или какой-то другой процесс периодически запускается и пакетно записывает данные кэша в источник.
Это эффективная стратегия для случаев, когда мы не хотим нести затраты на задержку записи в источник в основном процессе приложения, а кэш достаточно надежен, чтобы мы были уверены в том, что не потеряем данные до того, как они будут выгружены в источник. Эта стратегия требует, чтобы запись в источник никогда не приводила к сбоям при выгрузке данных из кэша, или чтобы существовал механизм разрешения несоответствий.
Refresh Ahead
В этой стратегии мы упреждающе обновляем все или часть данных в кэше по мере истечения срока его действия. Как решать, что именно перезагружать, зависит от приложения. Обратите внимание, что приложение все равно может столкнуться с промахами в кэше, если не все данные могут быть перезагружены, поэтому данная техника обычно сочетается с "кэшированием на основе чтения", но с идеей, что процесс перезагрузки должен сделать промахи в кэше более редкими.
Это не очень распространенная техника, потому что она требует создания процесса для определения истекающих данных и их перезагрузки на основе некоторой умной логики. Обычно приложениям это не нужно.
Выбор реализации кэша
Теперь, когда мы знаем о различных стратегиях кэширования, давайте подумаем, какой тип реализации кэша следует использовать. Хотя технически для кэширования можно использовать любую структуру данных/средство, доступ к которому происходит быстрее, чем к исходной версии, типичными реализациями кэша являются хранилища ключевых значений того или иного типа. Популярны три типа реализаций кэша.
In-memory
В этом случае читающее приложение загружает данные в свою основную память (в виде хэш-таблицы или карты) и использует ее в качестве кэша. Это обеспечивает максимально быстрый доступ, поскольку данные доступны буквально как переменная программы. Это также самая простая возможная реализация, поскольку она не вносит никаких новых элементов в архитектуру системы. Существует множество библиотек, позволяющих абстрагировать детали реализации кэширования/определения и т. д. от пользовательского кода.
У этого стиля есть и несколько недостатков. Кэш находится внутри приложения, поэтому, если приложение падает, кэш исчезает и должен быть восстановлен при запуске приложения. Объем памяти приложения увеличивается, а количество данных, которые можно кэшировать, ограничено. Этот тип кэша также является локальным для сервера приложений. Если у вас запущено несколько экземпляров приложений, у каждого из них будет свой кэш (трата памяти), и они могут временно не синхронизироваться друг с другом, если один экземпляр перезагрузит свой кэш, а другие еще нет.
External
В качестве внешнего кэша мы можем использовать автономную систему, например Redis или Memcached. По сути, это все равно что иметь внешний сервер, к которому обращаются все узлы приложения и который хранит хэш-таблицу вместо того, чтобы хранить ее внутри приложения. Это вводит новый элемент для управления в архитектуру, но создает центральный кэш, который является долговечным и гарантирует, что все экземпляры приложения видят одно и то же значение кэша.
У этого типа кэша есть проблема устойчивости к сбоям. Если сервер кэша по какой-либо причине выходит из строя, приложение не будет работать. В некоторых реализациях эта проблема решается с помощью дублирующих кэшей, которые синхронизируются с "сервером-лидером", но могут подменить его в случае сбоя (Redis Sentinel использует этот механизм). Это обеспечивает отказоустойчивость ценой усложнения конструкции.
External Distributed
И кэш в памяти, и внешний кэш страдают от некоторых проблем с масштабированием. Объем данных, который может быть сохранен в любом из них, ограничен объемом памяти одного сервера. С появлением крупномасштабных систем и увеличением объема данных, подлежащих кэшированию, это становится узким местом.
Чтобы преодолеть это, мы можем использовать внешнюю, но распределенную реализацию кэша, например, кластер Redis. В этой архитектуре данные распределяются между несколькими экземплярами кэш-серверов. По мере роста объема данных в этот "кластер" можно добавлять новые серверы, что делает эту архитектуру горизонтально масштабируемой. Распределение данных между серверами обычно управляется самой реализацией кэша. Приложение для чтения может продолжать рассматривать кластер как единое целое при чтении данных.
Это полномасштабная распределенная архитектура, которая сопровождается всеми сопутствующими проблемами, такими как отказ узлов, разделение мозга, перераспределение данных и т. д. Кроме того, это единственная возможная архитектура в самых высоких веб-масштабах.
Ссылки на наши ресурсы – ниже: