《基于实践,设计一个百万级别的高可用 & 高可靠的 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)在线的实时推送。
难点在于:每个人都存一份相同的“你脱单了”的消息,对磁盘和带宽造成了很大的浪费(这就是写扩散)。所以优化的方案是:群消息实体存储一份,用户只存消息 ID 索引。
二次优化后的发送群消息的流程优化如下:(读扩散)
1)遍历群聊的成员并发送消息;
2)先存一份消息实体;
3)然后群聊所有人都存一份消息实体的 ID 引用;
4)查询每个成员的在线状态;
5)在线的实时推送。
5.1.1.2 特点
读扩散:读取操作很重,写入操作很轻;资源消耗相对小一些
写扩散:读取操作很轻,写入操作很重;资源消耗相对大一些
从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),详细的方案可以在微信团队分享的这篇文章里找到答案:《微信后台团队:微信后台异步消息队列的优化升级实践分享》。
5.1.2 消息模型
5.1.2.1 消息数据模型
常见的消息业务需求,可以抽象为多个消息模型概念:用户/用户关系/用户设备/用户连接状态/消息/消息队列;
5.1.2.2 消息模型概念
用户
用户->用户终端设备:每个用户能够多端登录并收发消息;
用户->消息:考虑到读扩散,每个用户与消息的关系都是 1:n;
用户->消息队列:考虑到读扩散,每个用户都会维护自己的一份“消息列表”(1:1),如果考虑到扩容,甚至可以开辟一份消息溢出列表接收超出“消息列表”容量的消息数据(此时是 1:n);
用户->用户连接状态:考虑到用户能够多端登录,那么 app/web 都会有对应的在线状态信息(1:n);
用户->联系人关系:考虑到用户最终以某种业务联系到一起,组成多份联系人关系,最终形成私聊或者群聊(1:n);
联系人关系
业务决定用户与用户之间的关系:比如说,某个家庭下有多少人,这个家庭群聊就有多少人;在 ToB 场景,在钉钉企业版里,我们往往有企业群聊这个存在;
消息
消息->消息队列:考虑到读扩散,消息最终归属于一个或多个消息队列里,因此群聊场景它会分布在不同的消息队列里;
消息队列
消息队列:确切说是消息引用队列,它里面的索引元素最终指向具体的消息实体对象
用户连接状态
用户连接状态:
- 对于 app 端:网络原因导致断线,或者用户手动 kill 掉应用进程,都属于离线
- 对于 web 端:网络原因导致浏览器断网,或者用户手动关闭标签页,都属于离线
- 对于公众号:无法分别离线在线
- 对于小程序:无法分别离线在线
用户终端设备
终端设备:客户端一般是 Android&IOS,web 端一般是浏览器,还有其他灵活的 WebView(公众号/小程序)
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 消息编号的时候,标记该消息已经发送成功;
弊端:这种方案,因为客户端逐条 ACK 消息编号,所以会导致客户端和服务端交互次数过多。当然,客户端可以异步批量 ACK 多条消息,从而减少次数。
基于滑动窗口 ACK
实现:
(1)客户端在接收到消息编号之后,和本地的消息编号进行比对。
如果比本地的小,说明该消息已经收到,忽略不处理;
如果比本地的大,使用本地的消息编号,向服务端拉取大于本地的消息编号的消息列表,即增量消息列表。
拉取完成后,更新消息列表中最大的消息编号为新的本地的消息编号;
(2)服务端在收到 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 依赖
数据通信协议 STOMP
STOMP 协议与 HTTP 协议很相似,它基于 TCP 协议,使用了以下命令:
CONNECT
SEND
SUBSCRIBE
UNSUBSCRIBE
BEGIN
COMMIT
ABORT
ACK
NACK
DISCONNECT
STOMP 的客户端和服务器之间的通信是通过“帧”(Frame)实现的,每个帧由多“行”(Line)组成:通过 MESSAGE 帧、RECEIPT 帧或 ERROR 帧实现,它们的格式相似。
第一行包含了命令,然后紧跟键值对形式的 Header 内容。
第二行必须是空行。
第三行开始就是 Body 内容,末尾都以空字符结尾。
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 消息系统:
业务闭环(消息是如何写入存储,消息是如何消费掉,在线消息是如何实现,离线消息是如何实现,群聊/私聊有何不一样,多端消息如何实现)
解 Bug 填坑(在线消息收不到,第三方推送证书如何配置)
代码优化(单体架构拆分微服务)
存储优化(1.0 版本的 redis 存储到 2.0 版本的 redis+mysql)
性能优化(未读提醒等接口性能优化)
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 服务的客户们,都不愿意花太多钱在巨大的内存资源拓展上;
腾讯:从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),至于原因我不得而知,应该有性能和资源的考量在里头;
网易:万人群技术方案采用了“聚合+分层/组+增量”的设计思路(了解了解就好,具体的算法和源码我们是不知道的,参考这个思路就好)
参考:《网易云信技术分享:IM中的万人群聊技术方案实践总结》
7.3 微信:离线消息方案对比
(1)业务一般是使用线程池来完成推送任务的处理,通过第三方 sdk 的引用,完成推送消息能力对接;所以消息推送效率和效果有以下两个关键点
第一个瓶颈是:推送任务执行速度取决于线程池内部的任务列表容量(一般是阻塞队列,拒绝策略是超出容量则阻塞提交);
第二个瓶颈是:离线推送使用的是第三方 SDK 接入方式,即是说通过接口对接请求第三方手机厂商的服务器,此时往往有接口调用频率的限流和次数限制;
(2)微信/QQ 等作为应用市场的一级 app,在 app 保活机制上,自然做的非常好,包括系统权限也可能愿意开后门,至于具体优化方法和策略可以参考下面的文章;
参考:《微信后台团队:微信后台异步消息队列的优化升级实践分享》
八、成果展示
如果我们公司定位是一个弱 IM 产品(指标:用户规模量不超过 20 万,日活在 100 人上下,系统 QPS<=80,消息年增量在百万数据级别),一般来讲够用了,毕竟不是定位在 QQ/微信这种大平台的即时通信应用。
如果说你想自己弄一个系统来自己玩,这不失为一个模仿方案。
如果说是大厂,这个就权当一个参考方案,自取所需。
九、参考文献
十、其他
两年前从架构师手上接过来的 IM 消息系统模块,让我逐步培养了架构思维,见贤思齐,感谢恩师~
多说一句,同学们在日常开发中,也要学会参考业界的解决方案,思考如何维护整套系统的高可用,思考如何解决大流量背景下的存储优化等关键问题。
以上抛砖引玉,欢迎留言讨论,一起进步~~
笔者是腾讯的一名普通后台搬砖工,喜欢技术交流与分享,保持饥渴,一起进步!
公众号“后台技术汇”:原创 Java 后台开发技术栈的知识分享,分享程序猿专属干货与福利,希望对各位有所帮助。
版权声明: 本文为 InfoQ 作者【后台技术汇】的原创文章。
原文链接:【http://xie.infoq.cn/article/4061081a5ce66137a8c021994】。文章转载请联系作者。
评论 (10 条评论)