15 мс на ответ: как мы добились высокой скорости работы API Gateway

от автора

Меня зовут Николай Кокоулин, я бэкенд-разработчик в Ви.Tech — это IT-дочка ВсеИнструменты.ру. В этой статье поделюсь нашим опытом о том, как мы в ходе разделения монолитного приложения на микросервисы столкнулись с вызовом: как сохранить производительность и масштабируемость системы при росте нагрузки.

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

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

Целевые значения, к которым мы стремимся это 99.9% доступности, штатная обработка свыше 1000 запросов в секунду с временем ответа сервиса не выше 15мс на 99-ом персентиле

Способы кэширования

Мы рассмотрели несколько вариантов кэширования:

  1. Внешние кэш-системы (Redis, Memcached): Данные хранятся в памяти другой системы. Они хорошо масштабируются и просто интегрируются в инфраструктуру. Но мы теряем время на сетевом взаимодействии;

  2. Внутреннее кэширование (In-Memory Cache): Данные хранятся непосредственно в памяти приложения. Это уменьшает задержки, но для правильной работы нужен механизм синхронизации между подами и ДЦ;

  3. Двухуровневое кэширование: Комбинирование внутреннего и внешнего кэша. Приложение в памяти держит LRU-кэш, остальные данные хранятся во внешней системы, например, в Redis;

  4. Контекстное кэширование и предсказание запросов: Благодаря алгоритмам машинного обучения мы можем предугадывать запросы и заранее кэшировать данные. Это повышает шансы, что запросы попадут в кэш, что улучшает общую производительность;

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

Способы синхронизации данных

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

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

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

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

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

Сравнение методов синхронизации

Вариант

Преимущества

Недостатки

Сложность

Локальный кэш без синхронизации

Простая реализация, нет дополнительных зависимостей

Низкая согласованность; Высокая нагрузка на сервисы;

низкая

Брокеры сообщений

Высокая скорость синхронизации; Оперативное обновление данных;

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

средняя

Синхронизация через внешний кэш

сравнительно простая реализация;

Единая точка кэширования;

Задержки при обращении к Redis;

Зависимость от внешнего сервиса;

средняя

Стримы

Высокая скорость, децентрализация синхронизации, нет внешних зависимостей

Сложность в реализации стриминга;

Поддержка инфраструктуры;

Сложная архитектура;

высокая

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

Обновление кэша для согласованности данных

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

Инвалидация при изменении источника данных

Тут особо вариантов нет, данные изменились — сбрось кэш, разница может быть только в доставке информации о необходимости сброса кэша. В нашем решении используется связка Kafka и Redis Pub/Sub для реализации событийного подхода к инвалидации

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

Затем Redis Pub/Sub используется для мгновенного уведомления всех подов о необходимости сброса конкретных ключей внутреннего кэша, что снижает задержки по сравнению с периодическим опросом состояний. Такой вариант обеспечил нам практически мгновенную консистентность кэша при минимальных накладных расходах на инфраструктуру. К тому же нам не пришлось держать консьюмеры в каждом экземпляре сервиса.

Оптимизации хранения только востребованных данных

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

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

Инвалидация на основе частоты использования (LFU)
Алгоритм Least Frequently Used удаляет из кэша те записи, к которым обращаются реже всего. Предположим, что менее востребованные данные с большой вероятностью не понадобятся в ближайшем будущем поэтому удалим их. Но такой подход требует высоких накладных расходов на отслеживание частоты обращений.

Инвалидация на основе времени последнего использования (LRU)
Алгоритм Least Recently Used удаляет из кэша те записи, к которым не обращались дольше всего. Тут мы предполагаем, что недавно использованные данные вероятнее всего будут востребованы снова. У этого варианта относительно простая реализация, но она может не эффективно работать для данных с равномерной частотой доступа.

Комбинированные подходы (например, 2Q)
Комбинированные алгоритмы, такие как 2Q, сочетают принципы LRU и FIFO, предоставляя более гибкое управление кэшем. Они разделяют кэш на несколько сегментов, это позволяет более эффективно учитывать как времени последнего использования, так и частоту использования данных. По сути это двунаправленная очередь, подобный алгоритм используется например в PostgreSQL. К сожалению такой подход имеет высокую сложность реализации и настройки.

Итоговое решение

В нашем проекте для управления кэшем был выбран комбинированный подход, сочетающий использование TTL на уровне редиса и LRU на уровне пода. Получился своего рода 2Q алгоритм, где сначала неиспользуемые данные вытесняются в Redis, а затем вытесняются по времени из Redis’a Это позволило автоматически очищать кэш от устаревших данных, одновременно учитывая давность их использования. Подход с TTL на уровне Redis был выбран исходя из того, что актуальные данные уже лежат в памяти и их нет смысла долго держать в Redis, он в таком случае отработает исключительно как инструмент синхронизации.

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

Материалы используемые для написания статьи и выбора решения:

https://arpitbhayani.me/blogs/2q-cache/

https://medium.com/princeton-systems-course/sushi-a-machine-learning-based-cache-algorithm-23f5f11dc8b

https://groups.google.com/g/memcached/c/OiScvRbGaU8?pli=1


ссылка на оригинал статьи https://habr.com/ru/articles/890954/