互联网发展至今,IM(即时通讯聊天应用)一直是互联网上最为成功也是最为平常的应用类型。尤其现今的移动互联网时代,因即时通讯技术的发展和普及,IM这种即时通讯应用已乎达成了各即时通讯应用运营者梦寐已求的所谓“全时在线”,而这种“全时在线”及其应用体验的背后,回归到技术本质就是各种行为消息(或者说信息)的实时性、必达性。
本文将要讨论的是即时IM应用中极其重要但也不被用户感知的消息送达保证机制(即QoS机制),文中将给出目前主流的参考实现思路。
消息的可靠性,即消息的不丢失和不重复,是IM系统中的一个难点。当初QQ在技术上(当时叫OICQ)因为以下两点原因才打败了ICQ:
- QQ的消息投递可靠(消息不丢失,不重复);
- QQ的垃圾消息少(它antispam做得好,这也是一个难点,但不是本文重点讨论的内容)。
今天,本文将用十分通俗的语言,来讲述IM系统中消息可靠性的问题。
IM的客户端与服务器通过发送报文(也就是请求包)来完成消息的传递。
报文分为三种:
- 请求报文(request,后简称为为R);
- 应答报文(acknowledge,后简称为A);
- 通知报文(notify,后简称为N)。
这三种报文的解释如下:
- R:客户端主动发送给服务器的报文
- A:服务器被动应答客户端的报文,一个A一定对应一个R
- N:服务器主动发送给客户端的报文
- client-A向im-server发送一个消息请求包,即msg:R
- im-server在成功处理后,回复client-A一个消息响应包,即msg:A
- 如果此时client-B在线,则im-server主动向client-B发送一个消息通知包,即msg:N(当然,如果client-B不在线,则消息会存储离线)
从流程图中容易看到,发送方client-A收到msg:A后,只能说明im-server成功接收到了消息,并不能说明client-B接收到了消息。在若干场景下,可能出现msg:N包丢失,且发送方client-A完全不知道,例如:
- 服务器崩溃,msg:N包未发出
- 网络抖动,msg:N包被网络设备丢弃
- client-B崩溃,msg:N包未接收
结论是悲观的:接收方client-B是否有收到msg:N,发送方client-A完全不可控,那怎么办呢?
我们来参考网络传输协议的实现:UDP是一种不可靠的传输层协议,TCP是一种可靠的传输层协议,TCP是如何做到可靠的?答案是:超时、重传、确认。(实际上IM中,数据通讯层无论用的是UDP还是TCP协议,都同样需要消息送达保证(即QoS)机制,原因在于IM的通信是A端-Server-B端的3方通信,而非传统C/S或B/S这种2方通信)。
要想实现应用层的消息可靠投递,必须加入应用层的确认机制,即:要想让发送方client-A确保接收方client-B收到了消息,必须让接收方client-B给一个消息的确认,这个应用层的确认的流程,与消息的发送流程类似:
- client-B向im-server发送一个ack请求包,即ack:R
- im-server在成功处理后,回复client-B一个ack响应包,即ack:A
- 则im-server主动向client-A发送一个ack通知包,即ack:N
至此,发送“你好”的client-A,在收到了ack:N报文后,才能确认client-B真正接收到了“你好”。
你会发现,一条消息的发送,分别包含(上)(下)两个半场,即msg的R/A/N三个报文,ack的R/A/N三个报文。一个应用层即时通讯消息的可靠投递,共涉及6个报文,这就是im系统中消息投递的最核心技术(如果某个im系统不包含这6个报文,不要谈什么消息的可靠性)。
期望六个报文完成消息的可靠投递,但实际情况下:
- msg:R,msg:A 报文可能丢失:
此时直接提示“发送失败”即可,问题不大;- msg:N,ack:R,ack:A,ack:N这四个报文都可能丢失:
(原因如1.4所述,可能是服务器奔溃、网络抖动、或者客户端奔溃),此时client-A都收不到期待的ack:N报文,即client-A不能确认client-B是否收到“你好”。
那怎么办呢?
client-A发出了msg:R,收到了msg:A之后,在一个期待的时间内,如果没有收到ack:N,client-A会尝试将msg:R重发。可能client-A同时发出了很多消息,故client-A需要在本地维护一个等待ack队列,并配合timer超时机制,来记录哪些消息没有收到ack:N,以定时重发。
一旦收到了ack:N,说明client-B收到了“你好”消息,对应的消息将从“等待ack队列”中移除。
1.6中提到过,msg:N报文,ack:N报文都有可能丢失:
- msg:N 报文丢失:说明client-B之前压根没有收到“你好”报文,超时与重传机制十分有效
- ack:N 报文丢失:说明client-B之前已经收到了“你好”报文(只是client-A不知道而已),超时与重传机制将导致client-B收到重复的消息。
启示:
平时使用qq,或许大伙都有类似的体验,弹出一个对话框“因为网络原因,消息发送失败,是否要重发”,此时,有可能是对方没有收到消息(发送方网络不好,msg:N丢失),也可能已经收到了消息(接收方网络不好,反复重传后,ack:N依然丢失),出现这个提示时,大伙不妨和对端确认一下,看是哪种情况。
解决方法也很简单,由发送方client-A生成一个消息去重的msgid,保存在“等待ack队列”里,同一条消息使用相同的msgid来重传,供client-B去重,而不影响用户体验。
1)上述设计理念,由客户端重传,可以保证服务端无状态性(架构设计基本准则);
2)如果client-B不在线,im-server保存了离线消息后,要伪造ack:N发送给client-A;
3)离线消息的拉取,为了保证消息的可靠性,也需要有ack机制,但由于拉取离线消息不存在N报文,故实际情况要简单的多,即先发送offline:R报文拉取消息,收到offline:A后,再发送offlineack:R删除离线消息。
1)im系统是通过超时、重传、确认、去重的机制来保证消息的可靠投递,不丢不重;
2)切记,一个“你好”的发送,包含上半场msg:R/A/N与下半场ack:R/A/N的6个报文。
个人消息是一个1对1的ack,群消息就没有这么简单了,群消息存在一个扩散系数,im群消息的可靠投递问题感兴趣的可查阅相关资料。
在第一章节中我们讨论了在线实时消息的投递可以通过应用层的确认、发送方的超时重传、接收方的去重等手段来保证业务层面消息的不丢不重。
但实时在线投递针对的是消息收发双方都在线的情况(如当发送方用户A发送消息给接收方用户B时,用户B是在线的),那如果消息的接收方用户B不在线,系统是如何保证消息的可达性的呢?这就是本章节要讨论的问题。
- Step 1:用户A发送一条消息给用户B;
- Step 2:服务器查看用户B的状态,发现B的状态为“offline”(即B当前不在线);
- Step 3:服务器将此条消息以离线消息的形式持久化存储到DB中(当然,具体的持久化方案可由您IM的具体技术实现为准);
- Step 4:服务器返回用户A“发送成功”ACK确认包(注:对于消息发送方而言,消息一旦落地存储至DB就认为是发送成功了)。
关于 “Step 4” 的补充说明:
请一定要理解“Step 4”,因为现在无论是传统的PC端IM(类似QQ这样的——可以在UI上看到好友的在线、离线状态)还是目前主流的移动端IM(强调的是用户全时在线——即你看不到好友到底在线还是离线,反正给你的假像就是这个好友“应该”是在线的),消息发送出去后,无论是对方实时在线收到还是对方不在线而被服务端离线存储了,对于发送方而言只要消息没有因为网络等原因莫名消失,就应该认为是“被收到了”。
从技术的角度讲,消息接收方收到的消息应答ACK包的真正发起者,实际上有两种可能性:一种是由接收方发出、而另一种是由服务端代为发送(这在MobileIMSDK开源工程里被称作“伪应答”)。
-① 存储离线消看书的表主要字段大致如下:
-- 消息接收者ID
receiver_uid varchar(50),
-- 消息的唯一指纹码(即消息ID),用于去重等场景,单机情况下此id可能是个自增值、分布式场景下可能是类似于UUID这样的东西
msg_id varchar(70),
-- 消息发出时的时间戳(如果是个跨国IM,则此时间戳可能是GMT-0标准时间)
send_time time,
-- 消息发送者ID
sender_uid varchar(50),
-- 消息类型(标识此条消息是:文本、图片还是语音留言等)
msg_type int,
-- 消息内容(如果是图片或语音留言等类型,由此字段存放的可能是对应文件的存储地址或CDN的访问URL)
msg_content varchar(1024),
…
② 离线消息拉取模式:
接收方B要拉取发送方A给ta发送的离线消息,只需在receiver_uid(即接收方B的用户ID), sender_uid(即发送方A的用户ID)上查询,然后把离线消息删除,再把消息返回B即可。
③ 离线消息的拉取,如果用SQL语句来描述的话,它可以是:
SELECT msg_id, send_time, msg_type, msg_content
FROM offline_msgs
WHERE receiver_uid = ? and sender_uid = ?
④ 离线拉取的整体流程如下图所示:
Stelp 1:用户B开始拉取用户A发送给ta的离线消息;
Stelp 2:服务器从DB(或对应的持久化容器)中拉取离线消息;
Stelp 3:服务器从DB(或对应的持久化容器)中把离线消息删除;
Stelp 4:服务器返回给用户B想要的离线消息。
如果用户B有很多好友,登陆时客户端需要对所有好友进行离线消息拉取,客户端与服务器交互次数就会比较多。
① 拉取好友离线消息的客户端伪代码:
// 登陆时所有好友都要拉取
for(all uid in B’s friend-list){
// 与服务器交互
get_offline_msg(B,uid);
}
② 优化方案1:
先拉取各个好友的离线消息数量,真正用户B进去看离线消息时,才往服务器发送拉取请求(手机端为了节省流量,经常会使用这个按需拉取的优化)。
③ 优化方案2:
如下图所示,一次性拉取所有好友发送给用户B的离线消息,到客户端本地再根据sender_uid进行计算,这样的话,离线消息表的访问模式就变为->只需要按照receiver_uid来查询了。登录时与服务器的交互次数降低为了1次。
④ 方案小结:
通常情况下,主流的的移动端IM(比如微信、手Q等)通常都是以“优化方案2”为主,因为移动网络的不可靠性加上电量、流量等资源的昂贵性,能尽量一次性干完的事,就尽可能一次搞定,从而提供整个APP的用户体验。
用户B一次性拉取所有好友发给ta的离线消息,消息量很大时,一个请求包很大、速度慢,容易卡顿怎么办?
如何保证可达性,上述步骤第三步执行完毕之后,第四个步骤离线消息返回给客户端过程中,服务器挂点,路由器丢消息,或者客户端crash了,那离线消息岂不是丢了么(数据库已删除,用户还没收到)?
确实,如果按照上述的1、2、3、4步流程,的确是的,那如何保证离线消息的绝对可靠性、可达性?
如同在线消息的应用层ACK机制一样,离线消息拉时,不能够直接删除数据库中的离线消息,而必须等应用层的离线消息ACK(说明用户B真的收到离线消息了),才能删除数据库中的离线消息。这个应用层的ACK可以通过实时消息通道告之服务端,也可以通过服务端提供的REST接口,以更通用、简单的方式通知服务端。
如果用户B拉取了一页离线消息,却在ACK之前crash了,下次登录时会拉取到重复的离线消息么?
确实,拉取了离线消息却没有ACK,服务器不会删除之前的离线消息,故下次登录时系统层面还会拉取到。但在业务层面,可以根据msg_id去重。SMC理论:系统层面无法做到消息不丢不重,业务层面可以做到,对用户无感知。
优化后的拉取过程,如下图所示:
假设有N页离线消息,现在每个离线消息需要一个ACK,那么岂不是客户端与服务器的交互次数又加倍了?有没有优化空间?
如上图所示,不用每一页消息都ACK,在拉取第二页消息时相当于第一页消息的ACK,此时服务器再删除第一页的离线消息即可,最后一页消息再ACK一次(实际上:最后一页拉取的肯定是空返回,这样可以极大地简化这个分页过程,否则客户端得知道当前离线消息的总页数,而由于消息读取延迟的存在,这个总页数理论上并非绝对不变,从而加大了数据读取不一致的可能性)。这样的效果是,不管拉取多少页离线消息,只会多一个ACK请求,与服务器多一次交互。
正如本文中所列举的问题所描述的那样,保证“离线消息”的可达性比大家想象的要复杂一些,常见优化总结如下:
1)对于同一个用户B,一次性拉取所有用户发给ta的离线消息,再在客户端本地进行发送方分析,相比按照发送方一个个进行消息拉取,能大大减少服务器交互次数;
2)分页拉取,先拉取计数再按需拉取,是无线端的常见优化;
3)应用层的ACK,应用层的去重,才能保证离线消息的不丢不重;
4)下一页的拉取,同时作为上一页的ACK,能够极大减少与服务器的交互次数。
全栈开发之技术栈