公众号后台回复“
面试
”,获取精品学习资料
扫描下方海报了解
专栏详情
本文来源:crossoverJie
前言
先简单说下本次的主题,由于我最近做的是物联网相关的开发工作,其中就不免会遇到和设备的交互。
最主要的工作就是要有一个系统来支持设备的接入、向设备推送消息;同时还得满足大量设备接入的需求。
所以本次分享的内容不但可以满足物联网领域同时还支持以下场景:
-
基于
WEB
的聊天系统(点对点、群聊)。
-
WEB
应用中需求服务端推送的场景。
-
基于 SDK 的消息推送平台。
技术选型
要满足大量的连接数、同时支持双全工通信,并且性能也得有保障。
在 Java 技术栈中进行选型首先自然是排除掉了传统
IO
。
那就只有选 NIO 了,在这个层面其实选择也不多,考虑到社区、资料维护等方面最终选择了 Netty。
最终的架构图如下:
现在看着蒙没关系,下文一一介绍。
协议解析
既然是一个消息系统,那自然得和客户端定义好双方的协议格式。
常见和简单的是 HTTP 协议,但我们的需求中有一项需要是双全工的交互方式,同时 HTTP 更多的是服务于浏览器。我们需要的是一个更加精简的协议,减少许多不必要的数据传输。
因此我觉得最好是在满足业务需求的情况下定制自己的私有协议,在我这个场景下其实有标准的物联网协议。
如果是其他场景可以借鉴现在流行的
RPC
框架定制私有协议,使得双方通信更加高效。
不过根据这段时间的经验来看,不管是哪种方式都得在协议中预留安全相关的位置。
协议相关的内容就不过讨论了,更多介绍具体的应用。
简单实现
首先考虑如何实现功能,再来思考百万连接的情况。
注册鉴权
在做真正的消息上、下行之前首先要考虑的就是鉴权问题。
就像你使用微信一样,第一步怎么也得是登录吧,不能无论是谁都可以直接连接到平台。
所以第一步得是注册才行。
如上面架构图中的
注册/鉴权
模块。通常来说都需要客户端通过
HTTP
请求传递一个唯一标识,后台鉴权通过之后会响应一个
token
,并将这个
token
和客户端的关系维护到
Redis
或者是 DB 中。
客户端将这个 token 也保存到本地,今后的每一次请求都得带上这个 token。一旦这个 token 过期,客户端需要再次请求获取 token。
鉴权通过之后客户端会直接通过
TCP长连接
到图中的
push-server
模块。
这个模块就是真正处理消息的上、下行。
保存通道关系
在连接接入之后,真正处理业务之前需要将当前的客户端和 Channel 的关系维护起来。
假设客户端的唯一标识是手机号码,那就需要把手机号码和当前的 Channel 维护到一个 Map 中。
这点和之前 SpringBoot 整合长连接心跳机制 类似。
同时为了可以通过 Channel 获取到客户端唯一标识(手机号码),还需要在 Channel 中设置对应的属性:
public static void putClientId(Channel channel, String clientId) {
channel.attr(CLIENT_ID).set(clientId);
}
获取时手机号码时:
public static String getClientId(Channel channel) {
return (String)getAttribute(channel, CLIENT_ID);
}
这样当我们客户端下线的时便可以记录相关日志:
String telNo = NettyAttrUtil.getClientId(ctx.channel());
NettySocketHolder.remove(telNo);
log.info("客户端下线,TelNo=" + telNo);
这里有一点需要注意:存放客户端与 Channel 关系的 Map 最好是预设好大小(避免经常扩容),因为它将是使用最为频繁同时也是占用内存最大的一个对象。
消息上行
接下来则是真正的业务数据上传,通常来说第一步是需要判断上传消息输入什么业务类型。
在聊天场景中,有可能上传的是文本、图片、视频等内容。
所以我们得进行区分,来做不同的处理;这就和客户端协商的协议有关了。
不管是哪种只有可以区分出来即可。
消息解析与业务解耦
消息可以解析之后便是处理业务,比如可以是写入数据库、调用其他接口等。
我们都知道在 Netty 中处理消息一般是在
channelRead()
方法中。
在这里可以解析消息,区分类型。
但如果我们的业务逻辑也写在里面,那这里的内容将是巨多无比。
甚至我们分为好几个开发来处理不同的业务,这样将会出现许多冲突、难以维护等问题。
所以非常有必要将消息解析与业务处理完全分离开来。
这时面向接口编程就发挥作用了。
这里的核心代码和
「造个轮子」——cicada(轻量级 WEB 框架)
是一致的。
都是先定义一个接口用于处理业务逻辑,然后在解析消息之后通过反射创建具体的对象执行其中的
处理函数
即可。
这样不同的业务、不同的开发人员只需要实现这个接口同时实现自己的业务逻辑即可。
伪代码如下:
想要了解 cicada 的具体实现请点击这里:
https://github.com/TogetherOS/cicada
上行还有一点需要注意;由于是基于长连接,所以客户端需要定期发送心跳包用于维护本次连接。同时服务端也会有相应的检查,N 个时间间隔没有收到消息之后将会主动断开连接节省资源。
这点使用一个
IdleStateHandler
就可实现,更多内容可以查看
Netty(一) SpringBoot 整合长连接心跳机制
消息下行
有了上行自然也有下行。比如在聊天的场景中,有两个客户端连上了
push-server
,他们直接需要点对点通信。
这时的流程是:
这就是一个下行的流程。
甚至管理员需要给所有在线用户发送系统通知也是类似:
遍历保存通道关系的 Map,挨个发送消息即可。这也是之前需要存放到 Map 中的主要原因。
伪代码如下:
具体可以参考:
https://github.com/crossoverJie/netty-action/
分布式方案
单机版的实现了,现在着重讲讲如何实现百万连接。
百万连接其实只是一个形容词,更多的是想表达如何来实现一个分布式的方案,可以灵活的水平拓展从而能支持更多的连接。
再做这个事前首先得搞清楚我们单机版的能支持多少连接。影响这个的因素就比较多了。
结合以上的情况可以测试出单个节点能支持的最大连接数。
单机无论怎么优化都是有上限的,这也是分布式主要解决的问题。
架构介绍
在将具体实现之前首先得讲讲上文贴出的整体架构图。
先从左边开始。
上文提到的
注册鉴权
模块也是集群部署的,通过前置的 Nginx 进行负载。之前也提过了它主要的目的是来做鉴权并返回一个 token 给客户端。
但是
push-server
集群之后它又多了一个作用。那就是得返回一台可供当前客户端使用的
push-server
。
右侧的
平台
一般指管理平台,它可以查看当前的实时在线数、给指定客户端推送消息等。
推送消息则需要经过一个推送路由(
push-server
)找到真正的推送节点。
其余的中间件如:Redis、Zookeeper、Kafka、MySQL 都是为了这些功能所准备的,具体看下面的实现。
注册发现
首先第一个问题则是
注册发现
,
push-server
变为多台之后如何给客户端选择一台可用的节点是第一个需要解决的。
这块的内容其实已经在
分布式(一) 搞定服务注册与发现
中详细讲过了。
所有的
push-server
在启动时候需要将自身的信息注册到 Zookeeper 中。
注册鉴权
模块会订阅 Zookeeper 中的节点,从而可以获取最新的服务列表。结构如下:
以下是一些伪代码:
应用启动注册 Zookeeper。
对于
注册鉴权
模块来说只需要订阅这个 Zookeeper 节点:
路由策略
既然能获取到所有的服务列表,那如何选择一台刚好合适的
push-server
给客户端使用呢?
这个过程重点要考虑以下几点:
-
尽量保证各个节点的连接均匀。
-
增删节点是否要做 Rebalance。
首先保证均衡有以下几种算法:
还有一个问题是:
当我们在重启部分应用进行升级时,在该节点上的客户端怎么处理?
由于我们有心跳机制,当心跳不通之后就可以认为该节点出现问题了。那就得重新请求
注册鉴权
模块获取一个可用的节点。在弱网情况下同样适用。
如果这时客户端正在发送消息,则需要将消息保存到本地等待获取到新的节点之后再次发送。
有状态连接
在这样的场景中不像是 HTTP 那样是无状态的,我们得明确的知道各个客户端和连接的关系。
在上文的单机版中我们将这个关系保存到本地的缓存中,但在分布式环境中显然行不通了。
比如在平台向客户端推送消息的时候,它得首先知道这个客户端的通道保存在哪台节点上。
借助我们以前的经验,这样的问题自然得引入一个第三方中间件用来存放这个关系。
也就是架构图中的存放
路由关系的Redis
,在客户端接入
push-server