0%

消息协议设计(2)

消息可用性

什么是IM系统的消息时序一致性

实现时序性的技术难点:

  1. 没有全局时钟,各个节点的时间戳不一致:
    在真正的生产模式,分布式环境下,客户端+服务端后台的各种后台服务,都各自分布在不同的机器上,机器之间都是使用的本地时钟,没有一个所谓的“全局时钟”(也没办法做到真正的全局时钟),那么所谓的消息时序也就没有真正意义上的时序基准点。所以消息时序问题显然不是“本地时间”可以完全决定的。

  2. 多方发送问题:
    服务端分布式的情况下,不能用“本地时钟”来保证时序性,那么能否用接收方本地时间表示时序呢?答案是不行的,因为消息的发送方可能有多个(群聊),多个发送方的消息到达接收方的时间是不一样的,所以接收方的本地时间也不能作为时序的基准。

  3. 多方接收问题:
    多发送方不能保证时序,假设只有一个发送方,能否用发送方的本地时间表示时序呢? 很遗憾,由于多个接收方的存在,无法用发送方的本地时间,表示“绝对时序”

  4. 网络传输与多线程问题:
    既然多发送方与多接收方都难以保证绝对时序,那么假设只有单一的发送方与单一的接收方,能否保证消息的绝对时序一致性呢? 结论是悲观的,由于网络传输与多线程的存在,这仍然不行

如何保证绝对的消息时序一致性?

我们可以先从简单的角度开始考虑,例如一对一单聊模式:

假设两人一对一聊天,发送方A一次发出msg1、msg2、msg3三条消息,给接收方B,如何保证这三条消息的时序一致性。

我们知道,发送方A依次发出的msg1、msg2、msg3三条消息,到底服务端后,再由服务端中转发出时,这个顺序由于多线程的网络的问题,是有可能乱序的。但是实际上一对一聊天的两个人,并不需要全局消息时序的一致(因为聊天只在两人的同一会话在发生),只需要对于同一个发送方A,发给B的消息时序一致就行了。

常见优化方案,在A往B发出的消息中,加上发送方A本地的一个绝对时序(比如本机时间戳),来表示接收方B的展现时序。

那么当接收方B收到消息后,即使极端情况下消息可能存在乱序到达,但因为这个乱序的时间差对于普通用户来说体感是很短的,在UI展现层按照消息中自带的绝对时序排个序后再显示,用户其实是没有太多感知的。

多对多群聊的消息一致性保证思路:

假设N个群友在一个IM群里聊天,应该怎样保证所有群员收到消息的显示时序一致性呢?

首先:不能像一对一聊天那样利用发送方的绝对时序来保证消息顺序,因为群聊发送方不单点,时间也不一致。

或许:我们可以利用服务器的单点做序列化。

如上图所示,此时IM群聊的发送流程为:

1)sender1发出msg1,sender2发出msg2;
2)msg1和msg2经过接入集群,服务集群;
3)service层到底层拿一个唯一seq,来确定接收方展示时序;
4)service拿到msg2的seq是20,msg1的seq是30;
5)通过投递服务讲消息给多个群友,群友即使接收到msg1和msg2的时间不同,但可以统一按照seq来展现。

这个方法:

1)优点是:能实现所有群友的消息展示时序相同;
2)缺点是:这个生成全局递增序列号的服务很容易成为系统瓶颈。

还有没有进一步的优化方法呢?

从技术角度看:群消息其实也不用保证全局消息序列有序,而只要保证一个群内的消息有序即可,这样的话,“消息id序列化”就成了一个很好的思路。

上图这个方案中,service层不再需要去一个统一的后端拿全局seq(序列号),而是在service连接池层面做细小的改造,保证一个群的消息落在同一个service上,这个service就可以用本地seq来序列化同一个群的所有消息,保证所有群友看到消息的时序是相同的。

长连接与短链接的区别

  1. HTTP协议与TCP/IP协议的关系

    HTTP的长连接和短连接本质上是TCP长连接和短连接。HTTP属于应用层协议,在传输层使用TCP协议,在网络层使用IP协议。IP协议主要解决网络路由和寻址问题,TCP协议主要解决如何在IP层之上可靠的传递数据包,使在网络上的另一端收到发端发出的所有包,并且顺序与发出顺序一致。TCP有可靠,面向连接的特点。

  2. 如何理解HTTP协议是无状态的:

    HTTP协议是无状态的,指的是协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。也就是说,打开一个服务器上的网页和你之前打开这个服务器上的网页之间没有任何联系。HTTP是一个无状态的面向连接的协议,无状态不代表HTTP不能保持TCP连接,更不能代表HTTP使用的是UDP协议(无连接)。

  3. 什么是长连接、短链接:

    在HTTP/1.0中,默认使用的是短连接。也就是说,浏览器和服务器每进行一次HTTP操作,就建立一次连接,但任务结束就中断连接。如果客户端浏览器访问的某个HTML或其他类型的 Web页中包含有其他的Web资源,如JavaScript文件、图像文件、CSS文件等;当浏览器每遇到这样一个Web资源,就会建立一个HTTP会话。

    但从HTTP/1.1起,默认使用长连接,用以保持连接特性。使用长连接的HTTP协议,会在响应头有加入这行代码:

    1
    Connection:keep-alive

    在使用长连接的情况下,当一个网页打开完成后,客户端和服务器之间用于传输HTTP数据的 TCP连接不会关闭,如果客户端再次访问这个服务器上的网页,会继续使用这一条已经建立的连接。Keep-Alive不会永久保持连接,它有一个保持时间,可以在不同的服务器软件(如Apache)中设定这个时间。实现长连接要客户端和服务端都支持长连接。

    HTTP协议的长连接和短连接,实质上是TCP协议的长连接和短连接。

    • TCP连接:

      当网络通信时采用TCP协议时,在真正的读写操作之前,server与client之间必须建立一个连接,当读写操作完成后,双方不再需要这个连接 时它们可以释放这个连接,连接的建立是需要三次握手的,而释放则需要4次握手,所以说每个连接的建立都是需要资源消耗和时间消耗的

      经典的三次握手示意图:

      经典的四次握手关闭图:

    • TCP短链接:

      我们模拟一下TCP短连接的情况,client向server发起连接请求,server接到请求,然后双方建立连接。client向server 发送消息,server回应client,然后一次读写就完成了,这时候双方任何一个都可以发起close操作,不过一般都是client先发起 close操作。为什么呢,一般的server不会回复完client后立即关闭连接的,当然不排除有特殊的情况。从上面的描述看,短连接一般只会在 client/server间传递一次读写操作

      短连接的优点是:管理起来比较简单,存在的连接都是有用的连接,不需要额外的控制手段

    • TCP 长连接:

      接下来我们再模拟一下长连接的情况,client向server发起连接,server接受client连接,双方建立连接。Client与server完成一次读写之后,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。

      首先说一下TCP/IP详解上讲到的TCP保活功能,保活功能主要为服务器应用提供,服务器应用希望知道客户主机是否崩溃,从而可以代表客户使用资源。如果客户已经消失,使得服务器上保留一个半开放的连接,而服务器又在等待来自客户端的数据,则服务器将应远等待客户端的数据,保活功能就是试图在服务 器端检测到这种半开放的连接。

      如果一个给定的连接在两小时内没有任何的动作,则服务器就向客户发一个探测报文段,客户主机必须处于以下4个状态之一:

      客户主机依然正常运行,并从服务器可达。客户的TCP响应正常,而服务器也知道对方是正常的,服务器在两小时后将保活定时器复位。

      客户主机已经崩溃,并且关闭或者正在重新启动。在任何一种情况下,客户的TCP都没有响应。服务端将不能收到对探测的响应,并在75秒后超时。服务器总共发送10个这样的探测 ,每个间隔75秒。如果服务器没有收到一个响应,它就认为客户主机已经关闭并终止连接。

      客户主机崩溃并已经重新启动。服务器将收到一个对其保活探测的响应,这个响应是一个复位,使得服务器终止这个连接。

      客户机正常运行,但是服务器不可达,这种情况与2类似,TCP能发现的就是没有收到探查的响应。

    • 长连接短链接操作过程:

      短连接的操作步骤是:

      建立连接——数据传输——关闭连接…建立连接——数据传输——关闭连接

      长连接的操作步骤是:

      建立连接——数据传输…(保持连接)…数据传输——关闭连接

  4. 长连接和短连接的优点和缺点:

    由上可以看出,长连接可以省去较多的TCP建立和关闭的操作,减少浪费,节约时间。对于频繁请求资源的客户来说,较适用长连接。不过这里存在一个问题,存活功能的探测周期太长,还有就是它只是探测TCP连接的存活,属于比较斯文的做法,遇到恶意的连接时,保活功能就不够使了。在长连接的应用场景下,client端一般不会主动关闭它们之间的连接,Client与server之间的连接如果一直不关闭的话,会存在一个问题,随着客户端连接越来越多,server早晚有扛不住的时候,这时候server端需要采取一些策略,如关闭一些长时间没有读写事件发生的连接,这样可 以避免一些恶意连接导致server端服务受损;如果条件再允许就可以以客户端机器为颗粒度,限制每个客户端的最大长连接数,这样可以完全避免某个蛋疼的客户端连累后端服务。

    短连接对于服务器来说管理较为简单,存在的连接都是有用的连接,不需要额外的控制手段。但如果客户请求频繁,将在TCP的建立和关闭操作上浪费时间和带宽。

    长连接和短连接的产生在于client和server采取的关闭策略,具体的应用场景采用具体的策略,没有十全十美的选择,只有合适的选择。

背景介绍

可靠性:消息一单显示发送成功就必定送达到对端, 一致性: 任意时刻消息保证与发送端顺序一致。

消息可靠一致对于IM来说就是指:可达有序,不重不漏

设计IM必须要有端到端的设计思维,底层对可靠性的保证仅能保证底层的可靠,而不能保证上层的可靠,底层的可靠近视减少了发生故障的概率:底层可靠不等于上层可靠

  • 消息的端到端可靠性 = 上行消息可靠 + 服务端业务可靠 + 下行消息可靠
  • 消息的端到端一致性 = 上行消息一致 + 服务端业务一致 + 下行消息一致

TCP帮助我们做到了哪一步呢?

  1. 客户端A发送msg1和msg2两个消息给服务端
  2. msg1和msg2在一个tcp连接上到达服务端

现在的问题:

  1. 在传递给业务层时服务端进程崩溃,但是客户端A认为以及送达,服务端业务层无感知,因此消息丢失
  2. msg1和msg2在到达应用层,解析后交由两个线程处理,msg2暹罗表并发给客户端B,造成消息乱序
  3. msg1消息存储失败,msg2消息存储成功先发送给了客户端B,造成丢失且乱序

TCP/UDP是双方通信,而IM本质是三方通信。

我们的设计目标:

  • 设计一个能够保证消息端到端可用性的协议
  • 满足 基本协议 中的评估标准

技术难点:

  1. 三方通信,网络层面无法保证消息必达
  2. 没有全局始终,确定唯一顺序,确实符合因果顺序的
  3. 多客户端发送消息/多服务端接收消息/多线程多协程处理消息,顺序难以确定

方案选型

及时性,可达性,幂等性,时序性

  1. 消息及时:服务端实时接收消息并实施在线发送。
  2. 消息可达:超时重试,ACK确认
  3. 消息幂等: 分配seqID,服务端存储seqID
  4. 消息有序:seqID可比较,接收端能按照发送端端顺序对消息排序

上行消息

方案:

Client 严格递增:

  1. 客户端A创建绘画与服务器建立长连接
  2. 在发送消息msg1时分配一个clientID 此值在会话内严格递增
  3. 连接建立时clientID 初始位0
  4. 服务端将上一次收到消息的clientID 缓存位preClientID, 当且仅当clientID = preClientID + 1时,才接受消息
  5. 仅当服务端接收到消息后才回复客户端A,ACK消息
  6. 仅当客户端A收到服务端对消息ACK的回复,才禁止重发(可设置最大三次)

!! 弱网环境下,可以通过优化传输层协议(比如协议升级为Quic)来优化,长连接不适合在弱网环境工作,丢包和断线 属于传输层问题。

收益:

  1. 任意时刻仅存储一个消息ID
  2. 保证严格的有序性
  3. 实现简单,可用
  4. 长连接通信延迟低
  5. 以发送方顺序为标准

代价:

  1. 弱网情况下,消息丢包严重时将造成大规模消息重发,导致网络瘫痪影响消息及时性
  2. 无法保证群聊中的消息因果顺序

ClientID 链表

  1. 客户杜昂A使用本地时间戳作为clientID,并在每次发送消息的时候携带上个消息的clientID
  2. 服务端存储上个消息的clientID 记作preClientID, 只有preClientID 和当前消息的preClientID 对比,匹配上则说明消息未丢失,否则拒绝

代价:

  1. 协议的消息带宽

client list

  1. 服务端正对每一个连接存储多个clientID,形成clientID list
  2. 使用此client List作为滑动窗口,来保证消息幂等

收益:

  1. 减少弱网充传时的消息风暴问题

代价:

  1. 实现更加复杂
  2. 网关需要更多的内存维护连接状态
  3. 由于传输层使用tcp,已经对弱网有一定的优化,应用层也维护滑动窗口收益不大

消息转发

服务端对于接收到的信息分配seqID,异步存储消息,处理业务逻辑,将消息转发给客户端B。

为什么要分配seqID?

IM场景中聊天会话至少有两个客户端参与(单聊/群聊),因此任何一个客户端分配的clientID都无法保证消息的全局有序性,因此服务端需要分配seqID来保证消息的全局有序性。因此clientID仅是保证消息按客户端A发送的顺序到达服务端,服务端需要在整个会话范围内分配一个全局递增的ID。

消息转发的可用性如何保证?

方案:

如果服务端在分配seqID前此请求失败或进程崩溃怎么办? 服务端在分配seqID之后再回复ACK消息

收益:

保证了分配seqID消息的可用性

代价:

  1. ACK回复变慢,收发消息变慢
  2. 如果消息存储失败消息丢失
  3. seqID分配成为性能瓶颈

方案:

如果服务端在存储消息,业务处理,接入层路由时失败怎么办?

  1. 消息存储后再回复ACK,如果ACK失败则客户端重试时再次幂等地回复ACK
  2. 一单消息存储,如果服务端崩溃导致长连接断开,客户端重新建立连接时可以发送一个pull信令,拉去历史消息进行消息补洞,一次保证消息可用性
  3. 如果消息存储后,仅是业务层失败,接入层长连接无感知,业务层需要做一场捕获,并追加pull信令请求给到客户端B,主动出发其拉去历史消息

收益:

  1. 保证了业务处理全流程等可用性
  2. 在出现异常情况时,可毫秒级出发接收端,保证消息及时性

代价:

  1. 上行消息的p95延迟增加
  2. 整体通信复杂度增加
  3. 应对弱网环境需要协议升降级机制

方案:

可以将消息交给MQ异步存储,MQ来保证消息不丢失

seqID无需全局有序,只需要保证在一个会话内有序即可,因此可以使用分布式ID生成器来生成seqID,保证全局唯一性。

收益:

异步写入,优化p95延迟
解决了seqID分配的单点瓶颈

下行消息

方案:

方案 收益 代价
客户端定期轮询发起pull请求参到最新消息 实现简单,保证可用性 1. 客户端耗电高(用户体验差)
2. 消息时延高,不满足及时性
依赖seqID的严格递增

1. 用redis incrby 生成seqID, key是sessionID/connID
2. 按消息到达服务端的顺序分配seqID,使其具有会话范围内的全局序
3. 服务端保证seqID严格递增的前提下将消息发送给客户端B,客户端B也是按preSeqID == seqID+1的方式来做到顺序
4. 服务端需等待客户端的ACK消息,否则超时后需要重传
1. 实现简单,可以快速上线
2. 最大程度的保证严格递增
1. 弱网重传问题
2. Redis 存在单点问题,难以保证严格递增
3. 需要维护超时重传消息队列以及定时器
4. 不能解决客户端B不在线时消息的传递
应对redis的单点故障,seqID的趋势递增

1. 使用lua脚本,存储maxSeqID以及当前的nodeID的runID
2. lua脚本每次获取ID时,都会检查当前node的runID和存储的runID是否一致
3. 发现不一致时,说明发生了主从切换,然后对maxSeqID进行一次跳变保证递增,避免从节点由于同步数据不及时分配了一个曾经分配过的ID出去
4. 客户端B在发现消息不连续时不直接拒绝,而是发送pull信号进行增补
5. 如果获取不到消息,则说明是seqID的跳变导致,不再进一步处理
6. 如果客户端B不在线,查询用户状态后仅存储不推送即可
1. 尽最大可能保证连续性
2. 任意时刻保证单调递增性
3. 由于使用会话级别的seqID,则不需要全局分布式ID生成,redis可以使用cluster模式进行水平扩展
4. 识别了用户是否在线状态,减少了网络带宽资源耗费
1. 协议交互变得更加复杂,实现难度上升
2. 可评估用户规模进行决策是否支持如此级别的可用性
3. 群聊场景,将造成消息风暴
推拉结合+服务端打包整流

1. 客户端B在本地存储最收到的seqID的值记作maxSeqID
2. 服务端发送消息时,携带上一条消息的seqID记作preSeqID和当前seqID
3. 客户端B按收消息时通过对比maxSeqID == preSeqID 则接收否则拒绝
4. 服务端在存储消息存储时,要存储上一条消息的seqID,形成逻辑链表
5. 客户端发现preSeqID不一致,则退化为pull请求去拉缺失的消息
解决消息丢失问题 1. 收益不大,且在消息存储时要多一个preSeqID
SeqID 链表 1. 屏蔽了对seqID 趋势递增的依赖 1. 收益不大,且在消息存储时要多一个preSeqID

Plato v1.0.0 设计文档

1. 系统架构

系统包含以下核心组件:

  • clientA: 消息发送端
  • clientB: 消息接收端
  • Server: 中心服务器
  • Redis: 用于存储seqID
  • DataBus: 消息总线

2. 消息流程

详细流程步骤:

  1. 客户端A创建连接后,分配一个clientID,从0开始即可,发送一个消息时获取clientID并自增
  2. 启动一个消息计时器,等待ack消息的回复,或者超时后触发重传
  3. 基于tcp连接将msg1发送给服务端
  4. 服务端请求redis使用sessionID进行分片,incrBy获得seqID
  5. 异步写入MQ,保证消息可靠存储
  6. 立即回复客户端A ack消息,告诉他消息已经可靠送达
  7. 启动一个下行消息定时器,等等客户端B的ack消息,或者超时后触发重传
  8. 客户端A收到ack消息后,取消定时器
  9. 服务端发起下行消息请求,将msg1发送给客户端B
  10. 客户端B根据当前session的maxSeqID+1是否等于当前消息的seqID来决定是否接收
  11. 客户端B回复服务端消息已经确认或者拒绝
  12. 服务端根据客户端B回复决定是进行消息补洞还是关闭定时器

3. Plato协议规范

3.1 基础消息协议

消息格式由以下部分组成:

  • FixedHeader: 固定头部
  • VarHeader: 可变头部
  • MsgBody: 消息体

3.2 FixedHeader结构定义

1
2
3
4
5
6
7
type FixedHeader struct {
version byte // 版本号
msgType byte // 消息类型
msgLen uint32 // 消息长度
varHeadLen uint32 // 可变头长度
crc32Sum uint32 // CRC32校验和
}

3.3 消息类型定义

3.3.1 Uplink消息

1
2
3
4
5
6
syntax = "proto3";

message UplinkMessageHeader {
uint32 client_id = 1;
uint64 session_id = 2;
}

3.3.2 Downlink消息

1
2
3
4
5
6
syntax = "proto3";

message DownlinkMessageHeader {
uint32 seq_id = 1;
uint64 session_id = 2;
}