《基于实践,设计一个百万级别的高可用&高可靠的IM消息系统》
一、写在开头
大家好,我是公众号“后台技术汇”的博主“一枚少年”。
本人从事后台开发工作3年有余了,其中让我感触最深刻的一个项目,就是在两年前从架构师手上接过来的IM消息系统模块。
下面我将从开发者的视角出发,一步一步的与大家一起剖析:如何去设计一个能支撑起百万级别的高可用高可用的IM消息系统架构;
主要围绕着七个主题进行说明:项目背景、背景需求、实现原理、开发方案、对比方案、成果展示和参考文献。

二、项目背景
我们仔细观察就能发现,生活中的任何类型互联网服务都有IM系统的存在,比如:
基础性服务类-腾讯新闻(评论消息)
商务应用类-钉钉(审批工作流通知)
交流娱乐类-QQ/微信(私聊群聊&讨论组&朋友圈)
互联网自媒体-抖音快手(点赞打赏通知)

总结:
三、系统需求
我们将IM系统的需求需要满足四点:高可靠性、高可用性、实时性和有序性。

四、架构设计
4.1 架构设计
IM消息-微服务:拆分为用户微服务&消息连接服务&消息业务服务
IM消息-存储架构:兼容性能与资源开销,选择reids&mysql
IM消息-高可用:可以支撑起高并发场景,选择Spring提供的websocket
IM消息-支持多端消息同步:app端、web端、微信公众号、小程序消息
IM消息-支持在线与离线消息场景
4.2 架构图

4.3 分层架构

五、实现原理
5.1 消息存储模型
5.1.1 读扩散和写扩散
5.1.1.1 概念
我们举个例子说明什么是读扩散,什么是写扩散:
一个群聊“相亲相爱一家人”,成员:爸爸、妈妈、哥哥、姐姐和我(共5人);

因为你最近交到女朋友了,所以发了一条消息“我脱单了”到群里面,那么自然希望爸爸妈妈哥哥姐姐四个亲人都能收到了。
1)遍历群聊的成员并发送消息;
2)查询每个成员的在线状态;
3)成员不在线的存储离线;
4)成员在线的实时推送。
数据模型如下:

难点在于:如果第四步发生异常,群友会丢失消息,那么会导致有家人不知道“你脱单了”,造成催婚的严重后果。
1)遍历群聊的成员并发送消息;
2)群聊所有人都存一份;
3)查询每个成员的在线状态;
4)在线的实时推送。

难点在于:每个人都存一份相同的“你脱单了”的消息,对磁盘和带宽造成了很大的浪费(这就是写扩散)。
1)遍历群聊的成员并发送消息;
2)先存一份消息实体;
3)然后群聊所有人都存一份消息实体的ID引用;
4)查询每个成员的在线状态;
5)在线的实时推送。

5.1.1.2 特点
读扩散:读取操作很重,写入操作很轻;资源消耗相对小一些
写扩散:读取操作很轻,写入操作很重;资源消耗相对大一些
从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),详细的方案可以在微信团队分享的这篇文章里找到答案:《》。
5.1.2 消息模型
5.1.2.1 消息数据模型
常见的消息业务需求,可以抽象为多个消息模型概念:

5.1.2.2 消息模型概念
- 对于app端:网络原因导致断线,或者用户手动kill掉应用进程,都属于离线
- 对于web端:网络原因导致浏览器断网,或者用户手动关闭标签页,都属于离线
- 对于公众号:无法分别离线在线
- 对于小程序:无法分别离线在线
5.1.3 消息存储
对于消息存储方案,本质上只有三种方案:要么放在内存,要么放在磁盘,要么两者结合存储(据说大公司为了优化性能,活跃的消息数据都是放在内存里面的,毕竟有钱~)。
下面分别解析这两个方案的优点与弊端:
方案一、考虑性能,数据全部放到 redis 进行存储
方案二、考虑资源,数据用redis + mysql进行存储
5.1.3.1 方案一:redis
前提 用户&联系人关系,由于是业务数据,因此统一默认使用关系型数据库存储
流程图

(1)用户发消息
(2)
redis 创建一条实体数据&一个实体数据计时器 (3)redis 在 B用户的用户队列 添加实体数据引用
(4)B用户拉取消息(
)后续5.2会提及拉模式 解决方案 用户队列,zset(score确保有序性)
消息实体列表,hash(msg_id确保唯一性)
消息实体计数器,hash(支持群聊消息的引用次数,倒计时到零时则删除实体列表的对应消息,以节省资源)
优点 1、内存操作,响应性能好
弊端 1、内存消耗巨大,eg:除非大厂,小公司的服务器的宝贵内存资源是耗不起业务的,随着业务增长,不想拓展资源,就需要手动清理数据了
2、受 redis 容灾性策略影响较大,如果 redis 宕机,直接导致数据丢失(可以使用redis的集群部署/哨兵机制/主从复制等手段解决)
5.1.3.2 方案二:redis+mysql
前提 用户&联系人关系,由于是业务数据,因此统一默认使用关系型数据库存储
流程图

(1)用户发消息
(2)
mysql 创建一条实体数据 (3)redis 在 B用户的用户队列 添加实体数据引用
(4)B用户拉取消息(
5.2会提及拉模式 )解决方案 用户队列,zset(score确保有序性)
消息实体列表,转移到mysql(表主键id确保唯一性)
消息实体计数器,hash(删除这个概念,因为磁盘可用总资源远远高于内存总资源,哪怕一直存放mysql数据库,在业务量百万级别时也不会有大问题,如果是巨大体量业务就需要考虑分表分库处理检索数据的性能了)
优点: 1、抽离了数据量最大的消息实体,大大节省了内存资源
2、磁盘资源易于拓展 ,便宜实用
弊端: 1、磁盘读取操作,响应性能较差(从产品设计的角度出发,你维护的这套IM系统究竟是强IM还是弱IM)
5.2 消息消费模式
5.2.1 拉模式
选用消息拉模式的原因
(1)由于用户数量太多(观察者),服务器无法一一监控客户端的状态,因此消息模块的数据交互使用拉模式,可以节约服务器资源;
(2)当用户有未读消息时,由客户器主动发起请求的方式,可以及时刷新客户端状态。
5.2.2 ack机制
ack机制
基于拉模式实现的数据拉取请求(第一次fetch接口)与数据拉取确认请求(第二次fetch接口)是成对出现的;
客户端二次调用fetch接口,需要将上次消息消费的锚点告诉服务端,服务器进而删除已读消息。
ack实现方案
基于每一条消息编号 ACK
基于滑动窗口 ACK
如果比本地的小,说明该消息已经收到,忽略不处理; 如果比本地的大,使用本地的消息编号,向服务端拉取大于本地的消息编号的消息列表,即增量消息列表。 拉取完成后,更新消息列表中最大的消息编号为新的本地的消息编号;
好处
第一次获取消息完成之后,如果没有ack机制,流程是:
(1)服务器删除已读消息数据
(2)服务端把数据包响应给客户端
如果由于网络延迟,导致客户端长时间取不到数据,这时客户端会断开该次HTTP请求,进而忽略这次响应数据的处理,最终导致消息数据被删除而后续无法恢复。
有了ack机制,哪怕第一次获取消息失败,客户端还是可以继续请求消息数据,因为在ack确认之前,消息数据都不会删除掉。
5.2.3 请求模型

5.3 消息实时通信
5.3.1 spring-messaging模块
Spring框架4.0引入了一个新模块 —— spring-messaging模块,它包含了很多来自于Spring Integration项目中的概念抽象,比如:Message消息、消息频道MessageChannel、消息句柄MessageHandler等。此模块还包括了一套注释,可以把消息映射到方法上,与Spring MVC基于注释的编程模型相似。
Spring框架提供了对使用STOMP子协议的支持。
STOMP,Streaming Text Orientated Message Protocol,流文本定向消息协议。STOMP是一个简单的消息传递协议, 是一种为MOM(Message Oriented Middleware,面向消息的中间件)设计的简单文本协议。
maven依赖
org.springframework
spring-messaging
数据通信协议STOMP
STOMP协议与HTTP协议很相似,它基于TCP协议,使用了以下命令:
CONNECT
SEND
SUBSCRIBE
UNSUBSCRIBE
BEGIN
COMMIT
ABORT
ACK
NACK
DISCONNECT
STOMP的客户端和服务器之间的通信是通过“帧”(Frame)实现的,每个帧由多“行”(Line)组成:通过MESSAGE帧、RECEIPT帧或ERROR帧实现,它们的格式相似。
5.3.2 长连接机制
5.3.2.1 连接建立
nginx配置:设置http可以升级为websocket协议;
http三次握手:客户端&服务端双方确保发送和接受能力正常;
升级websocket:客户端以登录令牌“token”标识用户连接;
服务端内存将“token”与长连接会话“Session”缓存到一个 ConcurrentHashMap,这样便能以O(n)的效率检索到指定用户的长连接并发送通知包;
5.3.2.2 双工通信协议
客户端保活机制:客户端发送“ping”包,服务端接受到,返回“pong”包,这是最基础的保活手段;
(保活机制放在客户端,减轻服务端压力,同时节省服务端资源) 新消息通知协议:前后端约定使用固定的通知协议做为通知信号(eg,“msg.route.new”),确保数据量小,宽带消耗低;
5.3.2.3 服务端剔除无效连接
使用定时调度任务:轮训缓存好的 ConcurrentHashMap,检索每个长连接会话是否超时,超时则关闭以节省资源;
5.4 微服务设计
5.4.1 微服务划分
一般来说IM微服务,能拆分为基础的三个微服务:用户服务&消息业务服务&消息连接管理服务。
参考架构图:

IM消息系统包括了三个微服务:
,他们分工合作如下:用户微服务、消息连接管理微服务和消息业务微服务 用户微服务
(1)用户设备的登录&登出:设备号存库,连接状态更新,其他登录端用户踢出等;
消息连接管理微服务
(1)状态保存:保存用户设备长连接对象
(2)剔除无效连接:轮训已有长连接对象状态,超时删除对象
(3)接受客户端的心跳包:刷新长连接对象的状态
消息业务微服务
(1)消息存储:参考5.1-消息存储模型,进行私聊/群聊的消息存储策略
(2)消息消费:参考5.2-消息消费模式,进行消息获取响应与ack确认删除
(3)消息路由:用户在线时,路由消息通知包到“消息连接管理微服务”,以通知用户客户端来取消息;
5.4.2 消息路由
相信看完“ 5.4.1 微服务划分”,了解到微服务之间也有通信手段:消息业务服 -> 消息连接管理服,两者之间可以通过 websocket 实现主动或被动的双工通信,以支持实时消息的路由通知。
5.5 离线消息方案
离线推送方案上,调研了下,同行一般都会考虑采用两种方案:
企业自研后台离线PUSH系统
企业自行对接第三方手机厂商PUSH系统
5.5.1 企业自研后台离线PUSH系统
原理 在应用级别,客户端与后台离线PUSH系统保持长连接,当用户状态被检测为离线时,通过这个长连接告知客户端“有新消息”,进而唤醒手机弹窗标题。
弊端 随着安卓和苹果系统的限制越来越严格,一般客户端的活动周期被限制的死死的,一旦客户端进程被挪到后台就立马被kill掉了,导致客户端保活特别难做好。(这也是很多中小企业头疼的地方,毕竟只有微信或者QQ这种体量的一级市场APP,手机系统愿意给他们留后门来做保活)
5.5.2 企业自行对接第三方厂商PUSH系统
原理 在系统级别,每个硬件系统都会与对应的手机厂商保持长连接,当用户状态被检测为离线时,后台将推送报文通过HTTP请求,告知第三方手机厂商服务器,进而通过系统唤醒app的弹窗标题。
弊端 (1)作为应用端,消息是否确切送达给用户侧,是未知的;推送的稳定性也取决于第三方手机厂商的服务稳定性;
(2)额外进行sdk的对接工作,增加了工作量;
(3)第三方厂商随时可能升级sdk版本,导致没有升级sdk的服务器出现推送失败的情况,给Sass系统部署带来困难;
(4)推送证书配置也要考虑到维护成本
分类 ios推送

android推送(华为/小米/OPPO/魅族/个推等)

5.6 总结
5.6.1 安全性
传输安全性使用https访问;使用私有协议,不容易解析;
内容安全性端到端加密,中间任何环节都不能解密;即发送和接收端交换互相的密钥来解密,服务器端解密不了;服务器端不存储消息;
5.6.2 一致性
消息一致性:保证消息不乱序;
消息唯一id:有多种方式,如由统一的MySQL/Redis统一生成、或由snowflake算法生成等,此时若要支持高并发,则要考虑该生成器对高并发的支持情况;
5.6.3 可靠性
上述方案用到了ack机制,同时消息创建过程尽量确保操作原子性,并且封装为一个事务(虽然分开mysql&redis存储让分布式事务变得较高难度)。
5.6.4 实时性
通用方案都是采用websocket,但是某些低版本的浏览器可能不支持websocket,所以实际开发时,要兼容前端所能提供的能力进行方案设计。
六、实现方案
6.1 重点难点
作为研发者,曾经有两年多的时间都在维护迭代公司的IM消息系统:
6.2 可优化点
用户量巨大的系统的高可用方案之一,是部署多部连接管理服务器,以支撑更多的用户连接
用户量巨大的系统的高可用方案之二,是对单部连接管理服务,使用Netty进行框架层优化,让一个服务器支撑更多的用户连接
消息量巨大的系统,可以考虑对消息存储进行优化
不同的地区会存在业务量差异,比如在某些经济发达的省份,IM系统面临的压力会比较大,一些欠发达省份,服务压力会低一点,所以这块可以考虑数据的冷热部署
七、对比方案
7.1 网易:IM分层架构对比
本文服务的分层架构,可以参考: 4.3 分层架构

对比网易云的IM架构系统

差异点
(1)Gateway层差异
网易云的IM架构,它所支撑的用户群体体量更大,流量也更大,在Gateway层就做了相当多的设计,我看到有:Link(IM/Chatroom)、WebLink(IM/Chatroom)、LBS(Location Based Service),以及最后的API Gateway;
而我这边主要业务是用于Saas服务,因此侧重于提供一套统一解决方案的设计,包括:消息的整个链路(存储/路由/消费),数据存储使用 redis+mysql的存储方案等等;
LBS,定位技术来获取定位设备当前的所在位置;(
以下是我自己的一些个人观点,欢迎大家留言讨论哈~ )(1)用于解决“冷热数据”存储优化
eg,新疆等经济欠发达的地区,可以优先考虑存储容量,性能置后;一些活跃地区,则优先考虑性能。
(2)用于解决节点的性能瓶颈
eg,不同的应用业务集群的配置有差异,因此可以根据用户所在的位置,提供不同等级的性能服务。
(2)Service层差异
网易云的IM架构,在 Service层 分为服务节点集群部署与后端API(具体部署情况会比Saas更加复杂,因为互联网架构是一个整体,容灾性跟可用性必定更加健全);
我这边Saas服务,业务往往独立性较强,所以往往一个大项目,服务节点最多2~3个物理机组成集群;
相似点
(1)Feature层,对于消息模块的定义,都将短信/邮件等归类到消息模块里面;
(2)Protocol层,我这边应用层协议是HTTP/STOMP(Websocket的封装),跟大厂的通信协议的使用没有太多区别;
(3)Client层,app、web、小程序、公众号,终端应用类型大差不差;
7.2 网易云:群聊技术方案对比
市面上主流的IM产品中,微信群是500人上限,QQ群是3000人上限(3000人群是按年付费升级,很贵,不是为一般用户准备的)。
一方面,从产品的定义上群成员数量不应过多,另一方面,技术成本也是个不可回避的因素。
本文采用的群聊消息解决方案是读扩散,是优先考虑资源消耗的,毕竟Sass服务的客户们,都不愿意花太多钱在巨大的内存资源拓展上;
腾讯:从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),
至于原因我不得而知,应该有性能和资源的考量在里头 ;网易:万人群技术方案采用了“聚合+分层/组+增量”的设计思路
(了解了解就好,具体的算法和源码我们是不知道的,参考这个思路就好)

7.3 微信:离线消息方案对比
(1)业务一般是使用线程池来完成推送任务的处理,通过第三方sdk的引用,完成推送消息能力对接;所以消息推送效率和效果有以下两个关键点
第一个瓶颈是:推送任务执行速度取决于线程池内部的任务列表容量(一般是阻塞队列,拒绝策略是超出容量则阻塞提交);
第二个瓶颈是:离线推送使用的是第三方SDK接入方式,即是说通过接口对接请求第三方手机厂商的服务器,此时往往有接口调用频率的限流和次数限制;
(2)微信/QQ等作为应用市场的一级app,在app保活机制上,自然做的非常好,包括系统权限也可能愿意开后门,
至于具体优化方法和策略可以参考下面的文章 ;
八、成果展示
如果我们公司定位是一个弱IM产品(指标:用户规模量不超过20万,日活在100人上下,系统QPS<=80,消息年增量在百万数据级别),一般来讲够用了,毕竟不是定位在QQ/微信这种大平台的即时通信应用。
如果说你想自己弄一个系统来自己玩,这不失为一个模仿方案。
如果说是大厂,这个就权当一个参考方案,自取所需。
九、参考文献
十、其他
两年前从架构师手上接过来的 IM 消息系统模块,让我逐步培养了架构思维,见贤思齐,感谢恩师~
多说一句,同学们在日常开发中,也要学会参考业界的解决方案,思考如何维护整套系统的高可用,思考如何解决大流量背景下的存储优化等关键问题。
以上抛砖引玉,欢迎留言讨论,一起进步~~
