消息可用性
设计目标
- 设计一个满足消息端到端可用性的协议
- 满足之前消息设计中的协议评估标准
技术挑战
- 三方通信,网络层面无法保证消息必达 【可靠性】
- 没有全局时钟,确定唯一顺序,且是符合因果顺序的 【乱序】
- 多客户端发送消息/多服务端接受消息/多线程多协程处理消息,顺序难以确定 【乱序】
方案选型
及时性,可达性,幂等性,时序性
- 消息即时:服务端实时接收消息并在线发送
- 消息可达:超时重试,ACK确认
- 消息幂等:分配seqID,服务端存储seqID
- 消息有序:seqID可以比较,接收端能按照发送端的顺序对消息排序
上行消息方案
clientID 严格递增
- 客户端A创建会话时和服务端建立长连接
- 发送消息msg时分配一个clientID,这个值在会话内严格递增
- 连接建立时clientID初始化为0
- 服务端缓存上次clientID,记为preClientID,当且仅当clientId = preClientId+1时接受消息
- 仅当服务端接受消息后才回复客户端A ack
- 当客户端A收到服务端ack后,才认为发送成功,取消重发(最大设置为3次)
收益:
- 任意时刻仅存储一个消息ID
- 保证严格有序
- 实现简单,可用
- 长连接通信延迟低
- 以发送方顺序为标准
代价:
- 弱网环境下,消息丢包时将会造成大规模消息重发,导致网路瘫痪影响消息及时性
- 无法保证群聊中的消息因果顺序
弱网问题,可以通过优化传输层协议(比如升级协议为Quic)来优化,长连接不适合在弱网环境中工作,丢包和断线,属于传输层问题。
clientID 链表
- 本地时间戳为clientID,在每次发送消息带上上一个消息的clientID
- 服务端存储上一个clientID为preClientID,只有两个preClientID匹配则接受
收益:
- 和clientID严格递增类似
代价:
- 协议带宽增加(多一个preClientID)
clientID list
- 服务端针对每个连接存储多个clientID,形成clientID list
- 使用此client list作为滑动窗口,来保证消息幂等(缓存提前到达的非连续消息)
收益:
- 减少弱网重传时的消息风暴问题
代价:
- 实现更加复杂
- 网关层需要更多内存维护连接状态
- 由于传输层使用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的可用性
代价:
- ack回复变慢,说法消息变慢
- 如果消息存储失败,消息将丢失
- seqID分配成为性能瓶颈
如果服务端在存储消息、业务处理、接入层路由时失败怎么办?
- 消息存储后再回复ack,如果ack失败则客户端重试时再次幂等地回复ack
- 一旦消息存储,如果服务崩溃导致长连接断开,客户端重新连接时可以发送一个pull信令,拉取历史消息进行消息补洞,一次保证消息可用性
- 如果消息存储后,仅是业务层失败,接入层长连接无感知,业务层需要做异常捕获,并追加pull信令给客户端B,主动触发其拉取历史信息
收益:
- 保证了业务处理全流程的可用性
- 在出现异常时,可毫秒级触发接收端,保证消息及时性
代价:
- 上行消息P95延迟将增加
- 整体通信复杂度增高
- 应对弱网环境则需要协议升降级机制
可以将消息存储交给MQ异步处理,MQ来保证消息不丢失(异步优化了p95延迟)
seqID无需全局有序,仅保证在会话内有序即可(解决了seqID分配的单点瓶颈)
下行消息方案
服务端将消息发送给客户端B,其协议设计依赖于seqID的生成方式
客户端定期轮训发去pull请求
收益:
- 实现简单,保证可用性
代价:
- 客户端耗电量高(体验极差)
- 消息延迟高,不满足及时性
依赖seqID的严格递增
- 用redis incrby生成seqID,key是sessionID/connID
- 按消息到达服务端的顺序分配seqID,使其具有会话范围内的全局序
- 服务端保证seqID严格递增的前提下将消息发送个客户端B,客户端B也是按preSeqID = seqID+1来做幂等
- 服务端等待客户端B的ack消息,否则超时后需要重传
优点:
- 实现简单,可以快速上线
- 最大程度上保证严格递增
代价:
- 弱网重传问题
- Redis存在单点问题,难以保证严格递增
- 需要维护超时重传消息队列以及定时器
- 不能解决客户端B不在线时消息的传递
应对redis单点问题,seqID的趋势递增
- 使用lua脚本,存储maxSeqID以及当前node的runID
- lua脚本每次获取ID时,都会检查node的runID和存储的runID是否一致
- 发现不一致,说明发生了主从切换,然后对maxSeqID进行一次跳变保证递增,避免从节点由于同步数据不即时分配了一个过去分配过的ID
- 客户端B发现消息不连续不是直接拒绝,而是发送pull信令进行补洞
- 如果拉去不到任何消息,说明seqID是跳变导致,不再进行进一步处理
- 如果客户端B不在线,查询用户状态后存储不推送即可
优点:
- 尽最大可能保证连续性
- 任意时刻保证单调和递增性
- 使用会话级别的seqID,则不需要使用全局的分布式ID生成,redis可以使用cluster模式进行水平扩展
- 识别了用户是否在线的状态,减少网络带宽的消耗
代价:
- 协议交互变得复杂,实现难度上升
- 可评估用户规模进行决策是否支持如此级别的可用性
- 群聊场景,将造成消息风暴
推拉结合+服务端打包整流,可以解决消息风暴问题,但是实现更加复杂
seqID 链表
- 客户端B在本地存储最后接收到的seqID的值记做maxSeqID
- 服务端发消息时,携带上一次消息的seqID机做preSeqID和当前seqID
- 客户端B接受消息时通过对比maxSeqID = preSeqID则接受
- 服务端在设计消息存储时,要存储上一条消息的seqID,形成逻辑链表
- 客户端发现preSeqID不一致,则退化为pull请求去拉取缺失的消息
优点:
- 屏蔽了对seqID趋势递增的依赖
代价:
- 收益不大,且在消息存储的时候要多存储一个preSeqID
项目 v1.0.0 方案
- 客户端A创建一个连接后,分配一个clientID,从0开始即可,发送一个消息时获得clientID并自增。
- 启动一个消息计时器,等待ack回复,或者超时后触发重传
- 基于tcp连接将msg1发送给服务端
- 服务端请求redis使用sessionID进行分片,incrby获取seqID
- 异步写入MQ,保证消息存储可靠性
- 立即回复客户端A ack消息,告诉他消息已经送达
- 启动一个下行消息定时器,等等客户端B的ack消息,或者超时候出发重传
- 客户端A收到ack消息后,取消定时器
- 启动一个下行消息请求,将msg1发送给客户端B
- 客户端B根据当前session的maxSeqID+1是否等于当前消息的seqID来决定是否接受
- 客户端B回复服务端消息已经确定或者拒绝
- 服务端根据客户端B回复决定是进行消息补洞还是关闭定时器