Pull to refresh

Сколько нужно ядер cpu, чтобы выдержать 30k+ rps?

Level of difficultyMedium
Reading time6 min
Views9.5K

Меня зовут Вадим Ивахин, я техлид в Vi.Tech — это IT-дочка ВсеИнструменты.ру.

Я и мои коллеги трудимся над большим количеством проектов и используем в своей работе различные инструменты, в том числе MongoDB. В этой статье я не стану рассказывать о том, что такое MongoDB. Хочу рассказать о её интересной и удобной особенности — механизме Watch, и о том, как с его помощью спроектировать приложение, способное выдержать десятки тысяч rps.

❕ Из официальной документации можно узнать, что Watch инициирует change stream cursor для replica set или sharded cluster. Change stream позволяет приложению получить простой доступ к изменениям данных в режиме реального времени без непосредственного чтения replica set oplog. Replica set oplog — это специальная ограниченная в объёме коллекция, которая хранит упорядоченные во времени логические операции записи всех данных replica set. Oplog (операционный журнал) — это базовый механизм репликации MongoDB.

Watch — это механизм, который позволяет приложению отслеживать изменения данных.

Сама по себе функция слежения за изменениями данных не нова, и есть во многих СУБД, например, в PostgreSQL или Aerospike. Наверняка сталкивались с реализацией на уровне приложения с использованием очередей. Не углубляясь в детали отмечу, что Watch прост и удобен в использовании. Немаловажно то, что он основан на внутреннем механизме логической репликации.

Предыстория

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

К слову о ценообразовании — это достаточно сложный процесс у нас в компании. Все цены считаются на лету. На цену товара в конкретном городе влияют параметры, относящиеся к логистике, самому товару, его бренду, рубрике и многим другим сущностям и факторам. Для приложения это означает, что один клиентский запрос цены приводит к нескольким запросам в БД, и это 90+% работы приложения. 

Когда в процессе разработки мы добрались до нагрузочного тестирования, эти 90+% вылились в неплохой аппетит приложения к ресурсам cpu и сильную нагрузку на БД.

Обычное решение проблемы производительности чтения — это горизонтальное масштабирование. Но добавить реплик приложения и БД никогда не поздно, и мы пошли другим путём.

Первое применение Watch

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

Мы подготовили структуру для хранения данных в памяти приложения, начали отслеживать изменения справочников с помощью Watch, а затем полностью скопировали все данные из БД в нашу структуру — и по сути, создали реплику данных в памяти приложения.

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

  • количество запросов в БД сократилось в восемь раз

  • потребление ресурсов cpu как приложением, так и базой данных снизилось в несколько раз

  • среднее время ответа сократилось с 15 мс до 1 мс

  • результаты нагрузочного тестирования улучшились с 5k до 25k rps, только вначале мы упирались в ресурсы cpu нашего приложения, а потом узким горлышком стали ресурсы cpu стенда, который генерировал нагрузку

Пример с бенчмарком

Для наглядности написал небольшой модуль на Go с интерфейсом условного репозитория пользователей и несколькими реализациями этого интерфейса для:

  • Percona MongoDB v6, standalone

  • Redis v7

  • Watch, Percona MongoDB v6, replica set

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

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

Для тех, кто не знаком с Go — расшифрую вывод команды
  • Первое значение — это количество операций, выполненных за установленное время

  • Второе — среднее время выполнения одной операции

  • Третье — среднее число аллоцированных за одну операцию байт

  • Четвёртое — среднее количество аллокаций на операцию.

Аллокация — это размещение данных в куче приложения, достаточно дорогостоящая операция, но гораздо дешевле операций сетевого i/o

Разумеется, тест синтетический, но разница в четыре порядка говорит сама за себя.

Особенности и недостатки

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

  • Watch использует механизм репликации данных. А у всякой репликации есть лаг. Пусть он и небольшой: всего несколько милли- или даже микросекунд, однако стоит помнить о нём, чтобы не выстрелить себе в ногу там, где требуется актуальное состояние.

  • С Watch вы не получаете полноценную реплику данных. Её функциональность будет ограничена той структурой, которую вы выберете для хранения данных. Например, выбрав map, built-in хэш-таблицу в Go, вы лишитесь всех сортировок.

  • Для запуска приложения с Watch необходимо получить все требуемые данные — это может занять некоторое время, в течение которого приложение не будет готово к работе. Скорее всего вам потребуется механизм, позволяющий определить, готово ли приложение к работе, например startup probe в kubernetes. Также могут возникнуть проблемы с сетью или БД, если у вас достаточно большой объём данных и одновременно запускается большое количество экземпляров приложения с Watch.

  • Watch работает в отдельном потоке, поэтому обработка ошибок становится для разработчика сложнее. Например, произошёл разрыв сетевого соединения. В потоке с запущенным Watch возникнет ошибка. Скорее всего вам нужно сделать так, чтобы потоки, обрабатывающие клиентские запросы каким-либо образом учитывали возникшую ошибку. А каким именно — решать вам.

  • В Watch есть так называемый resume token, который позволяет начать получать изменения с определённой позиции в oplog. Его можно использовать, чтобы избежать полной синхронизации данных после разрыва соединения. Однако, вам нужно быть готовым к тому, что в БД этого токена не окажется. Например, за время отсутствия соединения oplog полностью перезаписался или БД была восстановлена из бэкапа и т.п. Если такое произойдет, вам нужно будет заново получить все требуемые данные.

Следующий шаг

Подход с отслеживанием изменений хорошо себя зарекомендовал и мы взяли его на вооружение. Через некоторое время появился новый сервис — каталог оптовых клиентов, в котором все данные благодаря Watch находятся в памяти приложения. Он удивил нас средним временем ответа в 120 микросекунд (не милли) и околонулевым потреблением ресурсов cpu.

Но это не высоконагруженный сервис, в пике у него бывает 2-3k rpm, поэтому специально для статьи я провёл нагрузочное тестирование, чтобы показать вам, как он будет чувствовать себя под нагрузкой в десятки тысяч rps и с ограничением ресурсов cpu.


Для нагрузочного тестирования выбрал k6. С помощью cpulimit выставил для приложения лимит в 100%. Запускал с разной продолжительностью от десяти секунд до часа. Все тесты проводил на iMac M1 16Gb. В среднем результаты выглядят так, независимо от продолжительности теста:

VUs 1: 11764.635296/s
VUs 2: 22621.28281/s
VUs 3: 30136.867098/s
VUs 4: 33953.873868/s
VUs 5: 34378.0007/s
VUs 10: 36411.354622/s
VUs 100: 37089.101345/s
VUs 200: 35869.574804/s

Команда:

VIRTUAL_USERS=(1 2 3 4 5 10 100 200); for i in $VIRTUAL_USERS; do echo -n "VUs
$i: "; k6 run --vus $i --duration 100s script.js -q | grep http_reqs | cut -d: -f2 |
cut -d' ' -f3; done

VUs — это параметр k6, означает число виртуальных пользователей. Если отбросить значения VUs 1, 2, 3, которые больше говорят о производительности одного потока k6, то в среднем получается 35540 запросов в секунду. Согласитесь, впечатляющие цифры. Добавьте к этому, что приложение не зависит от ресурсов СУБД, и его горизонтальное масштабирование ничем не ограничено.

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

Итоги

Отслеживание изменений с помощью Watch позволило нам: 

  • В системе ценообразования сократить количество запросов в БД в 8 раз.

  • В каталоге оптовых клиентов полностью избавиться от запросов в БД.

  • Значительно снизить потребление CPU приложением.

  • Научиться проектировать высоконагруженные сервисы с минимальным потреблением ресурсов CPU.

Идеи на будущее

Если вас заинтересовала тема этой статьи, могу предложить пару интересных идей с отслеживанием изменений данных:

  • если вы практикуете микросервисную архитектуру, то можете отказаться от клиент-серверного взаимодействия в реальном времени ваших микросервисов в тех случаях, когда совокупный объём данных позволяет разместить их в памяти клиентских приложений. Используйте потоки, например, gRPC или Nats, чтобы доставлять изменения до потребителей.

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

Спасибо, что дочитали до конца! Надеюсь, кому-нибудь из вас эта статья помогла взглянуть на привычные задачи под другим углом.

Tags:
Hubs:
Total votes 23: ↑19 and ↓4+17
Comments34

Articles