Свежий взгляд на HighLoad

Приветствую вас, дорогие друзья! Меня зовут Алексей Солонков, я с 2016 года занимаюсь разработкой высоконагруженных систем. Подписывайтесь на мой телеграм-канал Go HypeLoad, чтобы ничего не пропустить. Сегодня я начинаю свой цикл статей о HighLoad. В этой статье мы разберем общие нюансы построения высоконагруженных систем. В дальнейшем начнем погружаться в детали.

Итак, поехали. О происхождении термина HighLoad, как и о его значении, известно мало. Впервые он появился в российском сегменте Интернета. И остается наиболее популярным в русскоязычном комьюнити. В англоговорящей среде вас не поймут, если вы спросите про HighLoad. Там распространен термин High Volume, и он же является аналогом. И если взять десять человек и задать им вопрос, а что же такое хайлоад, то ответы будут у всех разными. Поскольку никакой устоявшейся формулировки нет, я опишу свое видение. Если вы с ним не согласны – напишите свой вариант в комментариях.

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

Свежий взгляд на HighLoad

Как правило, возникновение такой ситуации обусловлено недочетами в проектировании, мешающими горизонтальному масштабированию приложения. Вы можете возразить, что для небольших решений архитектура неважна. Все верно. Но большинство сервисов сегодня не предполагают ограничения в пользователях (привет, Интернет!). Любая потенциальная заявка на успех для сервиса может привести к экспоненциальному росту нагрузки. Перепишем, когда понадобится? Да, я тоже люблю себя обманывать тем, что начну бегать по утрам со следующего понедельника. Скорее всего, времени на техдолг у вас не будет из-за горящего бэклога новых фич, которые вам нужно было сделать еще вчера. Поэтому, если вы достаточно ленивы и мудры, то постараетесь изначально упростить себе жизнь, приняв во внимание чужой опыт.

Путь в тысячу миль начинается с первого шага. Так и мы начнем с малого. Один мой замечательный знакомый придумал метод ретроспективы в будущее для анализа того, что собираешься сделать. Я дал имя этому методу - retro-in-future. Мы будем им пользоваться. Плюсом, не будем множить сущее без необходимости (принцип Бритвы Оккама).

Хайлоад, строго говоря, можно разделить на два типа. Нагруженные по вычислениям (compute intensive). И нагруженные по данным (data intensive). Первое встречается редко и выходит за рамки нашей статьи. А второе встречается часто и будет предметом наших изысканий. Под хайлоадом, в дальнейшем, мы будем подразумевать нагруженные по данным приложения.

Довольно слов, перейдем к проектированию. Мы решили разработать свое приложение и донести его миру посредством веб-версии и мобильного приложения. Мобильную версию и веб-версию мы будем называть клиентами, поскольку речь пойдет о серверной части. Серверное приложение будет взаимодействовать со своими клиентами посредством API. Подключаем наш метод retro-in-future и начинаем накидывать проблемы, которые могут у нас возникнуть в процессе развития и реализации такой схемы (буду рад услышать ваш ход мыслей в комментариях). Какая проблема возникнет у нас первой? Предположу, что контекстно-зависимые методы API, предназначенные для разных типов клиентов. Набор методов API обеспечивает нам интерфейс взаимодействия. И содержание в нем похожих методов, предназначенных для разных типов клиентов, будет не лучшим решением. Мы примем волевое решение и разделим интерфейсы API. Отдельный интерфейс для каждого типа клиентов. Для аргументов про избыточность и дублирование кода есть контраргументы про простоту развития разными разработчиками и независимое горизонтальное масштабирование ввиду разности нагрузок.

Мы продолжаем наш полет фантазии и ожидаемо подходим к извечному вопросу. Монолит или микросервисы? А почему бы не сделать монолит с красивой архитектурой, где логика будет инкапсулирована в отдельных слоях? Монолит – не памятник. Любой продукт живет пока развивается. Развивать монолит можно через боль с пробросом функционала через слои абстракции. Деления слоев абстракции на группы бизнес-логики. И мучительный рефакторинг ввиду того, что архитектура больше нам не подходит и требует переделки. Из плюсов мы получаем отсутствие сетевых задержек и снижение избыточности кодовой базы. А вот минусов хоть отбавляй. На первых порах монолит допустим. Но для ленивых программистов это пустая трата времени. Посмотрим в сторону микросервисов. Какова идеальная гранулярность микросервисов? Вспомним первый принцип SOLID под названием Принцип единой ответственности (single responsibility principle). В контексте микросервисов он гласит, что у одного микросервиса должна быть одна ответственность, а также, что за одну ответственность отвечает ровно один микросервис. Подумайте, почему так. Но у любого правила есть исключения. Если у нас есть два микросервиса, межсервисное взаимодействие которых генерирует огромное количество сетевого трафика, то стоит задуматься об их объединении. Прежде чем вшивать нужный микросервис в микросервис-потребитель стоит убедиться, что никто более им не пользуется (и не планирует). Чтобы не возникла ситуация, когда у микросервиса будет два независимых интерфейса API. Ну и хорошей практикой будет обсудить данное решение с коллегами. Лично я при выборе гранулярности микросервисов исхожу из количества независимых потребителей и потенциальной потребности в горизонтальном масштабировании. Мой совет – между монолитом и микросервисами выбирайте микросервисы. В перспективе, сшить микросервисы в монолит значительно проще, чем разрезать монолит на микросервисы, распутывая лапшу зависимостей.

Плавно мы подошли к вопросам масштабирования. Горизонтальное или вертикальное масштабирование? Вертикальное масштабирование, известное также как масштабирование вверх (scale up), означает добавление большего количества вычислительных ресурсов. Звучит просто. Но! Цена на ресурсы растет непропорционально быстро. Разного рода сложные решения, типа RAID-массивов, дублирования по питанию с горячей заменой, источники бесперебойного питания, резервные каналы связи – это дорого и сложно. Дополнительно, у вас появляется единая точка отказа (SPOF). При выходе из строя сервера, ваше приложение перестает быть доступным. Я советую использовать дешевые инстансы, расположенные в разных дата-центрах. Трафик всегда можно распределить правильно с использованием GeoDNS между ДЦ (дата-центр) и load-balancer внутри одного ДЦ.

Пришло время взглянуть на наше приложение с общего ракурса. Хороший архитектор разобьет систему по уровням, а затем начнет прорабатывать каждый уровень. Так поступим и мы. Выделим следующие уровни: web tier, cache tier, data tier. У вас они могут отличаться. Но концептуально принцип будет тем же. Чтобы никакой уровень у нас не стал боттлнеком (bottleneck – бутылочное горлышко, или узкое место), мы должны предусмотреть избыточность (redundancy) для каждого из уровней.

Веб-уровень (web-tier) включает в себя front и back (веб-фронт, мобильные приложения и серверные API для них). Какое узкое горлышко для горизонтального масштабирования здесь может возникнуть? Правильно, состояние (state). А именно, пользовательские данные (user session data). В данном контексте, архитектура может быть stateful (с состоянием) и stateless (без состояния). Если пользовательские данные у нас хранятся на том веб-сервере, куда обратился конечный пользователь (посредством веб или мобильного приложения), значит у нас stateful-архитектура. Если же сервер не хранит пользовательские состояния, то его можно отнести к stateless. Такие сервера можно масштабировать горизонтально, не переживая о том, что пользователь может попасть на другой сервер. Конечно, можно с помощью балансировщика и хеш-функции от идентификатора пользователя всегда направлять пользователя на один и тот же сервер. Но если сервер станет недоступен или потребуется его масштабировать? Для реализации stateless-архитектуры нам потребуется общее хранилище состояний (shared storage). Хранилище должно обладать свойствами персистентности (сохранять состояние во времени). Таким хранилищем может быть реляционная база данных, Memcached/Redis, NoSQL и так далее. Остановимся на NoSQL, поскольку его проще всего масштабировать.

Уровень кэширования (cache-tier) является временным хранилищем результатов дорогостоящих вычислений или часто используемых данных. Как правило, уровень кэширования выступает промежуточным слоем между web-tier и data-tier. Стратегии кэширования зависят от типа данных, размера и паттернов доступа. Наиболее простой является стратегия сквозного кэширования (read-through cache). Она проста: если данные найдены в кэше, возвращаются они. Если данных в кэше нет, они запрашиваются в data-tier, сохраняются в кэш и возвращаются клиенту. Выработка стратегий кеширования – задача нетривиальная. Напишите в комментариях, какие стратегии кэширования вы используете и почему? Мои соображения относительно кеширования следующие:

1. Кэшируйте, если данные часто читаются, но редко изменяются. Например, словари.

2. Обязательно предусматривайте политику истечения срока действия (expiration policy). Обычно задается через TTL (time-to-live) в секундах. Выбор оптимального TTL придет с опытом.

3. Консистентность. Данные в кэше и в data-tier должны быть синхронизированы.

4. Политика замещения (eviction policy). Когда кэш заполнится, придется что-то удалять. Наиболее популярной является политика LFU (Least Frequently Used – наиболее редко используемый) или FIFO (First In First Out – первым пришел, первым ушел).

Поскольку уровень кеширования призван снизить время загрузки/ответа, также здесь стоит упомянуть о CDN (Content Delivery Network). Это геораспределенное хранилище статического контента (картинки, видео, скрипты, файлы). Контент будет отдан с ближайшего (по сетевой задержке) к потребителю сервера. Стратегия размещения данных в CDN аналогична сквозному кешированию. Здесь также применяется TTL.

Уровень данных (data tier) подразумевает персистентные хранилища данных. Для масштабирования баз данных используются два подхода (особо искушенные практикуют их комбинации):

1. Репликация

2. Шардирование

Если операций записи немного, но нагрузка на чтение высока, можно использовать репликацию (Replication). В простейшем случае, у нас есть одна master database, в которую осуществляется запись. И множество slave databases (являются репликами мастера), из которых осуществляется чтение. Если у нас выйдет из строя последняя slave-реплика, то чтение будет временно происходить из master. Если же из строя выйдет master, то его роль на себя возьмет одна из slave-реплик. Конечно, здесь могут возникнуть проблемы с тем, что на момент повышения роли slave до master реплика не будет содержать все актуальные данные. Как бы вы решили данную проблему? Напишите в комментариях.

Шардирование (Sharding) подходит, когда у нас много операций записи и данные растут быстро. Каждый шард (shard) содержит аналогичную схему данных, но уникальный набор данных. Выбор нужного шарда для пользователя происходит посредством хеш-функции. В простейшем случае, формула хеша может быть такой: user_id % 4, где 4 – общее количество шардов. Остаток от деления здесь позволяет всегда отправлять одного и того же пользователя на один и тот же шард. Здесь у нас возникнут проблемы с решардированием, неравномерной нагрузкой между шардами и объединением данных. Мы коснемся их в последующих статьях. А пока можете подумать о них самостоятельно.

С избыточностью по уровням мы разобрались. Теперь стоит задуматься о межсервисном взаимодействии при горизонтальном масштабировании. Как будут взаимодействовать между собой микросервисы? Синхронно (REST, GRPC) или асинхронно посредством очереди сообщений? Вопрос нетривиальный и зависит от ситуации.

Внешнее взаимодействие с клиентами организуйте через балансировщики и REST. Здесь важно гарантированное время ответа. Это является примером синхронного взаимодействия. GRPC или REST? Если нагрузки нет – используйте REST. По нему проще сгенерировать документацию (Swagger, Backstage) и проще тестировать. Чтобы снизить издержки на сериализацию\десериализацию каждого сообщения, уменьшить избыточность данных при высокой нагрузке для межсервисного взаимодействия внутри системы – смотрите в сторону GRPC.

Если же вам необходимо построить конвеер вычислений, где каждый микросервис выполняет свою работу независимо от остальных – смотрите в сторону асинхронного взаимодействия. В такой схеме у нас есть роли publisher и consumer. Микросервис с ролью publisher выполняет отправку событий (events) в брокер сообщений. Микросервисы с ролью consumer вычитывают подходящие им события, выполняют обработку/обогащение данных и могут положить результат обработки в другую очередь брокера сообщений. И так можно построить сколь угодно сложные цепочки обработки данных. Каждый этап легко масштабировать, увеличивая или уменьшая количество консьюмеров в зависимости от нагрузки. Хорошим примером может являться почтовая рассылка или построение отчетов.

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

· Логирование

· Метрики

· CI/CD

Логирование необходимо нам для мониторинга ошибок. Как правило, логирование имеет несколько уровней: info, debug, warn, error. Если вы занимаетесь продуктовой разработкой, то уровней логирования у вас может быть больше. Расскажите в комментариях о том, какие уровни вы используете и как ими управляете.

Метрики предназначены для сбора разных типов показателей о состоянии системы во времени. Они могут быть полезны как для бизнеса, так и для оценки состояния системы в целом. Условно, метрики можно разделить на метрики уровня хоста (CPU, memory, disk, I/O и т.д.), агрегированные метрики уровней системы (производительность data tier, cache tier и так далее), и бизнес метрики (количество пользователей за сутки, деньги и другие бизнес-показатели).

CI/CD (continuous integration and continuous delivery) – средства автоматизации, предназначенные для упрощения деплоя приложений в разные среды с предварительными авто-тестами, проверками кода и множеством иных инструментов.

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

1. Микросервисы предпочтительнее монолита.

2. Горизонтальное масштабирование предпочтительнее вертикального.

3. Асинхронное взаимодействие удобно для конвееров обработки, синхронное – для систем с гарантированным уровнем задержки.

4. Разделяйте систему по уровням (tiers) и стремитесь к избыточности (redundancy) в каждом из уровней.

5. На уровнях обработки бизнес-логики стремитесь к stateless-архитектуре. Состояния выносите в отдельное персистентное хранилище.

6. Кешируйте запросы и статику, но помните о консистентности данных.

7. Масштабируйте data tier (базы данных) посредством шардирования и/или репликации.

8. Обеспечивайте поддержку нескольких независимых дата-центров.

9. Используйте инструменты логирования, мониторинга и автоматизации (CI/CD).

Спасибо, что прочли мой лонгрид. Подписывайтесь на мой телеграм-канал Go HypeLoad, чтобы не пропустить полезную и актуальную информацию. И до скорых встреч, друзья!

1111
Начать дискуссию