消息可用性

设计目标

  1. 设计一个满足消息端到端可用性的协议
  2. 满足之前消息设计中的协议评估标准

技术挑战

  1. 三方通信,网络层面无法保证消息必达 【可靠性】
  2. 没有全局时钟,确定唯一顺序,且是符合因果顺序的 【乱序】
  3. 多客户端发送消息/多服务端接受消息/多线程多协程处理消息,顺序难以确定 【乱序】

方案选型

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

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

上行消息方案

clientID 严格递增

  • 客户端A创建会话时和服务端建立长连接
  • 发送消息msg时分配一个clientID,这个值在会话内严格递增
  • 连接建立时clientID初始化为0
  • 服务端缓存上次clientID,记为preClientID,当且仅当clientId = preClientId+1时接受消息
  • 仅当服务端接受消息后才回复客户端A ack
  • 当客户端A收到服务端ack后,才认为发送成功,取消重发(最大设置为3次)

收益:

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

代价:

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

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

clientID 链表

  • 本地时间戳为clientID,在每次发送消息带上上一个消息的clientID
  • 服务端存储上一个clientID为preClientID,只有两个preClientID匹配则接受

收益:

  1. 和clientID严格递增类似

代价:

  1. 协议带宽增加(多一个preClientID)

clientID list

  • 服务端针对每个连接存储多个clientID,形成clientID list
  • 使用此client list作为滑动窗口,来保证消息幂等(缓存提前到达的非连续消息)

收益:

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

代价:

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

收益不大解释:同一条tcp上的消息是有序的,但是发送方由于多个线程等原因,可能导致msg2在tcp流中位于msg1前面,所以msg2先收到,msg1后收到,那么在之前的做法中,会把msg2丢失,等待客户端重传。现在的做法是,将msg2缓存起来,后续直接续上。采用List的做法仅能为这种场景提供加速,但是如果是正常的msg1和msg2顺序发送,因为网不好,导致丢包重传,tcp本身的SACK方法已经有缓存,重传丢失的包即可。所以说List这种做法收益不大,还增加了coding成本和维护成本。

消息转发

分配seqID,一步存储消息,处理业务逻辑,将消息转发给客户端B

为什么要分配seqID?

  • IM场景中聊天会话至少有两个客户端参与(单聊/群聊),因此任何一个客户端分配的clientID都不能作为会话内的消息ID,否则会产生顺序冲突(参考全局时钟问题),因此clientID仅是保证消息按客户端A发送的顺序到达服务端,服务端需要在整个会话范围内分配一个全局递增ID

如果服务端在分配seqID前此请求失败或进程崩溃怎么办?

  • 服务端在分配seqID之后再回复ACK消息。

收益:保证了分配seqID的可用性

代价:

  1. ack回复变慢,说法消息变慢
  2. 如果消息存储失败,消息将丢失
  3. seqID分配成为性能瓶颈

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

  • 消息存储后再回复ack,如果ack失败则客户端重试时再次幂等地回复ack
  • 一旦消息存储,如果服务崩溃导致长连接断开,客户端重新连接时可以发送一个pull信令,拉取历史消息进行消息补洞,一次保证消息可用性
  • 如果消息存储后,仅是业务层失败,接入层长连接无感知,业务层需要做异常捕获,并追加pull信令给客户端B,主动触发其拉取历史信息

收益:

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

代价:

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

可以将消息存储交给MQ异步处理,MQ来保证消息不丢失(异步优化了p95延迟)

seqID无需全局有序,仅保证在会话内有序即可(解决了seqID分配的单点瓶颈)

下行消息方案

服务端将消息发送给客户端B,其协议设计依赖于seqID的生成方式

客户端定期轮训发去pull请求

收益:

  1. 实现简单,保证可用性

代价:

  1. 客户端耗电量高(体验极差)
  2. 消息延迟高,不满足及时性

依赖seqID的严格递增

  • 用redis incrby生成seqID,key是sessionID/connID
  • 按消息到达服务端的顺序分配seqID,使其具有会话范围内的全局序
  • 服务端保证seqID严格递增的前提下将消息发送个客户端B,客户端B也是按preSeqID = seqID+1来做幂等
  • 服务端等待客户端B的ack消息,否则超时后需要重传

优点:

  1. 实现简单,可以快速上线
  2. 最大程度上保证严格递增

代价:

  1. 弱网重传问题
  2. Redis存在单点问题,难以保证严格递增
  3. 需要维护超时重传消息队列以及定时器
  4. 不能解决客户端B不在线时消息的传递

应对redis单点问题,seqID的趋势递增

  • 使用lua脚本,存储maxSeqID以及当前node的runID
  • lua脚本每次获取ID时,都会检查node的runID和存储的runID是否一致
  • 发现不一致,说明发生了主从切换,然后对maxSeqID进行一次跳变保证递增,避免从节点由于同步数据不即时分配了一个过去分配过的ID
  • 客户端B发现消息不连续不是直接拒绝,而是发送pull信令进行补洞
  • 如果拉去不到任何消息,说明seqID是跳变导致,不再进行进一步处理
  • 如果客户端B不在线,查询用户状态后存储不推送即可

优点:

  1. 尽最大可能保证连续性
  2. 任意时刻保证单调和递增性
  3. 使用会话级别的seqID,则不需要使用全局的分布式ID生成,redis可以使用cluster模式进行水平扩展
  4. 识别了用户是否在线的状态,减少网络带宽的消耗

代价:

  1. 协议交互变得复杂,实现难度上升
  2. 可评估用户规模进行决策是否支持如此级别的可用性
  3. 群聊场景,将造成消息风暴

推拉结合+服务端打包整流,可以解决消息风暴问题,但是实现更加复杂

seqID 链表

  • 客户端B在本地存储最后接收到的seqID的值记做maxSeqID
  • 服务端发消息时,携带上一次消息的seqID机做preSeqID和当前seqID
  • 客户端B接受消息时通过对比maxSeqID = preSeqID则接受
  • 服务端在设计消息存储时,要存储上一条消息的seqID,形成逻辑链表
  • 客户端发现preSeqID不一致,则退化为pull请求去拉取缺失的消息

优点:

  1. 屏蔽了对seqID趋势递增的依赖

代价:

  1. 收益不大,且在消息存储的时候要多存储一个preSeqID

项目 v1.0.0 方案

v1.0.0方案

  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回复决定是进行消息补洞还是关闭定时器