IM 收件箱机制(三)

在IM中,有了长连接之后,如何完成服务端与客户端的数据同步也是很重要的一环。

通常会有两种方案,一个是服务端直接转发 ,一个是收件箱机制。我们以消息类型的数据为例。

服务端直接转发:

是服务端收到消息A,存储完成后,直接将消息A的具体内容通过长连接通道发送给客户端B。我们把这种方式叫做服务端直接转发。

收件箱:

服务端收到消息后,不直接转发该消息给客户端,而是将消息id、消息所在会话id推送给客户端的消息收件箱,客户端发现消息收件箱有数据后,择机通过长连接 or 短连接拉取消息具体内容。

服务端直接转发

服务端直接转发是最简单的,但是在实际操作运行中,会面临几个问题。

消息阻塞

首先,在富文本的场景中,一个富文本的消息大小可以达到1M。前面说过,长连接的通道是全双工的,允许同时双向通信。但是他的并发量只有1,所以在服务端向客户端传输消息A的时候,其他数据需要等待传输完成后才能再次传输。

如果用户当前在会话A中聊天,但是在会话B中,收到了上百条富文本消息,由于下行通道堵塞,会导致用户无法及时看到会话A中的消息。

ACK

在IM应用中,比实时性更优先的原则是数据真实性,即不能丢数据。所以需要有一套ACK机制,当客户端收到消息A并落库后,会给服务端发送一个回包,用于表示这条消息拉到了。

服务端直接转发方案,如果因为网络抖动原因,客户端没有收到该消息,则服务端还需要再次重试推送该消息,那么会有大量的网络带宽浪费,对服务端也有重试的成本。

消息聚合拉取

单次只拉取一条消息,对于客户端和服务端都是资源的浪费。将多条消息聚合拉取,能够节省网络资源和服务端计算资源。


消息优先级拉取

在一般的IM应用中,有很多用户不关心的群聊,但又不能退出群聊。所以会有会话分组或者折叠会话等功能。这些会话对消息实时性要求不是很高,如果服务端收到消息后直接转发该消息,会让客户端失去灵活拉取的主动权。

收件箱机制

基于前面提到的几个实际场景。结合信箱的原理,我们采用收件箱机制

在收件箱机制中,服务端收到消息后,不直接转发该消息给客户端,而是将消息id、消息所在会话id推送给客户端的消息收件箱,这个数据量很小。

客户端收到该推送后,将该消息id落库,并回包给服务端,让服务端感知到,该消息id,客户端已经收到了,服务端无需再次推送该消息了。

回包完成后,客户端会根据该消息的优先级,确认是否获取该消息id对应的消息体。也可以做聚合,批量拉取一系列消息体。

这里有人可能会疑惑,采用收件箱机制,会不会导致消息抵达实时性降低。实际上考虑到网络堵塞、消息聚合拉取等逻辑的存在,收件箱机制在业务优化后,可能比服务端直接转发效果更好。因为没有对应的A/B Test,无法确认两种方案在实时性上的区别。

采用收件箱机制后,在实际运用中,根据业务,会存在多个收件箱。比如消息收件箱、会话收件箱。在飞书中,还会有日程到期收件箱、文档变更收件箱等。

当然并不是所有服务端与客户端的通信都需要收件箱机制 ,根据业务复杂程度、信息数据量大小等,应当采用收件箱机制服务端直接转发相结合的方式。

我们以最复杂的业务,消息收件箱为例,查看一些技术细节。

首先,使用消息收件箱是为了保证消息有序、低延迟、数据不丢失

有序: 按照实际消息顺序获取,保证消息顺序

低延迟: 推与拉结合,保证实时性

数据不丢失: 通过ACK、重试,确保数据不丢失

消息收件箱

命令字

收件箱是一个概念上的东西,在实际开发中,客户端会和服务端约定一个命令字,比如10001,当服务端向客户端推送10001的时候,就代表当前数据是消息收件箱消息。当客户端收到10002时,就代表当前数据是会话收件箱消息。

一般的收件箱数据如下:

json 复制代码
{   // 推送命令字
    "command": "10001",  
    "data": {  
        // 消息所在会话id
        "conversation_id": "123456789_2",  
        // 消息的序列号
        "sequence_id": "123456789123456789"  
    }  
}

sequenceId

在上面的数据结构中,sequenceId是递增的(跨会话全局递增)。这个会有服务端保证,一般会分布式集群,会采用雪花算法(你可能听说过雪花算法)等方式生成,保证sequenceId的递增、唯一性。

sequenceId:

  1. 服务端每条消息入库前,都会生成sequenceId,该sequenceId是跨会话唯一、递增的。

  2. sequenceId的作用是用于标记收件箱数据位置,客户端基于sequenceId向服务端请求数据。

  3. 客户端每次请求会携带本地的lastSequenceId,服务端响应式会返回新的lastSequenceId+hasMore

  4. 客户端循环拉取,直到hasMore=false,确保本地数据完整。

当客户端收到10001推送后,会将该数据分发到对应的收件箱处理,即MessageEmailManager,在这里,会先对该数据落库,然后回包给服务端,表示客户端已经知道会话A中有消息123。服务端收到回包后,就不会再次推送消息123给客户端。

MessageEmailManager中,可以根据消息优先级,决定是否拉取该消息的具体内容,以及是否聚合拉取具体内容。

推拉结合

客户端在线时: 实时推送最新的sequenceId给客户端,客户端立即感知

登录/重连/冷启动时: 增量拉取,快速同步离线期间的数据

拉取失败时: 无限重试,确保拉取成功

整个流程图如下:

问题:为什么需要sequenceId?

思考这个问题:消息本身有个唯一的messageId,为什么不直接推送messageId给客户端,而是又新增了sequenceId专门用于推送呢?

首先,messageId也会保证全局的唯一性,在服务端用作消息的唯一标识,即数据库主key。

但是messageId存在两个缺陷:

  1. 以64位整型Long为例,一般会在messageId中固定前几位用于声明关键信息,如会话id等信息。

  2. messageId保证全局唯一,但是不保证消息是按照发送顺序递增的。

第二个缺陷,messageId不递增,导致它无法用于收件箱推送。对于消息推送来说,是推拉结合的,那么在冷启动的时候,需要上传本地已经获取到的消息:

  1. 如果使用messageId,那么就要把本地所有的messageId都发送给服务端。而且服务端还要做diff,才能计算出客户端需要的消息队列。

  2. 使用sequenceId,就简单很多。首先sequenceId是递增的,客户端只需要将本地的最新(or最大)sequenceId发送给服务端,服务端也只需要对比sequenceId大小,就可以计算出客户端需要的消息队列。

我们其实可以将sequenceId理解为版本号的概念,客户端只需要维护一个本地版本号,就可以和服务端同步剩余数据。

问题二:id会耗尽吗?

一般我们的id使用的都是Long类型,即64位整型的。那么思考一下,这个id会耗尽吗?

64 位有符号 Long 的最大值是 9223372036854775807,换算成亿为 92233720368.54775807 亿(约 9223 万亿)。

我们以国民级应用微信为例,假设日活有10亿,那么每个人要发送922万条消息才能耗尽这个id。我们把时间拉长到100年,那么每个人每年要发送9万条消息,每天发送250条消息。

上面的假设场景,是把所有极端情况拉满的,实际上大部分人在微信中一天内不会发送250条消息。

根据国外分析网站的数据wechat-statistics,微信每天会产生450亿条消息,那么Long型可以供使用20万天,即555年。

所以对于大部分应用,无需考虑id耗尽的问题。

消息空洞

上面几个原则很好理解,但是在实际开发中,还需要解决消息的空洞问题。

消息空洞: 即消息不连续,中间漏掉了消息

case1:网络抖动

想象这个场景,服务端依次收到了消息A消息B消息C。 依次通过推送告知客户端,有消息A消息B消息C三条新消息,需要客户端主动获取对应消息体内容。

但是由于网络抖动,消息B的推送丢失了。 客户端仅收到了消息A消息C,落库后回包给服务端。此时服务端虽然会重试推送消息B,但是用户此时正好在此会话中,需要展示消息A消息C。对于客户端来说,并不知道还有消息B,那么就会导致消息A消息C上屏展示了。这样就会对用户理解信息造成很大影响。

下图中,你的回复是图片+文字,但是对方只收到了"交配中"的文字,会让对方感觉很抽象。


case2:消息定位

一般来说,进入会话A,初始化只会拉取最新50条消息,用户滑动屏幕后才会触发加载。在卸载重装后,用户没有滑动屏幕加载更多消息,那么客户端SDK数据库中,只有最近50条消息。

用户通过搜索关键字,定位到了第10000条的消息。当用户在这第10000条消息滑动屏幕时,会调用loadMessage加载下一页的消息。

那么如何判断SDK数据库中的50条消息,是否能展示呢?

SDK怎么判断第10000条消息和最近50条消息是否连续呢?

我们从文字描述上看,此时肯定是不连续的,即出现了空洞。但对于SDK来说,一定要有一个可以量化的条件。

消息定位导致的消息空洞场景有很多:

用户在离线状态,群聊中产生了10000条消息,用户点击离线推送进入会话,也会和上面情景一样,产生消息空洞。

同理,点击被回复的消息/被引用的消息/被置顶的消息等,都有可能产生消息空洞。

如何保证消息连续性:

保证消息没有空洞,即连续性很重要。一般来说,服务端会在messageId的基础上,给每条消息计算出一个continueIdcontinueId连续递增的,从0、1、2、3一直到无穷大。

需要注意,continueId是绑定在具体的会话下面的,在这个会话下是连续递增的。

前面提到的sequenceId是跨会话的,在多个会话间是非连续递增的。

这样客户端就知道消息A消息C是不能上墙的,得主动拉取到消息B,才能一起上墙。

服务端连续递增id生成方案有很多,这里不多赘述分布式ID生成器(CosId)设计与实现

在消息收件箱中,不关心消息是否是连续的,只关心消息是递增的。在99%的情况下,消息收件箱中的消息都是连续的。而且大部分会话,用户是极低概率进入的,而且即使进入,也很极低概率会滑动查看全部的历史消息。

所以,我们无需在收件箱中就保证消息连续性。只需要在用户看到这部分消息时,保证连续即可。一般来说,用户进入会话查看消息流程:

  1. 用户进入会话A后,客户端业务会调用loadMessage(lastMessageId, pageSize),其中,lastMessageId是描点id,pageSize是想要获取的消息数量。

  2. 客户端业务向客户端SDK查询从指定锚点开始的历史消息。

  3. 当客户端SDK从本地数据库捞出对应的消息列表后,需要check continueId是否是连续的

  4. 如果消息continueId连续,就直接返回给业务方。

  5. 如果不连续,客户端SDK需要发起网络请求,向服务端拉取补齐消息,最终返回给客户端业务。

  6. 这个过程会有预加载、loading等方式优化用户体验

消息中各种id

**** 含义 作用
messageId 消息的唯一id,具有全局唯一性,但是不保证按照发送顺序递增 消息的主key
sequenceId 全局唯一性,且是递增的,按照发送顺序一次递增 用于客户端-服务端的消息同步
continueId 单个会话内连续递增 用于保证消息连续不空洞
相关推荐
沐怡旸2 小时前
【底层机制】Android内存管理技术深度解析:PMEM、ION与DMA-BUF Heaps
android·面试
帅锅锅0072 小时前
process 类权限详解
android·操作系统
2501_940094022 小时前
CHDroid 安卓上的游戏ROM CHD格式转换工具软件 游戏ROM容量压缩
android·游戏
猪哥帅过吴彦祖3 小时前
Flutter 从入门到精通:状态管理入门 - setState 的局限性与 Provider 的优雅之道
android·flutter·ios
用户69371750013843 小时前
Kotlin 协程 快速入门
android·后端·kotlin
金鸿客3 小时前
用Compose实现一个Banner轮播组件
android
狂团商城小师妹3 小时前
JAVA国际版同城服务同城信息同城任务发布平台APP源码Android + IOS
android·java·ios
老华带你飞4 小时前
记录生活系统|记录美好|健康管理|基于java+Android+微信小程序的记录生活系统设计与实现(源码+数据库+文档)
android·java·数据库·vue.js·生活·毕设·记录生活系统
峥嵘life4 小时前
Android16 更新fastboot版本解决fastbootd模式识别不到设备问题
android·学习