web服务端推送技术简介

Web端即时通讯技术因受限于浏览器的设计限制,一直以来实现起来并不容易,主流的Web端即时通讯方案大致有4种:传统Ajax短轮询、Comet技术、WebSocket技术、SSE(Server-sent Events)。

 

方案一、Ajax轮询拉取

 

轮询拉取,是最容易想到的实现方式:

  • 发送方发送了消息,先入队列

  • 网页端起一个timer,每个一段时间(例如10秒),发起一个轮询请求,拉取队列里的消息

  • 如果队列里有消息,就返回消息

  • 如果队列里无消息,就10秒后再次轮询

 

这种方式的优势是:实现简单,直观,容易理解,互联网兴起时,人数不多的聊天室就是这么玩的。

 

缺点也很明显:

  • 实时性差:最坏的情况下,1条消息进入队列后,10s之后才会收到

  • 效率低下:发消息是一个低频动作,如果10次轮询才收到1条消息,请求有效性只有10%,浪费了大量服务器资源

 

更要命的是,在这种方案下,实时性与效率是一对不可调和的矛盾:如果将轮询周期设为1/10,将时延缩短到1秒,意味着100次轮询才会收到1条消息,请求有效性则降为了1%。

 

 

方案二、HTTP长轮询

 

在Ajax轮询基础上做的一些改进,它的特点是:

  • 这是一条browser发往web-server的HTTP连接

  • 这条连接只用来收取推送通知

  • 不像普通的“请求-响应”式HTTP请求,这个HTTP会被服务端hold住,直到有推送通知到达,或者超过约定的时间

对于HTTP请求,为了提高效率,一般来说browser和web-server都会有一些设置,如果一条HTTP请求长时间没有数据(例如,120秒),会被断开。“通知连接”为了不被browser和web-server粗暴断开,一般会设置一个约定阈值(例如,小于120秒),由系统返回一个空消息,以便“优雅返回”。

 

通常把这种实现也叫做comet。

 

普通Ajax轮询与基于Ajax的长轮询原理对比:

 

 

更具体的,对于不同情况下长轮询接收消息是如何实现的?

 

场景1,发起通知连接时,消息队列里正好有消息,则:

  • 发起通知连接,正好队列里有消息

  • 实时把队列里的消息带回

  • 立马再发起通知连接

 

 

场景二,发起通知连接时,队列里无消息,则:

  • 发起通知连接时,队列里无消息

  • 一直等待,直到触发“时间阈值”,返回无消息

  • 立马再发起通知连接

 

 

场景三,新消息来时,正好有通知连接在,则:

  • 新消息来时,正好有通知连接在

  • 通知连接实时将消息带回

  • 立马再发起通知连接

 

上面三个场景的最终状态,都是“一定,永远,会有一条通知连接,连接在浏览器与服务器之间”,这样就能够保证消息的实时性。当然,有人会说,HTTP的返回与再次发起会有一个时间差,如果这个时间差,恰巧有新消息过来呢?

 

 

场景四,新消息来时,没有通知连接,则:

  • 新消息来时,没有通知连接

  • 把新消息放入队列

 

最后这个场景,发生的概率非常小,但也确保了在“HTTP的返回与再次发起会有一个时间差”内,消息不会丢失,在通知连接发起后,消息能够实时返回。

 

方案三、iframe流(streaming)

iframe流方式是在页面中插入一个隐藏的iframe,利用其src属性在服务器和客户端之间创建一条长连接,服务器向iframe传输数据,来实时更新页面。

  • 优点:消息能够实时到达;浏览器兼容好

  • 缺点:服务器维护一个长连接会增加开销;IE、chrome、Firefox会显示加载没有完成,图标会不停旋转。(Google 使用了称为“htmlfile”的 ActiveX 解决了在 IE 中的加载显示问题,并将这种方法应用到了 gmail+gtalk 产品中。)

 

以上方案均是请求/响应模式,并没有从根本上实现双向通信。

 

方案四、WebSocket

Websocket是一个独立的基于TCP的协议,与http协议兼容、却不会融入http协议。与http协议不同的请求/响应模式不同,Websocket在建立连接后,双方即可双向通信。

 

HTTP1.1与WebSocket的异同

1.相同点

 

  • 都是基于TCP的应用层协议。

  • 都使用Request/Response模型进行连接的建立。

  • 都可以在网络中传输数据。

  • 在连接的建立过程中对错误的处理方式相同,在这个阶段WebSocket可能返回和HTTP相同的返回码。

 

2.不同点

 

  • http协议基于请求应答,只能做单向传输,是半双工协议,而WebSocket是全双工协议,类似于socket通信,双方都可以在任何时刻向另一方发送数据。

  • WebSocket的连接不能通过中间人来转发,它必须是一个直接连接。

  • websocket传输的数据是二进制流,是以帧为单位的,http传输的是明文传输,是字符串传输,WebSocket的数据帧有序。

  • websocket的请求的头部和http请求头部不同。

 

 

方案五、SSE

 

SSE(Server-Sent Event,服务端推送事件)允许服务端向客户端推送新数据,是 HTML 5 规范中的一个组成部分。

 

与WebSocket的比较

  • 简单不说,SSE适用于更新频繁、低延迟并且数据都是从服务端到客户端。

  • 它和WebSocket的区别:

  • 便利,不需要添加任何新组件,用任何习惯的后端语言和框架就能继续使用,不用为新建虚拟机弄一个新的IP或新的端口号而劳神。

  • 服务器端的简洁。因为SSE能在现有的HTTP/HTTPS协议上运作,所以它能够直接运行于现有的代理服务器和认证技术。

  • WebSocket相较SSE最大的优势在于它是双向交流的,这意味着服务器发送数据就像从服务器接受数据一样简单,而SSE一般通过一个独立的Ajax请求从客户端向服务端传送数据,因此相对于WebSocket使用Ajax会增加开销。因此,如果需要以每秒一次或者更快的频率向服务端传输数据,就应该用WebSocket。

常用实现的对比

网页端收消息,采取哪种方式实现需结合具体实际情况决定。对于即时通信用户发送的“聊天消息”,对实时性要求很强,我们一般采用WebSocket方式实现双向通信;对于系统发给用户的“系统通知”,对实时性要求没那么高,我们可以采用轮询等“拉”的方式来实现服务端对客户端的数据“推送”。

 

鲁迅说,任何脱离业务场景的架构设计都是耍流氓。所以我们接下来针对不同场景进行分析,服务端与客户端的数据传递使用推送or 拉取?

 

 

Part 1:系统通知

一、系统对1的通知

典型业务,计数类通知:

  • 有10个美女添加了你为好友

  • 有8个好友私信了你

很多业务经常有这类计数通知,通知结果只针对你,这类通知是推送,还是拉取的呢?常见的有这样一些实践:

 

如果业务需求对计数需求需要实时展现,例如微博的加好友计数,假如希望实现不刷新网页,计数就实时变化:

  • 登录微博时,会有一个计数的拉取,对网页端的计数进行初始化

  • 在浏览微博的过程中,一旦有人加你为好友,服务端对网页端进行实时推送,告之增加了1个(或者N个)好友

 

这里的思路是,一开始得到初始值,后续推送增量值,由网页端计算最终计数并呈现最终结果。需要注意,针对不同业务,计数变化的差值可增可减。

 

上述方案的坏处是,一旦有消息丢失,网页端的计数会一直不一致,直至再次登录重新初始化计数。这个计算计数可以优化为在服务器直接计算并通知网页端最终的结果,网页端只负责呈现即可,这样网页端的逻辑会变轻。

 

如果业务对此类通知的展现不需要这么实时,完全可以通过拉取:

  • 只有在链接跳转,或者刷新网页时,才重新拉取最新的通知

这样系统的实现会最简单。需要注意,通知拉取要异步,不要影响主页面的快速返回。

 

系统对1的推送,例如针对1个用户的业务计数推送,计数的变化频率其实非常低,使用cache来存储这些计数能够极大提升系统性能。

 

 

二、系统对多的通知

系统对多的通知消息,会比系统对1的通知消息复杂一些,以两个场景为例:

  • QQ登录弹窗新闻

  • QQ右下角弹窗广告

 

IM登录弹窗新闻

这个通知的需求是:

  • 同一天,用户登录弹出的新闻是相同的(很多业务符合这样的场景),不同天新闻则不一样(但所有用户都一样)

  • 每天第一次登录弹出新闻,当天的后续登录不出新闻

 

不妨设有一个表存放弹窗新闻:

t_msg(msg_id, date, msg_content)

有一个表来存放用户信息:

t_user(user_id, user_info, …)

有一个表来存放用户收到的新闻弹窗:

t_user_msg(user_id, msg_id, date)

 

这里的实现明显不能采用推送的方式:

  • 将t_user_msg里对于所有user_id推送插入一个msg_id,表示未读

  • 在user每天第一次登录的时候,将当天的msg_id拉取出来,并删除,表示已读

  • 在user每天非第一次登录的时候,就拉取不到msg_id于是不会再次弹窗

这个笨拙的方式,会导致t_user_msg里有大量的脏数据,毕竟大部分用户并不会登录。

 

如果改为拉取的方式会好很多:

  • 在user每天第一次登陆时,将当天的msg_id拉取出来,并插入t_user_msg,表示已读

  • 在user每天非第一次登陆时,则会插入t_user_msg失败,则说明已读,不再进行二次弹窗展现

这个方式虽然有所优化,但t_user_msg的数据量依然很大。

 

还有一种巧妙的方式,去除t_user_msg表,改为在t_user表加一列,表示用户最近拉取的弹窗时间:

t_user(user_id, user_info, last_msg_date, …)

这样业务流程会升级为:

  • 在user每天第一次登录时,将当天的msg_id拉取出来,并将last_msg_date修改为今天

  • 在user每天非第一次登录时,发现last_msg_date为今天,则说明今天已读

这种方式不再存储消息与用户的笛卡尔关系,数据量会大大减少,是不是有点意思?

 

IM右下角弹窗广告

这个通知的需求是:

  • 每天会对一批在线用户推送相同的弹窗TIPS广告,例如球鞋广告,手机广告等

 

最直观的感受,这是一个for循环批量推送的过程。如果是推送,必须要考虑的问题是,推送限速控制,避免短时间内对系统造成冲击,引发雪崩。

 

能不能用拉取呢?

完全可以,这是一个对实时性要求不太高的场景,用户早1分钟晚1分钟收到这个广告影响不大,其实可以借助IM原本已有的keepalive请求,在请求返回时,告之“有消息拉取”,然后采用拉取的方式拉取广告消息。

 

这个方案的好处是,由于5KW在线用户的keepalive请求是均匀的,所以可以很均匀的将广告拉取的请求同样均匀的分散到一段时间内,避免5KW集中推送对系统造成冲击。

 

三、总结

广义系统通知,究竟是推送还是拉取呢?不同业务,不同需求,实现方式不同。

系统对1的通知:

  • 实时性要求高,进行推送

  • 实时性要求低,可以拉取

 

系统对N的通知:

  • 登录弹窗新闻,拉取更佳,可以用一个last_msg_date来避免大量数据的存储

  • 批量弹窗广告,常见的方法是推送,需要注意限速,也可以拉取,以实现请求的均匀分散

 

 

Part 2:状态同步场景

 

状态同步,有好友状态的同步,有群友状态的同步,有的需要实时同步,有的能够容忍延时。结合具体场景来看下,状态同步,究竟是推还是拉。

 

不同的产品,会有不同的客户端状态,例如隐身、离线、忙碌、勿扰等,这些状态大多是产品功能需求。后文为了方便描述,假设客户端状态也只有在线和离线两种状态,后文统一称为“用户状态”。

 

如何获取好友的状态?

uid-A登录时,先去数据库拉取自己的好友列表,再去缓存获取所有好友的状态。

 

用户uid-A的好友uid-B状态改变时(由登录、登出等动作触发),uid-A如何同步这一事件?

这里就有推拉的设计折衷了。

  • 如果对于状态变更实时性要求不高,可以采用拉取

uid-A向服务器轮询拉取uid-B(其实是自己的全部好友)的状态,例如每1分钟一次,其缺点是:

(1)如果uid-B的状态改变,uid-A获取不实时,可能有1分钟时延

(2)如果uid-B的状态不改变,uid-A会有大量无效的轮询请求,非常低效

 

  • 如果对于状态变更实时性要求较高,则必须推送

uid-B状态改变时(由登录、登出等动作触发),服务端不仅要在缓存中修改uid-B的状态,还要将这个状体改变的通知推送给uid-B的在线好友。

推送的优势是:实时

缺点是:当在线好友量很大时,任何一个用户状态的改变,会扩散成N个实时通知,这个N叫做“消息风暴扩散系数”。假设一个IM系统平均每个用户有200个好友,平均有20%的好友在线,那么消息风暴扩散系数N=40,这意味着,任何一个状态的变化会变成40个推送请求。

 

群友状态的一致性,和好友状态的一致性相比,复杂在哪里?可不可以采用实时推送?

群这个业务场景大伙也非常之熟悉,你能够加入若干群(例如20个),假设平均每个群有200人,即你会有4000个群友。

 

理论上群友状态也可以通过实时推送的方式实现,以保证实时性。进一步讨论之前,先一起估算下这个业务场景下的“消息风暴扩散系数”。

 

假设平均每个用户加了20个群,平均每个群有200个用户,依然假设20%的用户在线,那么为了保证群友状态的实时性,每个用户登录,就要将自己的状态改变通知发送给20*200*20%=800个群友,N=800,意味着,任何一个状态的变化会变成800个推送请求。如果说好友状态实时推送,消息风暴扩散系数N=40尚可以接受,那么群友状态实时推送,N=800则是灾难性的。此类业务往往采用轮询拉取的方式,获得群友的状态。

 

轮询拉取群友状态也会给服务器带来过大的压力,还有什么优化方式?

群友的数据量太大,虽然每个用户平均加入了20个群,但实际上并不会每次登录都进入每一个群。不采用轮询拉取,而采用按需拉取,延时拉取的方式,在真正进入一个群时才实时拉取群友的在线状态,是既能满足用户需求(用户感觉是状态是实时、一致的,但其实是进入群才拉取的),又能降低服务器压力。这是一种常见方法。

 

总结

状态的实时性与一致性是一个较难解决的技术问题,不同的业务实现方式不同,一般来说:

  • 好友状态同步,是采用推送的方式同步

  • 群友状态同步,由于消息风暴扩散系数过大,一般采用拉取的方式同步

  • 群友状态同步,还能采用按需拉取的优化方式,进一步降低服务端压力

  • “消息风暴扩散系数”是指一个消息发出时,变成N个消息的扩散系数,这个系数一定程度上决定了技术采用推送还是拉取

 

 

Part 3:群消息已读回执

 

钉钉用于商务交流,其“强制已读回执”功能,让职场人无法再“假装不在线”,“假装没收到”。

 

有甚者,钉钉的群有“强制已读回执”功能,你在群里发出的消息,能够知道谁读了消息,谁没有读消息。

 

群消息的流程如何,接收方如何确保收到群消息,发送方如何收已读回执,究竟是拉取,还是推送,是今天要讨论的问题。

 

一、群消息投递流程,以及可达性保证

大家一起跟着楼主的节奏,一步一步来看群消息怎么设计。

 

核心问题1:群消息,只存一份?还是,每个成员存一份?

答:存一份,为每个成员设置一个群消息队列,会有大量数据冗余,并不合适。

 

核心问题2:如果群消息只存一份,怎么知道每个成员读了哪些消息?

答:可以利用群消息的偏序关系,记录每个成员的last_ack_msgid(last_ack_time),这条消息之前的消息已读,这条消息之后的消息未读。该方案意味着,对于群内的每一个用户,只需要记录一个值即可。

 

解答上述两个核心问题后,很容易得到群消息的核心数据结构。

群消息表:记录群消息。

group_msgs(msgid, gid, sender_uid, time, content);

各字段的含义为:消息ID,群ID,发送方UID,发送时间,发送内容。

 

群成员表:记录群里的成员,以及每个成员收到的最后一条群消息。

group_users(gid, uid, last_ack_msgid);

各字段的含义为:群ID,群成员UID,群成员最后收到的一条群消息ID。

 

在核心数据结构设计完之后,一起来看看群消息发送的流程。

 

业务场景:

(1)一个群中有A, uid1, uid2, uid3四名成员

(2)A, uid1, uid2在线,期望实时收到在线消息

(3)uid3离线,期望未来拉取到离线消息

其整个消息发送的流程1-4如上图:

(1)A发出群消息

(2)server收到消息后,一来要将群消息落地,二来要查询群里有哪些群成员,以便实施推送

(3)对于群成员,查询在线状态

(4)对于在线的群成员,实施推送

 

这个流程里,只要第二步消息落地完成,就能保证群消息不会丢失。

 

核心问题3:如何保证接收方一定收到群消息?

答:各个收到消息后,要修改各群成员的last_ack_msgid,以告诉系统,这一条消息确认收到了。

 

在线消息,离线消息的last_ack_msgid的修改,又各有不同。

对于在线的群友,收到群消息后,第一时间会ack,修改last_ack_msgid。

 

对于离线的群友,会在下一次登录时,拉取未读的所有群离线消息,并将last_ack_msgid修改为最新的一条消息。

 

核心问题4:如果ack丢失,群友会不会拉取重复的群消息?

答:会,可以根据msgid在客户端本地做去重,即使系统层面收到了重复的消息,仍然可以保证良好的用户体验。

 

上述流程,只能确保接收方收到消息,发送方仍然不知道哪些人在线阅读了消息,哪些人离线未阅读消息,并没有实现已读回执,那已读回执会对系统设计产生什么样的影响呢?

 

二、已读回执流程

对于发送方发送的任何一条群消息,都需要知道,这条消息有多少人已读多少人未读,就需要一个基础表来记录这个关系。

 

消息回执表:用来记录消息的已读回执。

msg_acks(sender_uid, msgid, recv_uid, gid,if_ack);

各字段的含义为:发送方UID,消息ID,回执方UID,群ID,回执标记。

 

增加了已读回执逻辑后,群消息的流程会有细微的改变。

步骤二,server收到消息后,除了要:

  • 将群消息落地

  • 查询群里有哪些群成员,以便实施推送

之外,还需要:

  • 插入每条消息的初始回执状态

 

接收方修改last_ack_msgid的流程,会变为:

(1)发送ack请求

(2)修改last_ack_msgid,并且,修改已读回执if_ack状态

(3)查询发送方在线状态

(4)向发送方实时推送已读回执(如果发送方在线)

 

如果发送方不在线,ta会在下次登录的时候:

(5)从关联表里拉取每条消息的已读回执

 

这里的初步结论是:

  • 如果发送方在线,会实时被推送已读回执

  • 如果发送方不在线,会在下次在线时拉取已读回执

 

三、流程优化方案

再次详细的分析下,群消息已读回执的“消息风暴扩散系数”,假设每个群有200个用户,其中20%的用户在线,即40各用户在线。群用户每发送一条群消息,会有:

  • 40个消息,通知给群友

  • 40个ack修改last_ack_msgid,发给服务端

  • 40个已读回执,通知给发送方

可见,其消息风暴扩散系数非常之大。

 

同时:

  • 需要存储40条ack记录

群数量,群友数量,群消息数量越来越多之后,存储也会成为问题。

 

是否有优化方案呢?

 

群消息的推送,能否改为接收方轮询拉取?

答:不能,消息接收,实时性是核心指标。

 

对于last_ack_msgid的修改,真的需要每个群消息都进行ack么?

答:其实不需要,可以批量ack,累计收到N条群消息(例如10条),再向服务器发送一次last_ack_msgid的修改请求,同时修改这个请求之前所有请求的已读回执,这样就能将40个发送给服务端的ack请求量,降为原来的1/10。

 

以上会带来什么副作用?

答:last_ack_msgid的作用是,记录接收方最近新取的一条群消息,如果不实时更新,可能导致,异常退出时,有一些群消息没来得及更新last_ack_msgid,使得下次登陆时,拉取到重复的群消息。但这不是问题,客户端可以根据msgid去重,用户体验不会受影响。

 

发送方在线时,对于已读回执的发送,真的需要实时推送么?

答:其实不需要,发送方每发一条消息,会收到40个已读回执,采用轮询拉取(例如1分钟一次,一个小时也就60个请求),可以大大降低请求量。

画外音:或者直接放到应用层keepalive请求里,做到0额外请求增加。

 

以上会带来什么副作用?

答:已读回执更新不实时,最坏的情况下,1分钟才更新回执。当然,可以根据性能与产品体验来折衷配置这个轮询时间。

 

如何降低数据量?

答:回执数据不是核心数据

  • 已读的消息,可以进行物理删除,而不是标记删除

  • 超过N长时间的回执,归档或者删除掉

 

四、总结

对于群消息已读回执,一般来说:

  • 如果发送方在线,会实时被推送已读回执

  • 如果发送方不在线,会在下次在线时拉取已读回执

 

如果要对进行优化,可以:

  • 接收方累计收到N条群消息再批量ack

  • 发送方轮询拉取已读回执

  • 物理删除已读回执数据,定时删除或归档非核心历史数据


 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值