找回密码
 会员注册
查看: 55|回复: 0

消息可靠性设计,看这一篇就够了

[复制链接]

6

主题

0

回帖

19

积分

新手上路

积分
19
发表于 2024-9-21 03:47:00 | 显示全部楼层 |阅读模式
随着直播、视频等应用的兴起,消息场景也丰富了起来,最典型的就是聊天。具体到教育行业,场景更多,比如签到、答题等,这些场景对消息可靠性提了更高的要求,毕竟不能老师点“签到”学生收不到。本文就聊一聊消息可靠性的方案设计。文章较长,欢迎收藏。1.背景1.1业务背景这是企鹅辅导的老师正在直播上课。在线直播课堂是在线教育的核心业务。直播和录播、回放的区别在于,在上课的过程中,老师和学生可以进行实时的互动。老师可以发鱼饼进行活跃气氛。可以发起签到,用来检查学生的到课情况。可以发答题卡,用来提问学生问题。辅导老师可以在聊天区,和学生进行沟通,或者学生回答老师的问题,达到一个互动的目标。这些直播互动的依赖是消息通道。在直播课堂中的消息通道是一个核心功能模块,它承载了直播课堂中的所有的课堂互动(答题卡,习题,红包,签到,举手,上下课命令,禁言等)和聊天。除了课堂中的互动消息之外,还承载了课堂中一些比较频繁的CGI请求,比如维持课堂在线的心跳,成员列表的更新等等。另外还有一些非课堂内的消息推送,也会通过这个消息通道下发,比如站内私信等。1.2为什么推送会丢消息一条消息从一个用户发送到服务端,再发送到另外一个用户,这中间经过了N个模块的转发和网络传输,如果有一个模块发送失败,就涉及到重试,重试也有最大的次数,多了可能阻塞后面的消息发送,少了可能消息就这样丢失了,这就意味着你可能丢消息,也可能收到重复的消息;由于模块之间都是异步的,消息在不同的服务进程上去处理,这就意味着,你收到的消息可能是乱序的。丢消息最主要的原因是多节点消息流动、网络抖动、单连接通道过载,而这些或多或少是比较难避免的。1.3业务某些场景需要可靠的消息企鹅辅导老师PC端和学生手机端在直播间都是通过下发push进行交互的,其中直播间举手,答题,签到等类型的push称之为可靠push,直播间公屏的聊天消息属于普通push。由于在线用户多push数量过大单通道压力增大或者网络卡顿等原因可能会导致学生端无法正常收到push。对于普通push来说丢失一到两条并不会引起多大的问题,但是对于可靠push的丢失,往往会引起客户端比较严重的问题。如老师下发了一个开启课中练习的push,客户端正确收到了,显示出了题目完整的遮住了直播画面,结束时老师下发关闭课中练习的push,因为学生端是多个,若是一个学生端未正常收到关闭push,那课中练习的画面就会一直遮挡住直播画面,该生只能退出直播间,重新进房拉取状态。比如老师发了个鱼饼红包,有些同学没有收到,感受仿佛错过了一个亿。2.怎么提高消息推送的可靠性2.1设计思路2.1.1 TCP协议可靠传输分析刚开始讨论在端上一起做消息可靠性的时候,有人说端上做的方案过于复杂,能不能单纯从推送上面去做的得更可靠一些呢?单纯从推送上面可以怎么去提升推送的可靠性?我们来看看TCP协议如何保证可靠传输: 1.确认和重传:接收方收到报文就会确认,发送方发送一段时间后没有收到确认就重传。 2.数据校验 3.数据合理分片和排序:UDP:IP数据报大于1500字节,大于MTU。这个时候发送方IP层就需要分片(fragmentation).把数据报分成若干片,使每一片都小于MTU.而接收方IP层则需要进行数据报的重组.这样就会多做许多事情,而更严重的是,由于UDP的特性,当某一片数据传送中丢失时,接收方便无法重组数据报.将导致丢弃整个UDP数据报.TCP会按MTU合理分片,接收方会缓存未按序到达的数据,重新排序后再交给应用层。 4.流量控制:当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。5.拥塞控制:当网络拥塞时,减少数据的发送。这是TCP连接上保证可靠传输的方案。这是两个端之间的一条连接,而且是底层连接之间的数据传输,这个只能保证一条连接是连通性的情况下,数据比较可靠。但是数据量大的话,还是会出现超时,如果网络卡顿的话,重传次数变多,消息出现阻塞;如果连接断开了,那么已经扔给这个连接的数据也会随之丢失。2.1.2 思路推演如上分析,基于连接上层,还需要做一层应用层的可靠传输。在接入服务层(ACC模块)和端之间的传输也有一层轻量级的可靠方案,其中包括确认和重传,数据合理合并,流量控制和拥塞控制(这个方案另外再起文章讨论)。但是这个依然是两个直接连接的端点之间的可靠方案,如果中途有多个节点的话,这个难度会直线上升。如果数据在多个节点之间流动,比如A-B-C,A与B之间有确认重试的机制,但是B并不会等待B收到C的确认,才给A返回确认,这个节点之间是异步的;如果B等待收到C的确认,才给A回复确认的话,这种是同步方案。节点链路越长,前面的节点消耗就越大。而且消息确认对方收到之前,消息存放什么地方,单连接的内存空间,还是单进程的内存空间,还是通过第三方存储(redis/kafka)来做缓存?应该由哪个模块来做这么复杂的可靠存储?不难想象,中间多节点的话,每个节点都这样做可靠是不太可能的,比较理想的方案就在消息开始端来做这样的存储,消息终端来重试比较好。参考一下一般情况下怎么提高上行消息(CGI,指客户端主动向服务端请求的数据)的可靠性?1.提高单通道的连通性和稳定性2.通过多通道保证通道的可靠性3.重试机制:因为拉取消息是知道客户端需要什么数据,失败了客户端是可以重试的,可以决定重试多少次。4.退而使用缓存:某些业务场景下可以使用缓存来兜底1和2在推送模式下都是可以去做的。比如单通道下,提升dns解析成功率,多地域多运营商接入地,端上跑马竞速选取接入vip方案。在多通道上,我们在wns和tiny通道下也一起互备了很长一段时间,不过在流量特别大的情况下,完全互备的话,成本也是加倍的。4缓存的话,在推送消息下并不适用3重试机制:因为拉取消息是知道客户端需要什么数据,失败了客户端是可以重试的,可以决定重试多少次。那推送消息的话,客户端如何知道自己将会收到什么消息呢?可以针对可靠的消息生成连续递增的seq,客户端就可以根据到达消息的seq,知道自己是缺了哪些,然后去重试拉取。最终思路:主要是在逻辑层来做一个重试和多通道兜底的可靠方案。后台将消息打上有序的seq标志,在端上来判断消息的可靠性,并且通过http拉的方式补全,排序,去重,再下发给业务侧使用,从而解决下行push系统存在丢失,重复,乱序的问题。目标:在保证去重和有序的情况下,尽量不丢失消息设计要点1:消息在入库的时候生成连续递增的seq客户端就可以根据到达消息的seq,知道自己是缺了哪些,然后去重试拉取缺失的消息、进行去重和排序。设计要点2:保证不丢vs保证有序最主要的问题点是:收到了超过当前下发最大seq的消息要不要下发?这里主要看业务需要,这里在通道层来看,有一些消息需要有序,有一些消息不需要有序,所以通道层主要保证不丢失,业务层自己来保证有序。当时和业务开发激烈讨论之后,因为整体的消息系统设计上,只有部分是可靠消息,而单类型消息命令字来说并没有产生自己的有序的seq,另外业务层在处理逻辑上只需要处理消息有丢的情况,而无需再去管消息是否有序,如果同时需要处理这两种情况,是很复杂的,而且每个业务都需要单独的去处理,这样成本很大。所以最后决定,保证有序,然后才是不丢失。设计要点3:push通道+空洞拉取+后缀拉取3个通道来提高到达率push通道也就是原来的推送通道。 空洞拉取当消息出现了空洞,比如:消息队列:1(2)3,2没有收到,那就是产生了一个空洞消息,这个时候可以触发空洞拉取来将2拉取回去。空洞拉取主要由push消息触发 后缀拉取当push通道出现了阻塞、断连、或者失效的情况下,消息队列:1(2)(3),还有一条定时后缀拉取的通道来兜底。后缀拉取带上当前处理的最大seq,去服务端拉取最新的消息。这样就可以比较有效的保证了消息的可靠性。设计要点4:拉取长连接通道vshttp短连接通道由于acc长连接集群,高负载自动扩容无法将连接自动无损重连到新机器上,需要手动预先部署。解决当前无法面对突峰情况,需要自动扩容的问题。减少机器成本成本,动态调整提高机器利用率。结论:使用短连接,可以动态扩缩容。设计要点5:空洞拉取要点如何进行空洞拉取,也是可靠方案中的关键,所以空洞拉取方案迭代过多次,这里说一下以前的做法,避免回头踩坑。历史方案1: 消息序列:1(2)3(4)5,2和4产生两个空洞,触发两个独立的空洞任务去处理,谁先回来先处理谁,2个任务之间无关联。后到的消息如果小于当前已处理的消息seq则丢弃。–>保证有序,但是丢失可能性大。历史方案2:基于1改动:后到的消息如果小于当前已处理的消息seq继续下发。–>尽量保证不丢,不保证有序。历史方案3: 消息序列:1(2)3(4)5。空洞2触发的时候,启动2s等待,空洞4被触发的时候,如果发现当前有空洞任务的时候,就合并进去,并不重置等待计时器。和空洞2一起开始拉取。–>减少了拉取次数,但是增加了拉取的压力,因为空洞4并没有等待够2s,可能推送的4马上也到达了,而且消息可能还没入拉取的redis,拉取失败导致消息失败下发。特别是瞬时大量消息的时候,这种情况尤甚。历史方案4: 消息序列:1(2)…1000。触发大空洞的时候,分页进行拉取,减少redis的拉取压力。–>但分页的话,如果排队等待拉取的话,造成消息顺延滞后,如果同时发起的话,没有起到redis减压的作用,在业务场景下来看,一般不需要那么多老消息,最终改成只需要拉取最新一定数量的消息。最终设计要点:所有空洞消息先至少等待2s再启动拉取.减少多余的拉取,因为很可能在这2s内,推送的消息能够到达。也可以避免多余的拉取数据统计。合并拉取,空洞触发300ms内的消息进行合并拉取。避免了瞬时大量消息的多乱序空洞消息拉取,减少本地并发空洞任务(最多2300/300=8个,非常可控),也减少了服务端压力。消息缺失过大的话,只需要拉取最新一定数量的消息。实现:空洞1等待2000+300ms,空洞2如果发现距离空洞1时间内300ms,则合并空洞任务,否则起新空洞任务。设计要点6:所有可以配置的地方可以在后台返回,动态改动配置,本地保底配置比如空洞拉取的等待时间,合并拉取时间,一次拉取条数。比如定时后缀拉取的时间,可以由后台根据消息密集程度动态算出。如果持续失败的话,端上需要进行退火策略进行重试。本地设置最小最大有效值,预防后台出错。设计要点7:下发逻辑设置一个当前处理消息的maxHandledSeq,递增进行循环消息处理。判断maxHandledSeq+1的消息:如果属于下面3种情况,则进行正常下发或伪下发(伪下发:此消息标记为处理过,但实际上没有下发消息给业务)。1.正常下发:消息状态为DONE2.伪下发:消息状态为FAILED,等待够2s再下发,主要是因为拉取失败会触发消息为FAILED的状态,等待够2s,可以使用PUSH迟到的消息补齐。 3.伪下发:其他消息状态,本地滞留2s(空洞等待时间)+3s(拉取超时时间)+300ms(消息合并时间)以上的时候直接下发了。下发逻辑是触发式的:两种情况下会触发。1.push推送,如果推送的seq==maxHandledSeq+1,则触发下发。 2.空洞或者后缀拉取回调,会触发下发。2.2PUSHSDK可靠推送模块整体设计方案2.2.1消息队列设计下面主要说一下JS的实现逻辑:在这个整个过程中,一个可靠队列只需要一个JS对象来表示,所有消息的状态流动都可以通过修改消息的状态来表示。在JS中可以直接使用一个对象来表示。只要指定seq,则可以快速取到相应的消息。在取消息和去重上都是非常快的。有序下发,只要按序递增seq,就可以按序取消息下发,不需要对消息进行排序。记录消息队列中消息数量,超过一定值之后,消息从旧到新进行批量删除。队列中始终保持最新一定数量的消息,用于去重。2.2.2消息状态转换2.3前后端整体设计方案消息入库的时候同时入kafka和redis,kafka用于推送,redis用于拉取。推送消息通过长连接通道下发,拉取消息通过http短连接进行拉取。3.测试及效果3.1TESTCASE及预期CASE举例:备注:空洞产生时间==为空洞消息后面的消息push的到达时间,也就是空洞消息被触发的时间拉取触发场景:1,2,(3) 预期操作:后缀拉取31,2,(3),4 预期操作:空洞拉取31,2,(3,时间t0),4,(5,时间t0+tns,tn0.3s),6 预期操作:空洞3,5间隔超过0.3s,空洞两次拉取,第一次拉取3,第二次拉取5。拉取请求回包的场景的处理:1,2,(3,时间t0),4,(5,时间t0+0.1s(0.1
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2024-12-28 08:04 , Processed in 0.435461 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表