02 | 消息收发架构:为你的App,加上实时通信功能

你好,我是袁武林。

前一篇文章中,我们从使用者的直观角度和从业者的实现维度,了解一个IM系统都应该具备哪些要素。但实际上,从我的角度来看,我更倾向于把“IM”看作是一门可以融入到各种业务系统中,为业务系统提供“实时交互”能力的技术模块。

比如,极客时间想在它的App中增加一个互动模块,支持用户点对点的实时聊天功能。那么,我们就可以相应地通过一些IM SDK的方式,快速地把即时消息的技术引入到已有的业务系统中。

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

所以,从某种程度上看,随着移动网络的快速发展以及资费的快速下降,即时消息技术也越来越多地被广泛应用到各种业务系统中,用于提升用户实时互动的能力。

那么,接下来,我们就一起从即时消息更细化的实现角度来看一看,给一个已有系统增加即时消息功能,大致上都有哪些具体工作。

如果为原有的业务系统增加实时消息模块,在不需要重建账号体系的前提下,整体上大概包括几块内容:

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

下面我们分别来看一下各部分大体需要做的工作都包括哪些。

消息存储

我们回想一下上一篇的内容,即时消息系统中,消息作为互动的载体,是必不可少的要素之一。

一般来说,大部分即时消息系统为了便于查看历史消息或者用于暂存离线消息,都需要对消息进行服务端存储,因此,我们先来看一看,这些互动过程产生的消息在服务端应该怎么存储或者暂存。

消息索引和消息内容

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

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

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

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

消息内容表用于存储消息维度的一些基本信息,比如消息ID、消息内容、消息类型、消息产生时间等。收发双方的两个索引表通过同一个消息ID和这个内容表关联。

这里假设张三给李四发送一条消息,消息存储在MySQL,或者类似的关系型数据库中,那么上面涉及的两张表大致如下:

  • 内容表

  • 索引表

比如张三给李四发了一条“你好”的消息,那么这个动作会向内容表存储一条消息。这条消息内容是这样的:ID为1001,消息内容是“你好”,消息类型是文本消息,还有当时消息创建的时间。

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

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

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

联系人列表

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

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

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

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

  • 最近联系人表

还是刚才那个例子,张三给李四发完消息后,除了在内容表和索引表插入记录,还会更新各自的最近联系人表,这里需要分别更新张三的最近联系人表和李四的最近联系人表。

比如更新张三的最近联系人表,如果和李四之前没有聊天记录,那么新插入一条联系人记录。联系人的对方UID为李四的UID,和这个联系人最新的一条消息ID是1001。

如果张三和李四之前已经有过聊天记录,那么只需要更新张三和李四的最新的一条聊天消息ID为1001,同样的办法再更新一次李四的联系人列表。

以上就是消息存储部分最重要的三个表,消息内容表、消息索引表、联系人列表。它们大致的存储结构,我们就设计好了。

消息收发通道

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

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

消息发送通道

发送通道的实现上有很多种方式,比如下面的两种。

  1. IM服务端提供一个HTTP协议的API接口,客户端需要发送消息时,调用这个接口把消息发给IM服务端。
  2. 客户端和IM服务端维护一个TCP长连接,客户端有消息发送时,会以私有协议来封装这条要发送的消息,然后通过这个TCP长连接把消息发给IM服务端。

所以,发送通道的实现相对比较简单,重点在于:IM服务端提供消息发送的API,发送方可以通过任意方式调用到这个API,把消息发出去即可。

消息接收通道

对于我们最常见的非P2P模式的IM系统来说,由于有一条消息要投递给某个接收方这个事件,接收方并没有办法能实时知道,只有IM服务端收到发送方发出的消息时能实时感知到,因此消息投递这个动作一般都是IM服务端触发的(这里,我们不去讨论由接收方通过轮询获取消息的模式)。

下面,我画了一张图来说明接收通道的业务逻辑,目前业界在消息接收通道的实现上较多采用的方式是下面这样的。

解释一下这张图。

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服务端存储消息未读数的分布式场景,如何保证这两个未读数的一致性也是一个比较有意思的事情,这个问题我会留到第6篇来和你详细讨论。

小结

上面我们从一条消息“产生、存储、接收”的整个生命周期出发,较为系统地从实现的角度上对消息系统的几个关键部分进行了讲述。可以简单地总结为下面几点。

  1. 消息的发送方通过发送通道来把消息提交到IM服务端。

  2. IM服务端接收到发送的消息后,会进行消息的存储以便于后续历史消息的查看,消息的存储从实现上可以分为:消息内容存储、消息索引存储、最近联系人列表存储。

  3. IM服务端接收到发送的消息后,还会针对接收方进行未读数的变更,以提醒用户查看未读的消息,消息未读数的实现上一般分为:用户维度的总未读和会话维度的会话未读。

  4. IM服务端进行完消息存储和未读变更后,会通过接收通道把消息推送给接收方,接收通道一般是通过IM服务端和消息接收方之间维护的长连接来实现,还会使用第三方操作系统级别的辅助通道,来提升“自建的长连接不可用“时,实时触达的能力。

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

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

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

你可以给我留言,我们一起讨论。感谢你的收听,我们下期再见。

精选留言

  • 王棕生

    2019-08-30 10:30:02

    1. 消息存储中,内容表和索引表如果需要分库处理,应该按什么字段来哈希? 索引表可以和内容表合并成一个表吗?
    答: 内容表应该按主键消息ID来哈希做分库分表处理,这样便于定位某一条具体的消息;索引表应该按索引的用户UID来哈希做分库分表处理,这样可以使得当前用户的所有联系人都落在一张表上,减少遍历所有表的麻烦。
    索引表可以与内容表合成一张表,好处是显而易见的,能减少拉取历史消息时的数据库IO,不好的地方就是消息内容冗余存储,浪费了空间。

    2. 能从索引表里获取到最近联系人所需要的信息,为什么还需要单独的联系人表呢?
    答: 如果从索引表中获取一个用户的所有联系人信息(包括最后一条聊天内容和时间)的话,SQL语句中会有分组后取top 1的操作,性能不理想; 另外当前用户与单个联系人之间的未读数需要维护,用联系人表的一个字段来存储,比用索引表方便许多。

    作者回复

    👍

    2019-08-30 23:13:37

  • Julien

    2019-08-30 07:05:53

    每节课的思考题可以在下一节课开始之前揭晓吗?谢谢。
  • hlai

    2019-11-29 17:36:46

    最后张图,客户端发消息到服务是通过API, 请问为什么不可以长连接么发过去呢?
    作者回复

    这里说一下:消息的收发是可以通过同一个长连通道来实现的,对于普通的聊天场景我甚至更推荐这种架构。对于消息下行扇出压力较大的场景(比如:直播、大型聊天室),考虑到下行通道的压力较大,稳定性方面保障会差一些。这种情况可以将上行独立拆分出来,保证用户发消息不会受到消息下推压力大的影响。

    2019-12-06 18:02:55

  • hgf

    2019-09-07 15:43:01

    可能行为:
    1. 发送消息
    2. 查看(历史)消息
    3. 联系人列表最新一条消息
    4. 未读消息数

    内容表应该按照消息ID索引来hash,原因:使得数据分布均匀,相对使用产生时间维度做hash更能均衡系统的查询压力,很少有人查询到几个月前的数据,绝大部部分人查看历史,都会查看最近1~3天内的。


    消息索引表按照索引用户ID来hash,原因:将索引用户相关的消息记录hash到相同的表,处理比较方便,避免跨表查询。


    为什么不要将消息内容表和消息索引表合并?
    会造成消息存储冗余,特别是群聊的时候。

    为什么消息索引表需要同时记录发送方-> 接收方;接收方-> 发送方的索引?因为在分库分表的情况下,可能依据索引用户ID进行hash,如果仅记录一条记录,例如,仅记录了发送方-> 接收方索引,那么所有发送法相同的索引会hash到同样的表,那么接受方在查询会话记录时,需要从多个库表中查询,反之,也是类似情况。

    为什么需要单独的联系人表?
    联系人列表中,仅仅需要最新一次的消息,如果从消息索引表中查询所有联系人的最新一次的消息,那么需要执行group by,order by 等操作,因为在消息索引表的记录数是远大于联系人表的,查询效率上差距明显,存储端的压力也较大
  • 666

    2019-09-13 15:58:50

    想了解一下像微博这类消息系统如果解决大V用户的消息收发的?比如用户给大v点赞、评论或者是私信
    作者回复

    点赞、评论这些目前采用的拉取模式,用户打开具体的赞箱和评论箱时才从服务端获取;私信是支持在线推送的,不过为了减少对大V的骚扰,对于未关注人私信没有系统push。

    2019-09-16 16:53:06

  • 关汉聪

    2019-12-18 14:05:44

    老师,您的一个回复中写到:“一般会先写缓存层,缓存层都成功的情况下,如果写有索引失败的情况可以先把失败的索引先写入到一个“失败队列”,由其他线程轮询尝试来写入。一般情况下,缓存层可以抗住db重试期间的数据可用性。”
    想问下,缓存一般都是缓解读压力用的,这里是为了缓解写压力,那么:
    1. 缓存服务器也是要主从或者集群吗,这样每次写入都保证主从缓存都写入后,在写入DB之前,返回消息发送方,成功的ACK,对吗?
    2. 如果缓存成功,索引写入DB失败,在“失败队列”被轮询的过程中,消息发送方接下来的每一条消息,为了保证消息的顺序,是不是都要等待之前失败的消息写入DB后,接下来才能发送给消息接收方?
    谢谢老师。
    作者回复

    1. 缓存在这里的作用主要是快速响应用户的写入请求,规避同步写db的重操作。缓存服务器是否需要采取主从集群可以根据实际访问量来评估,一般写完主后即可返回,由主负责同步消息到从。
    2. 由于消息id和产生时间在写入缓存前就已经生成好,失败队列重试导致的顺序错乱不影响最终消息到达客户端后的重排序。

    2019-12-26 20:40:47

  • Dxn

    2019-11-03 21:16:16

    索引表为啥要插入两条记录?插入一条也是一样的啊
    作者回复

    主要是考虑各自索引维护时的独立性:比如一方删除了消息不影响另一方查看类似的需求。

    2019-11-06 18:07:12

  • Alber

    2019-08-30 23:16:00

    消息存储有什么推荐的数据库吗
    作者回复

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

    2019-09-01 15:40:45

  • 小祺

    2019-09-03 11:14:04

    消息表和索引表合并后表结构:
    发消息用户ID ,收消息用户ID, 消息状态, 消息内容,消息类型,消息产生时间
    其中消息状态 0: 正常 1:发消息用户已删除 2: 收消息用户已删除
    如果两个用户都删除消息,那这条记录就被delete掉。
    既可以满足合并消息,又可以满足单个用户删除消息,请问这样设计是否可行?
    作者回复

    处理删除和合并上没问题哈,需要考虑一下分库分表场景下,收发双方查询历史消息应该怎么查呢?

    2019-09-03 22:31:51

  • 小可

    2019-08-30 08:26:43

    1.由于索性表与内容表有关联,分库时两张表都应该按内容表id哈希,如果按用户id哈希,如果记录与内容不在一个库,获取消息时还要夸库查询,增加了系统复杂度,也会影响性能;
    不能合并,一般IM系统都有群发消息功能,如果内容表合并到索引表,那内容数据冗余就太多了,从而占用存储空间

    2.索引表与联系人表两个的功能设计不同
    索引表主要存储消息历史,联系人存储用户关系,如果两个表合并,那么获取联系人时要对所有索引表消息进行聚合才能获取到,性能大大降低;另外如果进行删除联系人操作,必须要将与该联系人的所有消息删除才可以,而联系人表独立的话只许将联系人删除,历史消息可根据需求是否就行删除,当然删除历史消息就可以异步执行了
    作者回复

    这里需要考虑一个问题哈:我们在查询两个人之间的历史消息的时候是用户维度的查询还是消息维度的查询?如果按消息id哈希,查询两个人之间的历史消息在只有uid的情况下该怎么查呢?

    2019-08-30 22:33:45

  • 小袁

    2019-09-07 20:58:46

    索引表按照聊天对像的id来哈希,这样和某个对象的聊天记录可以落在一个库中,没问题。

    内容表我认为可以按时间分库,实际查询时一般都是从最近的聊天记录开始往前翻,或者看某一天的记录,这样按连续时间段来查询,不会只单独看一条记录吧。

    如果按消息id分库那岂不是翻某天的聊天记录可能要查好几个库了吗?
    作者回复

    按时间段来查询没问题,但是消息写入的时候就会都落到一个表里,写入压力会有问题,但内容表实现上确实可以在hash完后按时间进行分表。

    2019-09-09 21:21:45

  • geraltlaush

    2019-09-02 08:32:19

    之间面试被问到怎么设计存储群聊的聊天记录,保证聊天记录的有序性,没答出来,老师帮忙解答下
    作者回复

    一般来说群聊消息的存储上主要是考虑存储冗余的问题,一个群的消息只需要存一份即可,每条消息id通过自增序号或者“时间维度相关”的发号器来生成,通过这个id排序即可。群聊相关的话题后续课程还会细讲哈

    2019-09-02 19:23:40

  • Colin

    2019-08-30 12:45:32

    请教各位评论区的大神们:消息未读数更新时机大家都是怎么设计的?比如说:发一条消息,未读消息+1这个操作大家都是如何更新的?如果是在数据库更新,再同步到客户端,可能会出现消息到达了,未读数还没更新的现象,如果是在客户端+1,再保存到数据库,别人写个脚本就能把数据给改乱了,不安全。应该如何设计比较合理?
  • 挨踢菜鸟

    2019-08-30 09:03:18

    老师,请问websocket如何多实例部署,如何解决实例重启造成连接断开的问题
    作者回复

    没太理解到您的意思哈,websocket网关只有是无状态的多实例部署没啥问题的呀;实例重启断开连接是肯定的,需要解决的是断开后客户端需要有重连机制以及如果尽量减少实例重启的概率。

    2019-08-30 23:02:29

  • 阿基米德

    2020-07-18 20:35:49

    根据咱们的分库分表方式,用户在获取历史消息的时候接口如何设计呢,因为用户展示的历史既有收件箱的消息也有发件箱的消息,这两个消息要按照消息id顺序展示,这个合并的过程是客户端做还是服务端坐呢
  • 🐌🐌🐌

    2020-03-06 11:45:59

    QQ 或者微信app上面的消息总未读数 是如何实现的,这种情况下 APP与服务器端的长连接如何应用再后台运行的话 可能已经断了? QQ应用图标上的这个总未读数是如何获取的呢?
    作者回复

    当自己维护的服务端长连已经断开时,一般是是通过第三方系统级推送来实现,比如ios上是通过APNs连接来更新app的角标未读的。

    2020-03-13 15:52:53

  • 大智

    2019-10-22 09:33:20

    感谢老师分享。如分享中所说一条点对点消息的存储包括消息内容记录插入,双方各一条索引记录的插入,和最近联系人表的更新。如果消息内容插入其成功,其中一方索引记录插入成功,但另外一方索引记录插入失败的异常是如何处理的?多谢
    作者回复

    一般会先写缓存层,缓存层都成功的情况下,如果写有索引失败的情况可以先把失败的索引先写入到一个“失败队列”,由其他线程轮询尝试来写入。一般情况下,缓存层可以抗住db重试期间的数据可用性。

    2019-10-25 14:55:21

  • Flash

    2019-10-20 23:01:33

    老师您好,请问联系人表,不需要存最近联系人的时间吗,
    最近联系人列表应该是有多条,最新的排在前面吧。
    作者回复

    这里说的联系人表其实就是存的最近联系人以及和这个联系人最新的一条消息,排序的话会按照最新一条消息来排。

    2019-10-21 20:34:44

  • 飞翔

    2019-09-28 10:32:25

    老师 在开始聊天之前大家应该先成为好友吧。好友关系 怎么存呢
    作者回复

    好友关系存储上比较好处理,一般只需要双方uid,建立关系的时间,考虑到好友列表的使用场景(某个uid的所有好友列表),类似redis的sorted set结构是一个不错的选择。

    2019-09-30 09:08:07

  • 2019-09-04 01:04:13

    老师,那个websocket网关无状态是什么意思呀,和http无状态一样吗?长连接一般不是有状态的吗?
    作者回复

    这里说的websocket网关无状态是指用户不会绑定到具体的某一台网关机来连接,这样网关机就能够做到随意上下线和扩缩容了。

    2019-09-04 19:09:04