Представьте себе: у вас железнодорожная станция, сотни вагонов, десятки пользователей в системе, каждый раз кто-то нажимает кнопку "Обновить", чтобы узнать — разгрузили ли нужный вагон.
Вся логика обновления построена на "manual refresh". Да-да, пользователь сам жмёт кнопку, чтобы получить свежие данные. Система автоматической разгрузки или другой человек разгрузил что-то на другом конце станции, но вы об этом не узнаете, пока не перезагрузите страницу.
А ещё — избыток HTTP-запросов, polling, перегруженные серверы и полное отсутствие real-time взаимодействия.
Есть вариант! Масштабируемая и отказоустойчивая архитектура с использованием Redis Sentinel + Pub/Sub + WebSocket/SSE.
В статье расскажем, какие проблемы возникают с real-time в Kubernetes, почему стандартные WebSocket-подходы не работают при нескольких подах, как построить отказоустойчивую систему с Redis Sentinel, как сделать real-time UI, сохранив отказоустойчивость и масштабируемость, и как всё это запустить локально для отладки.
Проблема: Медленный и статичный интерфейс
Пользователи не видят изменения в реальном времени
Если кто-то разгрузил вагоны на другом компьютере, на UI это не отобразится без перезагрузки страницы
Частые HTTP-запросы (polling) перегружают сервер
Задача: Реализовать real-time отображение
✔ Сразу показывать обновление количества разгруженных вагонов всем пользователям
✔ Минимизировать нагрузку на сервер (избавиться от постоянного polling’а)
✔ Обеспечить масштабируемость и отказоустойчивость, даже если развернуто несколько подов в Kubernetes
Когда система состоит из нескольких микросервисов, важно организовать масштабируемую и отказоустойчивую доставку уведомлений.
Стандартный WebSocket или SSE (Server-Sent Events) в монолитах работает просто:
Клиенты подписываются → Сервер отправляет уведомления напрямую.
Но если сервис развернут в Kubernetes (K8s) с несколькими подами, возникают сложности:
Клиент подключается к одному поду и может не получить уведомление, отправленное другим подом
Балансировщик распределяет подключения случайно
Если под рестартуется – все подключения теряются
Как мы попробуем решить эту задачу?
Мы разделим архитектуру уведомлений на три ключевых компонента:
1) Бизнес-микросервисы (генераторы событий) – публикуют события в Redis Pub/Sub.
2) Redis Sentinel + Redis Pub/Sub (брокер сообщений) – обеспечивает маршрутизацию и отказоустойчивость.
3) Сервис уведомлений (Notifier Service) с WebSocket/SSE – подписывается на Redis и доставляет уведомления клиентам в реальном времени.
!Клиенту неважно, какой под обрабатывает его WebSocket/SSE – все они получают одно и то же уведомление.
Проблема: Недостатки обычного Redis в Kubernetes
Обычно используют Redis Pub/Sub, но у него есть минусы:
1) Один Redis без Sentinel — один instance (точка отказа, нет High Availability (Высокой Доступности))
2) Если Redis упадёт, то все поды потеряют соединение с уведомлениями
Какое решение нам нужно?
✔ Redis Sentinel автоматически переключает мастера при сбоях, а реплики обеспечивают высокую доступность и отказоустойчивость
✔ Сервис уведомлений подключается к Redis через Sentinel, поэтому при сбоях система автоматически переподключается к новому мастеру без простоев
✔ Сервис уведомлений в нескольких подах, который слушает Redis через Sentinel, получает уведомления через Pub/Sub и рассылает их пользователям через WebSocket и SSE
Что это нам даёт?
Если один Redis-узел падает, Sentinel автоматически назначает новый мастер, и сервис продолжает получать данные.
Если под сервиса уведомлений перезапускается, соединение SSE/WebSocket разрывается. Клиентский код обнаружит разрыв (onerror/onclose) и попробует автоматически переподключиться. Балансировщик Kubernetes направит новый запрос клиента на доступный под.
Нагрузка на сервер минимизирована — нет постоянных HTTP-запросов (polling).
Как это работает в коде?
dependencies
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> <version>3.4.2</version> </dependency> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.6.0.BETA2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
1. Публикация уведомлений в Redis Sentinel
@Service @RequiredArgsConstructor public class MessageNotificationService { private final ReactiveStringRedisTemplate redisTemplate; private final MessageNotificationConverter messageNotificationConverter; /** * Публикует уведомление в канал Redis Pub/Sub. * <p> * Уведомление отправляется во все подписанные сервисы через механизм * Pub/Sub, работающий в Redis Sentinel. * </p> * * @param notificationMessage Сообщение {@link NotificationMessageReq}, * которое нужно отправить подписчикам. (Ваша ДТО, которое должно быть приведено к единому формату с ответным DTO для удобства сериализации в JSON.) */ public void publish(NotificationMessageReq notificationMessage) { redisTemplate.convertAndSend( "notifications_channel", // Pub/Sub канал в Redis, в который публикуем сообщение messageNotificationConverter.convert(notificationMessage) // Преобразуем в JSON ).subscribe(); } }
Теперь уведомление реплицируется в кластер Redis Sentinel и доставляется на все поды в Kubernetes.
2. Чтение Redis Pub/Sub и пересылка через SSE/WebSockets
Конфигурация реактивных компонентов для работы с Redis и потоками уведомлений
import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.ReactiveStringRedisTemplate; import org.springframework.data.redis.listener.ReactiveRedisMessageListenerContainer; import reactor.core.publisher.Sinks; /** * Конфигурация реактивных компонентов для работы с Redis и потоками уведомлений. */ @Configuration @RequiredArgsConstructor public class ReactiveListenerConfig { private final ReactiveStringRedisTemplate redisTemplate; /** * Создаёт и настраивает {@link ReactiveRedisMessageListenerContainer}, который слушает каналы Redis. * <p> * Используется для подписки на сообщения в Redis с возможностью реактивной обработки. * </p> * * @return Экземпляр {@link ReactiveRedisMessageListenerContainer} с подключением к Redis. */ @Bean public ReactiveRedisMessageListenerContainer listenerContainer() { return new ReactiveRedisMessageListenerContainer(redisTemplate.getConnectionFactory()); } /** * Создаёт многопоточный (`multicast`) Sink для отправки уведомлений в реактивном стиле. * <p> * Позволяет подписчикам получать уведомления в реальном времени. * </p> * <p> * Используется для потоковой отправки объектов {@link NotificationMessageResp } - ваша придуманная ДТО. * </p> * * @return Экземпляр {@link Sinks.Many} для обработки уведомлений. */ @Bean public Sinks.Many<NotificationMessageResp> notificationSink() { return Sinks.many().multicast().directAllOrNothing(); } }
Конфигурация WebSocket
import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; /** * Конфигурация WebSocket для отправки уведомлений. */ @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { /** * Настраивает WebSocket STOMP Endpoints. * Клиенты подключаются через `/ws`. */ @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins("*"); // Подключение на `/ws` } /** * Настраивает брокер сообщений WebSocket. * Клиенты могут подписываться на `/topic/notifications`. */ @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.enableSimpleBroker("/topic"); // Канал для уведомлений registry.setApplicationDestinationPrefixes("/app"); } }
NotifierService подписывается на Redis Pub/Sub и отправляет уведомления WebSocket/SSE клиентам
@Service @RequiredArgsConstructor public class NotifierService { private final ReactiveRedisMessageListenerContainer listenerContainer; private final Sinks.Many<NotificationMessageResp> notificationSink; @PostConstruct public void subscribeToRedisNotifications() { listenerContainer.receive(new ChannelTopic("notifications_channel")) //слушаем тот же канал, в который слали сообщение .map(message -> messageNotificationConverter.convert(message.getMessage())) .doOnNext(notification -> { log.info(" Получено уведомление из Redis: {}", notification); // Отправка в SSE notificationSink.tryEmitNext(notification); // Отправка в WebSocket messagingTemplate.convertAndSend("/topic/notifications", notification); }) .subscribe(); } }
3. SSE + Heartbeat, чтобы соединение не разрывалось
Браузеры и балансировщики часто рвут SSE по "таймауту" – исправляем это "пингами"
@Operation(summary = "Получить поток уведомлений", description = "Позволяет подписаться на SSE-уведомления из Redis-канала `notifications_channel`.", responses = @ApiResponse( content = @Content(array = @ArraySchema(schema = @Schema(implementation = NotificationMessageResp.class)), mediaType = MediaType.TEXT_EVENT_STREAM_VALUE))) @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<NotificationMessageResp> getSseNotifications() { return Flux.merge( notificationSink.asFlux(), // Heartbeat ping Flux.interval(Duration.ofSeconds(2)) .map(e -> NotificationMessageResp.builder() .event(EventType.PING.name()) .data(RecordData.builder().timestamp(Instant.now()).build()) .build())) .share(); //Все подписанные клиенты получат сообщения одновременно! }
Инструкция: Как задать таймаут в HAProxy Ingress через Kubernetes Dashboard
Можно избавиться от этих строк:
Flux.interval(Duration.ofSeconds(2)) .map(e -> NotificationMessageResp.builder() .event(EventType.PING.name()) .data(RecordData.builder().timestamp(Instant.now()).build()) .build())
Если в вашем кластере Kubernetes используется HAProxy Ingress Controller
Шаги: Настройка таймаута в HAProxy Ingress (через UI)
1) Открываем Kubernetes Dashboard и переходим в раздел Networking → Routes.
2) Находим нужный сервис (notifications-service, если используем SSE/WebSocket).
3) Переходим во вкладку YAML (Редактирование манифеста).
4) В metadata.annotations добавляем следующую строку:
haproxy.router.openshift.io/timeout: "10m"
apiVersion: route.openshift.io/v1 kind: Route metadata: name: notifications-service annotations: haproxy.router.openshift.io/timeout: "10m" # Таймаут соединения (10 минут)
5) Сохраняем изменения и применяем конфигурацию.
Теперь HAProxy не будет разрывать соединения WebSocket/SSE при простое до 10 минут, а клиентский код автоматически переподключится в случае разрыва.
✔ Теперь SSE-соединение не разорвётся из-за тайм-аута!
Скриншёт


Производительность Redis Pub/Sub
Согласно документации Redis, система с 3 подами в Kubernetes может обрабатывать до 500 000 сообщений в секунду через Redis Pub/Sub, в зависимости от конфигурации серверов, сетевых задержек и размера сообщений.
Для нашей задачи такое решение более чем подходит.
Развертывание Redis Sentinel локально для экспериментов через Docker
1) Создаём файлы конфигурации
Создаём следующие файлы в рабочей директории:
docker-compose.yml
version: '3.8' services: redis-master: image: redis:7.2 container_name: redis-master restart: always ports: - "6379:6379" # Доступен на localhost:6379 networks: - redis_network command: [ "redis-server", "/etc/redis/redis.conf" ] volumes: - ./redis-master.conf:/etc/redis/redis.conf redis-slave: image: redis:7.2 container_name: redis-slave restart: always ports: - "6380:6379" # Доступен на localhost:6380 networks: - redis_network command: [ "redis-server", "/etc/redis/redis.conf", "--replicaof", "redis-master", "6379" ] volumes: - ./redis-slave.conf:/etc/redis/redis.conf redis-sentinel: image: redis:7.2 container_name: redis-sentinel restart: always ports: - "26379:26379" # Доступен на localhost:26379 networks: - redis_network command: ["redis-server", "/etc/redis/sentinel.conf", "--sentinel"] volumes: - ./sentinel.conf:/etc/redis/sentinel.conf networks: redis_network: driver: bridge
redis-master.conf
port 6379 bind 0.0.0.0 appendonly yes protected-mode no
redis-slave.conf
port 6379 bind 0.0.0.0 appendonly yes replicaof redis-master 6379 protected-mode no
sentinel.conf
port 26379 bind 0.0.0.0 sentinel monitor mymaster 127.0.0.1 6379 2 sentinel down-after-milliseconds mymaster 5000 sentinel failover-timeout mymaster 10000 sentinel parallel-syncs mymaster 1
Выполняем команду:
docker compose up -d
Как проверить работоспособность Redis Sentinel после развертывания?
После запуска Docker Compose с Redis Sentinel, можно выполнить следующие команды:
1) Проверка статуса Redis Sentinel
Убедимся, что Sentinel видит мастер-узел и следит за репликами:
docker exec -it redis-sentinel redis-cli -p 26379 info Sentinel
2) Проверка ролей мастер/реплика
Проверим, кто сейчас мастер и сколько реплик подключено:
docker exec -it redis-master redis-cli info replication
Если всё работает, увидим строку:
role:master connected_slaves:1
Аналогично можно проверить реплику:
docker exec -it redis-slave redis-cli info replication
Ответ должен содержать role:slave.
3) Тест Redis Pub/Sub (отправка уведомления)
Подписка на канал notifications_channel:
Открываем первый терминал и запускаем слушатель Redis:
docker exec -it redis-master redis-cli SUBSCRIBE notifications_channel
Публикация сообщения в канал:
Во втором терминале отправляем тестовое событие:
docker exec -it redis-master redis-cli PUBLISH notifications_channel "Test message"
В первом терминале должно появиться:
1) "message" 2) "notifications_channel" 3) "Test message"
Это значит, что Pub/Sub работает корректно!
Конфигурация spring-boot приложения для подключения к Redis Sentinel локально
application.yml
spring: data: redis: sentinel: master: ${SPRING_DATA_REDIS_SENTINEL_MASTER:mymaster} # Имя master узла, указанное в sentinel.conf nodes: ${SPRING_DATA_REDIS_SENTINEL_NODES:localhost:26379} # Список Sentinel узлов password: ${SPRING_DATA_REDIS_SENTINEL_PASSWORD:mystrongpassword} # Пароль для Sentinel (если он установлен) password: ${SPRING_DATA_REDIS_PASSWORD:mystrongpassword} # Пароль для Redis (отдельно от Sentinel). Для локальных эксперементов не пригодится. timeout: ${SPRING_DATA_REDIS_TIMEOUT:60000} # Таймаут в миллисекундах
Вывод: Почему Redis Sentinel + SSE/WebSocket — мощное решение?
✔ Уведомления отказоустойчивы благодаря Redis Sentinel — даже если одна нода Redis выходит из строя, система продолжает работать без перебоев.
✔ Поддержка SSE & WebSocket делает UI максимально реактивным — данные обновляются в реальном времени без необходимости ручного обновления страницы.
✔ Горизонтальное масштабирование в Kubernetes — сервис уведомлений легко масштабируется без дублирования событий.
✔ Redis Pub/Sub гарантирует, что все подписанные поды получают уведомления — нет проблем с балансировкой нагрузки.
✔ Стабильность соединений — обновление других микросервисов не влияет на сервис уведомлений, соединения SSE и WebSocket не разрываются при деплоях.
✔ Если Redis или сервис уведомлений вдруг перестанет работать, это затронет только механизм real-time обновления UI. Однако все остальные процессы, включая операции разгрузки вагонов, продолжат работать: пользователи смогут обновлять данные вручную.
Теперь система уведомлений в вашей архитектуре полностью отказоустойчива, масштабируема и готова к высоким нагрузкам!
Дисклеймер: Все DTO и URL-адреса в данной статье являются выдуманными и приведены исключительно в демонстрационных целях. Все конфигурационные файлы основаны на стандартных настройках. Любые совпадения с реальными сервисами, системами или инфраструктурой случайны и не являются преднамеренными.
