前一段时间笔者利用业余时间,基于Netty开发了一套基本功能比较完善的IM系统。该系统支持私聊、群聊、会话管理、心跳检测,支持服务注册、负载均衡,支持任意节点水平扩容。正好前一段,网上的一些读者,也希望笔者分享一些Netty或者IM相关的知识,所以今天笔者把开发的这套IM系统与大家分享,并讲述IM系统的基本原理。

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

github地址:

https://github.com/nicoliuli/chat

系统架构图

 

iOS IM架构 im软件架构_iOS IM架构

系统的架构图如上,整个系统是一个C/S系统,C端没有做复杂的图形化界面,而是用JAVA终端开发的(黑窗口),S端是Netty写的socket服务。ZK作为服务注册中心,Redis用来做分布式会话的缓存,并保存用户信息和轻量级的消息队列。

 

整个系统的工作原理如下:

 

服务端的工作原理

NettyServer启动,每启动一台Server节点,都会把自身的节点信息,如ip、port等信息注册到ZK上(临时节点),如上图启动了两台NettyServer,所以ZK上会保存两个Server的信息。同时ZK监听每台Server节点,如果Server宕机ZK就会删除当前机器所注册的信息(把临时节点删除),这样就完成了简单的服务注册的功能。

 

客户端的工作原理

Client启动时,会先从ZK上随机选择一个可用的NettyServe(随机表示可以实现负载均衡),拿到NettyServer的信息(IP和port)后与NettyServer建立链接。链接建立起来后,NettyServer端会生成一个Session,用来把当前客户端的Channel等信息组装成一个Session对象,保存在一个SessionMap里,同时也会把这个Session保存在Redis中。这个会话特别重要,通过会话,我们能获取当前Client和NettyServer的Channel等信息。

 

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要进行通信,怎么办?这个问题放在后面解答。

 

高效的数据传输

无论是IM系统,还是分布式的RPC框架,高效的网络传输,无疑会极大的提升系统的性能。数据通过网络传输时,一般把对象通序列化成二进制字节流数组,然后将数据通过socket传给对方服务器,对方服务器拿到二进制字节流后再反序列化成对象,达到远程通信的目的。在JAVA领域,Serializable的序列化方式有严重的性能问题,业界常用google的protobuf来实现序列化反序列化,protobuf支持不同的编程语言,可以实现跨语言的系统调用,并且有着极高的序列化反序列化性能,本系统也采用protobuf来做数据的序列化。

 

聊天时需要的元数据信息

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;
}

我们在使用各种聊天App时,会发各种各样的消息类型,有不同的消息格式,消息类型,发送时间,消息的收发人,聊天类型(群聊,私聊)。如上面的protobuf代码,字段的含义如下:

  • msg_id:表示消息的唯一id,可以用UUID表示
  • from_uid:消息发送者的uid
  • to_uid:消息接收者的uid
  • format:消息格式,我们使用各种聊天软件时,会发送文字消息,语音消息,图片消息等等等等,每种消息有不同的消息格式,我们用format来表示(由于本系统是java终端,format字段没有太大含义,可有可无)
  • msg_type:消息类型,比如登录消息、聊天消息、ack消息、ping、pong消息
  • chat_type:聊天类型,如群聊、私聊
  • timestamp:发送消息的时间戳
  • body:消息的具体内容,载体。
  • to_uid_list,这个字段用户群聊消息提高群聊消息的性能,具体作用会在群聊原理部分详细解释。

 

私聊原理

Client1给Client2发消息时,我们需要构建上面的消息体,from_uid是Client1的uid,to_uid是Client2的uid,NettyServer收到消息后解析到to_uid字段,从SessionMap或者Redis中保存的Session集合中获取to_uid即Client2的Session,从Session中取出Client2的Channel,然后将消息发给Client2。

 

群聊原理

群聊有两种实现方式:

  • 假设一个群有100人,如果Client1给一个群的所有人发消息,其实相当于Client1分别给其余99人分别发一条消息,我们直接在Client端,通过循环,分别给群里的99人发消息即可,相当于Client发送给NettyServer发送了99次相同的消息(除了to_uid不同)。
  • 上述方案有很严重的性能问题,Client1通过循环99次,分别把消息发给NettyServer,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%。

回答上面留下来的疑问,如果多个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的消息。

 

链接断开,如何处理?

如果Client与NettyServer,由于某种原因(客户端退出、服务端重启、网络因素等)断开链接,我们必须要从SessionMap删除会话和Redis中保留的数据,如果不清除这两类数据的话,很有可能Client1发送给Client2的消息,可能会发给其他用户,或者就算Client2处于登录状态,Client2也收到不到消息。我们可以在Netty框架中的channelInactive方法里,处理链接断开后的会话清除操作。

 

ping、pong的作用

当Client与NettyServer建立链接后,如果双端网络较差,Client与NettyServer断开链接后,NettyServer没有感知到,所以也就没有清除SessionMap和Redis中的数据,会造成严重的问题。此时就需要一种ping/pong机制。通过定时任务,Client每隔一段时间给NettyServer发一个ping消息,NettyServer收到ping消息后给客户端回复一个pong消息,确保客户端和服务端能一直保持链接状态,如果Client与NettyServer断连了,NettyServer可以立即发现并清空会话数据。Netty中的我们可以在Pipeline中添加IdleStateHandler,可达到这样的目的。

 

为Server和Client添加Hook

如果NettyServer重启了或者进程被kill掉,我们需要清除当前节点的SessionMap(其实不用清理SessionMap,数据在内存里重启会自动删除的)和Redis保存的Client的链接信息,我们需要遍历SessionMap找出所有的uid,然后一一清除Redis的数据,然后优雅退出。此时,我们就需要为我们的NettyServer添加一个Hook,来做数据清理。

 

对方不在线该如何处理消息?

Client1给对方发消息,我们通过SessionMap或Redis拿不到对方的会话数据,表明对方不在线,此时,我们需要把消息存储在离线消息表中。当对方下次登录时,NettyServer查离线消息表,把消息发给登录用户(最好是批量发送,提高性能)。