Spring Websocket в кластере tomcat
В нашем текущем приложении мы используем Spring Websockets через STOMP. Мы стремимся масштабироваться горизонтально. Есть ли какие-либо рекомендации относительно того, как мы должны обрабатывать трафик websocket через несколько экземпляров tomcat и как мы можем поддерживать информацию о сеансе на нескольких узлах. Есть ли рабочий образец, на который можно ссылаться?
Ответы
Ответ 1
Ваше требование можно разделить на 2 подзадачи:
-
Поддержание информации о сеансе на нескольких узлах: вы можете попробовать кластер сеансов Spring, поддерживаемый Redis (см. HttpSession с Redis). Это очень просто и уже имеет поддержку Spring Websockets (см. Spring Session и WebSockets).
-
Обрабатывать трафик веб-сайтов по нескольким экземплярам tomcat. Существует несколько способов сделать это.
- Первый способ: использовать полнофункциональный брокер (например, ActiveMQ) и попробовать новую функцию Поддержка нескольких серверов WebSocket (от: 4.2.0 RC1)
- Второй способ: использование полнофункционального брокера и реализация распределенного
UserSessionRegistry
(например: Использование Redis: D). Реализация по умолчанию DefaultUserSessionRegistry
с использованием хранилища в памяти.
Обновлено: я написал простую реализацию, используя Redis, попробуйте, если вам интересно
Чтобы настроить полнофункциональный брокер (брокерское реле), вы можете попробовать:
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
...
@Autowired
private RedisConnectionFactory redisConnectionFactory;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost") // broker host
.setRelayPort(61613) // broker port
;
config.setApplicationDestinationPrefixes("/app");
}
@Bean
public UserSessionRegistry userSessionRegistry() {
return new RedisUserSessionRegistry(redisConnectionFactory);
}
...
}
и
import java.util.Set;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.BoundSetOperations;
import org.springframework.data.redis.core.RedisOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.messaging.simp.user.UserSessionRegistry;
import org.springframework.util.Assert;
/**
* An implementation of {@link UserSessionRegistry} backed by Redis.
* @author thanh
*/
public class RedisUserSessionRegistry implements UserSessionRegistry {
/**
* The prefix for each key of the Redis Set representing a user sessions. The suffix is the unique user id.
*/
static final String BOUNDED_HASH_KEY_PREFIX = "spring:websockets:users:";
private final RedisOperations<String, String> sessionRedisOperations;
@SuppressWarnings("unchecked")
public RedisUserSessionRegistry(RedisConnectionFactory redisConnectionFactory) {
this(createDefaultTemplate(redisConnectionFactory));
}
public RedisUserSessionRegistry(RedisOperations<String, String> sessionRedisOperations) {
Assert.notNull(sessionRedisOperations, "sessionRedisOperations cannot be null");
this.sessionRedisOperations = sessionRedisOperations;
}
@Override
public Set<String> getSessionIds(String user) {
Set<String> entries = getSessionBoundHashOperations(user).members();
return (entries != null) ? entries : Collections.<String>emptySet();
}
@Override
public void registerSessionId(String user, String sessionId) {
getSessionBoundHashOperations(user).add(sessionId);
}
@Override
public void unregisterSessionId(String user, String sessionId) {
getSessionBoundHashOperations(user).remove(sessionId);
}
/**
* Gets the {@link BoundHashOperations} to operate on a username
*/
private BoundSetOperations<String, String> getSessionBoundHashOperations(String username) {
String key = getKey(username);
return this.sessionRedisOperations.boundSetOps(key);
}
/**
* Gets the Hash key for this user by prefixing it appropriately.
*/
static String getKey(String username) {
return BOUNDED_HASH_KEY_PREFIX + username;
}
@SuppressWarnings("rawtypes")
private static RedisTemplate createDefaultTemplate(RedisConnectionFactory connectionFactory) {
Assert.notNull(connectionFactory, "connectionFactory cannot be null");
StringRedisTemplate template = new StringRedisTemplate(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
return template;
}
}
Ответ 2
Горизонтальное масштабирование WebSockets на самом деле очень отличается от горизонтального масштабирования приложений с поддержкой состояния без учета состояния и состояния HTTP.
Горизонтальное масштабирование HTTP-приложения без учета состояния: просто разверните несколько экземпляров приложений на разных машинах и поставьте перед ними балансировку нагрузки. Существуют довольно разные решения балансировки нагрузки, такие как HAProxy, Nginx и т.д. Если вы находитесь в облачной среде, такой как AWS, вы также можете управлять такими решениями, как Elastic Load Balancer.
Горизонтальное масштабирование Stateful HTTP-приложения. Было бы здорово, если бы все приложения были безгражданны каждый раз, но, к сожалению, это не всегда возможно. Таким образом, при работе с HTTP-приложениями с поддержкой состояния вы должны заботиться о сеансе HTTP, который в основном является хранилищем локального для каждого другого клиента, где веб-сервер может хранить данные, которые хранятся в разных HTTP-запросах ( например, когда вы имеете дело с корзиной покупок). Ну, в этом случае при масштабировании по горизонтали вы должны знать, что, как я уже сказал, это хранилище LOCAL, поэтому ServerA не сможет обрабатывать сеанс HTTP, который находится на сервере ServerB. Другими словами, если по какой-либо причине Клиент1, обслуживаемый сервером ServerA, внезапно начинает обслуживаться ServerB, его сеанс HTTP будет потерян (и его корзина не будет!). Причинами могут быть неудача node или даже развертывание.
Чтобы решить эту проблему, вы не можете поддерживать HTTP-сеансы только локально, то есть вы должны хранить их на другом внешнем компоненте. Это несколько компонентов, которые могли бы обрабатывать это, например, любую реляционную базу данных, но это было бы накладными расходами. Некоторые базы данных NoSQL могут очень хорошо справляться с этим поведением по ключевым словам, например Redis.
Теперь, когда HTTP-сеанс хранится в Redis, если клиент начинает обслуживаться другим сервером, он будет загружать клиентский сеанс HTTP из Redis и загружать его в свою память, поэтому все будет продолжать работать, и пользователь не потеряет его HTTP-сеанс.
Вы можете использовать сеанс Spring для быстрого хранения сеанса HTTP в Redis.
Горизонтальное масштабирование приложения WebSocket. Когда соединение WebSocket установлено, сервер должен поддерживать соединение, открытое с клиентом, чтобы они могли обмениваться данными в обоих направлениях. Когда клиент слушает пункт назначения, например "/topic/public.messages", мы говорим, что клиент подписался на это место назначения. В Spring, когда вы используете подход simpleBroker
, подписки сохраняются в памяти, поэтому, что происходит, например, если Client1 обслуживается ServerA и хочет отправить сообщение с использованием WebSocket на Client2 обслуживается ServerB? Вы уже знаете ответ! Сообщение не будет доставлено клиенту2, потому что Server1 даже не знает о подписке Client2.
Таким образом, чтобы решить эту проблему, вам снова придется вытеснять подписки на WebSockets. Поскольку вы используете STOMP в качестве подпротокола, вам нужен внешний компонент, который может выступать в качестве внешнего брокера STOMP. Есть довольно много инструментов, способных это сделать, но я бы предложил RabbitMQ.
Теперь вы должны изменить конфигурацию Spring, чтобы она не поддерживала подписки в памяти. Вместо этого он будет делегировать подписки внешнему брокеру STOMP. Вы можете легко достичь этого с помощью некоторых базовых конфигураций, таких как enableStompBrokerRelay
.
Важно отметить, что сеанс HTTP отличается от сеанса WebSocket. Использование Spring Сессия для хранения сеанса HTTP в Redis абсолютно не связана с горизонтальным масштабированием WebSockets.
Я закодировал полное приложение для веб-чата с Spring Boot (и многое другое), которое использует RabbitMQ как Full External STOMP Broker, и public on GitHub, пожалуйста, скопируйте его, запустите приложение на своем компьютере и просмотрите детали кода.
Когда дело доходит до потери соединения с WebSocket, этого не может сделать Spring. На самом деле, например, клиентская сторона запрашивает повторное соединение, реализуя функцию обратного вызова пересоединения (например, поток подтверждения связи WebSocket, клиент должен начинать квитирование, а не сервер). Есть несколько клиентских библиотек, которые могут работать с этим прозрачно для вас. Это не дело SockJS. В приложении чата я также реализовал эту функцию повторного подключения.
Ответ 3
Сохранять информацию о сеансе на нескольких узлах:
Предположим, что у нас есть 2 хоста сервера, резервное копирование с балансировщиком нагрузки.
Websockets - это соединение сокетов от браузера к конкретному серверу host.eg host1
Теперь, если хост1 опустится, соединение сокета из балансира нагрузки - хост 1 сломается.
Как spring снова откроет соединение с websocket из балансировки нагрузки на хост 2? браузер не должен открывать новое подключение к сети.