Проектирование распределенных систем: Базовые паттерны

Курс посвящен переходу от монолитной архитектуры к распределенной. Мы разберем, как разделять систему на микросервисы, обеспечивать их связность через API Gateway и Service Discovery, а также балансировать нагрузку для достижения высокой доступности.

От монолита к микросервисам: стратегии декомпозиции и цена распределенности

От монолита к микросервисам: стратегии декомпозиции и цена распределенности

В 2023 году команда Amazon Prime Video опубликовала статью, которая вызвала бурю в инженерном сообществе: они переписали свой сервис мониторинга качества видео из микросервисов обратно в монолит. Это решение сократило их инфраструктурные расходы на 90% и радикально упростило масштабирование. Как же так? Ведь годами индустрия твердила, что микросервисы — это единственный путь к High-Load.

В предыдущем модуле мы довели наш Go-монолит до предела: избавились от избыточных аллокаций, внедрили пулы воркеров и оптимизировали блокировки. Наш бинарник работает молниеносно. Но архитектура — это всегда компромисс. Рано или поздно монолит упирается не в процессор или память, а в людей и организационную структуру.

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

Пределы монолита: когда Scale-Out уже не спасает

Мы помним, что монолит легко масштабируется горизонтально (Scale-Out), если он спроектирован как Stateless-приложение. Вы ставите балансировщик нагрузки, запускаете 50 копий одного и того же Go-бинарника и успешно обрабатываете растущий RPS.

Проблемы начинаются не на серверах, а в репозитории:

  1. Когнитивная перегрузка: Кодовая база разрастается до сотен тысяч строк. Ни один разработчик больше не понимает систему целиком.
  2. Страх деплоя: Изменение в логике генерации PDF-отчетов может случайно сломать процесс оплаты, потому что они используют общую библиотеку работы с базой данных. Релизы становятся редкими и тяжелыми.
  3. Конфликт ресурсов: Модуль обработки изображений требует мощных CPU, а модуль кэширования профилей — огромного объема RAM. В монолите мы вынуждены покупать дорогие серверы, где есть и то, и другое, переплачивая за простаивающие ресурсы.

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

Стратегии декомпозиции: как правильно резать?

Самая частая ошибка при переходе к микросервисам — техническая декомпозиция. Архитекторы выделяют «Сервис баз данных», «Сервис бизнес-логики» и «API-сервис». Это приводит к созданию распределенного монолита: чтобы добавить одно поле в профиль пользователя, вам придется править код и деплоить все три сервиса одновременно.

Правильный подход опирается на Domain-Driven Design (DDD) и декомпозицию по бизнес-возможностям.

Bounded Context (Ограниченный контекст)

В DDD есть ключевое понятие — Bounded Context. Это явная граница, внутри которой бизнес-термин имеет одно конкретное значение.

Рассмотрим пример e-commerce платформы. Что такое «Товар»?

  • Для отдела Каталога товар — это фотография, описание, SEO-теги и отзывы.
  • Для отдела Склада товар — это габариты, вес, номер полки и остаток в штуках.
  • Для отдела Биллинга товар — это цена, ставка налога и скидка.

Если мы попытаемся создать единый ProductService с гигантской таблицей в БД, команды будут постоянно блокировать друг друга. Вместо этого мы выделяем три независимых микросервиса (Каталог, Склад, Биллинг), каждый из которых имеет свою базу данных и свое представление о «Товаре», связанное только общим идентификатором (ID).

Цена распределенности: заблуждения сетевых вычислений

Разрезав монолит на независимые сервисы, мы совершаем фундаментальный сдвиг: локальный вызов функции превращается в сетевой запрос.

В монолите вызов billing.Charge(userID, amount) — это инструкция процессора. Она выполняется за наносекунды. Она либо завершается успешно, либо вызывает панику (которую можно перехватить).

В микросервисах тот же вызов превращается в HTTP или gRPC запрос по сети. И здесь вступают в силу классические Fallacies of Distributed Computing (Заблуждения распределенных вычислений), сформулированные Питером Дойчем еще в 1994 году. Вот главные из них:

  1. «Сеть надежна». Маршрутизаторы падают, кабели повреждаются, балансировщики перегружаются. Запрос может потеряться по пути туда, или, что еще хуже, ответ может потеряться по пути обратно.
  2. «Задержка сети равна нулю». Вызов функции в памяти занимает ~1 наносекунду. Сетевой вызов внутри одного дата-центра — ~1 миллисекунду. Это в миллион раз медленнее.
  3. «Пропускная способность бесконечна». Передача огромных JSON-объектов между сервисами быстро забьет сетевые каналы, вызывая деградацию всей инфраструктуры.

Самая опасная проблема распределенных систем — частичный отказ (Partial Failure). Когда вы вызываете функцию в монолите, вы точно знаете результат. Когда вы делаете HTTP-запрос в сервис биллинга и получаете таймаут (ошибка сети), вы находитесь в слепой зоне:

  • Запрос не дошел до биллинга? Значит, деньги не списаны.
  • Запрос дошел, биллинг списал деньги, но ответ потерялся на обратном пути?

Если в этой ситуации мы просто повторим запрос (Retry), мы можем списать деньги с клиента дважды.

Теорема CAP: фундаментальный компромисс

Поскольку сеть ненадежна (мы доказали это выше), распределенные системы подчиняются жесткому математическому закону — теореме CAP. Она утверждает, что распределенная система может гарантировать не более двух из трех свойств одновременно:

  • Consistency (Согласованность): Каждый клиент видит одни и те же данные в любой момент времени.
  • Availability (Доступность): Любой запрос получает успешный ответ, даже если часть узлов системы вышла из строя.
  • Partition Tolerance (Устойчивость к разделению): Система продолжает работать, даже если сеть между узлами разорвана.

В реальном мире отказы сети неизбежны. Поэтому свойство P (Partition Tolerance) не обсуждается — мы обязаны его поддерживать. Истинный выбор архитектора всегда сводится к компромиссу между C (Согласованностью) и A (Доступностью).

Пример выбора AP (Доступность важнее): Лента социальной сети. Если связь между дата-центрами прервалась, лучше показать пользователю слегка устаревшие посты (пожертвовать согласованностью), чем выдать ошибку 500 (пожертвовать доступностью).

Пример выбора CP (Согласованность важнее): Криптовалютная биржа или банковский перевод. Если сеть между сервисом балансов и сервисом транзакций нестабильна, система обязана отклонить операцию. Лучше отказать в обслуживании (пожертвовать доступностью), чем позволить потратить одни и те же деньги дважды (пожертвовать согласованностью).

Резюме

Переход от монолита к микросервисам — это не способ заставить код работать быстрее. Наоборот, из-за сетевых задержек (RTT) и накладных расходов на сериализацию данных (JSON/Protobuf) обработка одного запроса всегда становится медленнее.

Микросервисы — это инструмент масштабирования организационной структуры и изоляции отказов. Мы сознательно меняем простоту локальных вызовов на сложность сетевого взаимодействия.

В следующих главах мы начнем разбирать базовые паттерны, которые были изобретены индустрией для укрощения этой сетевой сложности: как управлять тысячами точек входа (API Gateway), как сервисам находить друг друга в динамическом облаке (Service Discovery) и как балансировать между ними трафик.

API Gateway: единая точка входа и паттерн Backend for Frontend (BFF)

API Gateway: единая точка входа и паттерн Backend for Frontend (BFF)

Разделив монолит на независимые микросервисы (Bounded Contexts), мы решили проблему изоляции команд и масштабирования бэкенда. Но это породило новую архитектурную проблему на стороне клиента. Представьте мобильное приложение интернет-магазина: чтобы отобразить экран товара, ему теперь нужно узнать цену в сервисе биллинга, наличие — в сервисе склада, отзывы — в сервисе комментариев, а рекомендации — в ML-сервисе.

Если мобильное приложение будет запрашивать каждый сервис напрямую, мы получим классическую проблему N+1, но уже не в локальной сети дата-центра, а поверх нестабильного мобильного интернета.

Антипаттерн прямого взаимодействия (Direct Client-to-Microservice)

Прямое обращение клиента к десяткам микросервисов — это первый инстинкт после распила монолита, который быстро приводит к деградации системы.

Почему этот подход не работает под нагрузкой:

  1. Сетевые задержки (Latency): Мобильная сеть имеет высокий Round Trip Time (RTT). Если RTT равен 100 мс, то 5 последовательных запросов к разным сервисам добавят полсекунды только на сетевые перелеты, не считая времени обработки.
  2. Связность (Coupling): Клиент должен знать адреса всех микросервисов. Если мы решим разбить сервис отзывов на два (тексты и рейтинги), нам придется выпускать обновление мобильного приложения.
  3. Избыточность данных: Сервис каталога может возвращать JSON на 50 КБ со всеми характеристиками товара, тогда как мобильному экрану нужны только название и картинка (2 КБ). Мы тратим батарею и трафик пользователя впустую.
  4. Безопасность: Каждый микросервис вынужден самостоятельно реализовывать проверку токенов авторизации, защиту от DDoS и Rate Limiting.

Системе нужен фасад.

API Gateway: умный фасад распределенной системы

API Gateway — это архитектурный паттерн, представляющий собой единую точку входа (Reverse Proxy) для всех внешних клиентов. Он инкапсулирует внутреннюю структуру приложения и предоставляет клиентам унифицированный API.

Вместо того чтобы стучаться в 5 разных сервисов, клиент делает один запрос к API Gateway: GET /api/v1/mobile/product/123.

Gateway берет на себя несколько критических функций (Cross-Cutting Concerns):

  • Маршрутизация (Routing): Перенаправление запроса /users в сервис пользователей, а /orders — в сервис заказов.
  • Аутентификация (Auth Offloading): Gateway один раз проверяет JWT-токен клиента, извлекает user_id и передает его во внутренние микросервисы в доверенных HTTP-заголовках. Внутренним сервисам больше не нужно проверять подписи токенов.
  • Rate Limiting и защита: Отсечение паразитного трафика до того, как он дойдет до бизнес-логики.
  • Агрегация данных: Самая важная функция для снижения Latency.

Агрегация: Fan-Out / Fan-In на уровне шлюза

Вместо того чтобы заставлять клиента делать 5 запросов, API Gateway принимает один запрос и под капотом запускает паттерн Fan-Out (который мы разбирали в модуле по Concurrency). Он параллельно опрашивает нужные микросервисы, дожидается ответов (Fan-In), склеивает их в один JSON и отдает клиенту.

Поскольку API Gateway находится в том же дата-центре, что и микросервисы, сетевая задержка между ними минимальна (менее 1 мс). Общее время ожидания для клиента определяется не суммой задержек, а самым медленным микросервисом: Ttotal=max(T1,T2,T3)+TgatewayT_{total} = \max(T_1, T_2, T_3) + T_{gateway}.

Реализация API Gateway на Go

Go — идеальный язык для написания API Gateway. Благодаря легковесным горутинам и неблокирующему I/O, шлюз на Go может держать десятки тысяч открытых соединений (Concurrency) при минимальном потреблении памяти.

В стандартной библиотеке Go уже есть мощный инструмент для создания прокси — httputil.ReverseProxy. А для агрегации данных мы используем context.Context.

Если мобильный клиент обрывает соединение (пользователь закрыл приложение или заехал в туннель), ctx.Done() мгновенно отменяет все параллельные запросы к микросервисам, спасая систему от выполнения бесполезной работы (защита от утечек горутин и лишней нагрузки на БД).

Проблема единого шлюза: от микросервисов обратно к монолиту

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

Требования разных клиентов кардинально отличаются:

  • Веб-версии (Desktop): Нужна максимальная детализация. Большие таблицы, все характеристики товара, тяжелые JSON.
  • Мобильному приложению: Нужна компактность. Только суть, оптимизация под слабый интернет.
  • Smart TV / Умным часам: Специфичные форматы, голосовые команды, минимум текста.

Если одна команда отвечает за единый API Gateway, она становится "бутылочным горлышком" (Bottleneck) для всей компании. Разработчики мобильного приложения просят добавить поле в ответ, веб-разработчики просят его убрать. Код шлюза обрастает бесконечными if is_mobile { ... } else if is_watch { ... }, превращаясь в неподдерживаемый монолит.

Паттерн Backend for Frontend (BFF)

Чтобы решить проблему монолитного шлюза, архитекторы используют паттерн Backend for Frontend (BFF).

Суть BFF: вместо одного глобального API Gateway мы создаем отдельный Gateway для каждого типа клиента.

Характеристика Единый API Gateway Паттерн BFF
Количество точек входа Одна для всех По одной на каждый тип клиента (Web, iOS, Android)
Владение кодом Отдельная команда инфраструктуры Команда разработки конкретного клиента (Mobile team владеет Mobile BFF)
Сложность агрегации Высокая (учет требований всех) Низкая (только то, что нужно конкретному клиенту)
Дублирование кода Минимальное Возможны повторы логики маршрутизации

BFF — это архитектурный компромисс (Trade-off). Мы сознательно идем на дублирование некоторого кода (например, базовой маршрутизации) в Web BFF и Mobile BFF ради автономности команд. Команда iOS-разработчиков может сама написать свой BFF на Go, агрегировать данные так, как нужно их приложению, и деплоить его независимо от веб-команды.

Когда использовать BFF, а когда обычный API Gateway?

  • Если у вас один основной клиент (например, только веб-приложение) или клиенты требуют абсолютно идентичных данных — используйте единый API Gateway.
  • Если у вас несколько разных интерфейсов (Веб, iOS, Android, публичный API для партнеров), и их требования к данным/форматам сильно расходятся — распиливайте фасад на BFF.

В сложной инфраструктуре эти паттерны часто комбинируют: запросы сначала попадают на глобальный инфраструктурный API Gateway (который занимается только SSL-терминацией, защитой от DDoS и Rate Limiting), а затем маршрутизируются на специфичные BFF, которые уже занимаются агрегацией бизнес-данных.

Мы решили проблему связи клиента с дата-центром. Но остается открытым вопрос: как сам API Gateway или BFF узнает, на какие IP-адреса отправлять запросы? В облачной среде поды с микросервисами постоянно создаются и удаляются, их IP-адреса меняются каждую минуту. Жестко прописывать их в конфигах невозможно. Эту задачу решает механизм Service Discovery, который мы разберем далее.

Service Discovery: механизмы регистрации и обнаружения в динамических кластерах

Service Discovery: механизмы регистрации и обнаружения в динамических кластерах

В прошлой главе мы установили API Gateway в качестве фасада нашей распределенной системы. Он принимает внешний запрос, например GET /api/v1/orders, и должен перенаправить его в микросервис заказов. Но куда именно?

В эпоху монолитов и статических серверов конфигурация маршрутизации была тривиальной: мы просто прописывали IP-адрес 192.168.1.50 в конфиг Nginx. В современных облачных средах и оркестраторах вроде Kubernetes инстансы микросервисов эфемерны. Они создаются при автомасштабировании (Scale-Out), переезжают на другие узлы при нехватке памяти (OOM) и уничтожаются при релизах. IP-адреса меняются каждую минуту.

API Gateway не может опираться на статические адреса. Ему нужен механизм, который в реальном времени отвечает на вопрос: «По каким IP-адресам прямо сейчас работают здоровые инстансы сервиса Orders?». Этот механизм называется Service Discovery (Обнаружение сервисов).

Service Registry: динамическая телефонная книга

Сердцем любого механизма Service Discovery является Service Registry (Реестр сервисов). Архитектурно это высокодоступное Key-Value хранилище, где ключом выступает имя сервиса (например, orders-api), а значением — список адресов его инстансов.

Самые популярные реализации реестров: HashiCorp Consul, etcd, Apache ZooKeeper и Netflix Eureka.

Чтобы реестр всегда содержал актуальные данные, инстансы микросервисов должны взаимодействовать с ним на протяжении всего своего жизненного цикла. Этот процесс состоит из трех этапов:

  1. Registration (Регистрация): При запуске инстанс отправляет в реестр свой IP-адрес, порт и метаданные (версия, датацентр).
  2. Heartbeat (Пульс): Инстанс периодически (например, каждые 10 секунд) отправляет сигнал в реестр, подтверждая, что он жив. Альтернативный подход — когда сам реестр опрашивает health-check эндпоинты инстансов.
  3. Deregistration (Дерегистрация): При штатном завершении работы инстанс просит удалить себя из реестра. Если инстанс упал аварийно (например, выдернули кабель питания), реестр удалит его автоматически, когда истечет таймаут ожидания Heartbeat.

Главное правило Service Discovery: реестр должен возвращать адреса только тех инстансов, которые способны обработать запрос. Наличие IP-адреса в реестре — это гарантия того, что инстанс жив прямо сейчас.

Два паттерна обнаружения: Client-Side и Server-Side

Имея актуальный реестр, мы должны решить, кто именно будет в него обращаться. В архитектуре распределенных систем существует два противоположных подхода.

1. Client-Side Discovery (Обнаружение на стороне клиента)

При этом подходе клиент (в нашем случае API Gateway или другой микросервис) берет на себя всю ответственность.

Он напрямую обращается к Service Registry, запрашивает список всех доступных IP-адресов целевого сервиса, локально применяет алгоритм балансировки нагрузки (например, Round Robin) и отправляет запрос напрямую на выбранный инстанс.

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

  • Отсутствие лишних сетевых прыжков (hops): Запрос идет напрямую от клиента к целевому сервису.
  • Децентрализация балансировки: Нет единого компонента (Load Balancer), который мог бы стать узким местом (Bottleneck).

Недостатки:

  • Умный клиент: В каждый микросервис необходимо встроить сложную логику общения с реестром и балансировки. Если у вас полиглот-архитектура (Go, Python, Java), придется поддерживать библиотеки Service Discovery для каждого языка.

2. Server-Side Discovery (Обнаружение на стороне сервера)

При этом подходе клиент ничего не знает о реестре. Он отправляет запрос на единый статический адрес балансировщика нагрузки (Load Balancer). Балансировщик сам обращается к Service Registry, выбирает инстанс и проксирует запрос.

Именно этот паттерн реализован в Kubernetes через ресурс Service и kube-proxy. Ваш Go-код просто делает HTTP-запрос на имя http://orders-service, а инфраструктура Kubernetes сама находит нужный Pod и перенаправляет трафик.

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

  • Тонкий клиент: Микросервису не нужны специальные библиотеки, он делает обычный HTTP/gRPC запрос. Идеально для полиглот-архитектуры.
  • Инкапсуляция инфраструктуры: Разработчики бизнес-логики не думают о том, как работает Service Discovery.

Недостатки:

  • Дополнительный сетевой hop: Запрос всегда проходит через промежуточный узел, что незначительно увеличивает Latency.
  • Балансировщик как узкое место: Балансировщик должен выдерживать весь внутренний трафик кластера.
Характеристика Client-Side Discovery Server-Side Discovery
Кто опрашивает реестр? Сам микросервис (клиент) Промежуточный Load Balancer
Сложность клиента Высокая (нужен SDK) Низкая (стандартный HTTP-клиент)
Сетевые задержки Минимальные Выше (из-за лишнего hop)
Примеры использования gRPC-балансировка, Netflix OSS (Ribbon) Kubernetes Services, AWS ALB

Service Registry и теорема CAP

В первой главе мы разбирали теорему CAP и выяснили, что при разрыве сети (Partition Tolerance) распределенная система должна выбрать между согласованностью (Consistency) и доступностью (Availability). Service Registry — это распределенная база данных, и этот выбор для нее критически важен.

CP-реестры (Consul, etcd, ZooKeeper)

Эти системы выбирают строгую согласованность. Они используют алгоритмы консенсуса (Raft или Paxos). Для успешной записи (регистрации нового инстанса) требуется подтверждение от большинства узлов кластера реестра (кворума).

Если сеть распадается на две части, та часть кластера реестра, где осталось меньшинство узлов, перестанет принимать запросы. Плюс: Клиент никогда не получит устаревший список IP-адресов. Минус: Во время сбоя сети новые инстансы не смогут зарегистрироваться, а клиенты не смогут получить адреса, даже если сами целевые микросервисы работают идеально.

AP-реестры (Netflix Eureka)

Эти системы выбирают доступность. Узлы реестра обмениваются данными асинхронно (eventual consistency). Здесь нет строгих лидеров и кворума.

Если сеть распадается, каждый узел реестра продолжает отвечать на запросы, используя те данные, которые у него есть. Плюс: Service Discovery продолжает работать даже при серьезных сетевых авариях. Минус: Клиент может получить IP-адрес инстанса, который уже давно мертв (потому что информация о его смерти еще не долетела до этого узла реестра).

Что лучше для Service Discovery?

Архитектурный компромисс (Trade-off) здесь неочевиден. На первый взгляд, строгая консистентность (etcd) кажется надежнее. Однако в контексте Service Discovery доступность (AP) часто важнее.

Лучше получить список, в котором есть пара нерабочих IP-адресов, чем не получить список вообще.

Если клиент (API Gateway) получит от AP-реестра устаревший IP-адрес мертвого инстанса, запрос просто упадет с ошибкой соединения. Клиент, используя паттерны отказоустойчивости (Retry), мгновенно повторит запрос на следующий IP-адрес из списка и успешно обслужит пользователя. Если же CP-реестр откажется отвечать из-за потери кворума, клиент вообще не будет знать, куда слать запросы, и вся система ляжет, даже если 99% микросервисов физически исправны.

Итог

Мы решили проблему эфемерности облачных сред: теперь наш API Gateway и внутренние сервисы всегда знают актуальные IP-адреса друг друга благодаря Service Registry.

Однако получение списка из NN доступных IP-адресов порождает следующую задачу. Если у сервиса заказов сейчас работает 50 инстансов, на какой именно из них отправить текущий запрос? Выбор случайного адреса может привести к тому, что один инстанс будет перегружен, а остальные будут простаивать. О том, как математически эффективно распределять трафик, мы поговорим в следующей главе, посвященной алгоритмам балансировки нагрузки.

Алгоритмы балансировки нагрузки: от Round Robin до Least Connections

Алгоритмы балансировки нагрузки: от Round Robin до Least Connections

В прошлой главе мы разобрали, как Service Registry решает проблему эфемерных IP-адресов. Теперь у нашего API Gateway или клиента (в случае Client-Side Discovery) есть актуальный список, например, из 10 доступных инстансов сервиса биллинга. Возникает следующий архитектурный вопрос: когда приходит новый HTTP-запрос, на какой именно из этих 10 адресов его отправить?

Выбор правильного узла — это не просто маршрутизация. Это механизм защиты системы от каскадных сбоев, минимизации Latency и эффективной утилизации ресурсов. Разберем эволюцию алгоритмов балансировки: от самых примитивных до тех, что используются в высоконагруженных распределенных системах.

Round Robin: Идеальная справедливость в вакууме

Самый простой и интуитивно понятный алгоритм — Round Robin (Круговой перебор). Балансировщик просто отправляет каждый новый запрос на следующий по списку сервер. Когда список заканчивается, перебор начинается сначала.

Математически это описывается простой операцией взятия остатка от деления: i=(i+1)modNi = (i + 1) \bmod N

Где ii — индекс текущего сервера, а NN — общее количество доступных серверов. Например, если у нас 3 сервера (N=3N = 3), индексы будут чередоваться: 0, 1, 2, 0, 1, 2.

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

  • O(1) по памяти и процессору: алгоритму нужно хранить только одно целое число (текущий индекс).
  • Равномерность: если придет 10 000 запросов, каждый из 10 серверов получит ровно по 1000.

Проблема: Round Robin исходит из двух ложных предпосылок, которые разбиваются о реальность High-Load систем:

  1. Все серверы имеют одинаковую вычислительную мощность.
  2. Все запросы требуют одинакового времени на обработку.

Weighted Round Robin: Учет гетерогенного железа

В облачной инфраструктуре серверы редко бывают идентичными. Во время миграции или автомасштабирования в кластере могут одновременно находиться мощные инстансы (например, 16 ядер) и слабые (4 ядра). Обычный Round Robin отправит на них равное количество запросов, что приведет к перегрузке (Saturation) слабого узла и простою мощного.

Решение — Weighted Round Robin (WRR). Каждому узлу назначается вес WW, пропорциональный его мощности.

Формула вероятности выбора узла PP становится зависимой от его веса: P=WiWP = \frac{W_i}{\sum W}

Где WiW_i — вес конкретного сервера, а знаменатель — сумма весов всех серверов. Например, Сервер А имеет вес 3, а Сервер Б — вес 1. Балансировщик отправит три запроса подряд на Сервер А, и только один — на Сервер Б.

WRR решает проблему разного железа, но остается слеп к природе самих запросов.

Least Connections: Адаптация к тяжелым запросам

Представьте сервис обработки изображений. Один пользователь загружает аватарку на 50 КБ (запрос выполняется 10 мс), а другой — панораму на 20 МБ (запрос выполняется 5 секунд).

Если использовать Round Robin, может возникнуть ситуация, когда по случайности все «тяжелые» запросы попадут на Сервер А. У него скопится очередь из долгих задач, в то время как Сервер Б быстро обработает свои «легкие» аватарки и будет простаивать.

Алгоритм Least Connections (Наименьшее количество соединений) решает эту проблему за счет обратной связи. Балансировщик отслеживает, сколько активных (еще не завершенных) запросов находится на каждом сервере прямо сейчас.

Логика выбора описывается поиском минимума: min(C1,C2,,CN)\min(C_1, C_2, \dots, C_N)

Где CC — счетчик активных соединений для каждого узла. Новый запрос всегда отправляется туда, где значение CC минимально. Если Сервер А завис на обработке панорамы, его счетчик CC не уменьшается, и балансировщик автоматически начнет перенаправлять новые запросы на свободный Сервер Б.

Power of Two Choices (P2C): Оптимизация для High-Load

Least Connections отлично работает для десятков серверов. Но что, если у нас кластер из 1000 микросервисов?

Чтобы найти узел с минимальным количеством соединений, балансировщику нужно на каждый входящий запрос просканировать массив из 1000 элементов — это операция со сложностью O(N)O(N). При нагрузке в 50 000 RPS балансировщик сам станет узким местом (Bottleneck), тратя процессорное время на поиск минимума.

Здесь на сцену выходит математическая оптимизация — Power of Two Choices (P2C). Вместо того чтобы проверять все серверы, алгоритм:

  1. Выбирает два случайных сервера из пула.
  2. Сравнивает их счетчики соединений Crand1C_{rand1} и Crand2C_{rand2}.
  3. Отправляет запрос на тот, где соединений меньше.

Сложность падает с O(N)O(N) до константной O(1)O(1). Математически доказано, что выбор лучшего из двух случайных узлов предотвращает эффект «стадного инстинкта» (когда все запросы летят на один внезапно освободившийся сервер) и распределяет нагрузку почти так же эффективно, как полный перебор, но без затрат на CPU.

Consistent Hashing: Балансировка с сохранением состояния (Stateful)

Все предыдущие алгоритмы предполагают, что наши микросервисы Stateless (без состояния), как мы обсуждали в первых главах. Любой узел может обработать любой запрос.

Но существуют Stateful-компоненты, например, in-memory кэши (Redis, Memcached). Если мы будем балансировать запросы к кэшу через Round Robin, данные пользователя с ID=42 сегодня попадут на Сервер А, а завтра на Сервер Б. Это приведет к промаху кэша (Cache Miss) и лишней нагрузке на базу данных.

Нам нужна Sticky Session (липкая сессия) — гарантия, что запросы с одинаковым ключом всегда попадают на один и тот же сервер. Наивный подход — использовать остаток от деления хэша ключа на количество серверов: Node=Hash(Key)modNNode = Hash(Key) \bmod N

Фатальная проблема наивного подхода: Если один из серверов упадет, NN изменится (например, с 5 на 4). Изменение делителя в формуле приведет к тому, что для 80% всех существующих ключей результат деления изменится. Весь кластер кэшей мгновенно станет невалидным, что вызовет Thundering Herd (громовое стадо) запросов в БД и убьет систему.

Решение — Consistent Hashing (Консистентное хэширование).

Вместо привязки к количеству серверов NN, мы используем абстрактное кольцо хэшей от 00 до 23212^{32}-1.

  1. Хэшируем IP-адреса серверов и помещаем их на это кольцо.
  2. Хэшируем ключ запроса (например, User_ID=42) и получаем точку на кольце.
  3. Движемся по кольцу по часовой стрелке, пока не встретим первый попавшийся сервер.

При падении одного сервера его ключи просто переходят к следующему соседу по часовой стрелке. Остальные серверы сохраняют свои ключи. Инвалидируется не 80% кэша, а только 1/N1/N часть.

Резюме: как выбрать алгоритм?

Выбор алгоритма балансировки — это классический архитектурный компромисс (Trade-off):

  • Если сервисы Stateless и запросы легковесные — берите Round Robin (минимальный оверхед).
  • Если железо разное — добавляйте веса (Weighted Round Robin).
  • Если запросы сильно отличаются по времени выполнения — используйте Least Connections.
  • Если у вас тысячи инстансов и огромный RPS — спасает Power of Two Choices (P2C).
  • Если важна локальность данных (кэши, шардирование БД) — только Consistent Hashing.

Балансировка на уровне Gateway или клиента решает задачу распределения запросов. Но как сами сервисы общаются между собой на сетевом уровне? В следующей главе мы спустимся на уровень протоколов и разберем выбор между классическим HTTP/REST, бинарным gRPC и паттерном Service Mesh.

Межсервисное взаимодействие: выбор между gRPC, HTTP и Service Mesh

Межсервисное взаимодействие: выбор между gRPC, HTTP и Service Mesh

В предыдущих главах мы научились находить нужный инстанс в кластере и балансировать нагрузку так, чтобы не перегрузить отдельные узлы. Но когда IP-адрес выбран и TCP-соединение установлено, возникает следующий вопрос: в каком виде передавать данные?

В монолите компоненты общались через вызов функций в оперативной памяти — это занимало наносекунды. В распределенной системе каждый вызов превращается в сетевой запрос. И то, как именно мы упакуем и отправим этот запрос, напрямую определяет, сколько ресурсов CPU мы сожжем впустую и какую задержку (Latency) получим на перцентиле p99.

HTTP/REST и JSON: удобство ценой CPU

Исторически стандартом де-факто для микросервисов стал HTTP/1.1 с передачей данных в формате JSON (часто в стиле REST).

Его главное преимущество — абсолютная прозрачность. Любой разработчик может открыть терминал, написать curl и прочитать ответ глазами. JSON не требует предварительной компиляции схем, он гибок и поддерживается любым языком программирования.

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

  1. Дорогой парсинг (CPU-bound). JSON — это текстовый формат. Чтобы превратить строку {"user_id": 123} в структуру Go, процессор должен посимвольно разобрать текст, найти кавычки, понять типы данных и аллоцировать память (часто с использованием медленной рефлексии). На масштабах десятков тысяч RPS сериализация JSON становится главным потребителем процессорного времени.
  2. Отсутствие строгих контрактов. В REST нет встроенного механизма гарантии типов. Если сервис А начнет отдавать user_id как строку "123" вместо числа 123, сервис Б упадет с ошибкой парсинга (Fail-fast). Спецификации вроде OpenAPI (Swagger) помогают, но они опциональны и часто рассинхронизируются с реальным кодом.
  3. Head-of-Line Blocking в HTTP/1.1. В рамках одного TCP-соединения HTTP/1.1 запросы выполняются строго последовательно. Если мы отправили тяжелый запрос на генерацию отчета, следующий за ним легкий запрос на проверку статуса будет ждать, пока первый не завершится.

gRPC и Protobuf: бинарный стандарт High-Load

Для решения этих проблем компания Google разработала gRPC — фреймворк для удаленного вызова процедур (RPC), который стал индустриальным стандартом для внутренних коммуникаций в распределенных системах.

gRPC меняет правила игры на двух уровнях: формате данных и транспортном протоколе.

Protocol Buffers (Protobuf) вместо JSON

gRPC использует Protobuf — бинарный формат сериализации. Вместо передачи ключей ("user_id") передаются только их числовые теги.

Вы заранее описываете строгий контракт в файле .proto:

message GetUserRequest {
  int64 user_id = 1;
}

Специальный компилятор protoc генерирует из этого файла готовый Go-код. Результат:

  • Строгая типизация: Невозможно отправить строку вместо числа — код просто не скомпилируется.
  • Скорость: Бинарный парсинг сводится к простым побитовым операциям. Никакой рефлексии, минимум аллокаций. Потребление CPU падает в разы по сравнению с JSON.
  • Компактность: Бинарный payload занимает меньше места в сети, снижая время передачи.

HTTP/2 вместо HTTP/1.1

gRPC работает поверх протокола HTTP/2. Его ключевая особенность — мультиплексирование.

HTTP/2 разбивает данные на мелкие бинарные фреймы. Это позволяет отправлять десятки независимых запросов и получать ответы по одному TCP-соединению одновременно, перемешивая их фреймы в полете.

Мультиплексирование решает проблему Head-of-Line Blocking и избавляет от необходимости держать пулы из сотен TCP-соединений между микросервисами.

Стена сложности распределенных систем

Допустим, мы перевели наши 50 микросервисов на gRPC. Скорость выросла, CPU отдыхает. Но мы сталкиваемся с новой реальностью: сеть ненадежна.

Чтобы система не развалилась от моргания сети, в каждом сервисе нужно реализовать:

  • Таймауты и повторные попытки (Retries).
  • Предохранители (Circuit Breaker) для защиты от каскадных сбоев.
  • Балансировку нагрузки на стороне клиента (Client-Side Load Balancing), так как gRPC использует долгоживущие соединения.
  • Шифрование трафика (mTLS) для безопасности.
  • Сбор метрик и распределенную трассировку (Distributed Tracing).

Если писать всё это на Go, мы получим огромную общую библиотеку (например, company-core-lib). Все команды будут обязаны втаскивать её в свои сервисы.

А теперь представьте, что команда Data Science написала сервис на Python, а фронтендеры подняли BFF на Node.js. Нам придется переписывать и поддерживать эту сложнейшую сетевую логику на трех разных языках. Мы нарушили главное правило микросервисов — независимость технологического стека.

Service Mesh и паттерн Sidecar

Чтобы не тащить инфраструктурную сложность в бизнес-код, архитекторы используют Service Mesh (сервисную сетку).

Service Mesh — это выделенный инфраструктурный слой для обеспечения безопасного, быстрого и надежного межсервисного взаимодействия. Он реализуется через архитектурный паттерн Sidecar (коляска).

Вместо того чтобы встраивать логику в код приложения, мы ставим рядом с каждым инстансом микросервиса крошечный прокси-сервер (например, Envoy). В Kubernetes они делят один Pod.

Как это работает:

  1. Ваш Go-сервис вообще ничего не знает про кластер. Если ему нужен сервис биллинга, он делает простой HTTP или gRPC запрос на localhost:8080.
  2. Sidecar-прокси перехватывает этот локальный запрос.
  3. Sidecar сам идет в Service Registry, находит IP-адреса биллинга, выбирает наименее загруженный (Least Connections), шифрует трафик (mTLS) и отправляет его по сети.
  4. На другой стороне Sidecar биллинга принимает зашифрованный трафик, расшифровывает, проверяет права и передает локально в приложение биллинга.

Data Plane и Control Plane

Service Mesh разделен на две части:

  • Data Plane (Плоскость данных): Это те самые тысячи Sidecar-прокси, через которые физически текут байты. Они выполняют маршрутизацию, балансировку и сбор метрик.
  • Control Plane (Плоскость управления): Центральный мозг (например, Istio). Он не пропускает через себя трафик, но управляет прокси-серверами: раздает им актуальные IP-адреса (Service Discovery), обновляет сертификаты для mTLS и пушит правила маршрутизации.

Service Mesh позволяет разработчикам писать простой код, сфокусированный на бизнес-логике. Вся магия распределенных систем — от балансировки до шифрования — происходит прозрачно на уровне инфраструктуры.