IM学习总结

前言

个人出于兴趣关于IM的一些学习总结,欢迎各位大佬指点,我会从客户端发送端,服务器,客户端接收端三个层次,然后描述各个层次作用以及注意点。此篇文章要求有一定的netty和IM基础,不懂得地方可以评论我会尽力解答。

文章中一些概念的解释

消息风暴扩散系数:是指一个消息发出时,变成N个消息的扩散系数,这个系数与业务及数据相关,一定程度上它的大小决定了技术采用推送还是拉取。如果对业务实时性要求较高,可以采用推送的方式同步;如果实时性要求不高,可以采用轮询拉取的方式同步。
举例

  • 好友状态,如果对实时性要求较高,可以采用推送的方式同步;如果实时性要求不高,可以采用轮询拉取的方式同步;
  • 群友的状态,由于消息风暴扩散系数过大,可以采用按需拉取,延时拉取的方式同步;
  • 系统消息/开屏广告等对实时性要求不高的业务,可以采用拉取的方式获取消息;

IM通信数据格式参考:
跟着源码学IM(二):自已开发IM很难?手把手教你撸一个Andriod版IM_7-2.jpg
在此基础上还可以定义一种回执数据格式ACK,MsgType有三种,分别是sent(已发送),delivered(已送达), read(已读),以及加密相关的。

举例:
消息回执表:用来记录消息的已读回执
msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);
各字段的含义为:发送方UID,消息ID,回执方UID,群ID,回执标记。

消息通信注意TCP出现拆包/粘包的原因,那么,如何解决呢?

通常来说,有以下四种解决方式:

  • 1)消息定长;
  • 2)用回车换行符作为消息结束标志;
  • 3)用特殊分隔符作为消息结束标志,如\t、\n等,回车换行符其实就是特殊分隔符的一种;
  • 4)将消息分为消息头和消息体,在消息头中用字段标识消息总长度。
  • netty针对以上四种场景,给我们封装了以下四种对应的解码器:
  • 1)FixedLengthFrameDecoder,定长消息解码器;
  • 2)LineBasedFrameDecoder,回车换行符消息解码器;
  • 3)DelimiterBasedFrameDecoder,特殊分隔符消息解码器;
  • 4)LengthFieldBasedFrameDecoder,自定义长度消息解码器。

一、客户端发送端

功能点:

1.启动netty的IdleStateHandler进行心跳检测,检测不到了就关闭channel

2.单独开辟线程在channel记录时间实现重连逻辑,进行最大重试次数进行重试连接。

3.发送方每次发送一个消息,就必须要等待对方的ack回应,并且在ack确认消息中应该带有收到的id以便发送方识别。
4.发送方需要维护一个等待ack的队列。 每次发送一个消息之后,就将消息和一个计时器入队,另外存在一个线程一直轮询队列,如果有超时未收到ack的,就取出消息重发。
5.客户端登录时,先去数据库拉取自己的好友列表,再去缓存获取所有好友的在线状态,如果接收方下线要对自己的好友进行推送自己的下线状态。

注意点:

1.已读回执过多怎么办?
答:轮询拉取,比如群里发送方每发一条消息,会收到40个已读回执,采用轮询拉取(例如1分钟一次,一个小时也就60个请求),可以大大降低请求量。
2.发送方不在线怎么办?
答:在线实时推送,不在则考虑下次在线再拉取。
3.接收方下线要对自己的好友进行推送自己的下线状态,如果是群那怎么办,所有好友都互相推送系统可以承受住吗?
答:客户端发送方主动拉取,考虑到加的群一般很多所以只有进入的那个群才会开始拉取。
4.为什么需要单独开辟线程在channel记录时间进行重连逻辑?
在调用 ctx.writeAndFlush() 发送消息获取回调时。
其中是 isSuccess 并不能作为消息发送成功与否的标准:这是因为这里的 success 只是告知我们消息写入了 TCP 缓冲区成功了而已。
所以我们不能依据此来关闭客户端的连接,而是要判断 Channel 上绑定的时间与当前时间只差是否超过了阈值。超过了这个时间没收到ack则认为断线需要重连,指定时间收到ack则认为连接正常。

一个单独的线程来判断是否需要重连,不依赖于 IdleStateHandler。不然会因为IdleStateHandler 作为一个 ChannelInbound 也重写了 channelInactive() 方法,导致你自己写的重连代码逻辑被忽略

5.消息已读同步如何保证?
1)同步状态维护,为用户的每一个Session,维护一个时间戳,保存最后的读消息时间;
2)同一个人发出的消息,排序按消息附带的本地时间来排。不同人的消息,按照服务器时间排序。

6.为什么不能用keepalive做心跳?
考虑一种情况,某台服务器因为某些原因导致负载超高或者说掉线了无法及时响应,TCP探测周期太长。对客户端而言,这时的最好选择就是断线后重新连接其他服务器,而不是一直认为当前服务器是可用状态,一直向当前服务器发送些必然会失败的请求。
KeepAlive 并不适用于检测双方存活的场景,这种场景还得依赖于应用层的心跳。应用层心跳有着更大的灵活性,可以控制检测时机,间隔和处理流程,甚至可以在心跳包上附带额外信息。从这个角度而言,应用层的心跳的确是最佳实践。netty种pipeline 中加入了一个 10秒没有收到写消息的 IdleStateHandler,到时他会回调 ChannelInboundHandler 中的 userEventTriggered 方法。
7.超时未收到ack的消息有两种处理方式:

  • 1)和tcp一样不断发送直到收到ack为止。
  • 2)设定一个最大重试次数,超过这个次数还没收到ack,就使用失败机制处理,节约资源。可以主动断开和客户端接收方的连接,剩下未发送的消息就作为离线消息入库,客户端断连后尝试重连服务器即可。

二、服务器

功能点:

1.离线消息:
如果用户当前不在线,就必须把消息持久化下来,等待用户下次上线再推送,可以考虑使用mysql存储离线消息。为了方便地水平扩展,我们使用消息队列进行解耦。

  • 1)服务器接收到消息后如果发现接受方不在线,就发送给消息队列入库;
  • 2)接收方登录时,服务器从库里拉取离线消息进行推送。

注意点:

如何保证离线消息可靠传输?

1.服务器返回客户端“发送成功”ACK确认包(对于消息发送方而言,消息一旦落地存储至DB就认为是发送成功了)。离线消息删除也是同样的,要确保收到接收方ACK以后才进行消息删除。

2.防止离线消息重复推送:
我们思考一下多端登录的情况,Alice有两台设备同时登陆,在这种并发的情况下,我们就需要某种机制来保证离线消息只被读取一次。

这里利用CAS机制来实现:

  • 1)首先取出所有has_read=false的字段;
  • 2)检查每条消息的has_read值是否为false,如果是,则改为true。这是原子操作,修改成功则推送,失败则不推送。

3.服务器推送离线消息过多要考虑分页拉取,这样可以保证显示的实时性

4.服务端自动剔除离线客户端

5.IM的群聊消息,究竟存1份(即扩散读方式)还是存多份(即扩散写方式)?

任何架构方案都不是灵光一现,而是逐步迭代优化产生的:

  • 方案1:群聊消息存多份,只存在线,消息容易丢;
  • 方案2:群聊消息存多份,所有群友都存储,消息冗余多;
  • 方案3:群聊消息存多份,只存ID,未利用偏序;
  • 终极方案:群聊消息存一份,只存last_ack_msgid。

客户端接收方

注意点:

不重复

有的时候因为网络原因可能导致ack收到较慢,发送方就会重复发送,那么接收方必须有一个去重机制。

去重的方式是给每个消息增加一个唯一id。这个唯一id并不一定是全局的,只需要在一个会话中唯一即可。例如某两个人的会话,或者某一个群。如果网络断连了,重新连接后,就是新的会话了,id会重新从0开始。

不乱序

接收方需要在当前会话中维护收到的最后一个消息的id,叫做lastId。

每次收到一个新消息, 就将id与lastId作比较看是否连续,如果不连续,就放入一个暂存队列 queue中稍后处理。如果是重复的就直接发送ACK

1)当前会话的lastId=1,接着服务器收到了消息msg(id=2),可以判断收到的消息是连续的,就处理消息,将lastId修改为2;
2)但是如果服务器收到消息msg(id=3),就说明消息乱序到达了,那么就将这个消息入队,等待lastId变为2后,(即服务器收到消息msg(id=2)并处理完了),再取出这个消息处理。

降低离线拉取ACK带来的额外与服务器的交互次数

不用每一页消息都ACK,在拉取第二页消息时相当于第一页消息的ACK,此时服务器再删除第一页的离线消息即可,最后一页消息再ACK一次(实际上:最后一页拉取的肯定是空返回,这样可以极大地简化这个分页过程

否则客户端得知道当前离线消息的总页数,而由于消息读取延迟的存在,这个总页数理论上并非绝对不变,从而加大了数据读取不一致的可能性)
这样的效果是,不管拉取多少页离线消息,只会多一个ACK请求,与服务器多一次交互。

接收方累计收到N条群消息再批量ack,避免一次性ack带来的性能影响

IM登录相关:

可以看看这个
http://www.52im.net/thread-2863-1-1.html

常见登录方式:

在这里插入图片描述

第三方登录:

在这里插入图片描述

群消息处理策略

群消息怎么存?
“不管是否在线,都冗余一份群消息”带来的问题是:同一条消息存储了很多次,对磁盘和带宽造成了很大的浪费。

很容易想到的优化是:群消息实体存储一份,用户只冗余消息ID。

  • 方案1:群聊消息存多份,只存在线,消息容易丢;
  • 方案2:群聊消息存多份,所有群友都存储,消息冗余多;
  • 方案3:群聊消息存多份,只存ID,未利用偏序;
  • 终极方案:群聊消息存一份,只存last_ack_msgid。
    为了减少消息风暴,可以批量ACK;
    如果收到重复消息,需要msg_id去重,让用户无感知;

对于群消息已读回执,怎么处理?

  • 如果发送方在线,会实时被推送已读回执;
  • 如果发送方不在线,会在下次在线时拉取已读回执。

如果要对进行优化,可以:

  • 接收方累计收到N条群消息再批量ack;
  • 会带来什么副作用?
  • 批量消息再ack可能导致异常退出时候有些消息没有发送ack,然后客户端收到重复消息,这里要注意客户端去重
  • 发送方轮询拉取已读回执。
  • 还可能存在的问题:群离线消息过多:拉取过慢。
    解决方案:分页拉取(按需拉取)

群消息一定要主动推送吗,能否改为接收方轮询拉取?
答:不能,消息接收,实时性是核心指标。服务器必须马上推送到另一个客户端,但是不一定要马上收获ack

群聊消息发送原理

群聊消息的分发通常有两种技术实现方式,我们一一来看看。

方式一:假设一个群有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。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值