写点什么

《基于实践,设计一个百万级别的高可用 & 高可靠的 IM 消息系统》

发布于: 2021 年 04 月 06 日
《基于实践,设计一个百万级别的高可用&高可靠的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. 用户

  2. 用户->用户终端设备:每个用户能够多端登录并收发消息;

  3. 用户->消息:考虑到读扩散,每个用户与消息的关系都是 1:n;

  4. 用户->消息队列:考虑到读扩散,每个用户都会维护自己的一份“消息列表”(1:1),如果考虑到扩容,甚至可以开辟一份消息溢出列表接收超出“消息列表”容量的消息数据(此时是 1:n);

  5. 用户->用户连接状态:考虑到用户能够多端登录,那么 app/web 都会有对应的在线状态信息(1:n);

  6. 用户->联系人关系:考虑到用户最终以某种业务联系到一起,组成多份联系人关系,最终形成私聊或者群聊(1:n);


  1. 联系人关系

  2. 业务决定用户与用户之间的关系:比如说,某个家庭下有多少人,这个家庭群聊就有多少人;在 ToB 场景,在钉钉企业版里,我们往往有企业群聊这个存在;


  1. 消息

  2. 消息->消息队列:考虑到读扩散,消息最终归属于一个或多个消息队列里,因此群聊场景它会分布在不同的消息队列里;


  1. 消息队列

  2. 消息队列:确切说是消息引用队列,它里面的索引元素最终指向具体的消息实体对象


  1. 用户连接状态

  2. 用户连接状态:

- 对于 app 端:网络原因导致断线,或者用户手动 kill 掉应用进程,都属于离线

- 对于 web 端:网络原因导致浏览器断网,或者用户手动关闭标签页,都属于离线

- 对于公众号:无法分别离线在线

- 对于小程序:无法分别离线在线


  1. 用户终端设备

  2. 终端设备:客户端一般是 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,阿里云 20G 内存,百万业务量下,每 2~3 个月就消耗了 50%资源,需要手动清理数据

  • 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 依赖


        <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 帧实现,它们的格式相似。

  1. 第一行包含了命令,然后紧跟键值对形式的 Header 内容。

  2. 第二行必须是空行。

  3. 第三行开始就是 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 消息系统包括了三个微服务:用户微服务、消息连接管理微服务和消息业务微服务,他们分工合作如下:

  • 用户微服务

  • (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 掉了,导致客户端保活特别难做好。

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 消息系统:

  1. 业务闭环(消息是如何写入存储,消息是如何消费掉,在线消息是如何实现,离线消息是如何实现,群聊/私聊有何不一样,多端消息如何实现)

  2. 解 Bug 填坑(在线消息收不到,第三方推送证书如何配置)

  3. 代码优化(单体架构拆分微服务)

  4. 存储优化(1.0 版本的 redis 存储到 2.0 版本的 redis+mysql)

  5. 性能优化(业务数据未读提醒的接口性能优化)

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、小程序、公众号,终端应用类型大差不差;


参考:《网易IM云千万级并发消息处理能力的架构设计与实践

7.2 网易云:群聊技术方案对比


市面上主流的 IM 产品中,微信群是 500 人上限,QQ 群是 3000 人上限(3000 人群是按年付费升级,很贵,不是为一般用户准备的)。

一方面,从产品的定义上群成员数量不应过多,另一方面,技术成本也是个不可回避的因素。


  • 本文采用的群聊消息解决方案是读扩散,是优先考虑资源消耗的,毕竟 Sass 服务的客户们,都不愿意花太多钱在巨大的内存资源拓展上;

  • 腾讯:从公开的技术资料来看,微信的群聊消息应该使用的是存多份(即扩散写方式),至于原因我不得而知,应该有性能和资源的考量在里头

  • 网易:万人群技术方案采用了“聚合+分层/组+增量”的设计思路(了解了解就好,具体的算法和源码我们是不知道的,参考这个思路就好)



参考:《网易云信技术分享:IM中的万人群聊技术方案实践总结

微信后台团队:微信后台异步消息队列的优化升级实践分享

7.3 微信:离线消息方案对比

  • (1)我这边业务一般是使用线程池来完成推送任务的处理,通过第三方 sdk 的引用,完成推送消息能力对接;所以消息推送效率和效果有以下两个关键点

  • 第一个瓶颈是:推送任务执行速度取决于线程池内部的任务列表容量(一般是阻塞队列,拒绝策略是超出容量则阻塞提交);

  • 第二个瓶颈是:离线推送使用的是第三方 SDK 接入方式,即是说通过接口对接请求第三方手机厂商的服务器,此时往往有接口调用频率的限流和次数限制;

  • (2)微信/QQ 等作为应用市场的一级 app,在 app 保活机制上,自然做的非常好,包括系统权限也可能愿意开后门,至于具体优化方法和策略可以参考下面的文章


参考:《微信后台团队:微信后台异步消息队列的优化升级实践分享

八、成果展示


现网用户量超过 20 万,QPS=100 左右,消息总量在百万级别,一般来讲够用了。

  • 如果说你想自己弄一个系统来自己玩,这不失为一个模仿方案,因为百万级别的业务量已经检验过这个方案。

  • 如果说你在业务体量更大的企业供职(一般都有自己的一套高性能实现方案),这个就权当一个参考方案,自取所需。

九、参考文献


十、其他


两年前从架构师手上接过来的 IM 消息系统模块,让我逐步培养了架构思维,见贤思齐,感谢恩师。

多说一句,在日常开发里,我们同学们也要学会参考业界的解决方案,思考如何维护整套系统的高可用,思考如何解决大流量背景下的存储优化等关键问题。

以上抛砖引玉,欢迎留言讨论,一起进步~~



  • 笔者是腾讯的一名普通后台搬砖工,喜欢技术交流与分享,保持饥渴,一起进步!

  • 公众号“后台技术汇”:原创 Java 后台开发技术栈的知识分享,分享程序猿专属干货与福利,希望对各位有所帮助。



发布于: 2021 年 04 月 06 日阅读数: 511
用户头像

Diligence is the mother of success. 2018.03.28 加入

公众号:后台技术汇 笔者是鹅厂的一名普通搬砖工,主要从事Java后台开发,喜欢技术交流与分享,保持饥渴,一起进步!

评论 (10 条评论)

发布
用户头像
收藏比点赞多系列,希望朋友可以顺手点个赞,蟹蟹~~
2021 年 04 月 10 日 18:34
回复
用户头像
收到反馈,完善文章内容:
5.2.2 ack 机制(增加“ack实现方案”内容)
ack实现方案: 基于每一条消息编号 、基于滑动窗口
2021 年 04 月 07 日 12:23
回复
用户头像
笔者文中举的例子很有趣,那么问题来了,笔者几时脱单😅
2021 年 04 月 07 日 10:45
回复
同学,这个是很好的超纲问题..
2021 年 04 月 07 日 10:55
回复
用户头像
最核心的东西长链接没有讲🤔
2021 年 04 月 06 日 22:30
回复
感谢指出遗漏的地方,一个IM系统涉及到的点太多了,我梳理文章的时候还是漏掉了一点内容,现在已经补全了长连接的部分,参考下面的标题内容(抛砖引玉..)
5.3.2 长连接机制
5.3.2.1 连接建立
5.3.2.2 双工通信协议
5.3.2.3 服务端剔除无效连接
2021 年 04 月 06 日 23:01
回复
用户头像
笔者文中举的例子很有趣,那么问题来了,笔者几时脱单😅
2021 年 04 月 06 日 12:05
回复
老铁,你的这个问题超纲了..
2021 年 04 月 06 日 15:41
回复
用户头像
🤔 🤔 🤔 若有所思
2021 年 04 月 06 日 12:01
回复
哪里有迷惑,可以指出哈,知无不答。
2021 年 04 月 06 日 20:15
回复
没有更多了
《基于实践,设计一个百万级别的高可用&高可靠的IM消息系统》