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

Термины:
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 и форвардить сообщения ему. Таким образом, это решение обеспечивает хороший баланс между простотой реализации и масштабируемостью, превращая изначально stateful-сервис в stateless-компонент, но требует введения дополнительной инфраструктурной компоненты.

Минусы:
Внешняя зависимость
Плюсы:
Простота
Приложение становится stateless
Но что если мы не хотим использовать внешнее хранилище и реализовать все подручными средствами?
2. Hash-Ring
Идея простая - присваиваем каждому серверу С свой айди, также необходима реализация некой хэш функции, которая будет трансформировать этот айди в число <= кол-ву реплик. Ну и каждому экземпляру А необходимо знать о всех своих репликах. Тогда если необходимый нам сервер не подключен к текущему инстансу A, то достаточно открыть стрим с нужным нам экзмепляром А и форвардить сообщения ему.
См. примеры реализации: Amazon DynamoDB, Apache Cassandra

Минусы:
Если изменяется кол-во реплик, то необходимо делать перерасчет ключа и переподключаться к нужной реплике, часть ключей перераспределяется, что может вызвать временную несогласованность.
Плохая балансировка (могут возникнуть хот-споты, при равномерном хэшировании нагрузка может неравномерно распределяться)
Плюсы:
Относительная простота
Отсутствие внешних зависимостей
А если мы хотим убрать лишние переподключения и сделать балансировку?
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) |
Сложность реализации | 🔽 Низкая (интеграция готовых решений) | 🔽 Средняя (консистентное хеширование) | 🔺 Высокая (алгоритмы согласования) | 🔽 Низкая (широковещательная рассылка) |
Балансировка нагрузки | ✅ Хорошая (брокер распределяет равномерно) | ⚠️ Средняя (риск хот-спотов без виртуальных нод) | ✅ Хорошая (динамическое перераспределение) | ❌ Очень слабая (дублирование трафика) |
Масштабируемость | ✅ Высокая (горизонтальное масштабирование брокера) | ✅ Высокая (но дорогая перебалансировка) | ✅ Высокая (децентрализованная адаптация) | ❌ Низкая (экспоненциальный рост трафика) |
Потребление ресурсов | 🔽 Низкое (точечная коммуникация) | 🔽 Низкое (прямая маршрутизация) | 🔺 Среднее (фоновая синхронизация) | 🔺 Высокое (дублирование сообщений) |
Отказоустойчивость | ✅ Высокая (репликация в брокере) | ⚠️ Средняя (потеря ноды = потеря её данных) | ✅ Высокая (автоматическое восстановление) | ✅ Высокая (избыточность данных) |
Устойчивость к изменению реплик | ✅ Высокая (прозрачное масштабирование) | ❌ Низкая (перебалансировка ключей) | ✅ Высокая (автоматическое обнаружение) | ✅ Высокая (новые ноды сразу в рассылке) |
Долгоживущие подключения | ❌ Нет (клиент ↔ брокер) | ❌ Нет (рвутся при перебалансировке) | ✅ Да (прямые стримы) | ❌ Нет (нестабильные соединения) |
Так, ну на этом, мне кажется, все :-) Стоит помнить что выбор решения строго зависит от вашего юзкейса, может быть такое что самый неоптимальный на первый взгляд способ лучше всего подойдет вашей системе, а может и наоборот, тут необходимо отталкиваться от многих условий. Поэтому, эта статья ни в коем случае не панацея, моей задачей было осведомление читателя с такой проблемой и возможными путями ее решения.