了解对应的java知识可看我这篇文章:
java框架零基础从入门到精通的学习路线(超全)
设计该系统的业务逻辑,之后针对性的优化
从整体框架掌握各个深层次的框架知识点,以此查漏补缺
其他系统设计如下:
以下的文章知识点主要来源于:系统设计面试题-如何设计10亿流量的即时通讯(IM)系统?
记笔记的同时融入了一些自已的见解和拓展
所需的功能?
需考虑的约束条件?
接入层优化:
轮询拉模式无法保证实时性要求
可通过TCP的长连接push给客户
客户端如何与服务端建立长连接
a. 客户端通过公网IP socket编程直接连接服务器
b. 通过IPConf服务发送公网IP给客户端,灵活使用调用长连接(客户端自已选择的话,可以选择一个最优。将其缓存起来,如果连接失败,选择其他ip进行连接,减少ipconfig的压力)
c. 建立长连接之后,业务逻辑层与uid进行映射
d. IPConf通过协调服务与业务逻辑层进行交互,根据机器来负载均衡
长连接占用资源,而且跟DB结合在一起,读写会比较慢
拆分服务,长连接负责收发消息,业务服务器负责业务块
调度资源优化网络通信会 频繁迭代消息来支持 业务开发,本身有状态的业务重启比较缓慢
变化以及不变化的业务进行拆开,大致通过状态机服务
长连接服务专门收发消息,并且更新状态机,ip服务通过调取状态机来调度策略
频繁的变化(比如消息登入登出)通过控制长连接的断开,通过mq发送close用于长连接的调度
建立长连接的时候,客户端如何知道接入层服务器在哪
方案 | 优点 | 缺点 |
---|---|---|
广播 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稳定性 |
补充对应的知识点:一致性哈希算法的原理与应用剖析
存储层优化:
读少写多,如何控制消息分发不让资源耗尽?
尽可能将其控制消息并发(不是按照一个消息一个线程,而是启动个别线程通过分发的形式),保证群发消息的时候系统不会崩溃。
上面的情况可能会出现消息挤压,延迟增大?
a.cache打包压缩,让router一次发送,当窗口10ms发送一次,整体性能提升一次。而且b.通过推拉结合将其压缩打包(比如服务端发送一个请求让客户端专门去pull请求,减少消息的轮询)
存储系统有严重的写放大(也就是本身写A对BCD写消息,但是三者的通道都要写一遍),如何降低成本?
a.超大群降级为放大模式进行存储,消息仅同步写入收件箱(A读取BCD的消息的时候,只需要对应读取BCD的收件箱,读的复杂度为o(n),写只要一次)
b.群状态的消息异步存储
消息如何处理?如何保证万人群聊中的已读/未读一致性?
a.实时流处理,接收者已读消息下发同步,接收者消息已读通过异步落库
b.超大群消息,通过群状态变更服务进行降级
c.异步写入通过重试保证最终一致
写磁盘如何优化延迟?:
(同步数据的时候最好在磁盘处理)
分级存储:
该方案的优点有如下:
按照请求读取热度分级处理,而且对离线消息同步的range操作良好,也可支持群聊的推拉结合。
缺点如下:
本地缓存预热慢,服务重启会有抖动。
大量使用内存,运维难度高
需关注缓存命中率等问题
多元DB存储:(不同字段不同存储)
优点就是利用DB的又是补长取短,基于磁盘解决存储容量问题
缺点就是ROcksDB单机数据库,需要有自研的分布式代理层。磁盘kv读写磁盘,拉取消息性能收到影响
图数据库:
存储数据库关系的关系,会话消息列表的各种拉链关系
可运行近线OLAP查询快速识别的热点消息等,利用消息快速处理,准确命中
优点:
缺点就是图数据库运维成本比较高,不好维护
存储层代理服务:
增加这一层来屏蔽底层细节,代理层基于key做hash分片,基于一致性协议进行复制kv
超大群聊多个请求可能会拉取一份消息列表(做自旋cache,减少下游DB的数据库访问)
优点:
缺点就是增加了一层逻辑,复杂度增大
收发者的消息顺序一致以及消息不丢失
要保证消息不丢失?
TCP网络传输的数据是不会丢失,但是数据包是可能会丢失(TCP网络断、延迟)
明白其背景之后,可通过重试机制
但重试也有bug,如果ack丢失,消息无限重试发送,也会造成顺序的不一致(比如原本发送的答案是ABCD,结果消息为ABCCD,那样就会造成混乱)
解决上面的重复发送问题,可通过UUID的去重?
× ,此处使用UUID来对ack去重判断,是不合适的
发送的ack包(使用UUID),还需要通过一个全局表配对是否有重复
本身如果流量比较小,可以跟全局变量进行判断(使用一张表存储)
如果流量比较多,可以采取在很小的时间段进行判断
但是本身是亿级流量,即使很小的时间段,变量也是特别多
解决重复发送的问题?正确方式是?
借鉴TCP的三次握手机制,通过ack+1判断
保证消息有序?
通过上面的知识,已经知道消息不会重复且不会丢失
那可通过递增的ID进行排序
特别是服务器宕机之后,本身的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.批量拉取有利于消息压缩,提高带宽利用率。 |
无法解决上行消息的时序性 |
通过以上方案,找出更好的解决方法
链路复杂过长容易造成瓶颈,导致没有高可用
整个链路跨越公网(运营商),TCP如果开启Keepalive(心跳机制是2h),长连接如果超时没有回馈,就会将其断开。心跳机制应该取决于整个链路,要想维护一个端到端的有效性(内核间有效是可以维护的,但是业务逻辑不好维护),应该维护业务逻辑上的心跳机制。
1. 心跳机制:
心跳机制应该放在业务逻辑层中
包的大小: 包的心跳控制包不可过大,需控制在0.5kb以下
心跳的时间:
最好的方式是自适应心跳:前端通过固定心跳(没有链路固定好久可),后端通过测算NAT淘汰时间(自适应估算时间,取最小最大的临界值中间)
2. 断线重连:
背景1:坐高铁或者火车的时候,客户端网络原因频繁切换,会导致服务创建销毁,过度清空资源。如何更稳定快速的建立长连接?
解决方式:断开的一瞬间,启动一个session超时器,如果在这之后能连接上来,资源就不会被清空,就可进行重连,保证链接的稳定性(直接复用)
本身会创建一个TCP通路,通过fid与session进行关联即可
以上方式可以防止频繁的创建和销毁,不会让Redis雪崩
背景2:服务器如果崩盘导致客户端重连,请求过多造成雪崩如何处理?
解决方式: IPConf服务通过发现机制快速识别服务端节点故障,进行调度,客户端断线后,通过随机策略重连请求,获取到服务重新调度,如果原有服务器还在则优先选中(本身有服务故障自我发现,负载均衡机制)
3. 消息风暴:(如何保证消息的可靠性)
背景3:长连接下服务奔溃未发送的消息如何再次发送?
解决方式:
背景4:心跳的计数超时太多,导致大量的定时器占用内存资源,会造成整个系统卡顿造成消息超时?
解决方式:使用二叉堆(定时时间复杂度为logn),大量的创建和消除,瓶颈在于数据结构中,所以改为时间轮算法(但是定时精度有所缺失)
通过快链路优化TCP连接:
通过策略优化:
协议优化:
多数据中心通信,广域请求(跨数据中心请求,延迟比较高)
对此应该更大程度的减少广域请求(保证延迟比较少),这也是核心思想
客户端与服务端的通信,通过TCP的全双工进行处理
基本概念:
短连接与长连接
概念 | 大致情况 |
---|---|
短连接 | 客户端第一时间拉取服务端的数据,但过多的轮询,造成服务端端的过载(不选此方案)。但是在弱网情况下会选择短连接 |
长连接 | 可以减少这种轮询方式,更快的push给客户端(√) |
推拉模式
服务端将其请求push给客户端,客户端的应答通过pull模式
网络调用在整个系统是损耗是最大的,要考虑到消息风暴中(带宽容易被打满)
一般可靠性会与一致性来调和
背景:
设计一个即时通讯系统,底层的可靠与一致性只能保证底层的通信,但是不能保证上层系统,怎么设计这个架构是很大的学问?
传输过程中,整体架构的设计每一步都要保证可用性
可靠性:上行消息可靠 + 服务端业务可靠 + 下行消息可靠
一致性:上行消息一致 + 服务端业务一致 + 下行消息一致
整个架构传输过程中,可能会出现的问题:
客户端发送两条msg消息给服务端的时候,两条消息都在同一个TCP链路中到达服务端。
发送消息的时候一般会引发这三种情况:
- 客户端将其消息发送到服务端的时候(TCP层面的可靠性),之后发往业务逻辑层的时候,业务逻辑层崩溃造成了丢失,但服务端业务层未知,而客户端以为消息已经收到了
- 业务逻辑层的消息成功处理后。服务端多线程,处理分别进来的两个消息,消息体大小处理速度不一样,导致消息可能乱序
- 服务端的数据处理完毕到达客户端后。某一条消息存储失败,导致消息丢失乱序
通过上面的例子可看到,TCP/UDP只能保证底层数据的可靠性,并不能保证业务逻辑的可靠性。
特别的挑战难点在于:
方案选型:
为了解决上面的问题,而且要保证系统的及时性、可达性、幂等性以及时序性
客户端将其消息发送给服务端(保证此通路是可用的)
补充:
- 严格递增:ID 后一个比前一个大
- 趋势递增:ID的步长 后一个比前一个大
- clientID是单个客户端的ID,seqID是服务端的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
补充:
上面一直讲到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】的消息),如果确认有消息跳变,则进行补充(结合推拉的消息,此时会有网络异动。正常网络不会有这种现象,弱网比较多)
通过上面的文字版,将其整理成表格,具体如下:
消息转发保证可靠性
方案 | 优点 | 缺点 |
---|---|---|
服务在分配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 |
群聊点对点已经无法处理,可以通过批处理进行处理。
上面的消息如果过大,反而影响TCP的拆包,可以使用长短连接,群聊用短连接(用其优化)
服务端长连接push给客户端,让客户端主动pull,服务端主动发送短连接的http请求,减少服务端负载
plato 的总体大致流程如下: