Всем привет! В этой статье я хочу разобрать довольно-таки интересную и в то же время сложную тему - "Поддержание консистентного состояния в stateful сервисах при масштабировании"

Введение 

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

Stateful | Stateless
Stateful | Stateless

Термины:

Stateful - имеющий состояние

Инстанс - экземпляр

Консистентность - согласованность, актуальное состояние

Сервер - потребитель (C)

Клиент - поставщик (B)

Сервис - приложение-посредник между клиентом и сервером (A)

Пример

Давайте например возьмем сервис A к которому по gRPC стримам будут подключаться клиенты B0, B1 .. Bn,  и , сервера C0, C1 .. Cn, а он в свою очередь будет как-то обрабатывать эти сообщения.  Клиенты должны стримить сообщения одному и тому же серверу, то есть, если сервер Cn подключен к An, то и клиент Bn должен как-то доставлять сообщения до An чтобы сервер мог их забрать.

Клиент -> Сервис <- Сервер
Клиент -> Сервис <- Сервер

При одном инстансе никаких проблем тут не возникает, сервер подключился к сервису A, клиенты также подключаются к нему и спокойно отсылают сообщения. Но при количестве экземпляров сервиса A > 1 возникают трудности.

Решения

1. Использование хранилища/очереди 

Самое наверное простое решение - использовать какое-то хранилище. К примеру: создать топик в Kafka куда сервис А будет кидать все сообщения из стрима клиента B0, которые подходят по условию что сервер C0 не подключен к этому инстансу (если сервер C ждет сообщения в другом инстансе A). Тогда достаточно реализовать функционал, что на каждом A должен работать воркер который будет консьюмить этот топик, если у него подключен этот сервер C0 и форвардить сообщения ему.

Использование хранилища/очереди
Использование хранилища/очереди 

Минусы:

  • Внешняя зависимость

Плюсы:

  • Простота

Но что если мы не хотим использовать внешнее хранилище и реализовать все подручными средствами?

2. Hash-Ring

Идея простая - присваиваем каждому серверу С свой айди, также необходима реализация некой хэш функции, которая будет трансформировать этот айди в число <= кол-ву реплик. Ну и каждому экземпляру А необходимо знать о всех своих репликах. Тогда если необходимый нам сервер не подключен к текущему инстансу A, то достаточно открыть стрим с нужным нам экзмепляром А и форвардить сообщения ему.

См. примеры реализации: Amazon DynamoDB, Apache Cassandra

Хэш функция определяет что клиент должен отправлять данные в Ay
Хэш функция определяет что клиент должен отправлять данные в Ay

Минусы:

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

  • Плохая балансировка (могут возникнуть хот-споты, при равномерном хэшировании нагрузка может неравномерно распределяться)

Плюсы:

  • Относительная простота

  • Отсутствие внешних зависимостей

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

3. Gossip

Тут уже сложнее.. Нам нужно сделать как-то так, чтобы мы постоянно знали к какому экземпляру A подключены сервер и клиент. Для этого все реплики А должны давать друг другу информацию о каждом новом подключении и отключении. Вопрос когда это делать остается за вами, в момент когда появляется новое соединение или периодически, но стоит помнить о том, что от выбора может зависеть согласованность вашей системы (что-то типа синхронной и асинхронной репликации). Таким образом, любой инстанс должен знать куда ему подключаться если один из участников этой цепи уже ждет его, а если никого еще нет, то самому начинать работу и оповестить об этом остальных.

См. примеры реализации: SWIM, Epidemic Broadcast Trees, HashiCorp Consul

Реплики сервиса А обмениваются информацией о подключениях
Реплики сервиса А обмениваются информацией о подключениях

Минусы:

  • Сложная реализация

  • Возможны задержки для согласования состояний, eventual consistency

Плюсы:

  • Высокая отказоустойчивость и динамическая балансировка

4. Broadcasting

Чем-то похоже на первое решение. Также можно использовать очередь, но немного другим образом, ну или открыть стримы все со всеми и раскидывать сообщения всем, и, тот кто нужно, его обязательно получит. Говоря про очередь, тут наверняка неплохо справится Redis PUB/SUB, а впрочем, можно выбрать и любую другую, главное чтобы была возможность реализовать связь many-to-many. 

Реплики сервиса А стримят друг другу данные
Реплики сервиса А стримят друг другу данные

Минусы:

  • Излишнее потребление ресурсов (можно открыть стрим и не использовать или слишком часто открывать/закрывать его)

Плюсы:

  • Простота

Выводы

Критерий

Хранилище/очередь

Hash-Ring

Gossip

Broadcasting

Внешние зависимости

✅ Требуются (Kafka, Redis)

❌ Нет

❌ Нет

⚠️ Зависит от реализации (Redis PUB/SUB или P2P)

Сложность реализации

? Низкая (интеграция готовых решений)

? Средняя (консистентное хеширование)

? Высокая (алгоритмы согласования)

? Низкая (широковещательная рассылка)

Балансировка нагрузки

✅ Хорошая (брокер распределяет равномерно)

⚠️ Средняя (риск хот-спотов без виртуальных нод)

✅ Хорошая (динамическое перераспределение)

❌ Очень слабая (дублирование трафика)

Масштабируемость

✅ Высокая (горизонтальное масштабирование брокера)

✅ Высокая (но дорогая перебалансировка)

✅ Высокая (децентрализованная адаптация)

❌ Низкая (экспоненциальный рост трафика)

Потребление ресурсов

? Низкое (точечная коммуникация)

? Низкое (прямая маршрутизация)

? Среднее (фоновая синхронизация)

? Высокое (дублирование сообщений)

Отказоустойчивость

✅ Высокая (репликация в брокере)

⚠️ Средняя (потеря ноды = потеря её данных)

✅ Высокая (автоматическое восстановление)

✅ Высокая (избыточность данных)

Устойчивость к изменению реплик

✅ Высокая (прозрачное масштабирование)

❌ Низкая (перебалансировка ключей)

✅ Высокая (автоматическое обнаружение)

✅ Высокая (новые ноды сразу в рассылке)

Долгоживущие подключения

❌ Нет (клиент ↔ брокер)

❌ Нет (рвутся при перебалансировке)

✅ Да (прямые стримы)

❌ Нет (нестабильные соединения)

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

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