最近在项目中在做一个消息推送的功能,比如客户下单之后通知给给对应的客户发送系统通知,这种消息推送需要使用到全双工的websocket推送消息。

所谓的全双工表示客户端和服务端都能向对方发送消息。不使用同样是全双工的http是因为http只能由客户端主动发起请求,服务接收后返回消息。websocket建立起连接之后,客户端和服务端都能主动向对方发送消息。

上一篇文章Spring Boot 整合单机websocket介绍了websocket在单机模式下进行消息的发送和接收:

用户A用户Bweb服务器建立连接之后,用户A发送一条消息到服务器,服务器再推送给用户B,在单机系统上所有的用户都和同一个服务器建立连接,所有的session都存储在同一个服务器中。

单个服务器是无法支撑几万人同时连接同一个服务器,需要使用到分布式或者集群将请求连接负载均衡到到不同的服务下。消息的发送方和接收方在同一个服务器,这就和单体服务器类似,能成功接收到消息:

但负载均衡使用轮询的算法,无法保证消息发送方和接收方处于同一个服务器,当发送方和接收方不是在同一个服务器时,接收方是无法接受到消息的:

websocket集群问题解决思路

客户端和服务端每次建立连接时候,会创建有状态的会话session,服务器的保存维持连接的session。客户端每次只能和集群服务器其中的一个服务器连接,后续也是和该服务器进行数据传输。

要解决集群的问题,应该考虑session共享的问题,客户端成功连接服务器之后,其他服务器也知道客户端连接成功。

方案一:session 共享(不可行)

websocket类似的http是如何解决集群问题的?解决方案之一就是共享session,客户端登录服务端之后,将session信息存储在Redis数据库中,连接其他服务器时,从Redis获取session,实际就是将session信息存储在Redis中,实现redis的共享。

session可以被共享的前提是可以被序列化,而websocketsession是无法被序列化的,httpsession记录的是请求的数据,而websocketsession对应的是连接,连接到不同的服务器,session也不同,无法被序列化。

方案二:ip hash(不可行)

http不使用session共享,就可以使用Nginx负载均衡的ip hash算法,客户端每次都是请求同一个服务器,客户端的session都保存在服务器上,而后续请求都是请求该服务器,都能获取到session,就不存在分布式session问题了。

websocket相对http来说,可以由服务端主动推动消息给客户端,如果接收消息的服务端和发送消息消息的服务端不是同一个服务端,发送消息的服务端无法找到接收消息对应的session,即两个session不处于同一个服务端,也就无法推送消息。如下图所示:

解决问题的方法是将所有消息的发送方和接收方都处于同一个服务器下,而消息发送方和接收方都是不确定的,显然是无法实现的。

方案三:广播模式

将消息的发送方和接收方都处于同一个服务器下才能发送消息,那么可以转换一下思路,可以将消息以消息广播的方式通知给所有的服务器,可以使用消息中间件发布订阅模式,消息脱离了服务器的限制,通过发送到中间件,再发送给订阅的服务器,类似广播一样,只要订阅了消息,都能接收到消息的通知:

发布者发布消息到消息中间件,消息中间件再将发送给所有订阅者:

广播模式的实现

搭建单机 websocket

参考以前写的websocket单机搭建 文章,先搭建单机websocket实现消息的推送。

1. 添加依赖

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-freemarker</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency> <dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

2. 创建 ServerEndpointExporter 的 bean 实例

ServerEndpointExporter 的 bean 实例自动注册 @ServerEndpoint 注解声明的 websocket endpoint,使用springboot自带tomcat启动需要该配置,使用独立 tomcat 则不需要该配置。

@Configuration
public class WebSocketConfig {
//tomcat启动无需该配置
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

3. 创建服务端点 ServerEndpoint 和 客户端端

  • 服务端点
@Component
@ServerEndpoint(value = "/message")
@Slf4j
public class WebSocket { private static Map<String, WebSocket> webSocketSet = new ConcurrentHashMap<>(); private Session session; @OnOpen
public void onOpen(Session session) throws SocketException {
this.session = session;
webSocketSet.put(this.session.getId(),this); log.info("【websocket】有新的连接,总数:{}",webSocketSet.size());
} @OnClose
public void onClose(){
String id = this.session.getId();
if (id != null){
webSocketSet.remove(id);
log.info("【websocket】连接断开:总数:{}",webSocketSet.size());
}
} @OnMessage
public void onMessage(String message){
if (!message.equals("ping")){
log.info("【wesocket】收到客户端发送的消息,message={}",message);
sendMessage(message);
}
} /**
* 发送消息
* @param message
* @return
*/
public void sendMessage(String message){
for (WebSocket webSocket : webSocketSet.values()) {
webSocket.session.getAsyncRemote().sendText(message);
}
log.info("【wesocket】发送消息,message={}", message); } }
  • 客户端点
<div>
<input type="text" name="message" id="message">
<button id="sendBtn">发送</button>
</div>
<div style="width:100px;height: 500px;" id="content">
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
<script type="text/javascript">
var ws = new WebSocket("ws://127.0.0.1:8080/message");
ws.onopen = function(evt) {
console.log("Connection open ...");
}; ws.onmessage = function(evt) {
console.log( "Received Message: " + evt.data);
var p = $("<p>"+evt.data+"</p>")
$("#content").prepend(p);
$("#message").val("");
}; ws.onclose = function(evt) {
console.log("Connection closed.");
}; $("#sendBtn").click(function(){
var aa = $("#message").val();
ws.send(aa);
}) </script>

服务端和客户端中的OnOpenoncloseonmessage都是一一对应的。

  • 服务启动后,客户端ws.onopen调用服务端的@OnOpen注解的方法,储存客户端的session信息,握手建立连接。
  • 客户端调用ws.send发送消息,对应服务端的@OnMessage注解下面的方法接收消息。
  • 服务端调用session.getAsyncRemote().sendText发送消息,对应的客户端ws.onmessage接收消息。

添加 controller

@GetMapping({"","index.html"})
public ModelAndView index() {
ModelAndView view = new ModelAndView("index");
return view;
}

效果展示

打开两个客户端,其中的一个客户端发送消息,另一个客户端也能接收到消息。

添加 RabbitMQ 中间件

这里使用比较常用的RabbitMQ作为消息中间件,而RabbitMQ支持发布订阅模式

添加消息订阅

交换机使用扇形交换机,消息分发给每一条绑定该交换机的队列。以服务器所在的IP + 端口作为唯一标识作为队列的命名,启动一个服务,使用队列绑定交换机,实现消息的订阅:

@Configuration
public class RabbitConfig { @Bean
public FanoutExchange fanoutExchange() {
return new FanoutExchange("PUBLISH_SUBSCRIBE_EXCHANGE");
} @Bean
public Queue psQueue() throws SocketException {
// ip + 端口 为队列名
String ip = IpUtils.getServerIp() + "_" + IpUtils.getPort();
return new Queue("ps_" + ip);
} @Bean
public Binding routingFirstBinding() throws SocketException {
return BindingBuilder.bind(psQueue()).to(fanoutExchange());
}
}

获取服务器IP和端口可以具体查看Github源码,这里就不做详细描述了。

修改服务端点 ServerEndpoint

WebSocket添加消息的接收方法,@RabbitListener 接收消息,队列名称使用常量命名,动态队列名称使用 #{name},其中的nameQueuebean 名称:

@RabbitListener(queues= "#{psQueue.name}")
public void pubsubQueueFirst(String message) {
System.out.println(message);
sendMessage(message);
}

然后再调用sendMessage方法发送给所在连接的客户端。

修改消息发送

WebSocket类的onMessage方法将消息发送改成RabbitMQ方式发送:

@OnMessage
public void onMessage(String message){
if (!message.equals("ping")){
log.info("【wesocket】收到客户端发送的消息,message={}",message);
//sendMessage(message);
if (rabbitTemplate == null) {
rabbitTemplate = (RabbitTemplate) SpringContextUtil.getBean("rabbitTemplate");
}
rabbitTemplate.convertAndSend("PUBLISH_SUBSCRIBE_EXCHANGE", null, message);
}
}

消息通知流程如下所示:

启动两个实例,模拟集群环境

打开idea的Edit Configurations

点击左上角的COPY,然后添加端口server.port=8081

启动两个服务,端口分别是80808081。在启动8081端口的服务,将前端连接端口改成8081:

var ws = new WebSocket("ws://127.0.0.1:8081/message");

效果展示

源码

github源码

参考

Websocket集群解决方案的更多相关文章

  1. 高可用性、负载均衡的mysql集群解决方案

    高可用性.负载均衡的mysql集群解决方案 一.mysql的市场占有率 二.mysql为什么受到如此的欢迎 三.mysql数据库系统的优缺点 四.网络服务器的需求 五.什么是mysql的集群 六.什么 ...

  2. Terrocotta - 基于JVM的Java应用集群解决方案

    前言 越来越多的企业关键应用都必须采用集群技术,实现负载均衡(Load Balancing).容错(Fault Tolerance)和灾难恢复(Failover).以达到系统可用性(High Avai ...

  3. zookeeper、solrcloud、rediscluster集群解决方案

        集群解决方案 课程目标 目标1:说出什么是集群以及与分布式的区别 目标2:能够搭建Zookeeper集群 目标3:能够搭建SolrCloud集群 目标4:能够搭建RedisCluster集群 ...

  4. t-io 集群解决方案以及源码解析

    t-io 集群解决方案以及源码解析 0x01 概要说明 本博客是基于老谭t-io showcase中的tio-websocket-showcase 示例来实现集群.看showcase 入门还是挺容易的 ...

  5. springBoot+websocket集群系列知识

    WebSocket简介和spring boot集成简单消息代理 Spring Boot 集成 websocket,使用RabbitMQ做为消息代理 Spring Websocket实现向指定的用户发送 ...

  6. 关于websocket集群中不同服务器的用户间通讯问题

    最近将应用部署到集群时遇到一个问题,即用户命中不同的服务器导致的用户间无法进行websocket通讯,在网上搜索到类似问题但都没有具体解决方案. 于是用redis的订阅发布功能解决了该问题,具体流程如 ...

  7. spring websocket集群问题的简单记录

    目录 前言 解决方案 代码示例 前言 最近公司里遇到一个问题,在集群中一些websocket的消息丢失了. 产生问题的原理很简单,发送消息的服务和接收者连接的服务不是同一个服务. 解决方案 用中间件( ...

  8. Redis 集群解决方案 Codis

    (来源:开源中国社区 http://www.oschina.net/p/codis) Codis 是一个分布式 Redis 解决方案, 对于上层的应用来说, 连接到 Codis Proxy 和连接原生 ...

  9. Redis 集群解决方案比较

    调研比较了三个Redis集群的解决方案: 系统 贡献者 是否官方Redis实现 编程语言 Twemproxy Twitter 是 C Redis Cluster Redis官方 是 C Codis 豌 ...

随机推荐

  1. 微信小程序/校园社区论坛/微信云开发/云函数

    一.框架来源 1.非常非常感谢B站up主"梦千的曾哥哥"的开源框架. 没有他就没有这个小程序.如果有想做的同学,可以直接移步他的视频那里开始学习.我水平不够,就不教具体怎么配置环境 ...

  2. 记一次python + selenium小项目出现的问题与解决办法

    记一次python + selenium小项目出现的问题与解决办法 如何接入代理 def crawl_xdaili(self):#代理 可不用 需要时 解除注释 """ ...

  3. KingbaseES 逻辑备份还原加密

    KingbaseEs 支持在sys_dump备份时使用key进行加密.在sys_restore时,如果没提供key,或者key值不对,将无法进行恢复. [kingbase@dbhost03 ~]$ s ...

  4. 《!--suppress ALL --> 在Android XML 文件中的用途是什么?

    <!--suppress ALL --> 在Android XML 文件中的用途是什么? 警告一次又一次地出现在谷歌地图的 XML 文件中,但是当我使用时,所有警告都被禁用.那么压制所有评 ...

  5. losf命令详解

    一.概念:lsof全名list opened files,也就是列举系统中已经被打开的文件,进程打开的端口(TCP.UDP).linux环境中,任何事物都是文件,设备是文件,目录是文件,甚至socke ...

  6. 华南理工大学 Python第3章课后小测-1

    1.(单选)给出如下代码 s = 'Hello scut' print(s[::-1]) 上述代码的输出结果是(本题分数:4)A) HelloB) Hello scutC) olleH tucsD)  ...

  7. 《吐血整理》高级系列教程-吃透Fiddler抓包教程(23)-Fiddler如何优雅地在正式和测试环境之间来回切换-上篇

    1.简介 在开发或者测试的过程中,由于项目环境比较多,往往需要来来回回地反复切换,那么如何优雅地切换呢?宏哥今天介绍几种方法供小伙伴或者童鞋们进行参考. 2.实际工作场景 2.1问题场景 (1)已发布 ...

  8. Alertmanager高可用

    为了提升Promthues的服务可用性,通常用户会部署两个或者两个以上的Promthus Server,它们具有完全相同的配置包括Job配置,以及告警配置等.当某一个Prometheus Server ...

  9. Windows上Navicat工具远程连接PostgreSQL数据库

    首先,在pgdata(也就是在安装pg时指定的存放数据的文件见中)文件夹中,找到pg_hba.conf文件,在文件最后写入下面的内容: host all all 0.0.0.0/0 trust 接着, ...

  10. [题解] Codeforces 1349 D Slime and Biscuits 概率,推式子,DP,解方程

    题目 神题.很多东西都不知道是怎么凑出来的,随意设置几个变量,之间就产生了密切的关系.下次碰到这种题应该还是不会做罢. 令\(E_x\)为最后结束时所有的饼干都在第x个人手中的概率*时间的和.\(an ...