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

Введение 

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

Stateful | Stateless
Stateful | Stateless

Термины:

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

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

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

Сервер — потребитель ©

Клиент — поставщик (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)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


  1. Ak-47
    16.07.2025 20:39

    А знаете, почему в первом пункте, хорошее горизонтальное масштабирование, низкая нагрузка, хорошая баолансировка? ЕЩе и простая реализация..

    Потому, что приложение stateless!