Как оптимально использовать кэш для полностраничной выдачи SPA-приложения при высоких нагрузках

Как оптимально использовать кэш для полностраничной выдачи SPA-приложения при высоких нагрузках

Историческая справка

До SINGLE PAGE APPLICATION<br />
До SINGLE PAGE APPLICATION

Несколько лет назад процесс передачи веб-страниц происходил следующим образом: сервер получал запрос от клиента, обрабатывал его и возвращал полностью готовую HTML-страницу. Например, на платформах PHP типа Magento или Bitrix запросы обрабатывались сервером, который извлекал данные из СБД, формировал HTML-страницу и отправлял его на клиентскую сторону. Это был стандартный подход, но со временем фронтенд стал усложняться, и всё больше данных переносилось на клиентскую сторону.

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

Single Page Application<br />
Single Page Application

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

Главный минус SPA-приложений заключается в том, что на начальном этапе клиент получает «голый» HTML. Дальнейшая обработка страницы происходит фреймворком, который загружает данные и отображает контент. Это приводит к тому, что пользователи видят прелоадер на экране в первые несколько секунд загрузки. Но ещё более серьёзная проблема заключается в SEO — поисковым ботам нужен уже готовый контент, и отсутствие его может негативно сказаться на индексации страницы. Ещё одна сложность — это достижение высоких показателей PageSpeed (выведение в зеленую зону), так как оптимизировать SPA-приложение для скорости загрузки значительно сложнее.

На помощь приходит серверный рендеринг (SSR). Этот подход позволяет генерировать HTML на сервере, как это было раньше, но теперь данные берутся не из баз данных напрямую, а из API. Здесь появляется дополнительный бэкенд на JavaScript, который взаимодействует с фреймворком, запрашивает данные, формирует страницу и отправляет её клиенту.

Server side rendering (SSR)<br />
Server side rendering (SSR)

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

О проекте

Проект, о котором я расскажу, реализован на платформе Magento с кастомным интерфейсом (VueJS) и рядом микросервисов, которые используются для оптимизации. В системе представлено около 130 000 товаров, доступных в более чем 50 регионах. Из-за специфики проекта в Google Search Console отображается около 6 миллионов страниц, которые должны быть проиндексированы роботами поисковых систем. Такое большое количество страниц объясняется различными ценообразованиями и товарными предложениями для разных регионов — фактически, каждая комбинация этих факторов создаёт уникальную страницу.

Нагрузка на серверы стабильно поддерживается на уровне 300 запросов в секунду (RPS) на бэкенд при постоянных 1 000 пользователях онлайн. Это обусловлено тем, что товары представлены в разных регионах, и сайт должен поддерживать быструю отдачу страниц и стабильную производительность. Кроме того, в период пиковых нагрузок, например, во время «чёрной пятницы», необходимо также поддерживать стабильную работу.

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

На начальном этапе проекта использовался серверный рендеринг (SSR) только для поисковых ботов. Когда попытались включить SSR для клиентов, время загрузки страниц увеличилось до 2-5 секунд, что совершенно неприемлемо для пользователей. Для ботов время рендеринга составляло 1-2 секунды, но даже этого оказалось недостаточно, потому что при такой скорости индексирование 6 миллионов страниц могло занять до двух месяцев, что, очевидно, не является рабочим сценарием. Как результат, значительная часть страниц не попадала в SEO и органический поиск, что негативно сказывалось на бизнесе.

Забегая вперед, можно отметить, что разработанная инфраструктура микросервисов применима не только на платформе Magento и кастомном Vue, но и в других системах.

Проектирование

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

  1. Прогретость кэша. Это показатель того, насколько данные, хранящиеся в кэше, востребованы. Маленький кэш с «горячими» данными (часто запрашиваемыми) гораздо эффективнее, чем большой кэш с «холодными» данными из-за того, что там много кэш MSI.
  2. Актуальность данных. Очень важно своевременно обновлять кэш, чтобы в нём всегда находились актуальные данные. Например, если изменилась цена товара, мы должны оперативно обновить кэш, чтобы пользователи видели правильную информацию.
  3. Скорость/время ответа. Кэширование должно сокращать время ответа на запросы. Важно, чтобы система быстро отдавала данные из кэша.

В данном проекте был применен полностраничный кэш (Full Page Cache, FPC). Это решение работает достаточно просто: на первом этапе серверный рендеринг (SSR) обрабатывает запрос и формирует страницу, при этом создаётся уникальный ключ для кэша. Полностраничный кэш был выбран потому, что основной задачей было сократить время загрузки страницы до 100 мс в 95-м процентиле, что требовало максимальной оптимизации производительности.

Как оптимально использовать кэш для полностраничной выдачи SPA-приложения при высоких нагрузках

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

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

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

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

Выбираем key-value хранилище

При выборе key-value хранилища мы рассматривали несколько популярных решений. В наш топ вошли четыре варианта: Redis, KeyDB, Memcached и NATS.

  • Redis — одно из самых распространённых key-value хранилищ с широкими возможностями. Он поддерживает различные структуры данных, такие как списки, множества, и обеспечивает высокую производительность при работе с большими объемами данных. Redis также поддерживает репликацию и устойчивость данных через механизмы сохранения на диск.
  • KeyDB — это форк Redis, но с улучшениями, такими как многопоточность, что позволяет ему обеспечивать более высокую производительность на многопроцессорных системах. Он совместим с Redis, что упрощает миграцию, но при этом предлагает более оптимальные возможности для высокой нагрузки.
  • Memcached — одно из самых простых и быстрых решений для кэширования, оптимизированное для использования как in-memory хранилище. Оно не поддерживает сложные структуры данных, как Redis, но за счёт этого может быть еще быстрее для простых операций чтения и записи.
  • NATS — это не совсем классическое key-value хранилище, а система обмена сообщениями с высокой производительностью. Тем не менее, её можно рассматривать для кэширования или передачи данных между компонентами системы, особенно в распределенных микросервисных архитектурах.

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

Как достигается актуальность данных

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

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

У большинства key-value хранилищ нет встроенного решения для работы с тегами. Чтобы реализовать такой механизм, для каждого тега создается отдельный ключ, который хранит ключи на другие записи кэша. При необходимости все эти записи удаляются массово с помощью операций типа multi-delete. Мы использовали эту схему на Redis и KeyDB. Несмотря на все попытки распараллеливания и оптимизации, система со временем начала испытывать трудности.

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

Однако, несмотря на внедрение этого решения в серверный рендеринг (SSR), ожидаемого прироста производительности мы не получили. Поставленной цели в 100 мс на 95-м процентиле достичь не удалось. Более того, даже закэшированная страница могла выдаваться за 400-500 мс. Причина кроется в том, что наша среда исполнения (Node.js) однопоточная. Хотя у нас были микрозадачи и псевдопараллельность, если предыдущий запрос из кэша не был завершен, новый запрос задерживался, пока система рендерила текущий.

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

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

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

Микросервисы

У нас проект был на PHP, и при выборе фреймворков мы остановились на двух самых быстрых в своих категориях. В категории классических FPM-подходов мы выбрали Phalcon, написанный как C-расширение. Благодаря этому он самый быстрый, не считая неблокирующих/асинхронных.

В категории event-driven решений мы остановились на Swoole как самый быстрый неблокирующий PHP-фреймворк (по данным бенчмарка от https://web-frameworks-benchmark.netlify.app/result).

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

Реплицируем кеш

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

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

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

Как оптимально использовать кэш для полностраничной выдачи SPA-приложения при высоких нагрузках

Разделяем кеши

Мы разделили кэши на основе трех ключевых критериев: скорость выдачи, актуальность данных и частота попадания в кэш.

Роботы (поисковые боты)

  • Приоритет актуальности: низкий. Для поисковых роботов не критично, если информация в кэше немного устарела.
  • Приоритет попадания в кэш: средний. Так как роботы могут столкнуться с частыми «cache miss», важно, чтобы кэш был доступен, но его заполняемость не столь критична.
  • Приоритет скорости: средний. Хотя скорость важна, она не так критична, как для клиентов.

Для роботов:

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

Клиенты (пользователи):

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

Для клиентов:

  • Реплика кэша живет только час после чистки мастера. Этот короткий срок, учитывая прогрев кэша, достаточен для поддержания актуальности данных.
  • Реплика находится в быстром key-value хранилище в ОЗУ, что обеспечивает максимальную скорость доступа к данным и минимальное время ответа для пользователей.

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

Прогрев

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

  1. Прогрев самых популярных страниц на основе данных из Google Analytics. Это позволяет заранее кэшировать страницы, которые чаще всего посещаются пользователями:
    - по расписанию, чтобы поддерживать актуальность;
    - после сборки, чтобы новые версии страниц были сразу готовы к быстрой отдаче.
  2. Прогрев всех страниц по расписанию. Это помогает обеспечить наличие кэша для любых запросов, даже на редко посещаемые страницы.
  3. Интеграция с FPC-сервисом. Если Full Page Cache-сервис обнаруживает, что страницы отсутствуют в основном (мастер) кэше, но есть в реплике, он сигнализирует микросервису для их прогрева. Это помогает избежать ситуации, когда данные внезапно теряются и не могут быть быстро возвращены.

Нагрузочное тестирование

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

Для тестирования мы используем k6js, заменив ранее использовавшийся JMeter. k6js предоставляет удобные возможности для написания сценариев тестирования и интеграции с системами мониторинга.

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

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

Результат

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


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

44
11
7 комментариев

Спасибо, полезно. В закладки.

Думал впечатлится, но нет 2,4 сек много для десктоп, чекнул в PageSpeed Insights страницу ригла

Насколько я понял из статьи (если не так - поправьте), они писали про оптимизацию запроса страницы, а не фронта. FCP/LCP/INP и CLS - это метрики фронтовой обработки уже. Да, на них бэк косвенно влияет (что если страница отдается 2 секи от SSR, то это тупо +2секи на FCP/LCP). А тут речь про TTFB.

Antonio, добрый день.
Во-первых, спасибо за проявленный интерес к нашему сайту и клиентам, раз даже пошли снимать метрики🙏
Насчет них - реализация данного решения была на другом проекте. На Ригле также есть в планах.
Если интересно дообсудить - можно в ЛС или по почте (тоже с сайта можно взять).