200字范文,内容丰富有趣,生活中的好帮手!
200字范文 > 亿级流量 即时通讯IM系统 设计详解(全)

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

时间:2020-06-02 04:24:06

相关推荐

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

目录

前言1. 背景2. 高性能3. 高一致4. 高可用4.1 连接断路4.2 弱网4.3 异地多架构5. 高可靠5.1 上行消息5.2 消息转发 + 下行消息

前言

了解对应的java知识可看我这篇文章:

java框架零基础从入门到精通的学习路线(超全)

设计该系统的业务逻辑,之后针对性的优化

从整体框架掌握各个深层次的框架知识点,以此查漏补缺

其他系统设计如下:

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

以下的文章知识点主要来源于:系统设计面试题-如何设计10亿流量的即时通讯(IM)系统?

记笔记的同时融入了一些自已的见解和拓展

1. 背景

所需的功能?

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

需考虑的约束条件?

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

2. 高性能

接入层优化

轮询拉模式无法保证实时性要求

可通过TCP的长连接push给客户

客户端如何与服务端建立长连接

a. 客户端通过公网IP socket编程直接连接服务器

b. 通过IPConf服务发送公网IP给客户端,灵活使用调用长连接(客户端自已选择的话,可以选择一个最优。将其缓存起来,如果连接失败,选择其他ip进行连接,减少ipconfig的压力)

c. 建立长连接之后,业务逻辑层与uid进行映射

d. IPConf通过协调服务与业务逻辑层进行交互,根据机器来负载均衡

长连接占用资源,而且跟DB结合在一起,读写会比较慢

拆分服务,长连接负责收发消息,业务服务器负责业务块

调度资源优化网络通信会 频繁迭代消息来支持 业务开发,本身有状态的业务重启比较缓慢

变化以及不变化的业务进行拆开,大致通过状态机服务

长连接服务专门收发消息,并且更新状态机,ip服务通过调取状态机来调度策略

频繁的变化(比如消息登入登出)通过控制长连接的断开,通过mq发送close用于长连接的调度

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

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

存储层优化

读少写多,如何控制消息分发不让资源耗尽?

尽可能将其控制消息并发(不是按照一个消息一个线程,而是启动个别线程通过分发的形式),保证群发消息的时候系统不会崩溃。

上面的情况可能会出现消息挤压,延迟增大?

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

存储系统有严重的写放大(也就是本身写A对BCD写消息,但是三者的通道都要写一遍),如何降低成本?

a.超大群降级为放大模式进行存储,消息仅同步写入收件箱(A读取BCD的消息的时候,只需要对应读取BCD的收件箱,读的复杂度为o(n),写只要一次)

b.群状态的消息异步存储

消息如何处理?如何保证万人群聊中的已读/未读一致性?

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查询快速识别的热点消息等,利用消息快速处理,准确命中

优点:

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

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

存储层代理服务

增加这一层来屏蔽底层细节,代理层基于key做hash分片,基于一致性协议进行复制kv

超大群聊多个请求可能会拉取一份消息列表(做自旋cache,减少下游DB的数据库访问)

优点:

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

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

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配对策略(双ID方法),可保证消息的时序一致性下行消息(服务端)通过推拉结合,可保证高吞吐量

4. 高可用

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

4.1 连接断路

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

1. 心跳机制:

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

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

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

心跳的时间:

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

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

2. 断线重连

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

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

本身会创建一个TCP通路,通过fid与session进行关联即可

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

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

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

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

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

解决方式

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

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

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

4.2 弱网

通过快链路优化TCP连接:

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

通过策略优化:

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

协议优化:

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

4.3 异地多架构

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

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

5. 高可靠

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

基本概念

短连接与长连接

connID:客户端与服务端建立连接,通过长连接,可以通过全局id分配、雪花算法等来分配这个connID(只要保证全局唯一即可)sessionID:业务逻辑的处理。标记A与B之间的聊天框,主要是会话IDmsgID:也是业务逻辑的处理

推拉模式

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

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

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

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

背景

设计一个即时通讯系统,底层的可靠与一致性只能保证底层的通信,但是不能保证上层系统,怎么设计这个架构是很大的学问?

传输过程中,整体架构的设计每一步都要保证可用性

可靠性:上行消息可靠 + 服务端业务可靠 + 下行消息可靠

一致性:上行消息一致 + 服务端业务一致 + 下行消息一致

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

客户端发送两条msg消息给服务端的时候,两条消息都在同一个TCP链路中到达服务端。

发送消息的时候一般会引发这三种情况:

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

通过上面的例子可看到,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

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

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

设计一个 内存占用率比较小的方案 以及可靠的方案

对于以上讲到的方法,将其制作成表格:

对于上面的方案,选用的方案只要保证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】的消息),如果确认有消息跳变,则进行补充(结合推拉的消息,此时会有网络异动。正常网络不会有这种现象,弱网比较多)

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

消息转发保证可靠性

将其消息转发给mq异步存储,保证消息不丢失seqID无需全局有序,只要保证当前的session有序即可,解决单点瓶颈

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

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

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

上面的消息如果过大,反而影响TCP的拆包,可以使用长短连接,群聊用短连接(用其优化)

服务端长连接push给客户端,让客户端主动pull,服务端主动发送短连接的http请求,减少服务端负载

plato 的总体大致流程如下:

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

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。