本文由融云技术团队原创分享,原题“IM 消息数据存储结构设计”,内容有修订。
1、引言
在如今的移动互联网时代,IM类产品已是我们生活中不可或缺的组成部分。像微信、钉钉、QQ等是典型的以 IM 为核心功能的社交产品。另外也有一些应用虽然IM功能不是核心,但IM能力也是其整个应用极其重要的组成部分,比如在线游戏、电商直播等应用。
在IM技术应用场景越来越广泛的前提下,对即时通讯IM技术的学习和掌握就显的越来越有必要。
在IM庞大的技术体系中,消息系统无疑是最核心的,而消息系统中,最关键的部分是消息的分发和存储,而离线消息和历史消息又是这个关键环节中不可回避的技术要点。
本文将基于IM消息系统的技术实践,分享关于离线消息和历史消息的正确理解,以及具体的技术配合和实践,希望能为你的离线消息和历史消息技术设计带来最佳实践灵感。
(本文同步发布于:http://www.52im.net/thread-3887-1-1.html)
2、相关文章
技术相关文章:
融云技术团队分享的其它文章:
3、IM消息投递的一般做法
在通常的IM消息系统中,对于实时消息、离线消息、历史消息大概都是下面这样的技术思路。
对于在线用户:消息会直接实时发送到在线的接收方,消息发送完成后,服务器端并不会对消息进行落地存储。
而对于离线的用户:服务器端会将消息存入到离线库,当用户登录后,从离线库中将离线消息拉走,然后服务器端将离线消息删除。
这样实现的缺点就是消息不持久化,导致消息无法支持消息漫游,降低了消息的可靠性。
(PS:实际上,这其实也不能算是缺点,因为一些场景下存储历史消息并不是必须的,所谓的消息漫游能力也不是必备的,比如微信。)
而在我们设计的消息系统中,服务器只要接收到了发送方发上来的消息,在转发给接收方的同时也会在离线数据库及历史消息库中进行消息的落地存储,而历史消息的落地也就能支持消息漫游等相关功能了。
4、什么是离线消息和历史消息?
关于离线消息和历史消息,在技术上,我们是这样定义。
1)离线消息:
离线消息就是用户(即接收方)在离线过程中收到的消息,这些消息大多是用户比较关心的消息,具有一定的时效性。
以我们的系统经验来说,我们的离线消息默认只保存最近七天的消息。
用户(即接收方)在下次登录后会全量获取这些离线消息,然后在客户端根据聊天会话进行离线消息的UI展示(比如显示一个未读消息气泡等)。
(PS:用户离线的可能性在技术上其实是由很多种情况组成的,比如对方不在线、对方网络断掉了、对方手机崩溃了、服务器发送时出错了等等,严格来讲——只要无法实时发送成的消息,都算“离线消息”。)
2)历史消息:
历史消息存储了用户所有的聊天消息,这些消息包括发出的消息以及接收到的消息。
在客户端获取历史消息时,通常是按照会话进行分页获取的。
以我们的系统经验来说,历史消息的存储时间我们设计默认为半年,当然这个时间可以按实际的产品运营规则来定,没有硬性规定。
5、IM消息的发送及存储流程
以下是我们系统整体的消息发送及存储流程:
如上图所示:当用户发送聊天消息到服务器端后,首先会进入到消息系统中,消息系统会对消息进行分发以及存储。
这个过程中:对于在线的接收方,会选择直接推送消息。但是遇到接收方不在线或者是消息推送失败的情况下,也会有另外的消息获取方式,比如接收方会主动向服务器拉取未收到的消息。但是接收方何时来服务器拉取消息以及从哪里拉取是未知的,所以消息存入到离线库的意义也就在这里。
消息系统存储离线的过程中,为了不影响整个系统的更为平稳,我们使用了MQ消息队列进行IO解偶,所以聊天消息实际上是异步存入到离线库中的(通过MQ进行慢IO解偶,这其实也是惯常做法)。
在分发完消息后:消息服务会同步一份消息数据到历史消息服务中,历史消息服务同样会对消息进行落地存储。
对于新的客户端设备:会有同步消息的需求(所谓的消息漫游能力),而这也正是历史消息的主要作用。在历史消息库中,客户端是可以拉取任意会话的全量历史消息的。
6、IM离线消息、历史消息在存储逻辑上的区别
6.1 概述
通过上面的图中能清晰的看到:
1)离线消息我们存储介质选用的是 Redis;
2)历史消息我们选用的是 HBase。
对于为什么选用不同的存储介质,其实我们考虑的是离线消息和历史消息不同的业务场景和读写模式。
下面我们重点介绍一下离线消息和历史消息存储的区别。
6.2 离线消息存储模式——“扩散写”
离线消息的存储模式我们用的是扩散写。
如上图所示:每个用户都有自己单独的收件箱和发件箱:
1)收件箱存放的是需要向这个接收端同步的所有消息;
2)发件箱里存放的是发送端发出的所有消息。
以单聊为例:聊天中的两人会话中,消息会产生两次写,即发送者的发件箱和接收端的收件箱。
而在群的场景下:写入会被更加的放大(扩散),如果群里有 N 个人,那一条群消息就会被扩散写 N 次。
小结一下:
1)扩散写的优点是:接收端的逻辑会非常清晰简单,只需要从收件箱里读取一次即可,大大降低了同步消息所需的读的压力;
2)扩散写的缺点是:写入会被成指数地放大,特别是针对群这种场景。
6.3 历史消息存储模式——“扩散读”
历史消息的存储模式我们用的是扩散读。
因为历史消息中,每个会话都保存了整个会话的全量消息。在扩散读这种模式下,每个会话的消息只保存一次。
对比扩散写模式,扩散读的优点和缺点如下:
1)优点是:写入次数大大降低,特别是针对群消息,只需要存一次即可;
2)缺点是:接收端接收消息非常的复杂和低效,因为这种模式客户端想拉取到所有消息就只能每个会话同步一次,读就会被放大,而且可能会产生很多次无效的读,因为有些会话可能根本没有新消息。
6.4 小结
在 IM 这种应用场景下,通常会用到扩散写这种消息同步模型,一条消息产生一条,但是可能会被读多次,是典型的读多写少的场景。
一个优化好的IM系统,必须从设计上平衡读写压力,避免读或者写任意一个维度达到天花板。
当然扩散写这种模式也有其弊端,比如万人群,会导致一条消息,写入了一万次。
综合来讲:我们需要根据自己的业务场景做相应设计选择,以我们的IM系统为例,就是是根据了离线和历史消息的不同场景选择了写扩散和读扩散的组合模式。适合的才是最好的,没有必要死搬硬套理论。
7、IM客户端的拉取消息逻辑
7.1 离线消息拉取逻辑
对于IM客户端而言,离线消息的获取针对的是自己的整个离线消息,包括所有的会话(直白了说,就是上线时拉取此次离线过程中的所有未收取的离线消息)。
离线消息的获取是自上而下的方式(按时间序),我们的经验是一次获取 200 条(PS:如果离线消息过多,会分页多次拉取,拉取1“次”可以理解为拉取1“页”)。
在客户端拉取离线消息的信令中,需要带上当前客户端缓存的消息的最大时间戳。
通过上节的图我们应该知道,离线消息我们存储的是一个线性结构(指的是按时间顺序),Server 会根据这个时间戳向下查找离线消息。当重装或者新安装 App 时,客户端的“当前客户端缓存的消息的最大时间戳”可以传 0 上来。
Server 也会缓存客户端拉取到的最后一条消息的时间戳,然后根据业务场景,客户端类型等因素来决定从哪里开始拉取,如果没有拉取完 Server 会在拉取消息的应答中带相应的标记位,告诉客户端继续拉取,客户端循环拉取,直到所有离线消息拉完。
7.2 历史消息拉取逻辑
历史消息的获取通常针对的是单一会话。
在拉取过程中,需要向服务端提交两个参数:
1)对方的 ID(如果是单聊的话就是对方的 UserID,如果是群则是群组ID);
2)当前会话的最前面消息的时间戳(即当前会话最老一条消息的时间戳)。
Server据这两个参数,可以定位到这个客户端的此会话,然后一次获取 20 条历史消息。
消息的拉取时序上采用的是自下而上的方式(也就是时间序逆序),即从最后面往前翻。只要有消息,客户端可以一直向前翻,手动触发获取会话的历史消息。
上面的拉取逻辑,在IM界面功能上通常对应的是下拉或点击“加载更多”,比如这样:
8、本文小结
本文主要分享了IM中有关离线消息和历史消息的正确,主要包括离线消息和历史消息的区别,以及二者在存储、分发、拉取逻辑方面的最佳践等。如对文中内容有异议,欢迎留言讨论。
9、参考资料
[1] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
[4] 一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分等
[9] IM消息送达保证机制实现(一):保证在线实时消息的可靠投递
[10] 理解IM消息“可靠性”和“一致性”问题,以及解决方案探讨
(本文同步发布于:http://www.52im.net/thread-3887-1-1.html)