Меня зовут Вадим Ивахин, я техлид в 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, чтобы доставлять изменения до потребителей.

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

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

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


  1. lexastik
    14.11.2023 12:18
    -2

    Спасибо за статью. Отличный результат!


  1. Rio09
    14.11.2023 12:18
    -2

    Спасибо за статью!


  1. s0lar
    14.11.2023 12:18

    Правильно ли я понял, что watch срабатывает, когда нужно внести изменения именно в реплику? Собственно лаг как раз в этом месте? Насколько большим может быть этот лаг?

    И на сколько сократили количество ядер, в итоге?

    ps. Спасибо за статью


    1. BinCat Автор
      14.11.2023 12:18
      +1

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

      Без watch нам не хватало ресурсов трёх нод по 8cpu на каждой, чтобы выдержать обычную нагрузку, т.е. 24cpu. С watch 15 подов в среднем потребляют по 20% cpu, т.е. 3cpu. Получается, что сократили как минимум в 8 раз.


  1. qiwi_k
    14.11.2023 12:18

    Спасибо за статью, интересный кейс, приятный текст!
    Но да, сколько ядер в итоге то нужно для 30k+ rps? И было бы интересно еще узнать, на сколько сервисов в конечном счете этот прием лег: только на ценообразование и оптовых клиентов, или коллеги из других команд переняли опыт и смогли его масштабировать по всей компании, улучшив профит? Или mongo - не всегда целевое решение в компании, а довольно редкое? Вот как будто недосказанность есть легкая по этому кейсу) был бы рад еще рассказам о нечто подобном в вашей практике


    1. BinCat Автор
      14.11.2023 12:18

      сколько ядер в итоге то нужно для 30k+ rps

      Я писал, что с помощью cpulimit выставил для приложения лимит в 100%. Другими словами, ресурсов одного ядра достаточно.

      на сколько сервисов в конечном счете этот прием лег

      Завезли его ещё в один сервис, который сейчас на финишном этапе разработки.

      Или mongo - не всегда целевое решение в компании, а довольно редкое?

      Пожалуй, да. Хотя за последний год многие команды выбрали монгу в качестве основной СУБД для своих новых проектов.

      был бы рад еще рассказам о нечто подобном в вашей практике

      Постараюсь в обозримом будущем поделиться ещё чем-нибудь интересным.


  1. MuratYMT
    14.11.2023 12:18

    а если сущность в монге сильно другая чем в горячем хранилище? как тут быть?


    1. BinCat Автор
      14.11.2023 12:18

      В сообщении watch приходит модель БД. Никто не запрещает обработать(конвертнуть/обогатить/…) её так, как заблагорассудится, перед тем, как разместить в горячем хранилище.


  1. dabrahabra
    14.11.2023 12:18
    +1

    Минус в том что Watch это курсор с пуллингом, когда такох курсоров много сам пуллинг будет сжирать CPU базы.
    Второй минус - в Atlas в некоторых сценариях масштабирования базы Cahngestream Cursor просто перестают получать апдейты, каеф.
    А так в целом штука полезная, но не понятно причем тут 30k RPS - сократить количество запросов за счет InMem кэша да, но RPS просто не докатываются до MongoDB.


    1. BinCat Автор
      14.11.2023 12:18

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

      А много - это сколько?

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

      Не работал с atlas, но было бы интересно узнать, о каких сценариях масштабирования идёт речь, и о причинах, по которым change stream cursor перестаёт получать апдейты и при этом не возвращает ошибку.

      не понятно причем тут 30k RPS - сократить количество запросов за счет InMem кэша да, но RPS просто не докатываются до MongoDB.

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


  1. panter_dsd
    14.11.2023 12:18
    +2

    Не слишком ли рисковано строить архитектуру приложения на основе внутренних механизмов репликации БД? Не говоря уже о том, что данный подход выглядит несколько странно/непривычно с архитектурной точки зрения.


    1. BinCat Автор
      14.11.2023 12:18

      На самом деле подход довольно привычный. Это частный случай event driven архитектуры. Из непривычного здесь только то, что в роли агента (источника события) выступает не приложение, а СУБД, оно же служит и транспортом. То что Watch основан на внутреннем механизме репликации БД вообще восхитительно. Вы гарантировано получаете правильную последовательность событий.


      1. panter_dsd
        14.11.2023 12:18

        >То что Watch основан на внутреннем механизме репликации БД вообще восхитительно

        А на сколько внутренний механизм гарантирует обратную совместимость? Не будет ли проблем при обновлении версий монги?


        1. BinCat Автор
          14.11.2023 12:18

          Watch в mongodb существует с версии 4.4. Во всех последующих релизах вплоть до текущего стабильного 7.0 обратная совместимость не ломалась.


  1. sery_grey
    14.11.2023 12:18

    Спасибо за статью! Было бы интересно взглянуть на упрощённый пример реализации.


    1. BinCat Автор
      14.11.2023 12:18
      +1

      Ха! А я наоборот реализацию усложнил, чтобы обработать различные отказы.

      В ближайшее время постараюсь написать вариант реализации попроще.


  1. olku
    14.11.2023 12:18
    +2

    Вы построили event sourcing? Ясно, он будет отдавать состояние со скоростью памяти. А оплоги репликации бд как источник вместо вездесущей Кафки это интересно, спасибо.


  1. IlyaSolovev
    14.11.2023 12:18
    -1

    Спасибо за статью. Интересное решение, может пригодится.


  1. JustSokol
    14.11.2023 12:18

    Обычное решение проблемы производительности чтения — это горизонтальное масштабирование

    А вы не думали просто добавить кеш?

    Ценообразование явно выдерживает делей в часы-минуты поэтому скорее всего эффективно кешируется. Перед тем как скейлить дБ и тратить цпу на ненужные вычисления...

    Прочитал дальше - ровно то что сделали. Вотч как способ кеш обновлять. Окей.


    1. BinCat Автор
      14.11.2023 12:18

      «Ненужные вычисления» - это доли процента по сравнению с сетевыми i/o.


  1. titan_pc
    14.11.2023 12:18
    +1

    Доброго дня. Спасибо за статью. Суть ясна - копируем всё что есть из СУБД к себе в RAM, вместо Redis-а, а с помощью watch - у нас происходит обновление/инвалидация нашего внутреннего кэша.

    Но обмануть закон сохранения энергии ещё никому не удавалось. Когда данных много, ну например 100 Гб. Они явно уже в сервис не влезут. Появляется необходимость шардинга, но уже Ваших сервисов, например - по месяцам. Но один в поле не воин, одна шарда - это не отказоустойчиво. Надо 3 хотябы. Ну и вот Вы создали кластер редиса. Который работает быстрее редиса, потому что у редиса видимо нет API - чтобы данные напрямую из него тащить и watch интегрировать в него. Появился чудо-сложный балансировщик трафика, которому надо ещё правильно клиентские запросы балансировать по Вашим шардам-репликам. А ещё Вы сталкиваетесь с людскими хотелками - хотим отчёт за год, а у Вас данные по разным шардам...

    То есть, возникнет тот момент, когда Вы ощутите, что закон сохранения энергии начинает Вас догонять. Что все те проблемы, от которых Вы попытались убежать - настигли Вас, но в других местах. Отсюда возникает потребность сформировать общие положения и рекомендации на каких объёмах данных Вас ещё не догнало то, от чего Вы убежали. А когда начнёт догонять - Вы получите ценобразовательный Redis, подключенный через Watch к MongoDB. Со всеми проблемами редиса, будьте готовы к такому.

    А ещё было бы не плохо понять сложность самих запросов на чтение. Ну это простые выборки какие-то, или множественные соединения с кучей агрегаций и вычислительных слоёв. Есть ли там Иерархические группировки данных, например. То есть что Вы имели ввиду под чтением, какова его сложность.

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




    1. BinCat Автор
      14.11.2023 12:18

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

      В целом мы и не пытались разместить большие объёмы данных в RAM, я писал об этом:
      "семь из восьми сущностей — это справочные данные, а полный размер их коллекций не превышает нескольких мегабайт".

      Когда данных много, ну например 100 Гб. Они явно уже в сервис не влезут. Появляется необходимость шардинга, но уже Ваших сервисов, например - по месяцам. Но один в поле не воин, одна шарда - это не отказоустойчиво. Надо 3 хотябы. Ну и вот Вы создали кластер редиса. Который работает быстрее редиса, потому что у редиса видимо нет API - чтобы данные напрямую из него тащить и watch интегрировать в него. Появился чудо-сложный балансировщик трафика, которому надо ещё правильно клиентские запросы балансировать по Вашим шардам-репликам. А ещё Вы сталкиваетесь с людскими хотелками - хотим отчёт за год, а у Вас данные по разным шардам...

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

      А ещё было бы не плохо понять сложность самих запросов на чтение.

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

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


  1. bralva
    14.11.2023 12:18
    +1

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


    1. BinCat Автор
      14.11.2023 12:18

      Очень интересный вопрос. Постарался найти ответ на него в документации, но пока не получилось.


      1. bralva
        14.11.2023 12:18

        :) Вот я тоже как только не искал не могу найти, а в watch не видел чтобы можно было указать просто earliest, чтобы change stream читало с условного начала (хвоста) oplog но именно на монгороутере


  1. alexdora
    14.11.2023 12:18
    +1

    Пройдемся по тексту:

    выбрав map, built-in хэш-таблицу в Go, вы лишитесь всех сортировок.

    Спорное заявление. Много пакетов существует готовых и хорошо оптимизированных если не хотите делать сами. В общем и целом весь раздел особенности и недостатки – сплошные недостатки.

    Далее..
    Как статья которая рассказывает о Watch кейсе – ок

    Взять Mongo которая судя по описанию является узким местом во всей структуре, превратить её в какой-то фреймворк чтоб меньше делать и этим создать неуправляемое поведение ради кэша в RAM – интересный кейс.

    Ускорение удалось? Да! Успех ли это? Ну, если считать ускорение через попу носорога успехом – безусловно.

    Резюме такое: Буду максимальным душнилой, но не считаю что вы что-то ускорили и это некий "тру" путь к высоконагруженным приложениям. На вид очередная борьба с ведьмами из-за неправильного инжиниринга. Конечно, под капот я не заглядывал, но по описанию очень похоже. Более того сам горизантальный шаринг будет как снежный ком накручивать в дальнейшем усложнения в взаимодействии.
    Вот выше комментарий был что когда данных много, например 100гб. При текущей стоимости RAM – 100гб это ничто. Говорю как человек у которого есть сервис с кэшем внутри себя на 450гб. И если нужно больше, просто докупается либо RAM (если позволяет сервер), либо новый сервер. И как бЭ кому не хотелось рассказать о том что 450гб ого-го, это много...а если откажет...все придет к тому что одно приложение(монолит) которое внутри себя молотит и считает кэш на условном сервере будет быстрее во много-много раз чем любая связка Redis/Watch, и точек отказа меньше...и само поведение более предсказуемо.


    1. BinCat Автор
      14.11.2023 12:18

      Много пакетов существует готовых и хорошо оптимизированных если не хотите делать сами.

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

      Резюме такое: Буду максимальным душнилой, но не считаю что вы что-то ускорили и это некий "тру" путь к высоконагруженным приложениям. На вид очередная борьба с ведьмами из-за неправильного инжиниринга. Конечно, под капот я не заглядывал, но по описанию очень похоже. Более того сам горизантальный шаринг будет как снежный ком накручивать в дальнейшем усложнения в взаимодействии.Вот выше комментарий был что когда данных много, например 100гб. При текущей стоимости RAM – 100гб это ничто. Говорю как человек у которого есть сервис с кэшем внутри себя на 450гб. И если нужно больше, просто докупается либо RAM (если позволяет сервер), либо новый сервер. И как бЭ кому не хотелось рассказать о том что 450гб ого-го, это много...а если откажет...все придет к тому что одно приложение(монолит) которое внутри себя молотит и считает кэш на условном сервере будет быстрее во много-много раз чем любая связка Redis/Watch, и точек отказа меньше...и само поведение более предсказуемо.

      Если честно, ничего не понял) Какие ведьмы? Что шардинг накручивать будет? Что там ваш монолит молотит на условном сервере? При чём тут связка redis/watch. Но за интерес к статье спасибо!


      1. alexdora
        14.11.2023 12:18

        Да, я неправильно прочитал. Тогда по поводу built-in map вопрос снят.

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