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

Термины:
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

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