写点什么

跟着源码学 IM(十):基于 Netty,搭建高性能 IM 集群(含技术思路 + 源码)

作者:JackJiang
  • 2022 年 1 月 19 日
  • 本文字数:6975 字

    阅读完需:约 23 分钟

跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)

本文原题“搭建高性能的 IM 系统”,作者“刘莅”,内容有修订和改动。

1、引言

相信很多朋友对微信、QQ 等聊天软件的实现原理都非常感兴趣,笔者同样对这些软件有着深厚的兴趣。而且笔者在公司也是做 IM 的,公司的 IM 每天承载着上亿条消息的发送!


正好有这样的技术资源和条件,所以前段时间,笔者利用业余时间,基于 Netty 开发了一套基本功能比较完善的 IM 系统。该系统支持私聊、群聊、会话管理、心跳检测,支持服务注册、负载均衡,支持任意节点水平扩容。


这段时间,网上的一些读者,也希望笔者分享一些 Netty 或者 IM 相关的知识,所以今天笔者把开发的这套 IM 系统分享给大家。


本文将根据笔者这次的业余技术实践,为你讲述如何基于 Netty+Zk+Redis 来搭建一套高性能 IM 集群,包括本次实现 IM 集群的技术原理和实例代码,希望能带给你启发。


学习交流:



(本文已同步发布于:http://www.52im.net/thread-3816-1-1.html

2、本文源码

主地址:https://github.com/nicoliuli/chat

备地址:https://github.com/52im/chat


源码的目录结构,如下图所示:

3、知识准备



可能有人不知道 Netty 是什么,这里简单介绍下:


Netty 是一个 Java 开源框架。Netty 提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。


也就是说,Netty 是一个基于 NIO 的客户、服务器端编程框架,使用 Netty 可以确保你快速和简单的开发出一个网络应用,例如实现了某种协议的客户,服务端应用。


Netty 相当简化和流线化了网络应用的编程开发过程,例如,TCP 和 UDP 的 Socket 服务开发。


以下是有关 Netty 的入门文章:



如果你连 Java 的 NIO 都不知道是什么,下面的文章建议优先读:



Netty 源码和 API 的在线查阅地址:


1)Netty-4.1.x 完整源码(在线阅读版)

2)Netty-4.1.x 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 的基本认之,下面这几篇可以深入读一读:


  1. 强列建议将Protobuf作为你的即时通讯应用数据传输格式

  2. 全方位评测:Protobuf性能到底有没有比JSON快5倍?

  3. 金蝶随手记团队分享:还在用JSON? 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{

string msg_id = 1;
int64 from_uid = 2;
int64 to_uid = 3;
int32 format = 4;
int32 msg_type = 5;
int32 chat_type = 6;
int64 timestamp = 7;
string body = 8;
repeated int64 to_uid_list = 9;
复制代码

}


如上面的 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,可达到这样的目的。


如果你不明白心跳的作用,务必读以下文章:


  1. 为何基于TCP协议的移动端IM仍然需要心跳保活机制?

  2. 一文读懂即时通讯应用中的网络心跳包机制:作用、原理、实现思路等


也可以学习一下主流 IM 的心跳逻辑:


  1. 微信团队原创分享:Android版微信后台保活实战分享(进程保活篇)

  2. 微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)

  3. 移动端IM实践:实现Android版微信的智能心跳机制

  4. 移动端IM实践:WhatsApp、Line、微信的心跳策略分析


如果觉得理论不够直观,下面的代码实例可以直观地进行学习:


  1. 正确理解IM长连接的心跳及重连机制,并动手实现(有完整IM源码)

  2. 一种Android端IM智能心跳算法的设计与实现探讨(含样例代码)

  3. 自已开发IM有那么难吗?手把手教你自撸一个Andriod版简易IM (有源码)

  4. 手把手教你用Netty实现网络通信程序的心跳机制、断线重连机制


其实,心跳算法的实际效果,还是有一些逻辑技巧的,以下两篇建议必读:


  1. Web端即时通讯实践干货:如何让你的WebSocket断网重连更快速?

  2. 融云技术分享:融云安卓端IM产品的网络链路保活技术实践

14、技术关键点 4:为 Server 和 Client 添加 Hook

如果 NettyServer 重启了或者进程被 kill 掉,我们需要清除当前节点的 SessionMap(其实不用清理 SessionMap,数据在内存里重启会自动删除的)和 Redis 保存的 Client 的链接信息。


我们需要遍历 SessionMap 找出所有的 uid,然后一一清除 Redis 的数据,然后优雅退出。此时,我们就需要为我们的 NettyServer 添加一个 Hook,来做数据清理。


15、技术关键点 5:对方不在线该如何处理消息?Client1 给对方发消息,我们通过 SessionMap 或 Redis 拿不到对方的会话数据,这就表明对方不在线。


此时:我们需要把消息存储在离线消息表中,当对方下次登录时,NettyServer 查离线消息表,把消息发给登录用户(最好是批量发送,提高性能)。


IM 中的离线消息处理,也不是个简单的技术点,有兴趣可以深入学习一下:


  1. IM消息送达保证机制实现(二):保证离线消息的可靠投递

  2. 阿里IM技术分享(六):闲鱼亿级IM消息系统的离线推送到达率优化

  3. IM开发干货分享:我是如何解决大量离线消息导致客户端卡顿的

  4. IM开发干货分享:如何优雅的实现大量离线消息的可靠投递

  5. 喜马拉雅亿级用户量的离线消息推送系统架构设计实践

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框架入门长文:基本介绍、环境搭建、动手实战

[6] 理论联系实际:一套典型的IM通信协议设计详解

[7] 浅谈IM系统的架构设计

[8] 简述移动端IM开发的那些坑:架构设计、通信协议和客户端

[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)

[10] 一套原创分布式即时通讯(IM)系统理论架构方案

[11] 一套高可用、易伸缩、高并发的IM群聊、单聊架构方案设计实践

[12] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等

[13] 一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等

[14] 从新手到专家:如何设计一套亿级消息量的分布式IM系统

[15] 基于实践:一套百万消息量小规模IM系统技术要点总结


(本文已同步发布于:http://www.52im.net/thread-3816-1-1.html

用户头像

JackJiang

关注

还未添加个人签名 2019.08.26 加入

开源IM框架MobileIMSDK、BeautyEye的作者。

评论

发布
暂无评论
跟着源码学IM(十):基于Netty,搭建高性能IM集群(含技术思路+源码)