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

2,179 阅读17分钟

随着直播、视频等应用的兴起,消息场景也丰富了起来,最典型的就是聊天。具体到教育行业,场景更多,比如签到、答题等,这些场景对消息可靠性提了更高的要求,毕竟不能老师点“签到”学生收不到。本文就聊一聊消息可靠性的方案设计。文章较长,欢迎收藏。

1. 背景

1.1 业务场景

这是企鹅辅导的老师正在直播上课。
在线直播课堂是在线教育的核心业务。直播和录播、回放的区别在于,在上课的过程中,老师和学生可以进行实时的互动。

image.png

老师可以发鱼饼进行活跃气氛。可以发起签到,用来检查学生的到课情况。可以发答题卡,用来提问学生问题。辅导老师可以在聊天区,和学生进行沟通,或者学生回答老师的问题,达到一个互动的目标。这些直播互动的依赖是消息通道

image.png

在直播课堂中的消息通道是一个核心功能模块,它承载了直播课堂中的所有的课堂互动(答题卡,习题,红包,签到,举手,上下课命令,禁言等)和聊天。
除了课堂中的互动消息之外,还承载了课堂中一些比较频繁的 CGI 请求,比如维持课堂在线的心跳,成员列表的更新等等。
另外还有一些非课堂内的消息推送,也会通过这个消息通道下发,比如站内私信等。

image.png

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:拉取长连接通道 vs http 短连接通道

由于 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. 正常下发:消息状态为 DONE
  2. 伪下发:消息状态为 FAILED, 等待够 2s 再下发,主要是因为拉取失败会触发消息为 FAILED 的状态,等待够2s,可以使用 PUSH 迟到的消息补齐。
  3. 伪下发:其他消息状态,本地滞留 2s (空洞等待时间)+ 3s(拉取超时时间)+300ms(消息合并时间)以上的时候直接下发了。

下发逻辑是触发式的:两种情况下会触发。

  1. push 推送,如果推送的 seq == maxHandledSeq+1,则触发下发。
  2. 空洞或者后缀拉取回调,会触发下发。

image.png

2.2 PUSH SDK 可靠推送模块整体设计方案

image.png

2.2.1 消息队列设计

下面主要说一下 JS 的实现逻辑:
在这个整个过程中,一个可靠队列只需要一个 JS 对象来表示,所有消息的状态流动都可以通过修改消息的状态来表示。在 JS 中可以直接使用一个对象来表示。只要指定 seq,则可以快速取到相应的消息。在取消息和去重上都是非常快的。有序下发,只要按序递增 seq,就可以按序取消息下发,不需要对消息进行排序。记录消息队列中消息数量,超过一定值之后,消息从旧到新进行批量删除。队列中始终保持最新一定数量的消息,用于去重。

export interface MsgMap {
  [key: number]: MsgMapValue;
}
export interface MsgMapValue {
  status: string; // 消息状态
  seq: number; //消息seq
  createTime: number; //消息本地创建时间
  data?: Object; //具体消息内容
}

2.2.2 消息状态转换

export const MESSAGE_STATE = {
  INIT: 'INIT', //数据初始化,空洞被创建的时候,消息占位。
  WAITING: 'WAITING', //正在等待
  PULLING: 'PULLING', //正在拉取
  DONE: 'DONE', //数据正常
  FAILED: 'FAILED', // 空洞重拉失败
  DISPATCHED: 'DISPATCHED', // 已下发
};

2.3 前后端整体设计方案

消息入库的时候同时入 kafka 和 redis,kafka 用于推送,redis 用于拉取。推送消息通过长连接通道下发,拉取消息通过 http 短连接进行拉取。

image.png

3. 测试及效果

3.1 TEST CASE 及预期

CASE举例:
备注:空洞产生时间==为空洞消息后面的消息push的到达时间,也就是空洞消息被触发的时间

拉取触发场景:
1,2,(3)
预期操作:后缀拉取3
1, 2,(3),4
预期操作:空洞拉取3
1, 2,(3,时间t0),4,(5,时间t0+tns, tn<0.3s),6
预期操作:空洞3,5间隔未超过0.3s,空洞一次拉取3,5。
1, 2,(3,时间t0),4,(5,时间t0+tns, tn>0.3s),6
预期操作:空洞3,5间隔超过0.3s, 空洞两次拉取,第一次拉取3,第二次拉取5。

拉取请求回包的场景的处理:
1, 2,(3,时间t0),4,(5,时间t0+0.1s(0.1<0.3s)),6。这里t0+2.3s时会触发空洞一次拉取3,5。
场景一:正常拉取场景。这里触发空洞一次拉取3,5。接口耗时1.5s。接口返回消息3,5。
预期操作:业务侧收到1,2,(这里停顿到t0+3.8s(3.8s=空洞等待时间2s+合并区间0.3s+接口耗时1.5s))3,4,5,6。
场景二:丢失数据场景。这里触发空洞一次拉取3,5。接口耗时1.5s。接口返回消息3。
预期操作:业务侧收到1,2,(这里停顿到t0+3.8s(3.8s=空洞等待时间2s+合并区间0.3s+接口耗时1.5s))3,4,6。
场景三:接口超时场景。这里触发空洞一次拉取3,5。接口耗时超过3s。客户端主动结束请求,请求失败。
预期操作:业务侧收到1,2,(这里停顿到t0+5.3s(5.3s=空洞等待时间2s+合并区间0.3s+接口耗时5s)),4,6。

3.2 测试方法

保证可靠,就是要保证一切异常的情况,所以这里面的策略测试也是比较困难的,因为要模拟一切异常的情况。

由于逻辑比较复杂,在功能上测试很难测试到里面细节策略和异常策略。比较理想的方案是:需要有自动化的单元测试,不过这个单元测试的模拟,复杂度和实现这个可靠方案少不了多少。后面继续另外起项目完成。在保证一定的快速迭代的计划下,一些半手动的测试方案也是必须的。 当前的在 Web 实现的半手动测试方案:

  1. 打印日志
  2. 提供简单的模拟工具,模拟推送,模拟消息丢失。

以下是一些模拟case的代码片段:

// 模拟推送的函数
function pushPerson(seq){
  //
}
// 设置 基准seq, 可以用大于当前seq的10的倍数,好看一些
let seq = 510;

pushPerson(seq);
// 测试300ms合并空洞拉取
// 1、3空洞合并,5空洞单独发起
pushPerson(seq + 2);
setTimeout(() => {
pushPerson(seq + 4);
}, 200);
setTimeout(() => {
pushPerson(seq + 6);
}, 300);

// 测试空洞push补齐

// 1到的时候,1、2能够直接下发,无需等待
pushPerson(seq + 2); pushPerson(seq + 1);
// 1到的时候,1能够直接下发,无需等待,3等2s后,2,3下发
pushPerson(seq + 3); pushPerson(seq + 1);
// 1、2到的时候,1、2、3能够直接下发,无需等待
pushPerson(seq + 3); pushPerson(seq + 2); pushPerson(seq + 1);
// 1、2到的时候,1、2、3能够直接下发,无需等待
pushPerson(seq + 3); pushPerson(seq + 1); pushPerson(seq + 2);

// 1, 2,(3),4
// 预期操作:空洞拉取3
pushPerson(seq + 1); pushPerson(seq + 2); pushPerson(seq + 4);
// 1, 2,(3, 时间t0),4,(5, 时间t0 + 0.2s),6
// 预期操作:空洞一次拉取3,5
pushPerson(seq + 1); pushPerson(seq + 2); pushPerson(seq + 4);
setTimeout(() => {
pushPerson(seq + 6);
}, 200);
// 1, 2,(3, 时间t0),4,(5, 时间t0 + 3s),6
// 预期操作:空洞两次次拉取,第一次拉取3,第二次拉取5
pushPerson(seq + 1); pushPerson(seq + 2); pushPerson(seq + 4);
setTimeout(() => {
pushPerson(seq + 6);
}, 3000);

// 拉取请求回包的场景的处理:
// 1, 2,(3, 时间t0),4,(5, 时间t0 + 0.2s),6。这里t0 + 2s时触发空洞一次拉取3,5。
// 场景一:这里触发空洞一次拉取3,5。接口耗时1.2s。接口返回消息3,5。
// 预期操作:业务侧收到1,2,(这里停顿到t0 + 3.2s[t0 + 2s + 1.2s])3,4,5,6。
// 场景二:这里触发空洞一次拉取3,5。接口耗时1.2s。接口返回消息3。
// 预期操作:业务侧收到1,2,(这里停顿到t0 + 3.2s[t0 + 2s + 1.2s])3,4,6。
// 场景三:这里触发空洞一次拉取3,5。接口耗时超过3s。客户端主动结束请求,请求失败。
// 预期操作:业务侧收到1,2,(这里停顿到t0 + 5s[t0 + 2s + 3s])4,6。

pushPerson(seq + 1); pushPerson(seq + 2); pushPerson(seq + 4);
setTimeout(() => {
pushPerson(seq + 6);
}, 500);

在 console 面板中看日志来验证策略:

// 1, 2,(3, 时间t0),4,(5, 时间t0 + 0.2s),6
// 预期操作:空洞一次拉取3,5
pushPerson(seq + 1); pushPerson(seq + 2); pushPerson(seq + 4);
setTimeout(() => {
pushPerson(seq + 6);
}, 200);

看日志的方式还比较原始,只有很理解方案的人才可以看得懂。除此之外只能靠单元测试来认识这些数据日志了。

image.png

3.3 上线效果

可以算出到达率是近似100%。

image.png






扫码关注 IMWeb前端社区公众号,获取最新前端好文

微博、掘金、Github、知乎可搜索 IMWeb或 IMWeb团队关注我们。