原文链接:
https://xie.infoq.cn/article/4061081a5ce66137a8c021994
从事后台开发工作 3 年有余了,其中让我感触最深刻的一个项目,就是在两年前从架构师手上接过来的 IM 消息系统模块。
下面我将从开发者的视角出发,一步一步的与大家一起剖析:如何去设计一个能支撑起百万级别的高可用高可用的 IM 消息系统架构;
下面我主要围绕着七个主题进行说明:项目背景、背景需求、实现原理、开发方案、对比方案、成果展示和参考文献。
项目背景
我们仔细观察就能发现,生活中的任何类型互联网服务都有 IM 系统的存在,比如:
- 基础性服务类-腾讯新闻(评论消息)
- 商务应用类-钉钉(审批工作流通知)
- 交流娱乐类-QQ/微信(私聊群聊 &讨论组 &朋友圈)
- 互联网自媒体-抖音快手(点赞打赏通知)
总结:在这些林林总总的互联网生态产品里,消息系统作为底层能力,在确保业务正常与用户体验优化上,始终扮演了至关重要的角色。
系统需求
我们将 IM 系统的需求需要满足四点:高可靠性、高可用性、实时性和有序性。
1.架构设计
- IM 消息-微服务:拆分为用户微服务 &消息连接服务 &消息业务服务
- IM 消息-存储架构:兼容性能与资源开销,选择 reids&mysql
- IM 消息-高可用:可以支撑起高并发场景,选择 Spring 提供的 websocket
- IM 消息-支持多端消息同步:app 端、web 端、微信公众号、小程序消息
- IM 消息-支持在线与离线消息场景
2.架构图
3.分层架构
实现原理
实现原理我们通过六个单元模块来剖析:
- 消息存储模型:关注数据模型与存储工具选型
- 消息消费模式:关注高可用性与性能
- 消息实时通信:关注连接管理与框架选型
- 微服务设计:关注业务代码划分
- 离线消息方案:关注推送方式与实现方案
- 总结:总结方案的各项指标
P1 消息存储模型
读扩散和写扩散
我们举个例子说明什么是读扩散,什么是写扩散:
一个群聊“相亲相爱一家人”,成员:爸爸、妈妈、哥哥、姐姐和我(共 5 人);
因为你最近交到女朋友了,所以发了一条消息“我脱单了”到群里面,那么自然希望爸爸妈妈哥哥姐姐四个亲人都能收到了。
优化前的群聊消息发送的流程如下:
- 1)遍历群聊的成员并发送消息;
- 2)查询每个成员的在线状态;
- 3)成员不在线的存储离线;
- 4)成员在线的实时推送。
数据模型如下:
难点在于:如果第四步发生异常,群友会丢失消息,那么会导致有家人不知道“你脱单了”,造成催婚的严重后果。所以优化的方案是:不管群员是否在线,都要先存储消息。
一次优化后的发送群消息的流程优化如下:(写扩散)
- 1)遍历群聊的成员并发送消息;
- 2)群聊所有人都存一份;
- 3)查询每个成员的在线状态;
- 4)在线的实时推送。
数据模型如下:
难点在于:每个人都存一份相同的“你脱单了”的消息,对磁盘和带宽造成了很大的浪费(这就是写扩散)。所以优化的方案是:群消息实体存储一份,用户只存消息 ID 索引。
二次优化后的发送群消息的流程优化如下:(读扩散)
- 1)遍历群聊的成员并发送消息;
- 2)先存一份消息实体;
- 3)然后群聊所有人都存一份消息实体的 ID 引用;
- 4)查询每个成员的在线状态;
- 5)在线的实时推送。
数据模型如下:
特点
- 读扩散:读取操作很重,写入操作很轻;资源消耗相对小一些
- 写扩散:读取操作很轻,写入操作很重;资源消耗相对大一些
从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),详细的方案可以在微信团队分享的这篇文章里找到答案:《微信后台团队:微信后台异步消息队列的优化升级实践分享》。
消息模型
我们将消息业务需求抽象出六个消息模型点:用户/联系人关系/用户设备/用户连接状态/消息/消息队列;
- 用户
- 用户->用户终端设备:每个用户能够多端登录并收发消息;
- 用户->消息:考虑到读扩散,每个用户与消息的关系都是 1:n;
- 用户->消息队列:考虑到读扩散,每个用户都会维护自己的一份“消息列表”(1:1),如果考虑到扩容,甚至可以开辟一份消息溢出列表接收超出“消息列表”容量的消息数据(此时是 1:n);
- 用户->用户连接状态:考虑到用户能够多端登录,那么 app/web 都会有对应的在线状态信息(1:n);
- 用户->联系人关系:考虑到用户最终以某种业务联系到一起,组成多份联系人关系,最终形成私聊或者群聊(1:n);
- 联系人关系
- 业务决定用户与用户之间的关系:比如说,某个家庭下有多少人,这个家庭群聊就有多少人;在 ToB 场景,在钉钉企业版里,我们往往有企业群聊这个存在;
- 消息
- 消息->消息队列:考虑到读扩散,消息最终归属于一个或多个消息队列里,因此群聊场景它会分布在不同的消息队列里;
- 消息队列
- 消息队列:确切说是消息引用队列,它里面的索引元素最终指向具体的消息实体对象
- 用户连接状态
- 用户连接状态:
- 对于 app 端:网络原因导致断线,或者用户手动 kill 掉应用进程,都属于离线
- 对于 web 端:网络原因导致浏览器断网,或者用户手动关闭标签页,都属于离线
- 对于公众号:无法分别离线在线
- 对于小程序:无法分别离线在线
- 用户终端设备
- 终端设备:客户端一般是 Android&IOS,web 端一般是浏览器,还有其他灵活的 WebView(公众号/小程序)
消息存储
我们对于消息存储方案其实有两种方案,下面分别解析这两个方案的优点与弊端:
- 方案一:考虑性能,数据全部放到 redis 进行存储
- 方案二:考虑资源,数据用 redis mysql 进行存储
方案一:redis
- 前提
- 用户 &联系人关系,由于是业务数据,因此统一默认使用关系型数据库存储
- 流程图
- (1)用户发消息
- (2)redis 创建一条实体数据 &一个实体数据计时器
- (3)redis 在 B 用户的用户队列 添加实体数据引用
- (4)B 用户拉取消息(后续 5.2 会提及拉模式)
- 解决方案
- 用户队列,zset(score 确保有序性)
- 消息实体列表,hash(msg_id 确保唯一性)
- 消息实体计数器,hash(支持群聊消息的引用次数,倒计时到零时则删除实体列表的对应消息,以节省资源)
- 优点
- 1、内存操作,响应性能好
- 弊端
- 1、内存消耗巨大,eg,阿里云 20G 内存,百万业务量下,每 2~3 个月就消耗了 50%资源,需要手动清理数据
- 2、受 redis 容灾性策略影响较大,如果 redis 宕机,直接导致数据丢失(可以使用 redis 的集群部署/哨兵机制/主从复制等手段解决)
方案二:redis mysql
- 前提
- 用户 &联系人关系,由于是业务数据,因此统一默认使用关系型数据库存储
- 流程图
- (1)用户发消息
- (2)mysql 创建一条实体数据
- (3)redis 在 B 用户的用户队列 添加实体数据引用
- (4)B 用户拉取消息(5.2 会提及拉模式)
- 解决方案
- 用户队列,zset(score 确保有序性)
- 消息实体列表,转移到 mysql(表主键 id 确保唯一性)
- 消息实体计数器,hash(删除这个概念,因为磁盘可用总资源远远高于内存总资源,哪怕一直存放 mysql 数据库,在业务量百万级别时也不会有大问题,如果是巨大体量业务就需要考虑分表分库处理检索数据的性能了)
- 优点
- 1、抽离了数据量最大的消息实体,大大节省了内存资源
- 2、磁盘资源易于拓展 ,便宜实用
- 弊端
- 1、磁盘读取操作,响应性能较差(从产品设计的角度出发,你维护的这套 IM 系统究竟是强 IM 还是弱 IM)
P2 消息消费模式
拉模式
选用消息拉模式的原因
- (1)由于用户数量太多(观察者),服务器无法一一监控客户端的状态,因此消息模块的数据交互使用拉模式,可以节约服务器资源;
- (2)当用户有未读消息时,由客户器主动发起请求的方式,可以及时刷新客户端状态。
ack 机制
- 基于拉模式实现的数据拉取请求(第一次 fetch 接口)与数据拉取确认请求(第二次 fetch 接口)是成对出现的;
- 客户端二次调用 fetch 接口,需要将上次消息消费的锚点告诉服务端,服务器进而删除已读消息。
- 基于每一条消息编号 ACK
实现:客户端在接收到消息之后,发送 ACK 消息编号给服务端,告知已经收到该消息。服务端在收到 ACK 消息编号的时候,标记该消息已经发送成功;
弊端:这种方案,因为客户端逐条 ACK 消息编号,所以会导致客户端和服务端交互次数过多。当然,客户端可以异步批量 ACK 多条消息,从而减少次数。
- 基于滑动窗口 ACK
实现:
(1)客户端在接收到消息编号之后,和本地的消息编号进行比对。
- 如果比本地的小,说明该消息已经收到,忽略不处理;
- 如果比本地的大,使用本地的消息编号,向服务端拉取大于本地的消息编号的消息列表,即增量消息列表。
- 拉取完成后,更新消息列表中最大的消息编号为新的本地的消息编号;
(2)服务端在收到 ack 消息时,进行批量标记已读或者删除
好处:这种方式,在业务被称为推拉结合的方案,在分布式消息队列、配置中心、注册中心实现实时的数据同步,经常被采用。
ack机制的必要性
- 第一次获取消息完成之后,如果没有 ack 机制,流程是:
(1)服务器删除已读消息数据
(2)服务端把数据包响应给客户端
(3)如果由于网络延迟,导致客户端长时间取不到数据,这时客户端会断开该次 HTTP 请求,进而忽略这次响应数据的处理,最终导致消息数据被删除而后续无法恢复。
- 有了 ack 机制,哪怕第一次获取消息失败,客户端还是可以继续请求消息数据,因为在 ack 确认之前,消息数据都不会删除掉。
流程图
P3 消息实时通信
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 依赖
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
- 数据通信协议 STOMP
STOMP 协议与 HTTP 协议很相似,它基于 TCP 协议,使用了以下命令:
- CONNECT
- SEND
- SUBSCRIBE
- UNSUBSCRIBE
- BEGIN
- COMMIT
- ABORT
- ACK
- NACK
- DISCONNECT
STOMP 的客户端和服务器之间的通信是通过“帧”(Frame)实现的,每个帧由多“行”(Line)组成:通过 MESSAGE 帧、RECEIPT 帧或 ERROR 帧实现,它们的格式相似。
代码语言:javascript复制第一行包含了命令,然后紧跟键值对形式的 Header 内容。
第二行必须是空行。
第三行开始就是 Body 内容,末尾都以空字符结尾。
长连接机制
连接建立
- nginx 配置:设置 http 可以升级为 websocket 协议;
- http 三次握手:客户端 &服务端双方确保发送和接受能力正常;
- 升级 websocket:客户端以登录令牌“token”标识用户连接;
- 服务端内存将“token”与长连接会话“Session”缓存到一个 ConcurrentHashMap,这样便能以 O(n)的效率检索到指定用户的长连接并发送通知包;
双工通信协议
- 客户端保活机制:客户端发送“ping”包,服务端接受到,返回“pong”包,这是最基础的保活手段;(保活机制放在客户端,减轻服务端压力,同时节省服务端资源)
- 新消息通知协议:前后端约定使用固定的通知协议做为通知信号(eg,“msg.route.new”),确保数据量小,宽带消耗低;
服务端剔除无效连接
- 使用定时调度任务:轮训缓存好的 ConcurrentHashMap,检索每个长连接会话是否超时,超时则关闭以节省资源;
P4 微服务设计
微服务划分
微服务主要拆分为三个:用户 &消息业务 &消息连接管理。
- 参考架构图
- IM 消息系统包括了三个微服务:用户微服务、消息连接管理微服务和消息业务微服务,他们分工合作如下:
- 用户微服务
- (1)用户设备的登录 &登出:设备号存库,连接状态更新,其他登录端用户踢出等;
- 消息连接管理微服务
- (1)状态保存:保存用户设备长连接对象
- (2)剔除无效连接:轮训已有长连接对象状态,超时删除对象
- (3)接受客户端的心跳包:刷新长连接对象的状态
- 消息业务微服务
- (1)消息存储:参考 5.1-消息存储模型,进行私聊/群聊的消息存储策略
- (2)消息消费:参考 5.2-消息消费模式,进行消息获取响应与 ack 确认删除
- (3)消息路由:用户在线时,路由消息通知包到“消息连接管理微服务”,以通知用户客户端来取消息;
消息路由
相信看完“ 5.4.1 微服务划分”,了解到微服务之间也有通信手段:消息业务服 -> 消息连接管理服,两者之间可以通过 websocket 实现主动或被动的双工通信,以支持实时消息的路由通知。
P5 离线消息方案
离线推送方案上,我们考虑了两种方案:
1)自研后台离线 PUSH 系统
2)对接第三方手机厂商 PUSH 系统
自研后台离线 PUSH 系统
原理
在应用级别,客户端与后台离线 PUSH 系统保持长连接,当用户状态被检测为离线时,通过这个长连接告知客户端“有新消息”,进而唤醒手机弹窗标题。
弊端
- 随着安卓和苹果系统的限制越来越严格,一般客户端的活动周期被限制的死死的,一旦客户端进程被挪到后台就立马被 kill 掉了,导致客户端保活特别难做好。
第三方厂商 PUSH 系统
原理
- 在系统级别,每个硬件系统都会与对应的手机厂商保持长连接,当用户状态被检测为离线时,后台将推送报文通过 HTTP 请求,告知第三方手机厂商服务器,进而通过系统唤醒 app 的弹窗标题。
弊端
- (1)作为应用端,消息是否确切送达给用户侧,是未知的;推送的稳定性也取决于第三方手机厂商的服务稳定性;
- (2)额外进行 sdk 的对接工作,增加了工作量;
- (3)第三方厂商随时可能升级 sdk 版本,导致没有升级 sdk 的服务器出现推送失败的情况,给 Sass 系统部署带来困难;
- (4)推送证书配置也要考虑到维护成本
推送厂商分类
- ios 推送
- android 推送(华为/小米/OPPO/魅族/个推等)
P6 总结
1. 安全性
- 传输安全性使用 https 访问;使用私有协议,不容易解析;
- 内容安全性端到端加密,中间任何环节都不能解密;即发送和接收端交换互相的密钥来解密,服务器端解密不了;服务器端不存储消息;
2. 一致性
- 消息一致性:保证消息不乱序;
- 消息唯一 id:有多种方式,如由统一的 MySQL/Redis 统一生成、或由 snowflake 算法生成等,此时若要支持高并发,则要考虑该生成器对高并发的支持情况;
3. 可靠性
- 上述方案用到了 ack 机制,同时消息创建过程尽量确保操作原子性,并且封装为一个事务(虽然分开 mysql&redis 存储让分布式事务变得较高难度)。
4. 实时性
- 通用方案都是采用 websocket,但是某些低版本的浏览器可能不支持 websocket,所以实际开发时,要兼容前端所能提供的能力进行方案设计。
实现方案
工作日常
在前公司的工作中,有两年多的时间都在维护迭代公司的 IM 消息系统:
- 业务闭环(消息是如何写入存储,消息是如何消费掉,在线消息是如何实现,离线消息是如何实现,群聊/私聊有何不一样,多端消息如何实现)
- 解 Bug 填坑(在线消息收不到,第三方推送证书如何配置)
- 代码优化(单体架构拆分微服务)
- 存储优化(1.0 版本的 redis 存储到 2.0 版本的 redis mysql)
- 性能优化(业务数据未读提醒的接口性能优化)
可优化点
- 用户量巨大的系统的高可用方案之一,是部署多部连接管理服务器,以支撑更多的用户连接
- 用户量巨大的系统的高可用方案之二,是对单部连接管理服务,使用 Netty 进行框架层优化,让一个服务器支撑更多的用户连接
- 消息量巨大的系统,可以考虑对消息存储进行优化
- 不同的地区会存在业务量差异,比如在某些经济发达的省份,IM 系统面临的压力会比较大,一些欠发达省份,服务压力会低一点,所以这块可以考虑数据的冷热部署
对比方案
跟大厂的IM系统设计方案的比较,在“分层架构”、“群聊技术方案”、“离线方案”这三个方面,我们选取了:网易云IM架构、网易云信云视频系统和微信聊天系统进行比较。
由于篇幅所限,这部分内容可以参考原文的第七节:https://xie.infoq.cn/article/4061081a5ce66137a8c021994。
成果展示
由于篇幅所限,这部分内容可以参考原文的第八节:https://xie.infoq.cn/article/4061081a5ce66137a8c021994。
参考文献
- 《网易IM云千万级并发消息处理能力的架构设计与实践》
- 《从新手到专家:如何设计一套亿级消息量的分布式IM系统》
- 《一套亿级用户的IM架构技术干货(上篇):整体架构、服务拆分》
- 《一套亿级用户的IM架构技术干货(下篇):可靠性、有序性、弱网优化等》
- 《IM群聊消息的已读回执功能该怎么实现?》
- 《IM群聊消息究竟是存1份(即扩散读)还是存多份(即扩散写)?》
总结
两年前从架构师手上接过来的 IM 消息系统模块,让我逐步培养了架构思维,见贤思齐,感谢恩师。
多说一句,在日常开发里,我们同学们也要学会参考业界的解决方案,思考如何维护整套系统的高可用,思考如何解决大流量背景下的存储优化等关键问题。
以上抛砖引玉,欢迎留言讨论,一起进步~~