亿级流量 即时通讯IM系统 设计详解(全)

Posted 码农研究僧

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了亿级流量 即时通讯IM系统 设计详解(全)相关的知识,希望对你有一定的参考价值。

目录

前言

了解对应的java知识可看我这篇文章:
java框架零基础从入门到精通的学习路线(超全)

设计该系统的业务逻辑,之后针对性的优化
从整体框架掌握各个深层次的框架知识点,以此查漏补缺

其他系统设计如下:

  1. 秒杀系统设计详解
  2. 短域名系统设计详解(全)
  3. LVS四层负载均衡架构详解

以下的文章知识点主要来源于:系统设计面试题-如何设计10亿流量的即时通讯(IM)系统?
记笔记的同时融入了一些自已的见解和拓展

1. 背景

所需的功能?

  • 添加好友
  • 聊天会话列表
  • 单聊、群聊
  • 多终端登录(从数据库中拉取)
  • 消息漫游(一个终端收到很多消息后,登录另外一个终端实现同步)
  • 消息已读以及已读未读列表

需考虑的约束条件?

  • qps存储量
  • 可靠性
  • 收发消息的延迟
  • 消息时序的一致性(接收发消息的顺序、消息不重不漏)
  • 多人群聊
  • 可维护可运维

2. 高性能

接入层优化

  1. 轮询拉模式无法保证实时性要求
    可通过TCP的长连接push给客户

  2. 客户端如何与服务端建立长连接
    a. 客户端通过公网IP socket编程直接连接服务器
    b. 通过IPConf服务发送公网IP给客户端,灵活使用调用长连接(客户端自已选择的话,可以选择一个最优。将其缓存起来,如果连接失败,选择其他ip进行连接,减少ipconfig的压力)
    c. 建立长连接之后,业务逻辑层与uid进行映射
    d. IPConf通过协调服务与业务逻辑层进行交互,根据机器来负载均衡

  3. 长连接占用资源,而且跟DB结合在一起,读写会比较慢
    拆分服务,长连接负责收发消息,业务服务器负责业务块

  4. 调度资源优化网络通信会 频繁迭代消息来支持 业务开发,本身有状态的业务重启比较缓慢
    变化以及不变化的业务进行拆开,大致通过状态机服务
    长连接服务专门收发消息,并且更新状态机,ip服务通过调取状态机来调度策略
    频繁的变化(比如消息登入登出)通过控制长连接的断开,通过mq发送close用于长连接的调度

  5. 建立长连接的时候,客户端如何知道接入层服务器在哪

方案优点缺点
广播
1. 服务层将其消息发送给所有接入层
2.接入层只会处理自已的uid连接(通过map进行配对有无自已的uid)
实现简单,适合超大聊天室1.单聊场景过多的无效通信
2.消息风暴系统崩溃
一致性哈希
1.ipconf与业务逻辑使用同一个hash,按照uid进行分片落入服务器
2.通过统一的服务注册发现,水平扩展使用虚拟节点迁移,迁移的通过断线重连
计算简单无性能消耗
1.过度依赖服务发现
2.水平扩展需要连接迁移
3.一致性哈希的均匀性限制
路由服务层
1. 底层使用kv存储uid以及接入机器的映射关系
2.根据不同会话更新映射关系
3.使用路由服务以及业务逻辑来维护消息队列
4.任意路由服务消费mq解析此消息的路由信息确定接入层机器
5.根据接入层机器标志创建的routeKey,将消息发送给接入层专用的队列
1.mq可靠,解耦削峰
2.路由服务无状态可水平扩展
3.路由存储长连接以及映射关系
1.需要独立维护路由层集群
2.服务底层依赖kv以及mq稳定性

补充对应的知识点:一致性哈希算法的原理与应用剖析

存储层优化

  1. 读少写多,如何控制消息分发不让资源耗尽?
    尽可能将其控制消息并发(不是按照一个消息一个线程,而是启动个别线程通过分发的形式),保证群发消息的时候系统不会崩溃。

  2. 上面的情况可能会出现消息挤压,延迟增大?
    a.cache打包压缩,让router一次发送,当窗口10ms发送一次,整体性能提升一次。而且b.通过推拉结合将其压缩打包(比如服务端发送一个请求让客户端专门去pull请求,减少消息的轮询)

  3. 存储系统有严重的写放大(也就是本身写A对BCD写消息,但是三者的通道都要写一遍),如何降低成本?
    a.超大群降级为放大模式进行存储,消息仅同步写入收件箱(A读取BCD的消息的时候,只需要对应读取BCD的收件箱,读的复杂度为o(n),写只要一次)
    b.群状态的消息异步存储

  4. 消息如何处理?如何保证万人群聊中的已读/未读一致性?
    a.实时流处理,接收者已读消息下发同步,接收者消息已读通过异步落库
    b.超大群消息,通过群状态变更服务进行降级
    c.异步写入通过重试保证最终一致

写磁盘如何优化延迟?
(同步数据的时候最好在磁盘处理)

分级存储:

  • 按照uid的维度,在数据库上维护可排序的列表,根据活跃程度调节最大的条数。
  • 对于超大群聊退化为读扩散模式,维护会话级别中list并异步缓存消息状态。群聊中的会话根据ID对消息列表本地缓存LRU(本地的LRU可用HotRing算法,测热点数据)
  • 用protobug序列化压缩,减少存储空间
  • 消息队列广播状态给本地缓存 (也可使用Gossip算法)
  • 超过一定时间数据,压缩在文件系统中,提供OLAP查询

该方案的优点有如下:
按照请求读取热度分级处理,而且对离线消息同步的range操作良好,也可支持群聊的推拉结合。
缺点如下:
本地缓存预热慢,服务重启会有抖动。
大量使用内存,运维难度高
需关注缓存命中率等问题


多元DB存储:(不同字段不同存储)

  • rocksDB存储kv,key 为会话ID value 是序列化消息列表
  • 消息列表过长可对列表进行分列,key为会话ID,value为meta 索引信息
  • 按会话ID+分段seqID作为key,value存储消息列表,读取时做合并
  • 等等

优点就是利用DB的又是补长取短,基于磁盘解决存储容量问题
缺点就是ROcksDB单机数据库,需要有自研的分布式代理层。磁盘kv读写磁盘,拉取消息性能收到影响


图数据库

存储数据库关系的关系,会话消息列表的各种拉链关系
可运行近线OLAP查询快速识别的热点消息等,利用消息快速处理,准确命中

优点:

  1. 低延迟
  2. 可提供丰富的查询功能

缺点就是图数据库运维成本比较高,不好维护


存储层代理服务

增加这一层来屏蔽底层细节,代理层基于key做hash分片,基于一致性协议进行复制kv
超大群聊多个请求可能会拉取一份消息列表(做自旋cache,减少下游DB的数据库访问)

优点:

  1. 业务隔离
  2. 代理层无状态可水平扩展
  3. 消息列表缓存在代理层,减少底层内存负担

缺点就是增加了一层逻辑,复杂度增大

3. 高一致

收发者的消息顺序一致以及消息不丢失

要保证消息不丢失?

TCP网络传输的数据是不会丢失,但是数据包是可能会丢失(TCP网络断、延迟)
明白其背景之后,可通过重试机制

  • 上游客户端重试(数据获取不到的时候,通过重试并且服务端返回ack机制)
  • 下游服务端重试(同样通过重试并且客户端返回ack机制)

但重试也有bug,如果ack丢失,消息无限重试发送,也会造成顺序的不一致(比如原本发送的答案是ABCD,结果消息为ABCCD,那样就会造成混乱)

解决上面的重复发送问题,可通过UUID的去重?

× ,此处使用UUID来对ack去重判断,是不合适的

发送的ack包(使用UUID),还需要通过一个全局表配对是否有重复
本身如果流量比较小,可以跟全局变量进行判断(使用一张表存储)
如果流量比较多,可以采取在很小的时间段进行判断
但是本身是亿级流量,即使很小的时间段,变量也是特别多

解决重复发送的问题?正确方式是?

借鉴TCP的三次握手机制,通过ack+1判断

  • 上游:客户端在发送会话中生成一个消息,并且是自增的cid,服务端本身存储的是上次最大的cid。
    如果发送的消息不是cid+1,则将其丢弃(不可能一直等待,因为后续消息发不出去,服务会造成很大的浪费)
    此处的cid是单个客户端的,不同客户端可以重复
  • 下游(类似上游的处理方式):服务端每个发送的消息分配seqid,客户端本身存储的是上次最大的seqid。
    如果发送的消息不是seqid+1,则将其丢弃(类似上游的处理方式)
    此处的seqid是服务端的,需确保不重复而且递增

保证消息有序?

通过上面的知识,已经知道消息不会重复且不会丢失
那可通过递增的ID进行排序

  • 上游客户端 按照cid为每个消息分配seqid(之所以要进行排序,是因为服务端存储的不止一个客户端的seqid)
  • 下游按照seqid进行排序

特别是服务器宕机之后,本身的seqid在进行主从复制的时候,复制过程中会存在延迟

(重启的时候怎么保证ID不重复?)
每次Redis重启的时候,都会通过实例ID是否一样,如果不一样说明重启过。重启过则通过哈希加上时间戳等办法保证消息不会重复(保证消息递增,但是不会单调)

如何生成递增的消息ID?

可看这篇文章:分布式ID生成方法的超详细分析(全)

具体方案设计

方案优点缺点
纯拉数据
只保证上行(发送方)消息一致性
实现比较简单实时性差(只是按照服务器的标准)
单调递增ID
通过Redis+lua
实现比较简单1.通信次数过多,群聊性能差。
2.依赖分布实ID生成系统可靠性
双ID方法
发送方发送当前id以及上次消息id,接收方保留上次的id(接收双方通过对比这个id判断是否有消息遗漏)
不依赖id生成的单调性1.通信次数过多,群聊性能差。
2.下行消息(接收方)实现机制复杂
推拉结合
服务端通知客户端拉取消息,客户端pull消息(本身pull可做ack)
1.下行消息不需ack机制,服务端不需维护超时重发。
2.可一次性拉去所有会话,减少调用次数。
3.批量拉取有利于消息压缩,提高带宽利用率。
无法解决上行消息的时序性

通过以上方案,找出更好的解决方法

  • 上行消息(客户端)通过先前的ID配对策略(双ID方法),可保证消息的时序一致性
  • 下行消息(服务端)通过推拉结合,可保证高吞吐量

4. 高可用

链路复杂过长容易造成瓶颈,导致没有高可用

4.1 连接断路

整个链路跨越公网(运营商),TCP如果开启Keepalive(心跳机制是2h),长连接如果超时没有回馈,就会将其断开。心跳机制应该取决于整个链路,要想维护一个端到端的有效性(内核间有效是可以维护的,但是业务逻辑不好维护),应该维护业务逻辑上的心跳机制。

1. 心跳机制:

心跳机制应该放在业务逻辑层中

  • 通过服务端push给客户端(×),本身是即时性,数据量大的话也很不现实
  • 通过客户端push给服务端(√),将其心跳周期性的发送给服务端的网关,通过网关重置内部的定时器

包的大小: 包的心跳控制包不可过大,需控制在0.5kb以下

心跳的时间:

  • 心跳过长:断线客户端太多,效率低,资源利用率低
  • 心跳过短:心跳请求太多,造成网关流量压力过大

最好的方式是自适应心跳:前端通过固定心跳(没有链路固定好久可),后端通过测算NAT淘汰时间(自适应估算时间,取最小最大的临界值中间)

2. 断线重连

背景1:坐高铁或者火车的时候,客户端网络原因频繁切换,会导致服务创建销毁,过度清空资源。如何更稳定快速的建立长连接?

解决方式:断开的一瞬间,启动一个session超时器,如果在这之后能连接上来,资源就不会被清空,就可进行重连,保证链接的稳定性(直接复用)
本身会创建一个TCP通路,通过fid与session进行关联即可

以上方式可以防止频繁的创建和销毁,不会让Redis雪崩

背景2:服务器如果崩盘导致客户端重连,请求过多造成雪崩如何处理?

解决方式: IPConf服务通过发现机制快速识别服务端节点故障,进行调度,客户端断线后,通过随机策略重连请求,获取到服务重新调度,如果原有服务器还在则优先选中(本身有服务故障自我发现,负载均衡机制)


3. 消息风暴:(如何保证消息的可靠性)

背景3:长连接下服务奔溃未发送的消息如何再次发送?

解决方式

  1. 建立连接后,需要调用离线消息同步接口,主动拉取消息(主要为了同步网关的状态消息)
  2. 连接的状态信息单独存在在状态服务器中,和整个通讯系统可使用RPC或者共享内存(状态服务器可做持久化处理,类似快照处理)

背景4:心跳的计数超时太多,导致大量的定时器占用内存资源,会造成整个系统卡顿造成消息超时?

解决方式:使用二叉堆(定时时间复杂度为logn),大量的创建和消除,瓶颈在于数据结构中,所以改为时间轮算法(但是定时精度有所缺失)

4.2 弱网

通过快链路优化TCP连接:

  • 减少TCP数据包,IP会分片(超过1400字节),将其数据包控制在1400下
  • 拥塞控制窗口放大,避免收到拥塞
  • socket读写缓冲区,避免数据包溢出
  • 调整RTO初始时间,避免重试的网络拥塞
  • 禁用Nagle算法延迟,减少更少的数据包被缓存(TCP数据比较小的时候,会进行堆积到一定程度在发送,此处为了防止堆积)

通过策略优化:

  • 多个ip测速 选择最优的连接线路
  • 不同网络环境选择 不同超时时间,超时参数进行 动态下发 来 计算策略
  • 短链退化,链接状态数太多导致频繁链接,可以退化为轮询手法消息

协议优化:

  • 二进制协议
  • QUIC协议(基于UDP)

4.3 异地多架构

多数据中心通信,广域请求(跨数据中心请求,延迟比较高)

对此应该更大程度的减少广域请求(保证延迟比较少),这也是核心思想

5. 高可靠

客户端与服务端的通信,通过TCP的全双工进行处理

基本概念

短连接与长连接

概念大致情况
短连接客户端第一时间拉取服务端的数据,但过多的轮询,造成服务端端的过载(不选此方案)。但是在弱网情况下会选择短连接
长连接可以减少这种轮询方式,更快的push给客户端(√)
  • connID:客户端与服务端建立连接,通过长连接,可以通过全局id分配、雪花算法等来分配这个connID(只要保证全局唯一即可)
  • sessionID:业务逻辑的处理。标记A与B之间的聊天框,主要是会话ID
  • msgID:也是业务逻辑的处理

推拉模式
服务端将其请求push给客户端,客户端的应答通过pull模式

网络调用在整个系统是损耗是最大的,要考虑到消息风暴中(带宽容易被打满)

一般可靠性会与一致性来调和

  • 可靠性:消息成功发送后,端对端的到达
  • 一致性:任意时刻,消息的发送与接受顺序一致

背景
设计一个即时通讯系统,底层的可靠与一致性只能保证底层的通信,但是不能保证上层系统,怎么设计这个架构是很大的学问?
传输过程中,整体架构的设计每一步都要保证可用性
可靠性:上行消息可靠 + 服务端业务可靠 + 下行消息可靠
一致性:上行消息一致 + 服务端业务一致 + 下行消息一致

整个架构传输过程中,可能会出现的问题:

客户端发送两条msg消息给服务端的时候,两条消息都在同一个TCP链路中到达服务端。
发送消息的时候一般会引发这三种情况:

  1. 客户端将其消息发送到服务端的时候(TCP层面的可靠性),之后发往业务逻辑层的时候,业务逻辑层崩溃造成了丢失,但服务端业务层未知,而客户端以为消息已经收到了
  2. 业务逻辑层的消息成功处理后。服务端多线程,处理分别进来的两个消息,消息体大小处理速度不一样,导致消息可能乱序
  3. 服务端的数据处理完毕到达客户端后。某一条消息存储失败,导致消息丢失乱序

通过上面的例子可看到,TCP/UDP只能保证底层数据的可靠性,并不能保证业务逻辑的可靠性。

特别的挑战难点在于:

  • 不同操作系统分发的消息不同,导致网络层面无法保证消息的成功到达
  • 无法确定消息的顺序,没有全局时钟来确定唯一顺序
  • 多个客户端/多个服务端/多线程多协程 的处理消息,顺序更难以确定

方案选型
为了解决上面的问题,而且要保证系统的及时性、可达性、幂等性以及时序性

  • 及时性:端对端及时的接收发消息并且实时(即使在高峰期延迟也不应该过大)
  • 可达性:超时重试、ACK确认。
    所谓的超时重试,通过设置一个定时。
    客户端将其消息发送给服务端的时候,当服务端返回ack确认收到,客户端的定时器就会结束。
    服务端将其消息发送给客户端的时候,当客户端返回ack确认收到,服务端的定时器就会结束。
  • 幂等性:分配seqID、服务端存储seqID
    每个消息只能被存储一次,通过分配ID存在map里,确保消息接收不到在重发一次
  • 有序性:seqID可比较,服务端按照发送端的消息对消息进行排序
    ID不仅要保证唯一,还要保证有序(后续发送消息的ID比前面发送的ID要大)
    通过客户端ID与服务端ID,客户端发送的ID大,则服务端发送的ID也要大(保持一致)

5.1 上行消息

客户端将其消息发送给服务端(保证此通路是可用的)

补充:

  • 严格递增:ID 后一个比前一个大
  • 趋势递增:ID的步长 后一个比前一个大
  • clientID是单个客户端的ID,seqID是服务端的ID

大致方案如下:(流程一个个递进)

  1. 单纯使用客户端的ID来进行配对(不选用)。 应该依赖全局会话ID,而不是单独的客户端ID。
  2. clientID使用UUID,可保证有序性,但不能保证唯一性(唯一性的保证需要,将其映射存储在多个内存中,以map形式,会浪费空间(N多个客户端就要存个map))(不选用
  3. 服务端的映射应该为 connID -> preMaxCID,代表客户端传输过来的CID,要配对前一个最大的ID(保证现在传进来的ID比原先的要大,也就是preMaxCID + 1 = CID),此种情况,在弱网情况下,单独一个数据包丢失一直重试,会造成数据库崩溃。(选用),只不过弱网会特殊处理。
  4. 为了解决这种情况,可以使用滑动窗口,如果窗口没满(前面的ID未传,线程池就不会分配线程给予)。此种情况会造成长连接的资源维护(长连接状态)(不选用
  5. 趋势递增(链表的方式,但这会浪费协议的消息带宽)。客户端与服务端都会存储preID,两者配对成功之后,服务端就会存储客户端发送的ID,并且将其ID存储为preID。这是存储两个ID来比较。(不选用

设计一个 内存占用率比较小的方案 以及可靠的方案
对于以上讲到的方法,将其制作成表格:

方案优点缺点
clientID严格递增
1.客户端创建会话与服务器建立长连接,刚开始的clientID初始为0
2.发送第一条消息分配一个clientID,该值会在该会话内(表示一个A与B聊天的框)严格递增
3.服务端存储的消息通过 clientID = preClientID + 1判断
4. 服务端接收到消息后,返回给客户端ACK (如果没有接收到,最多三次重试)
1. 长连接通信延迟低
2.发送端顺序为准,保证严格有序
弱网情况下,单条数据丢失会造成重试导致数据瘫痪无法保证及时性
clientID 链表
1.客户端通过本地时间戳作为clientID + 上一个消息的clientID
服务端
2. 服务端也是存储preClientID以及clientID,只有当前preClientID配对了才接收clientID
同上浪费协议消息带宽
clientID 列表
1. 服务端针对每个连接存储多个clientID,形成clientID list
2.多个clientID 的列表作为滑动窗口,保证消息幂等
减少弱网重传的消息风暴1.实现复杂
2.网关层面需要更多内存,来维护连接状态
3.本身传输层TCP对弱网有所优化,应用层维护窗口收益不大

对于上面的方案,选用的方案只要保证clientID单调递增,特别是弱网情况下,通过优化传输层协议(QUIC)。本身长连接就不适合在弱网情况下工作,丢包和断线是传输层的问题。

上面的前提都是要在同一个sessionID,所以每次存储的时候不仅仅要存储clientID还需要存储sessionID

5.2 消息转发 + 下行消息

  • 分配seqID
  • 异步存储消息
  • 处理业务逻辑
  • 将其消息转发给其他的客户端

补充:

上面一直讲到clientID,还需要一个seqID(服务端分配的ID),本身会话中分为单聊和群聊,任何一个客户端的ID都不能作为整个会话的消息ID,否则会产生顺序冲突,必须要以服务端为主。
服务端需要在会话范围内分配一个全局递增的ID(比如客户端发出的msg1, msg2,可能由两个不同的终端发出。所以服务端发出的seq1,seq2,一大一小要跟客户端一样有一大一小,保证消息的有序性)


比如msg1,cid1,seq1。下一条消息为msg2,cid2,seq2 等等(每条消息都有它的生命周期,生命周期到了就会丢弃,减少带宽的存储)。保证同一个session的有序性


如何存储seqID:
redis 存储seqID,存在一个Redis(incrby),而且还要主从复制,如果一个会话的qps过高,还不能这么存储,Redis10w的数据会满。
从业务上处理,只保证会话内的每个消息有序,那么msgID sessionID seqID 拼接作为key,value为int的64位,通过哈希key变量来分组到不同的Redis。保证消息的唯一性可通过时间戳(比如雪花算法左移右移)。

如何让seqID持久化:
服务端的seqID单调递增跟客户端的处理方式一样。但有一点不一样的地方在于,客户端如果断掉的话,ID可以从零开始递增,但是服务端是全局的ID,从零递增会导致消息不一致。

如何保证消息的持久化,sessionID 业务逻辑永远是递增的(断电故障)。即使主从复制,在主节点故障的时候,从节点选举为主节点,也会有消息慢一拍,导致消息不一致,消息的回退。

为了解决上面的情况,需要将其Redis与lua结合在一起。每次取key节点,还需要比较ruaID(通过lua来保证一致性)。但是如果比较的ruaID真的不一致了(就像上面的主从复制的时候,主节点发生了故障),还是会回退处理。为了解决这种情况,seqID结合时间戳,该ID也是趋势递增。

趋势递增也会有bug(解决方案如下):
客户端可能收到的msg为1,10。中间的跳变缺失不好判断是真缺还是假缺,客户端会pull服务端(分页查询【1,10】的消息),如果确认有消息跳变,则进行补充(结合推拉的消息,此时会有网络异动。正常网络不会有这种现象,弱网比较多)


通过上面的文字版,将其整理成表格,具体如下:

消息转发保证可靠性

  1. 将其消息转发给mq异步存储,保证消息不丢失
  2. seqID无需全局有序,只要保证当前的session有序即可,解决单点瓶颈
方案优点缺点
服务在分配seqID前,请求失败或者进程崩溃?
分配seqID之后在回馈ACK
保证seqID可用1.ack回复慢,收发消息慢
2.seqID瓶颈
3.消息存储失败,消息将丢失
服务端存储消息、业务逻辑等失败处理?
1.消息存储后在回馈ACK,ACK失败则客户端重发
2.处理服务逻辑的时候断开,则客户端可通过pull拉取,补充消息的漏洞,保证消息可靠
3.消息存储后,但业务逻辑失败。(可通过异常捕获,并客户端pull拉取历史消息)
1.保证业务可用性
2.出现异常,处理瞬时(比较接近异常)
1.上行消息延迟增加
2.整体复杂度搞
3.弱网协议需要协议升降级

对应的下行消息保证可靠性,整体表格如下:

方案有点缺点
客户端定期轮询发送pull请求实现简单,可靠1.客户端耗电高(用户体验差)
2.消息延迟高,不及时
seqID的严格递增
1.按照消息到达服务端顺序,来分配seqID(用服务端来弄seqID,有全局性)。特别是使用redis incrby生成seqID,key是sessionID或者connID
2.服务端的seqID严格递增,回馈给客户端,客户端按照preSeqID = seqID + 1做到幂等性
3. 服务端等待客户端的ACK回馈,如果超时则重传
可最大程度保证严格单调递增1.弱网重传问题
2.Redis单点问题,无法保证严格单调递增
3.需要维护超时重传以及定时器
4.无法解决另外一个客户端不存在的传递消息
seqID趋势递增
1.使用lua脚本存储maxSeqID,每次获取该ID的时候,都会检查是否一致,如果不一致,则发生主从切换
2. 客户端发现消息不一致,则通过pull拉取命令,拉取不到,则说明屎seqID跳变。如果是另外一个终端不在线,则查询状态后仅存储而不推送
1.保证连续,任意时刻单调和递增
2. 会话级别seqID,不需要全局分布式ID,redis可用cluster水平扩展
3.可识别用户是否在线,减少网络带宽
群聊场景,有消息风暴
推拉结合 + 服务端打包解决消息风暴
seqID 链表
服务端与客户端都通过存储seqID以及preSeqID,通过比较前一个消息是否满足,如果不满足再通过pull拉取
屏蔽对seqID趋势递增依赖存储的时候还要多存储一个preSeqID

群聊点对点已经无法处理,可以通过批处理进行处理。

  • 将其多个msg存储在一个窗口中(窗口进行排序,发送给客户端),客户端同时对这么多个消息进行发送ack
  • 将其消息压缩(减少卡顿)

上面的消息如果过大,反而影响TCP的拆包,可以使用长短连接,群聊用短连接(用其优化)
服务端长连接push给客户端,让客户端主动pull,服务端主动发送短连接的http请求,减少服务端负载


plato 的总体大致流程如下:

  1. 客户端A创建连接之后,发送消息的时候分配一个clientID(从0开始,而且是递增)
  2. 启动消息定时器(ack回馈清除 或者 超时就会重传),将其消息发送给服务端
  3. 服务端通过redis将其session进行分片(可使用incryby)。异步写入mq,保证可靠传输
  4. 消息处理完成之后回馈给客户端A 一个ack,A客户端收到之后就会取消定时器(这一步的过程可以异步)
  5. 启动下行消息定时器,将其消息发送给客户端B,客户端B通过session的maxSeqID + 1来判定
  6. 客户端B回馈服务端来接收或者拒绝,以此判定是否关闭定时器

IM即时通讯开发可靠的亿级IM消息投递机制

即时通讯(IM)系统最基础、最重要的是消息的及时性与准确性,及时体现在延迟,准确则具体表现为不丢、不重、不乱序。

综合考虑业务场景、系统复杂度、网络流量、终端能耗等,我们的亿级分布式IM消息系统精心设计了消息收发机制,并不断打磨优化,形成了现在的消息可靠投递机制。

整体思路就是:

    1)客户端、服务端共同配合,互相补充;

    2)采用多重机制,从不同层面保障;

    3)拆分上下行,分别处理。

一个完整的IM消息交互逻辑,通常会为两段:

    1)消息上行段:即由消息发送者通过IM实时通道发送给服务端;

    2)消息下行段:由服务端按照一定的策略送达给最终的消息接收人。

消息上行段主要就是依赖IM的实时通道将消息传递给服务端。

这个阶段的消息可靠投递,需要从协议层进行保证,协议层需要提供可靠、有序的双向字节流传输,我们是通过自研的通信协议 RMTP(即 RongCloud Message Transfer Protocol)实现的。

客户端与服务端之间使用长连接,基于 RMTP 协议传输数据。

经过总结,消息下行段主要有三种行为。

1)客户端主动拉取消息,主动拉取有两个触发方式:

    ① 拉取离线消息:与 IM 服务新建立连接成功,用于获取不在线的这段时间未收到的消息;

    ② 定时拉取消息:在客户端最后收到消息后启动定时器,比如 3-5 分钟执行一次。主要有两个目的,一个是用于防止因网络、中间设备等不确定因素引起的通知送达失败,服务端客户端状态不一致,一个是可通过本次请求,对业务层做状态机保活。

2)服务端主动-发送消息(直发消息):

这是在线消息发送机制之一,简单理解为服务端将消息内容直接发送给客户端,适用于消息频率较低,并且持续交互,比如二人或者群内的正常交流讨论。

3)服务端主动-发送通知(通知拉取):

这是在线消息发送机制之一,简单理解为服务端给客户端发送一个通知,通知包含时间戳等可作为排序索引的内容,客户端收到通知后,依据自身数据,对比通知内时间戳,发起拉取消息的流程。

这种场景适用于较多消息传递:比如某人有很多大规模的群,每个群内都有很多成员正在激烈讨论。通过通知拉取机制,可以有效的减少客户端服务端网络交互次数,并且对多条消息进行打包,提升有效数据载荷。既能保证时效,又能保证性能。

在上行过程保证发送消息顺序,为了保证消息有序, 最好的方式是按照 userId 区分,然后使用时间戳排序。那么分布式部署情况下,将用户归属到固定的业务服务器上(PS:指的是同一账号的不同端固定连接到相同的业务服务器上),会使得上行排序变得更容易。同时归属到同一个服务器,在多端维护时也更容易。

客户端连接过程:

    1)客户端通过 APP server ,获取到连接使用的 token;

    2)客户端使用 token 通过导航服务,获取具体连接的 IM 接入服务器(CMP),导航服务通过 userId 计算接入服务器,然后下发,使得某一客户端可以连接在同一台接入服务器(CMP)。

小结一下就是:客户端发出消息后,通过接入服务,按照 userId 投递到指定消息服务器,生成消息 Id, 依据最后一条消息时间,确认更新当前消息的时间戳(如果存在相同时间戳则后延)。然后将时间戳,以及消息 Id,通过 Ack 返回给客户端 ; 然后对上行消息使用 userId + 时间戳进行缓存以及持久化存储,后续业务操作均使用此时间戳。

消息节点在处理完上行流程后,消息按照目标用户投递到所在消息节点,进入下行流程。

下行过程,按照目标 userId 以及本消息在上行过程中生成的时间戳,计算是否需要更新时间戳(正向)。

如果需要更新则对时间戳进行加法操作,直到当前用户时间戳不重复。

如此处理后,目标用户的存储以及客户端接收到消息后的排重可以做到一致,并且可以做到同一个会话内的时间戳是有序的。从而保证同一个接收用户的消息不会出现乱序。

至此:我们已经介绍完了消息的下行交互过程,消息下行过程中的具体实现方式并不简单,以下将详细展开。

1)直发消息:

即服务端主动发送(给目标客户端)的消息:

    1)客户端 SDK 依据本地存储的最新消息时间戳判断,用来做排序等逻辑;

    2)对同一个用户直发消息1条,其他转通知。通知拉取时候客户端选择本地最新一条消息时间戳作为开始拉取时间;

    3)在消息发送过程中,如果上一条消息发送流程未结束,下一条消息则不用直发(s_msg),而是用通知(s_ntf)。

即服务端主动发送通知(给目标客户端):

    1)服务端在通知体中携带当前消息时间戳。投递给客户端;

    2)客户端收到通知后,比对本地消息时间戳,选择是否发拉取消息信令;

    3)服务端收到拉取消息信令后,以信令携带的时间戳为开始,查询出消息列表(200 条或者 5M),并给客户端应答;

    4)客户端收到后,给服务端 ack,服务端维护状态;

    5)客户端拉取消息时使用的时间戳,是客户端本地最新一条消息的时间戳。

具体逻辑是:

    1)用户多个终端链接成功后,发送一条消息,这个消息到达 CMP(IM 接入服务) 后,CMP 做基础检查,然后获此用户的其他终端连接;

    2)服务把客户端上行的消息,封装为服务端下行消息,直接投递给用户的其他客户端。这样完成了发送方的多端抄送,然后将这条消息投递到 IM 服务。进入正常发送投递流程。即时通讯聊天软件app开发可以加蔚可云的v:weikeyun24咨询

针对上面的第2)点,发送方的多端同步没有经过 IM Server,这么做的好处是:

    1)比较快速;

    2)经过越少的服务节点,出问题的几率越小。

接收方多端同步

具体逻辑是:

    1)IM 服务收到消息后,先判断接收方的投递范围,这个范围指的是接收方用户的哪些的终端要接收消息;

    2)IM 服务将范围以及当前消息,发送到 CMP,CMP 依据范围,匹配接收方的终端,然后投递消息。

接收方多端消息同步范围的应用场景,一般都是针对所有终端。

但有一些特殊业务:比如我在 A 客户端上,控制另外某个端的状态,可能需要一些命令消息, 这时候需要这个作用范围,针对性的投递消息。

以上是关于亿级流量 即时通讯IM系统 设计详解(全)的主要内容,如果未能解决你的问题,请参考以下文章

im即时通讯开发:IM通信协议设计详解

im即时通讯开发:高性能HTTP服务端的负载均衡

IM即时通讯开发架构技术:整体架构服务拆分

IM即时通讯开发架构技术:整体架构服务拆分

IM即时通讯开发如何压缩移动网络下APP的流量消耗

基于Netty的IM即时通讯系统设计