Почему Websocket сложно масштабировать?
Websocket предоставляют важную функцию: двустороннюю связь. Что позволяет серверам передавать события клиентам без запроса клиента.
Двунаправленная природа веб-сокетов — это и благодать, и проклятие! Несмотря на то, что он обеспечивает множество вариантов использования веб-сокетов, он значительно усложняет реализацию масштабируемого сервера веб-сокетов по сравнению с HTTP-серверами.
Что делает Websocket уникальными?
Websocket — это протокол прикладного уровня, точно так же, как HTTP, который является еще одним протоколом прикладного уровня. Оба протокола реализуются через TCP-соединение. Но у них разные характеристики, и они представляют две разные страны в мире коммуникаций - если это имеет смысл.
HTTP имеет флаг для модели связи на основе запроса-ответа, а Websocket несет флаг для модели двунаправленной связи.
В попытке нарисовать более четкое представление о Websocket вы увидите сравнение HTTP и Websocket на протяжении всего поста. Но это не означает, что они являются конкурирующими протоколами, вместо этого у них обоих есть свои варианты использования.
Характеристики веб-сокета:
- Двусторонняя связь
- Долгоживущее TCP-соединение
- Протокол с отслеживанием состояния
Особенности HTTP:
- Коммуникация на основе ответа на запрос
- Кратковременное TCP-соединение
- Протокол без сохранения состояния
С сохранением состояния против Протоколы без состояния
Вы видели некоторые посты о создании серверов без состояния и бесконечно масштабируемых внутренних серверов. Они говорят вам использовать токены JWT для аутентификации без состояния и использовать лямбда-функции в вашем приложении без состояния и т.д.
О каком состоянии идет речь и почему оно так важно, когда речь идет о масштабировании серверных приложений?
Состояние - это вся информация, которую ваше приложение должно запомнить для правильной работы. Например, ваше приложение должно запоминать вошедших в систему пользователей. 99% приложений делают это (источник: trust me), и это называется управлением сеансами.
Состояние - это великая вещь! Почему люди ненавидят это и всегда пытаются создавать приложения без состояния?
Вам нужно где-то сохранить свое состояние, и это где-то обычно находится в памяти сервера. Но память вашего сервера приложений недоступна с других серверов, и начинается проблема.
Представьте сценарий:
- User A отправляет запрос на Server 1. Server 1 аутентифицирует User A и сохраняет свою Session A в памяти.
- User A делает второй запрос к Server 2. Server 2 ищет сохраненные сеансы, но не может найти Session A, поскольку он хранится на Server 1.
Чтобы ваш сервер стал масштабируемым, вам необходимо управлять состоянием вне вашего приложения. Например, вы можете сохранять сеансы в экземпляре Redis. Это делает состояние приложения доступным для всех серверов через Redis, а Server 2 может считывать Session A из Redis.
Websocket с отслеживанием состояния: открытие соединения Websocket похоже на свадьбу между клиентом и сервером: соединение остается открытым до тех пор, пока одна из сторон не закроет его (или не обманет его, конечно, из-за сетевых условий).
HTTP без сохранения состояния. С другой стороны, HTTP — сердцеед, он хочет закончить все как можно быстрее. После открытия HTTP-соединения клиент отправляет запрос, и как только сервер отвечает, соединение закрывается.
Помните, что соединения Websocket обычно живут долго, тогда как HTTP-соединения должны заканчиваться как можно скорее. В тот момент, когда вы вводите веб-сокеты в свое приложение, оно становится отслеживающим состояние.
Несмотря на то, что и HTTP, и Websocket построены поверх TCP, один может быть без сохранения состояния, а другой — с отслеживанием состояния. Имейте в виду, что даже в HTTP базовое TCP-соединение может быть долговечным.
Использование экземпляра Redis для хранения сокетов
В предыдущем примере с сессиями решение было простым. Используйте внешний сервис для хранения сеансов, чтобы любой другой сервер мог читать сеансы оттуда (экземпляр Redis).
Веб-сокеты — это другой случай, потому что ваше состояние — это не только данные о сокете, вы неизбежно сохраняете соединения на своем сервере. Каждое соединение через веб-сокет привязано к одному серверу, и другие серверы не могут отправлять данные на это соединение.
Теперь возникает вторая проблема: у вас должен быть способ, чтобы другие серверы могли отправлять сообщения на это соединение через веб-сокет. Для этого вам нужен способ отправки сообщений между серверами. К счастью, это уже называется брокером сообщений. Вы даже можете использовать механизм публикации/подписки Redis для отправки сообщений между вашими серверами.
Подведем итог тому, что мы уже обсудили:
- Соединения через веб-сокет сохраняют состояние
- Сервер веб-сокетов автоматически становится приложением с отслеживанием состояния.
- Чтобы приложения с отслеживанием состояния масштабировались, вам необходимо иметь внешнее хранилище состояния (пример: Redis).
- Соединения Websocket привязаны к одному серверу
- Серверы должны подключаться к брокеру сообщений, чтобы отправлять сообщения в веб-сокеты на других серверах.
Добавление экземпляра Redis в мой стек решает все проблемы масштабирования с веб-сокетами?
К сожалению нет. Что ж, есть еще одна проблема с масштабируемыми архитектурами веб-сокетов: балансировка нагрузки.
Веб-сокеты для балансировки нагрузки
Балансировка нагрузки — это метод, обеспечивающий, чтобы все ваши серверы распределяли примерно одинаковую нагрузку. На обычном HTTP-сервере это можно реализовать с помощью простых алгоритмов, таких как Round Robin. Но это не идеально для сервера Websocket.
Представьте, что у вас есть группа серверов с автоматическим масштабированием. Это означает, что по мере увеличения нагрузки развертываются новые экземпляры, а по мере снижения нагрузки некоторые экземпляры закрываются.
Поскольку HTTP-запросы недолговечны, нагрузка равномерно распределяется по всем экземплярам, даже если серверы добавляются/удаляются.
Соединения через веб-сокет являются долгоживущими (постоянными), что означает, что новые серверы не снимут нагрузку со старых серверов. Потому что старые серверы все еще сохраняют свои соединения через веб-сокеты. Например, скажем, Server 1 имеет 1000 открытых подключений к веб-сокетам. В идеале, когда добавляется новый сервер Server 2, вы хотите переместить 500 подключений через веб-сокет с Server 1 на Server 2. Но это невозможно с традиционными балансировщиками нагрузки.
Вы можете отказаться от всех подключений через веб-сокет и ожидать, что клиенты снова подключатся. Тогда у вас может быть 500/500 распределений веб-сокетов на ваших серверах, но это плохое решение, потому что:
- Серверы будут засыпаны запросами на переподключение, а нагрузка на сервер будет сильно колебаться.
- Если серверы часто масштабируются, клиенты будут часто переподключаться, что может негативно сказаться на пользовательском опыте.
- Это не элегантное решение - мы знаем, что вы, ребята, заботитесь об этом!
Самое элегантное решение этой проблемы называется: Согласованное хеширование.
Алгоритм балансировки нагрузки: согласованное хэширование
Существуют различные алгоритмы балансировки нагрузки, но последовательное хеширование — это нечто из другого мира.
Основная идея балансировки нагрузки с последовательным хэшированием заключается в следующем:
- Хешируйте входящее соединение с некоторым свойством, скажем userId => hashValue
- Затем вы можете использовать hashValue, чтобы определить, к какому серверу должен подключиться этот пользователь.
Это предполагает, что ваша хеш-функция равномерно распределяет userId по hashValue.
Теперь у вас все еще есть проблема при добавлении/удалении серверов. И решение состоит в том, чтобы сбрасывать соединения при добавлении или удалении новых серверов.
Как это решить теперь?
Прелесть этого решения в том, что при согласованном хешировании вам не нужно удалять все соединения, а нужно просто удалять только некоторые соединения. На самом деле, вы сбрасываете именно то количество соединений, которое вам нужно сбросить. Позвольте объяснить со сценарием:
- Изначально Server 1 имеет 1000 подключений.
- Добавлен Server 2
- Как только Server 2 добавлен, Server 1 запускает алгоритм перебалансировки.
- Алгоритм перебалансировки определяет, какие соединения через веб-сокет необходимо удалить, и если наша хэш-функция обнаруживает примерно 500 соединений, которые необходимо перейти на Server 2.
- Server 1 отправляет сообщение о повторном подключении этим 500 клиентам, и они подключаются к Server 2.
Гораздо более простое и эффективное решение
Discord управляет большим количеством соединений Websocket. Как решили проблему с балансировкой нагрузки?
Если вы изучите документы разработчика о том, как установить соединение через веб-сокет, вот как они это делают:
- Отправьте запрос HTTP GET на конечную точку
/gateway,
получите доступные URL-адреса сервера Websocket. - Подключиться к серверу Websocket.
Магия этого решения заключается в том, что вы можете контролировать, к какому серверу должны подключаться новые клиенты. Если вы добавите новый сервер, вы можете направить все новые соединения на новый сервер. Если вы хотите переместить 500 подключений с Server 1 на Server 2, просто удалите 500 подключений с Server 1 и укажите адрес Server 2 из конечной точки /gateway
.
Конечная точка /gateway
должна знать распределение нагрузки на все серверы и принимать решения на основе этого. Он может просто вернуть URL с сервера с минимальной нагрузкой.
Это решение работает и намного проще по сравнению с последовательным хешированием. Но метод последовательного хеширования не должен знать о распределении нагрузки на все серверы и не требует предварительного HTTP-запроса. Таким образом, клиенты могут подключаться быстрее, но это, как правило, не имеет большого значения. Кроме того, реализация согласованного алгоритма хеширования может быть сложной задачей.