文娱IM平台的消息补偿&加载机制

摘要

尽管IM产品五花八门、不可枚举,但就其最基础的能力归结起来不过就是三点:不丢、不重、保序。而这三大能力,最根本的就是不丢。试想,如果消息丢了,那么消息是否重复、消息是否有序就无从谈起了。本篇文章将重点介绍在文娱IM平台场景下如何实现消息不丢。

同时,消息的不丢失与消息的加载速度或多或少也有些联系。比如消息加载慢了,即使消息未丢失,用户也可能会以为是消息丢了;此外,消息的加载速度严重影响着用户的体验:试想,用户打开一个聊天窗口,下拉查看一下聊天记录,结果卡在那半天不动,猜一下用户接下来会干什么?骂街?无语?忍了?还是直接卸载APP?如果猜不到,那么有一点则是确定的,如果这种事情一直发生,时间一长,用户肯定不用了。本文也将着重介绍一下:在文娱IM平台下,如何快速实现消息加载,保证良好的用户体验。


名词解释

chatId:会话ID,租户唯一

chatSeqId:会话序列ID,递增,不一定连续

msgId: 消息ID,全局唯一

M:消息实体


消息不丢

严格来讲,消息不丢失是不可能的,比如网络抖动、服务异常等等因素都会导致消息的丢失;但是,让用户感知消息不丢失却是可能的。这里可不是欺骗用户,比如A给B发了一条消息M,结果M丢了,我们就骗B说A没给你发,这很愚蠢,也很容易穿帮(比如A和B坐在一起互发)。这里让用户无感知消息丢失的确切含义是:虽然M在某个链路中丢失了,但M最终仍会到达接收方且保序。


主动探知丢失事件

每一个消息都会自带一些属性,比如chatId,chatSeqId, msgId等。服务端在接收到每一个发送者发出的消息后,都会主动再附加上同一会话里面此消息的前一个消息的chatSeqId(preChatSeqId),然后再一起下发给接收者。接收者收到消息后,对比本地是否存在preChatSeqId, 若存在则正常渲染此消息;否则,证明存在消息丢失,就要先去服务端拉取丢失的消息,然后再一起渲染。详细流程见下图:

消息补偿图.png

例如A和B聊天,此时会话中已有chatSeqId为1,2,3,4的消息,此时当A给B再发送消息时,B收到消息的chatSeqId为6,同时此消息还会附带一个属性preChatSeqId=5,即告诉B,6之前的消息是5,存在消息丢失。此时,B就要去服务端拉取chatSeqId=5的消息,然后渲染5,6。


拉取策略

当感知存在消息丢失时,有几种拉取策略,单条拉取、位移拉取,批量拉取。


单条拉取

单条拉取顾名思义就是仅拉取一条。比如,当感知chatSeqId=5的消息丢失后,我们仅仅去拉取5。

但这种拉取方式存在一个致命的问题,比如网络就是不好,发一个丢一个,用户现在本地只有chatSeqId=1;假设此会话chatSeqId连续,那么当他收到chatSeqId=100(preChatSeqId=99)的时候,他要先拉99,然后再拉98,97,96……在高并发环境下,这样势必会造成大量请求,可能会给服务端带来雪崩的压力。


位移拉取

比如本地会话的chatSeqId为1,2,3,4。收到一条10的消息,那么拉取就从10开始拉,向上拉5条,然后去重(4到10最多5条)。

这种方式看似没问题,其实不然,因为有很多边界case的存在。比如,网络断断续续,客户端收到的chatSeqId可能也是断断续续的,每次还没来及补偿就又断网了,比如1,6,10,96,98,那么此时当收到100后,需要拉取5次。这种问题是可能一次性拉取过多,给服务器造成一定的压力;另外就是,用户可能只看最近的几十条,那么之前的消息就可能白加载了。


批量拉取

每次只从最近的chatSeqId,拉取固定条数的消息。还是上面的例子,本地存在1,6,10,96,98,那么此时收到100后,只拉取80-100的消息(比如固定拉取20条)然后展示。目前,我们的IM平台也采用的这种方式。


消息加载

消息加载涉及到两个方面,一是会话列表的加载;另一个是消息历史记录的加载。


ChatViewInbox

每个用户都会有一个会话维度的收件箱,这个收件箱会将用户所有会话的快照存储起来。每条记录代表用户的一个会话,包含此会话的最近一条消息,会话的类型等属性。

chatViewInbox.png

从图中可以看出,在群聊时,就成为了一个写扩散模型,即群中每个人都会存一份此群的快照。

在用户打开消息列表页面的时候,即可从各自的收件箱中快速拿取数据进行渲染,当然这里也用了读聚合,读聚合会在其他文章中详细说明,这里只是提一下。


消息预加载

ChatViewInbox主要用来支持会话列表页,也只存了每个会话的最近一条消息。当用户点击进入某个会话时,又会去请求服务端拉取数据,此时用户可能会看到转菊花的现象,这在某种程度上也影响了用户的体验。

消息预加载就是当用户启动APP时,或者第一次进入会话列表时,对可在首屏露出的会话,预先加载其部分消息历史记录。当用户点击会话进入后,已经可以满足用户2-3页历史记录查询的诉求,不用再去请求服务端,提高用户体验。当加载完首屏会话历史记录后,可再继续去加载第2页,第3页,一直到最后一页的会话历史记录。


Sync系统

先上图:

sync.png

解释一下,syncId是用户维度的,且连续递增。比如此时A的syncId是100,当用A收到了来自chat1的msg10,chat2的msg11,chat3的msg22时,A的syncId就增长到了103,且101对应msg10,102对应msg11,103对应msg22。

我们可以看到,这个sync系统也是一个写扩散模型。


感知消息丢失

当用户收到不连续的syncId时,就可以得知有消息丢失了,自己主动去拉取对应的syncId的消息即可。不过前文我们提了,我们是采用chatSeqId的方式去感知消息丢失并拉取的。为什么不用synId的方式呢?这两种的区别又是什么呢?

其实可以采用sync的方式去补偿,客户端第一次登录时服务端会下发一个syncId,后续根据是否连续即可判断是否需要补偿。chatSeqId的补偿方式可以看作是一种懒加载,即只关心本会话,别的不管。比如chat1丢了1条,chat2丢了100条,chatSeqId方式可以以会话数并发去补偿,且每个只补偿20个,因为够用了;但sync的方式却需要补偿101条,因为他无法判断会话维度,假设只需要每个会话补1条,他也不知道chat1的那条消息的syncId是多少,他只能补偿完所有101条。


消息加载

sync当然可以去加载消息。但用户多端登录时会有体验问题。比如用户A有两个设备,D1和D2,D1聊了一堆后,用D2登录,此时D2的syncId可能就是0,但服务端的syncId已经到1000了,用chatSeqId方式去加载会很快,但用sync方式就有点憋足了,因为他是会话无感知的,他不知道这1000条哪条属于哪个会话,他只能默默地都加载出来,这势必会对体验造成影响,即使是只加载最近的N条,因为有可能有一个会话就是倒数第N+1条,这条没出来,可能会话就没有被展示出来。


如何使用

sync系统是一把双刃剑,但用得好的话就是一把好的单刃剑了。如图所示,我们不用sync方式去做补偿,而是用他做预加载。上文说了,我们只加载部分chat中的最近N条消息,那么N条消息之前的消息如何加载?或者其他chat如何加载其消息呢?除了采用上文中的预加载模式,我们还使用sync方式,用客户端闲暇线程去默默异步加载这些消息。比如服务端下发的syncId=500,客户端本地syncId=100,那么这个线程就默默从500开始去加载,一直到100。

这样带来的好处就是历史消息本地完整,用户在下拉查看历史记录时,不会再去转菊花,而是直接显示。

评论区
Rick ©2018