消息收发架构

一个传统的视频网站如果想让自己的视频支持弹幕功能,也可以通过引入即时消息的技术,来让视频弹幕的参与者能实时、高效地和其他观看者进行各种互动。

一般来说首先需要制定好消息内容和未读数的存储,另外需要建立比原业务系统更加高效实时的消息收发通道,当然也包括依托第三方辅助通道来提升消息到达率。

消息索引和消息内容

这里,我以点对点消息的存储为例,来讲解一下。

**点对点消息的参与方有两个:消息发送方和消息接收方。**收发双方的历史消息都是相互独立的。互相独立的意思就是:假设发送方删除了某一条消息,接收方仍然可以获取到这条消息。

所以,从库表的设计上分析,这里需要索引表中收发双方各自有一条自己的索引记录:一条是消息发送方的发件箱索引,另一条是消息接收方的收件箱索引。

由于收发双方看到的消息内容实际都是一致的,因此还需要一个独立的消息内容表。

  • 内容表

image-20210817124853149

  • 索引表

image-20210817124931275

并且,它同时会往索引表里存储两条记录。

一条是张三的索引:内容有会话对方的 UID(李四的 UID),是发件箱的索引(也就是 0),同时记录这条消息的内容表里的消息 ID 为 1001。

另一条是李四的索引:内容有会话对方的 UID(张三的 UID),是收件箱的索引(也就是 1),同样也同时记录这条消息的内容表里的消息 ID 为 1001。

联系人列表

有了消息和索引后,如上一篇中的描述,一般 IM 系统还需要一个最近联系人列表,来让互动双方快速查找需要聊天的对象,联系人列表一般还会携带两人最近一条聊天消息用于展示。

这里你需要理解的是,和消息索引表的存储逻辑相比,联系人列表在存储上有以下区别。

  • 联系人列表只更新存储收发双方的最新一条消息,不存储两人所有的历史消息。
  • 消息索引表的使用场景一般用于查询收发双方的历史聊天记录,是聊天会话维度;而联系人表的使用场景用于查询某一个人最近的所有联系人,是用户全局维度。

在库表的设计上,联系人列表的存储实际和消息索引表类似,只不过消息索引表在接收到消息时,大部分情况都是插入操作,而联系人列表很多时候是更新操作。

消息收发通道

设计好消息的存储结构后,接下来,我们需要考虑的是:如何将消息发出去,以及怎么把消息投递给接收方。这里逻辑上涉及了两条通道:一条是消息发送通道,一条是消息接收通道。

发送方通过发送通道把消息从本地发送到 IM 服务端;IM 服务端通过接收通道把消息投递给接收方。

image-20210817125258649

解释一下这张图。

IM 服务端的网关服务和消息接收方设备之间维护一条 TCP 长连接(或者 Websocket 长连接),借助 TCP 的**全双工能力,也就是能够同时接收与发送数据的能力。**当有消息需要投递时,通过这条长连接实时把消息从 IM 服务端推送给接收方。

对于接收方不在线(比如网络不通、App 没打开等)的情况,还可以通过第三方手机操作系统级别的辅助通道,把这条消息通过手机通知栏的方式投递下去。

这里简单解释一下,常见的第三方操作系统级别的辅助通道。比如苹果手机的 APNs(Apple Push Notification Service)通道、Android 手机的 GCM 通道,还有各种具体手机厂商(如小米、华为等)提供的厂商通道。

这些通道由于是手机厂商来维护的,只要手机网络可通,因此可以在我们的 App 在没有打开的情况下,也能把消息实时推送下去。

当然,这些第三方操作系统级别的辅助通道也存在一些问题,因此大部分情况下也只是作为一个辅助手段来提升消息的实时触达的能力,这个在后续课程中,我会再详细说明。

因此,对于消息接收通道,重点在于需要在 IM 服务端和接收方之间,维护一个可靠的长连接,什么叫可靠的长连接呢,这里的可靠可以理解为下列两种情况。

  1. IM 服务端和接收方能较为精确地感知这个长连接的可用性,当由于网络原因连接被中断时,能快速感知并进行重连等恢复性操作。
  2. 可靠性的另一层含义是:通过这个长连接投递的消息不能出现丢失的情况,否则会比较影响用户体验。这个问题的解决会在后续第 3 篇的课程中来详细展开。

我在上面大概说明了一下,逻辑上消息收发通道各自的作用和一般的实现,当然这两条通道在实际的实现上,可以是各自独立存在的,也可以合并在一条通道中。

消息未读数

现在我们有了消息的收发通道和消息的存储,用户通过发送通道把消息发到 IM 服务端,IM 服务端对消息内容、收发双方的消息索引进行存储,同时更新双方的最近联系人的相关记录,然后 IM 服务端通过和消息接收方维护的接收通道,将消息实时推送给消息接收方。

如果消息接收方当前不在线,还可以通过第三方操作系统级别的辅助通道,来实时地将消息通过手机通知栏等方式推送给接收方。

整体上来看,一条消息从发送、存储、接收的生命之旅基本上比较完整了,但对于即时消息的场景来说,还有一个比较重要的功能,会对双方在互动积极性和互动频率上产生比较大的影响,这个就是消息的未读数提醒。

用过 QQ、微信的用户应该都有一个比较明显的感知,很多时候为了避免通知栏骚扰,会限制掉 App 在通知栏提醒权限,或者并没有注意到通知栏的提醒,这些情况都可能会让我们无法及时感知到“有人给我发了新的消息”这个事情。

那么作为一个重要的补救措施就是消息的未读提醒了。就我个人而言,很多时候是看到了 QQ 或者微信 App 的角标,上面显示的多少条未读消息,才打开 App,然后通过 App 里面具体某个联系人后面显示,和当前用户有多少条未读这个数字,来决定打开哪个联系人的聊天页进行查看。

上面通过未读提醒来查看消息的环节中涉及了两个概念:一个是我有多少条未读消息,另一个是我和某个联系人有多少条未读消息。

因此,我们在消息未读数的实现上,一般需要针对用户维度有一个总未读数的计数,针对某一个具体用户需要有一个会话维度的会话未读的计数。

那么,这两个消息未读数变更的场景是下面这样的:

  1. 张三给李四发送一条消息,IM 服务端接收到这条消息后,给李四的总未读数增加 1,给李四和张三的会话未读也增加 1;
  2. 李四看到有一条未读消息后,打开 App,查看和张三的聊天页,这时会执行未读变更,将李四和张三的会话未读减 1,将李四的总未读也减 1。

这个具体的未读数存储可以是在 IM 服务端(如 QQ、微博),也可以是在接收方的本地端上存储(微信),一般来说,需要支持“消息的多终端漫游”的应用需要在 IM 服务端进行未读存储,不需要支持“消息的多终端漫游”可以选择本地存储即可。

对于在 IM 服务端存储消息未读数的分布式场景,如何保证这两个未读数的一致性也是一个比较有意思的事情,

思考题

最后,留给你两个思考题。

\1. 消息存储中,内容表和索引表如果需要分库处理,应该按什么字段来哈希? 索引表可以和内容表合并成一个表吗?

\2. 能从索引表里获取到最近联系人所需要的信息,为什么还需要单独的联系人表呢?

\1. 消息存储中,内容表和索引表如果需要分库处理,应该按什么字段来哈希? 索引表可以和内容表合并成一个表吗? 答: 内容表应该按主键消息ID来哈希做分库分表处理,这样便于定位某一条具体的消息;索引表应该按索引的用户UID来哈希做分库分表处理,这样可以使得当前用户的所有联系人都落在一张表上,减少遍历所有表的麻烦。


消息存储有什么推荐的数据库吗

作者回复: 这个需要看具体的业务场景吧,比如考虑访问模型,数据量大小,读写的比例等等。在我们自己的场景里mysql和hbase,pika都有在使用。