作者:董礼 职位:后端工程师
背景
Web应用客户端和服务端的通信过程通常是客户端发出请求,服务端收到前端/APP端的请求后进行响应并返回结果给客户端,客户端处理为视图需要的数据格式进行展现。这种通信机制可以覆盖常规的应用场景,但随着互联网业务的发展,很多场景下对于信息的实时性提出了越来越高的要求,如及时消息、股票大盘实时展示、服务端事件推送等。
面对此类需求,以前常见的解决方案是http轮询,即由客户端固定频率对服务端资源进行http请求,如每个1s请求一个接口,资源发生变更时就能获取到。
采用http轮询带来的问题是,客户端需要不停的对服务端进行请求,自然会产生两个困境:
A. 若请求频率高间隔时间短,即使资源没有变更,客户端&服务端也要承担不停建立http请求的开销,且对目标资源进行很多次无效查询,浪费资源;
B. 如果请求频率低间隔时间长呢?这样避免了资源浪费,但客户端无法即时获取到目标资源的最新数据,对于实时性要求又无法满足了。
有没有一种通讯机制,在尽量节省资源的情况下,又能保障实时的消息传输呢?那么,接下来就轮到本文的主角Websocket技术登场了。
1、为什么选择WebSocket?
WebSocket是一种在单个TCP连接上进行双全工通信的协议,基于它可以实现服务端主动向各个订阅消息通道的客户端推送消息。而WebSocket是一种有状态协议,这意味着客户端和服务器之间的连接建立后将始终保持打开状态,直到任何一方关闭连接。
对比之下,http轮询方案由于需要浏览器不停向服务端发起请求,且请求中可能会包含较长的头部,必然会造成带宽资源的浪费。
WebSocket的优势就很明显了:能节省服务器资源和带宽,并且能够更实时地与客户端进行通讯,属实是多快好省了。
一般我们会选择使用WebSocket的几种场景:
-
服务端需要主动push消息至客户端(实时消息传输)。此场景下使用WebSocket向客户端(App/浏览器)提供持续的数据交换,由于使用的是一直保持的连接,因此这个过程更快且更节省资源。常见的应用为股票交易APP,不断向APP端传输行情和交易数据。
-
Web IM聊天室。这个场景一般出现在一些to B的企业应用服务中,企业内部的系统如ERP、CRM等,员工需要通过该系统进行一些内部消息传递,且系统内数据收到严格的管控不允许通过三方软件进行的时候。如用户信息的转发、事件信息的同步、通知等。
-
游戏。在游戏中,玩家数据需要不断在服务端和客户端之间传输,一般常见于小游戏和网页游戏。
2、单实例WebSocket应用介绍
在介绍WebSocket分布式集群方案之前,我们可以先看一下单实例情况下WebSocket节点的实现。
首先,WebSocket已经被SpringBoot很好地集成了,所以在SpringBoot上开发ws服务非常方便,也比较简单。
2.1 添加依赖
2.2 服务配置
在SpringBoot中使用WebSocket一般有这么三种方式:
STOMP(Simple Text Oriented Messaging Protocol),在WebSocket基础之上提供了一个基于帧的线路格式(frame-based wire format)层,对简单文本消息制定了规范格式(消息基于Text),目前很多服务端消息队列都支持STOMP,比如:RabbitMQ、 ActiveMQ等。
此案例中,我们使用STOMP方式配置WebSocket服务:
在上面的代码中,最后还注入了一个自定义拦截器,这个拦截器的目的是为了客户端断开连接的时候能够打印一下日志,当然,在业务场景中我们可以对其进行更多的扩展。拦截器代码如下:
2.3 编写消息Controller
至此一个简单的WebSocket单例应用就完成了。
但在生产环境中,是无法支撑几万人同时连接同一个服务器的,需要分布式部署或者集群来将请求连接负载均衡到到不同的服务下。
如果我们需要保障WebSocket服务高可用,必然需要部署多个节点,此时用户A和用户B在负载均衡策略下可能是连接在两个WebSocket节点中的,若这两个用户需要互相通信,我们应该如何处理呢?
3、WebSocket分布式集群
首先,WebSocket客户端-服务端建立连接的时候,会创建一个有状态的会话session,而服务器则保存维持连接的session。也就是说,客户端每次只能和WebSocket服务集群中的一个服务进行连接,后续的数据传输也是同这个服务器进行。
那么如果此时我们有A、B两个WebSocket节点部署,用户U1通过负载均衡连接了A节点,用户U2通过负载均衡连接了B节点,此时U1想要给U2发送消息,或者我们想通过服务端同时广播给U1、U2两个用户,该怎么做呢?
要解决WebSocket分布式集群这样的问题,肯定首先会考虑session的共享,客户端成功连接服务节点之后,其他服务器也知道客户端连接成功了该节点。
和WebSocket类似的http是怎么搞定集群问题的呢?
方案之一是共享session,客户端连接服务节点后,把session信息存在Redis中。当客户端连接其他节点时,从Redis获取session。
然而session被共享的前提是可以序列化,Websocket的session是无法序列化的,对应的是连接,连接到不同的服务节点,session也不同,无法被序列化。
方案之二是消息广播,既然我们不知道用户U1和U2处于哪个WebSocket节点中,那么可以通过消息中间件来帮助我们把要发送or转发的消息,进行全节点广播,节点接收到消息之后,再进行用户的消息发送。这样只要所有WebSocket节点都订阅了消息,就都能收到并进行转发。
当然,消息广播的模式需要消耗所有WebSocket节点的计算资源和带宽资源,并引入了一个新的中间件(如RabbitMQ),在大规模并发或者用户数很多的情况下并不是最优解,但对于B端SaaS产品而言,一般也已经足够满足生产需要了。
3.1 添加MQ中间件
由于我们的项目已经使用RabbitMQ作为消息中间件,此处便使用RabbitMQ进行介绍。首先,引入MQ中间件:
3.2 配置RabbitMQ
Exchange使用扇形交换机,消息分发给所有绑定该交换机的队列。可以选择用服务节点所在的ip+端口号为唯一标识为队列命名。
启动一个服务,创建队列绑定该交换机,实现广播消息的订阅:
3.3 用户和Session关系处理
我们可以在一个用户建立和WebSocket连接的时候,将用户ID与Session进行留存,便于后续的消息发送处理。
3.4 消息监听和WebSocket消息发送
添加MQ消息的接收方法:
@RabbitListener 接收消息,队列名称使用常量命名,动态队列名称使用 #{name},其中的name是Queue的bean 名称:
之前直接发送的消息,也要变更为向MQ发送,用户间消息通知的流程如下:
用户信息可以选择在客户端与服务节点建立连接的时候,把用户的http session或者token传递给服务端,这样服务端可以在此时将用户与WebSocket session一一对应的保存在redis或内存中。
如果是消息的定向发送,比如发送给U1、U2、U3,那么可以使用实体作为MQ消息体,在实体中传输指定的用户名,然后再于服务节点进行用户筛选,如果用户处于本节点,则向用户发送该消息,反之则忽略本次收到的消息。
4、总结
通过引入RabbitMQ消息中间件进行消息广播,我们可以实现WebSocket分布式集群间的消息流转,既可以很方便的由系统发布统一通知消息给所有用户,也可以确保连接在不同WebSocket节点的客户端之间可以进行消息互通。
但这个方案的弊端在于,引入了MQ中间件,且所有消息都需要进行广播,相当于所有消息都需要所有WebSocket节点参与处理,对于资源和带宽的浪费比较严重。对于高并发的场景,显然不太适用,比较适合To B的服务。