Привет, Хабр! Меня зовут Агаджанян Давид и ранее я опубликовал статью «простые highload паттерны на Go», в которой были рассмотрены простые подходы увеличения пропускной способности отдельно взятого экземпляра приложения без хардкора. Мне импонируют простые подходы, так как over engineering подходы требуются в исключительных проектах, и то чаще всего только в отдельно взятых подсистемах, в остальном можно воспользоваться давно известными лучшими практиками. Статья ориентирована на начинающих разработчиков и может служить отправной точкой для дальнейшего изучения. В этой статье подходы ориентированы на масштабирование приложения при верхнеуровневом рассмотрении. Подходы универсальны, приписка языка Go только потому, что сама реализация приложения на нем. Для наглядности, примеры будут упрощены, а чтобы было интереснее наблюдать, проведем замеры производительности. Ну и конечно же делитесь своим опытом масштабирования, буду рад взять на заметку. Правильный замер производительности как отдельная наука, необходимо создать правильную среду и снизить погрешности до минимума, в этой статье будут использованы стандартные инструменты и в качестве выходных значений будут использоваться усредненные с десятков запусков Есть приложение, которое крутится в бою и вполне себе успешно выполняет свою работу. Постепенно трафик растет, пользователи становятся ненасытнее и все активнее пользуются приложением. И вот настигает пик, при котором CPU/RAM физически уже не хватает и требуется принять меры. Решение простое и очень эффективное - создать реплики приложения Пример: есть приложение, которое в реальном времени производит сложные CPU расчеты. Трафик приложения растет, CPU железки подходит к пределу, а само приложение и так уже оптимизировано насколько хватило компетенций команды разработки. Расти и покупать более дорогостоящее оборудование уже не целесообразно Концепт: поставить несколько обычных железок и реплицировать на них приложение, поставив перед ними load balancer Реализация: примеры кода ниже, но они также доступны в github Попробуем провести замеры производительности одного сервиса и затем нескольких сервисов с балансировщиком нагрузки перед ними Для простоты имитации ограничения железки и наглядности воспользуемся инструментом docker и docker-compose Возможность ограничения по CPU в docker-compose была добавлена в версии "2.2". Подробнее про ограничения по ресурсам можно найти в этой документации Смотрим пример In this general example, the redis service is constrained to use no more than 50M of memory and 0.50 (50% of a single core) of available processing time (CPU), and has 20M of memory and 0.25 CPU time reserved (as always available to it). Документация говорит, что 0.5 означает, что контейнер получит не более 50% доступного процессорного времени одного ядра. Для наглядности ограничим наше приложение 20% Так как приложение на Go, необходим Dockerfile, с помощью которого оно будет заворачиваться в контейнер   Так как docker-compose.yml находится по пути ./one-instance/docker-compose.yml, а Dockerfile на уровень выше, то необходимо задать контекст ../ Запустим приложение командой  Нас интересуют две последние строчки, которые говорят о том, что приложение успешно запущено. Теперь проведем нагрузочное тестирование с помощью утилиты wrk. Для чистоты проведем его в среде докера. Находим образ с этой утилитой в хабе. Для того чтобы запущенный контейнер wrk смог достучаться до приложения, при запуске присоединим его к сети backend. Докер имеет свои правила формирования названия сети. Найти точное название сети можно выполнив команду. Результатом команды будет список созданных докером сетей. Видно название сети one-instance_backend Отлично, теперь проведем нагрузочное тестирование при одном потоке и одном соединении повторим при трех. Видим следующие результаты Теперь создадим три реплики приложения, поставим перед ними nginx для балансировки нагрузки и проведем аналогичное нагрузочное тестирование. Сформируем конфиг nginx и используем дефолтный способ балансировки round robin (поочередная отправка запросов серверам). Подробнее про другие способы балансировки можно почитать в документации  Обратите внимание, что для монтирования одного файла, необходимо указывать абсолютный путь к нему Проведем нагрузочное тестирование еще раз командами Видим следующий лог Соберем воедино и сравним результаты  RPS Latency one instance (1t, 1c) 169 22.55ms one instance (3t, 3c) 74 45.85ms three instances + lb (1t, 1c) 450 9.09ms three instances + lb (3t, 3c) 325 22.41ms Как ожидалось, путем горизонтального масштабирования удалось кратно увеличить как пропускную способность RPS, так и уменьшить latency. Подробнее про балансировку можно почитать system-design-primer Плюсы Один из плюсов, о котором ранее не говорилось, теперь сервис становится надежнее, так как в случае падения одной из реплик, есть другие, которые будут продолжать выполнять полезную работу. Теперь сервис не является единой точкой отказа. Вторым плюсом является то, что поверх контейнеров можно создать механизм, который мог бы сам автоматически добавлять/удалять реплики в зависимости от текущей нагрузки. Этим и другими полезными механизмами оркестрации занимается kubernetes и другие его соотечественники вроде Docker Swarm и другие. Минусы Подходит только для stateless приложений, которые не хранят состояния пользователей, то есть запрос может быть обработан любой репликой. Избавившись от единой точки отказа в виде одного экземпляра приложения, появляется единая точка отказа в виде балансировщика nginx, но никто не мешает и его отреплицировать и использовать более высокоуровневую балансировку. Есть приложение, в котором активно растет трафик и помимо основных, оно выполняет еще и вспомогательные функции, которые с ростом трафика начинают влиять на пользовательский опыт. Так почему не выполнять вспомогательные функции асинхронно? Пример: есть приложение, в котором фиксируются пользовательские события кликов и любых других действий. Количество необходимых для фиксирования событий постепенно растет, как и количество пользователей. В какой-то момент эта механика начинает сказываться на времени выполнения запроса пользователя Концепт: вынести фиксацию событий в асинхронный режим через отправку сообщений в шину данных и затем разгружать эту очередь отдельным сервисом Реализация: примеры кода ниже, но они также доступны в github Сымитируем прямой поход в сервис логов, проведем нагрузочное тестирование, затем добавим обработку через шину данных и сравним результаты    Запустим приложения командой  Запустим нагрузочное тестирование командами И увидим результаты Видим, что пропускная способность 56rps и 28rps при задержке 28ms и 105ms. Теперь вынесем обработку сервиса логов в асинхронный режим. В качестве шины данных используем Apache Kafka. Основное приложение будет писать сообщения в кафку, а сервис логов будет вычитывать эти сообщения. В этой статье мы не будем останавливаться на технических деталях Apache Kafka, почитать про нее подробнее можно в официальной документации Также вместе с Apache Kafka часто используется Apache Zookeeper для мониторинга/синхронизации брокеров/топиков/сообщений    Запустим нагрузочное тестирование командами Видим следующий лог Соберем воедино результаты и сравним  RPS Latency sync (1t, 1c) 56 28.20ms sync (3t, 3c) 28 105.07ms async (1t, 1c) 120 21.24ms async (3t, 3c) 87 40.31ms Видим, что асинхронная обработка увеличила RPS и при этом сократила latency, так как теперь не нужно ждать выполнения запроса внешним сервисом. Подробнее про асинхронную обработку можно почитать system-design-primer Плюсы Очевидный плюс в том, что теперь сложная работа выполняется асинхронно, тем самым увеличивается пропускная способность приложения Можно в зависимости от накопленного количества сообщений в очереди добавлять/удалять реплики приложения-потребителя Минусы Для асинхронного выполнения подойдут тяжелые операции, не требующие мгновенной реакции Появляется еще одна точка отказа в виде шины данных, которую необходимо поддерживать Есть приложение, в котором есть одно хранилище под все и, как правило, это какая-нибудь реляционная база данных. Уезжать из него очень не хочется, но пользовательский трафик растет с такой скоростью, что поддержание одного такого хранилища обходится дорого. Что же делать? Пример: допустим есть приложение и в качестве хранилища данных используется postgres. Количество пользовательских событий/данных/логов стремительно растет. Таблица с этими данными разбухает и запись в нее замедляется, как и чтение из нее. Сначала мы дробим данные на таблицы по месяцам вроде activities_01072022, activities_01082022, но и этот подход нас перестает устраивать, так как выгрузить полную историю пользователей требует большого количества джойнов по таблицам. Впереди ждет еще больше данных, ожидается, что сервис по праву будет как write-heavy, так и read-heavy. Вынесем эти события в эффективное для таких задач nosql хранилище данных. Концепт: как большинство крупных систем, данные делятся примерно на два типа. Первые - это те, которые критически важно держать в согласованности, любые операции над ними требуют ACID свойств базы данных. Пример таких данных - финансовые операции/транзакции/бонусные счета. К таким базам относятся mysql, postgres и другие. С другой стороны, существуют nosql базы данных, которые за счет ограничений дают эффективность при выполнении специальных типов задач. Например, redis хорошо подходит для кеширования данных, так как они находятся в RAM и за счет этого он имеет высокую пропускную способность с низким latency. Не будем зацикливаться на отдельных хранилищах, как и в программировании в целом, хранилища также имеют свою кухню со своими блюдами, где все зависит от потребностей. Реализация: примеры кода ниже, но они также доступны в github. В качестве примера возьмем приложение, которое будет на запрос пользователя генерировать данные и записывать их в хранилище, В качестве демонстрации разницы в пропускной способности возьмем коробочные postgres и cassandra.   Таблица, в которую будем писать данные   Таблица, в которую будем писать данные Проведем нагрузочное тестирование postgres командами Видим результат Проведем нагрузочное тестирование cassandra командами Видим результат Соберем воедино и сравним результаты  RPS Latency postgres (1t, 1c) 521 2.50ms postgres (12t, 12c) 323 42.74ms cassandra (1t, 1c) 355 2.81ms cassandra (12t, 12c) 1213 18.74ms По результатам видим, что хоть и postgres выигрывает у cassandra на 1 потоке и 1 соединении, на более высоких уже сильно уступает. Конечно, пример лишь для наглядности, в действительности оба этих хранилища по своему интересны и имеют в корне разные подходы к хранению данных. Например, postgres - реляционная БД и хранит данных в виде строк, с ACID свойствами. Cassandra же относится к семейству колоночных, эффективно сжимает тонны однотипных данных. Она отлично подходит если вы пишете тоннами данные (можно почитать например здесь каким образом удается добиться высокой пропускной способности при записи). Существует переписанный на C++ аналог ScyllaDB, говорят она еще быстрее Postgres имеет инструменты для шардирования и master-master репликации, но тем не менее на поддержание согласованности уходит много дополнительных ресурсы и это сказывается на работе всего кластера. По теореме CAP такая БД относится к CA, у нее отсутствует эффективность работы при расщеплении данных по разным нодам. Cassandra заточена под работу в режиме кластера и за счет consisting hashing + виртуальных нод практически безболезненно переживает добавление/удаление нод. Любая реплика может инициировать запись. По теореме CAP такие БД относятся к AP, то есть данные на нодах могут быть несогласованны. Мы по верхам прошлись по трем подходам к масштабированию приложений. Каждую из этих тем конечно же можно раскрыть гораздо глубже. Надеюсь, что это статья была полезна начинающим инженерам как отправная точка. Буду признателен любым конструктивным замечаниям. Спасибо!  Engineer