跟着源码学 IM(十):基于 Netty,搭建高性能 IM 集群(含技术思路 + 源码)
本文原题“搭建高性能的 IM 系统”,作者“刘莅”,内容有修订和改动。
1、引言
相信很多朋友对微信、QQ 等聊天软件的实现原理都非常感兴趣,笔者同样对这些软件有着深厚的兴趣。而且笔者在公司也是做 IM 的,公司的 IM 每天承载着上亿条消息的发送!
正好有这样的技术资源和条件,所以前段时间,笔者利用业余时间,基于 Netty 开发了一套基本功能比较完善的 IM 系统。该系统支持私聊、群聊、会话管理、心跳检测,支持服务注册、负载均衡,支持任意节点水平扩容。
这段时间,网上的一些读者,也希望笔者分享一些 Netty 或者 IM 相关的知识,所以今天笔者把开发的这套 IM 系统分享给大家。
本文将根据笔者这次的业余技术实践,为你讲述如何基于 Netty+Zk+Redis 来搭建一套高性能 IM 集群,包括本次实现 IM 集群的技术原理和实例代码,希望能带给你启发。
学习交流:
移动端 IM 开发入门文章:《新手入门一篇就够:从零开发移动端IM》
开源 IM 框架源码:https://github.com/JackJiang2011/MobileIMSDK
(本文已同步发布于:http://www.52im.net/thread-3816-1-1.html )
2、本文源码
主地址:https://github.com/nicoliuli/chat
备地址:https://github.com/52im/chat
源码的目录结构,如下图所示:
3、知识准备
重要提示:本文不是一篇即时通讯理论文章,文章内容来自代码实战,如果你对即时通讯(IM)技术理论了解的太少,建议先详细阅读:《新手入门一篇就够:从零开发移动端IM》。
可能有人不知道 Netty 是什么,这里简单介绍下:
Netty 是一个 Java 开源框架。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。
也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用 Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。
Netty 相当简化和流线化了网络应用的编程开发过程,例如,TCP 和 UDP 的 Socket 服务开发。
以下是有关 Netty 的入门文章:
如果你连 Java 的 NIO 都不知道是什么,下面的文章建议优先读:
Netty 源码和 API 的在线查阅地址:
4、系统架构
系统的架构如上图所示:整个系统是一个 C/S 系统,客户端没有做复杂的图形化界面而是用 Java 终端开发的(黑窗口),服务端 IM 实例是 Netty 写的 socket 服务。
ZK 作为服务注册中心,Redis 用来做分布式会话的缓存,并保存用户信息和轻量级的消息队列。
对于整个系统架构中各部分的工作原理,我们将在接下来的各章节中一一介绍。
5、服务端的工作原理
在上述架构中:NettyServer 启动,每启动一台 Server 节点,都会把自身的节点信息,如:ip、port 等信息注册到 ZK 上(临时节点)。
正如上节架构图上启动了两台 NettyServer,所以 ZK 上会保存两个 Server 的信息。
同时 ZK 将监听每台 Server 节点,如果 Server 宕机 ZK 就会删除当前机器所注册的信息(把临时节点删除),这样就完成了简单的服务注册的功能。
6、客户端的工作原理
Client 启动时,会先从 ZK 上随机选择一个可用的 NettyServer(随机表示可以实现负载均衡),拿到 NettyServer 的信息(IP 和 port)后与 NettyServer 建立链接。
链接建立起来后,NettyServer 端会生成一个 Session(即会话),用来把当前客户端的 Channel 等信息组装成一个 Session 对象,保存在一个 SessionMap 里,同时也会把这个 Session 保存在 Redis 中。
这个会话特别重要,通过会话,我们能获取当前 Client 和 NettyServer 的 Channel 等信息。
7、Session 的作用
我们启动多个 Client,由于每个 Client 启动,都会先从 ZK 上随机获取 NettyServer 的的信息,所以如果启动多个 Client,就会连接到不同的 NettyServer 上。
熟悉 Netty 的朋友都知道,Client 与 Server 建立接连后会产生一个 Channel,通过 Channel,Client 和 Server 才能进行正常的网络数据传输。
如果 Client1 和 Client2 连接在同一个 Server 上:那么 Server 通过 SessionMap 分别拿到 Client1 和 Client2 的会话,会话中包含 Channel 信息,有了两个 Client 的 Channel,Client1 和 Client2 便可完成消息通信。
如果 Client1 和 Client2 连接到不同的 NettyServer 上:Client1 和 Client2 要进行通信,该怎么办?这个问题放在后面解答。
8、高效的数据传输
无论是 IM 系统,还是分布式的 RPC 框架,高效的网络数据传输,无疑会极大的提升系统的性能。
数据通过网络传输时,一般把对象通序列化成二进制字节流数组,然后将数据通过 socket 传给对方服务器,对方服务器拿到二进制字节流后再反序列化成对象,达到远程通信的目的。
在 Java 领域,Java 序列化对象的方式有严重的性能问题,业界常用谷歌的 protobuf 来实现序列化反序列化(见《Protobuf通信协议详解:代码演示、详细原理介绍等》)。
protobuf 支持不同的编程语言,可以实现跨语言的系统调用,并且有着极高的序列化反序列化性能,本系统也采用 protobuf 来做数据的序列化。
关于 Protobuf 的基本认之,下面这几篇可以深入读一读:
另外:《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》一文中,“3、协议设计”这一节有关于 protobuf 在 IM 中的实战设计和使用,可以一并学习一下。
9、聊天协议定义
我们在使用各种聊天 APP 时,会发各种各样的消息,每种消息都会对应不同的消息格式(即“聊天协议”)。
聊天协议中主要包含几种重要的信息:
1)消息类型;
2)发送时间;
3)消息的收发人;
4)聊天类型(群聊或私聊)。
我的这套 IM 系统中,聊天协议定义如下:
syntax = "proto3";
option java_package = "model.chat";
option java_outer_classname = "RpcMsg";
message Msg{
}
如上面的 protobuf 代码,字段的具体含义如下:
1)msg_id:表示消息的唯一 id,可以用 UUID 表示;
2)from_uid:消息发送者的 uid;
3)to_uid:消息接收者的 uid;
4)format:消息格式,我们使用各种聊天软件时,会发送文字消息,语音消息,图片消息等等等等,每种消息有不同的消息格式,我们用 format 来表示(由于本系统是 java 终端,format 字段没有太大含义,可有可无);
5)msg_type:消息类型,比如登录消息、聊天消息、ack 消息、ping、pong 消息;
6)chat_type:聊天类型,如群聊、私聊;
7)timestamp:发送消息的时间戳;
8)body:消息的具体内容,载体;
9)to_uid_list:这个字段用户群聊消息提高群聊消息的性能,具体作用会在群聊原理部分详细解释。
10、私聊消息发送原理
Client1 给 Client2 发消息时,我们需要构建上节中的消息体。
具体就是:from_uid 是 Client1 的 uid、to_uid 是 Client2 的 uid。
NettyServer 收到消息后的处理逻辑是:
1)解析到 to_uid 字段;
2)从 SessionMap 或者 Redis 中保存的 Session 集合中获取 to_uid 即 Client2 的 Session;
3)从 Session 中取出 Client2 的 Channel;
4)然后将消息通过 Client2 的 Channel 发给 Client2。
11、群聊消息发送原理
群聊消息的分发通常有两种技术实现方式,我们一一来看看。
方式一:假设一个群有 100 人,如果 Client1 给一个群的所有人发消息,其实相当于 Client1 分别给其余 99 人分别发一条消息。我们可以直接在 Client 端,通过循环,分别给群里的 99 人发消息即可,相当于 Client 发送给 NettyServer 发送了 99 次相同的消息(除了 to_uid 不同)。
上述方案有很严重的性能问题:Client1 通过循环 99 次,分别把消息发给 NettyServer,NettyServer 收到这 99 条消息后,分别将消息发给群内其余的用户。先抛开移动端的特殊性(比如循环还没完成手机就有可能退到后台被系统挂起),显然 Client1 到 NettyServer 的 99 次循环存在明显不合理地方。
方式二:上节的消息体中 to_uid_list 字段就是为了解决这个方式一的性能问题的。Client1 把群内其余 99 个 Client 的 uid 保存在 to_uid_list 中,然后 NettyServer 只发一条消息,NettyServer 收到这一条消息后,通过 to_uid_list 字段解析群内其余 99 的 Client 的 uid,再通过循环把消息分别发送给群内其余的 Client。
可以看到:方式二的群聊时,Client1 与 NettyServer 只进行 1 次消息传输,相比于方式一,效率提高了 50%。
11、技术关键点 1:客户端分别连接在不同 IM 实例时如何通信?
针对本文中的架构,如果多个 Client 分别连接在不同的 Server 上,Client 之间应该如何通信呢?
为了回答这个问题,我们首先要明白 Session 的作用。
我们做过 JavaWeb 开发的朋友都知道,Session 用来保存用户的登录信息。
在 IM 系统中也是如此:Session 中保存用户的 Channel 信息。当 Client 与 Server 建立链接成功后,会产生一个 Channel,Client 和 Server 是通过 Channel,实现数据传输。当两端链接建立起来后,Server 会构建出一个 Session 对象,保存 uid 和 Channel 等信息,并把这个 Session 保存在一个 SessionMap 里(NettyServer 的内存里),uid 为 key,我们可以通过 uid 就可以找到这个 uid 对应的 Session。
但只有 SessionMap 还不够:我们需要利用 Redis,它的作用是保存整个 NettyServer 集群全部链接成功的用户,这也是一种 Session,但这种 Session 没有保存 uid 和 Channel 的对应关系,而是保存 Client 链接到 NettyServer 的信息,如 Client 链接到的这个 NettyServer 的 ip、port 等。通过 uid,我们同样可以从 Redis 中拿到当前 Client 链接到的 NettyServer 的信息。正是有了这个信息,我们才能做到,NettyServer 集群任意节点水平扩容。
当用户量少的时候:我们只需要一台 NettyServer 节点便可以扛住流量,所有的 Client 链接到同一个 NettyServer 上,并在 NettyServer 的 SessionMap 中保存每个 Client 的会话。Client1 与 Client2 通信时,Client1 把消息发给 NettyServer,NettyServer 从 SessionMap 中取出 Client2 的 Session 和 Channel,将消息发给 Client2。
随着用户量不断增多:一台 NettyServer 不够,我们增加了几台 NettyServer,这时 Client1 链接到 NettyServer1 上并在 SessionMap 和 Redis 中保存了会话和 Client1 的链接信息,Client2 链接到 NettyServer2 上并在 SessionMap 和 Redis 中保存了会话和 Client2 的链接信息。Client1 给 Client2 发消息时,通过 NettyServer1 的 SessionMap 找不到 Client2 的会话,消息无法发送,于是便从 Redis 中获取 Client2 链接在哪台 NettyServer 上。获取到 Client2 所链接的 NettyServer 信息后,我们可以把消息转发给 NettyServer2,NettyServer2 收到消息后,从 NettyServer2 的 SessionMap 中获取 Client2 的 Session 和 Channel,然后将消息发送给 Client2。
那么:NettyServer1 的消息如何转发给 NettyServer2 呢?答案是通过消息队列,如 Redis 中的 list 数据结构。每台 NettyServer 启动后都需要监听一个自己的 Redis 中的消息队列,这个队列用户接收其他 NettyServer 转发给当前 NettyServer 的消息。
Jack Jiang 点评:上述集群方案中,Redis 既作为在线用户列表存储中心,又作为集群中不同 IM 长连接实例的消息中转服务(此时的 Redis 作用相当于 MQ),那 Redis 不就成为了整个分布式集群的单点瓶颈了吗?
12、技术关键点 2:链接断开,如何处理?
如果 Client 与 NettyServer,由于某种原因(客户端退出、服务端重启、网络因素等)断开链接,我们必须要从 SessionMap 删除会话和 Redis 中保留的数据。
如果不清除这两类数据的话,很有可能 Client1 发送给 Client2 的消息,可能会发给其他用户,或者就算 Client2 处于登录状态,Client2 也收到不到消息。
我们可以在 Netty 框架中的 channelInactive 方法里,处理链接断开后的会话清除操作。
13、技术关键点 3:ping、pong 的作用
当 Client 与 NettyServer 建立链接后,由于双端网络较差,Client 与 NettyServer 断开链接后,如果 NettyServer 没有感知到,也就没有清除 SessionMap 和 Redis 中的数据,这将会造成严重的问题(对于服务端来说,这个 Client 的会话实际处于“假死”状态,消息是无法实时发送过去的)。
此时就需要一种 ping/pong 机制(也就是心跳机制啦)。
实现原理就是:通过定时任务,Client 每隔一段时间给 NettyServer 发一个 ping 消息,NettyServer 收到 ping 消息后给客户端回复一个 pong 消息,确保客户端和服务端能一直保持链接状态。如果 Client 与 NettyServer 断连了,NettyServer 可以立即发现并清空会话数据。Netty 中的我们可以在 Pipeline 中添加 IdleStateHandler,可达到这样的目的。
如果你不明白心跳的作用,务必读以下文章:
也可以学习一下主流 IM 的心跳逻辑:
如果觉得理论不够直观,下面的代码实例可以直观地进行学习:
其实,心跳算法的实际效果,还是有一些逻辑技巧的,以下两篇建议必读:
14、技术关键点 4:为 Server 和 Client 添加 Hook
如果 NettyServer 重启了或者进程被 kill 掉,我们需要清除当前节点的 SessionMap(其实不用清理 SessionMap,数据在内存里重启会自动删除的)和 Redis 保存的 Client 的链接信息。
我们需要遍历 SessionMap 找出所有的 uid,然后一一清除 Redis 的数据,然后优雅退出。此时,我们就需要为我们的 NettyServer 添加一个 Hook,来做数据清理。
15、技术关键点 5:对方不在线该如何处理消息?Client1 给对方发消息,我们通过 SessionMap 或 Redis 拿不到对方的会话数据,这就表明对方不在线。
此时:我们需要把消息存储在离线消息表中,当对方下次登录时,NettyServer 查离线消息表,把消息发给登录用户(最好是批量发送,提高性能)。
IM 中的离线消息处理,也不是个简单的技术点,有兴趣可以深入学习一下:
16、写在最后
代码写成这样,也算是了确了自已手撸 IM 的心愿。唯一遗憾的是,时间比较紧张,还没来得及实现消息 ack 机制,保证消息一定会送达,这个笔者以后会补充上去的。
好了,这就是我开发的这个简易的聊天系统,麻雀虽小,五脏俱全,大家有什么不明白的地方,可以直接在下方留言,笔者会一一回复的,谢谢大家。
17、系列文章
《跟着源码学IM(一):手把手教你用Netty实现心跳机制、断线重连机制》
《跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM》
《跟着源码学IM(三):基于Netty,从零开发一个IM服务端》
《跟着源码学 IM(四):拿起键盘就是干,教你徒手开发一套分布式 IM 系统》
《跟着源码学 IM(五):正确理解 IM 长连接、心跳及重连机制,并动手实现》
《跟着源码学 IM(六):手把手教你用 Go 快速搭建高性能、可扩展的 IM 系统》
《跟着源码学 IM(七):手把手教你用 WebSocket 打造 Web 端 IM 聊天》
《跟着源码学IM(八):万字长文,手把手教你用Netty打造IM聊天》
《跟着源码学IM(九):基于Netty实现一套分布式IM系统》
《跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)》(* 本文)
18、参考资料
[1] 新手入门:目前为止最透彻的的Netty高性能原理和框架架构解析
[2] 写给初学者:Java高性能NIO框架Netty的学习方法和进阶策略
[3] 史上最强Java NIO入门:担心从入门到放弃的,请读这篇!
[4] Java的BIO和NIO很难懂?用代码实践给你看,再不懂我转行!
[5] 史上最通俗Netty框架入门长文:基本介绍、环境搭建、动手实战
[7] 浅谈IM系统的架构设计
[8] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端
[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
[11] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践
[12] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等
[13] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等
[14] 从新手到专家:如何设计一套亿级消息量的分布式IM系统
[15] 基于实践:一套百万消息量小规模IM系统技术要点总结
(本文已同步发布于:http://www.52im.net/thread-3816-1-1.html )
评论