IM 服务
请使用电脑访问
https://myblog-ad4.pages.dev第13章 IM 服务
IM(Instant Messaging,即时通信)是一种实时通信方式,可以让用户通过互联网快速、安全、低成本地交换即时信息。
本章围绕 IM 服务作为用户即时通信后台的功能和技术方案展开。
本章学习路径:
13.1 IM 的意义与核心能力 |
13.1 IM 的意义与核心能力
通俗地说,IM 就是聊天,很多互联网应用都支持应用内聊天。
不同类型的应用都可能需要聊天能力:
电商类应用:买家咨询商品、物流、售后,好友之间分享商品、促销、优惠券 |
聊天功能是信息传递最快的方式之一。它能帮助互联网应用中的不同角色建立更紧密的联系,维护社交关系,也能增强用户对产品的忠诚度和黏性。
由于产品定位不同,各应用对聊天功能的要求也不同。纯粹的即时通信软件通常以聊天功能为核心卖点,所以能力更完整。
IM 的核心能力
即时通信软件通常具备以下能力:
实时投递:在线时,用户可以实时收到别人发来的消息 |
即使某些互联网应用不是通信软件,也可能只需要其中部分能力。但这些能力基本覆盖了聊天功能的核心。
13.2 IM 相关概念
在设计 IM 服务之前,需要先明确几个基础概念。
- 消息:用户发出的任何内容。
- 用户状态:在线、离线、挂起。
- 设备/终端:用户使用 IM 的客户端,通常包括移动端和 Web 端。
- 单聊:两个用户一对一聊天的模式。
- 群聊:多个用户聊天的模式。
- 会话:描述用户通过聊天建立的关联关系。通俗地说,微信中的每个聊天框都是一个会话,比如你和一个好友聊天,你们就建立了会话;你被拉到某个群里聊天,你就和这个群建立了会话。会话是 IM 服务的核心概念,它真正指明了消息应该被发送给哪些用户。
- 信箱:对于用户来说,每个会话都有一个抽象的信箱,作为会话中每条消息按照时间顺序由远及近排列的存储容器。
13.3 消息投递
IM 服务最重要的议题是消息投递。合格的消息投递应该具备以下特点:
准确性:消息只能被目标用户收到,不能串线(被其他用户收到) |
其中,准确性、不可丢失和实时性是本节重点;有序性会在 13.5 节继续展开。
13.3.1 存储消息:读扩散与写扩散
消息不可丢失意味着必须保存消息。
为了保证准确性,消息还要保存到其所属会话的信箱中。信箱主要有两种实现模式:
读扩散模式 |
1. 读扩散模式
读扩散模式下,每个会话占用一个信箱,与会话相关的用户共同读写这个信箱。

如上图所示,用户 u1 与用户 u2、用户 u3,以及群组 g1 的会话都各自对应一个信箱。
单聊场景:
u1 给 u2 发消息 → 写入 u1-u2 会话信箱 |
群聊场景:
g1 群组中的所有成员 |
读扩散的名字来自读取消息时的行为:
用户拉取新消息 |
读扩散模式的优点:
发送消息逻辑轻量,只需要写入会话信箱(单聊和群聊都是如此) |
读扩散模式的缺点:
读取消息存在明显读放大 |
2. 写扩散模式
写扩散模式下,会话信箱不再被所有相关用户全局共享,而是每个用户都拥有自己的会话信箱列表。

如上图所示,用户 u1、u2、u3 之间的单聊会话信箱,会在每个用户自己的会话信箱列表中冗余保存一份;群组 g1 的会话信息也会保存在群组内每个成员的信箱列表中。
单聊场景:
u1 给 u2 发消息 |
也就是说,发送消息的写请求被放大为原来的 2 倍。
群聊场景:
u1 给有 N 个成员的 g1 群发消息 |
也就是说,发送消息的写请求被放大为原来的 N 倍。
写扩散模式的缺点:
发送消息存在写放大 |
写扩散模式的优点:
用户接收消息非常简单 |
3. 读扩散和写扩散如何选型
选型的关键在于:群聊在业务中的比重,以及是否支持大群。
完全面向单聊: |
对于即时通信软件中的群聊,可以按阈值 N 做判断:
群成员人数 < N:采用写扩散 |
N 的取值通常来自 IM 服务压测结果:当群成员人数超过某个阈值后,发送消息出现明显性能劣化,就可以把这个阈值作为 N。
13.3.2 接收消息:拉模式与推模式
把消息存到信箱中,只能保证消息准确且不丢失,但消息还没有真正触达用户设备。
用户设备获取消息主要有两种方式:
拉模式 |
1. 拉模式
拉模式是用户设备周期性地主动向 IM 服务后台发起获取消息请求,由服务端返回所有相关会话的未读消息。
拉模式的实现方式较为简单,但是用户设备多久拉取一次消息并不好确定:为了实时投递消息,用户设备可以 1s 拉取一次消息,但是这会给 IM 服务后台带来巨大的访问压力,造成大量计算资源的浪费;如果 30s 拉取一次消息,那么消息的实时性表现会很差。
2. 推模式
推模式与拉模式相反。
用户每发送一条消息,IM 服务后台一方面把消息存储到会话信箱中,另一方面顺便把消息直接推送到目标用户设备上。
如下图所示:

u1 给 u2 发消息 |
推模式的优点:
借助长连接,消息实时触达能力强 |
推模式的缺点:
长连接容易受网络中断、网络抖动影响 |
3. 推拉结合模式
推模式和拉模式可以互补,所以业界绝大多数聊天场景采用推拉结合模式。
一种实现方式是直接推送消息:
发送者发送消息 |
推拉结合模式的消息推送架构如下图所示。

另一种实现方式是只推送通知:
服务端只推送“有一条新消息到来”的通知 |
如图下所示,这种方式不会直接推送消息正文,可以在客户端真正需要读取时再传输消息内容,从而节约一定网络带宽。

本章为了简化问题,后续默认讨论“直接推送消息”的推拉结合模式。
本节总结
消息投递要同时考虑准确性、不可丢失、实时性和有序性。
消息存储主要有两种模式:
读扩散:写入轻,读取重,适合会话数较少或大群场景 |
消息接收主要有三种方式:
拉模式:不易丢,但实时性一般 |
13.4 存储初探
一个同时支持读扩散和写扩散的完整 IM 服务,需要存储多类数据。
以下介绍的是明显需要存储的数据。但是目前每条数据的属性不一定是完整的,而且为了支持完整的IM服务功能,可能还需要其他数据。这些细节我们将在后续章节中讲解,并在13.8节中介绍最终的存储设计。
1. 消息表
用户发送的消息应该被存储到消息表中。
消息表的主要属性包括:
消息 ID:全局唯一标识一条消息 |
2. 会话表
用户聊天的渠道是会话,所以会话也需要保存,尤其是群聊会话。
会话表的主要属性包括:
会话 ID:全局唯一标识一个会话 |
3. 会话消息链
为了支持读扩散模式,对于每个会话都应该存储此会话与其产生的消息的关联关系。
读扩散时,用户读消息要去各个会话的“公共信箱”里读。所以服务端必须为每个会话维护一个消息列表来记录 “这个会话有哪些消息,以及这些消息的顺序是什么”。这个列表就是会话消息链。
会话 ID:指代一个会话 |
4. 用户消息链
为了支持写扩散模式,每个用户也都应该存储此用户与其需要接收的消息的关联关系,即“用户消息链”。
写扩散时,消息不是只写到一个公共会话信箱里,而是提前写到每个接收用户自己的“私人收件箱”里。所以服务端必须为每个用户维护一个消息列表来记录”这个用户应该收到哪些消息,这些消息分别来自哪个会话,以及这些消息在这个用户收件箱里的顺序是什么”。这个列表就是用户消息链。
用户消息链中的数据至少包含:
用户 ID:指代一个用户 |
5. 用户会话链
每个会话需要知道聊天用户有哪些;每个用户也需要有自己针对每个关联会话的设置。因此还需要保存会话与用户的关联关系,即“用户会话链”。
IM 系统既要知道某个会话里有哪些用户;也要知道””某个用户加入了哪些会话,并且这个用户对每个会话有什么设置””。所以服务端要维护一张“用户和会话的关系表”。这张关系表就是用户会话链。
用户会话链中的数据至少包含:
会话 ID:指代一个会话 |
13.5 消息的有序性保证
即时通信类似现实生活中的聊天,消息顺序非常重要。
如果消息顺序错乱,聊天内容就可能不符合逻辑,直接影响用户体验。
本节讨论两个问题:
哪些环节可能造成消息乱序 |
13.5.1 消息乱序
消息乱序可能出现在多个环节。
1. 客户端发送消息时乱序
如果客户端和服务端之间采用短连接,客户端每发送一条消息都建立一次连接,那么受公网环境影响,消息到达服务端的顺序可能和发送顺序不同。
例如用户按顺序发送:
A → B → C |
最终到达服务端的顺序可能变成:
C → B → A |
从发送者角度看,消息发生了乱序。
2. 服务端存储消息时乱序
即使消息 A、B、C 按顺序到达服务端,也可能由不同 IM 服务实例处理。
不同服务实例的处理延迟、访问数据库的网络延迟不同,最终消息可能按照 C、B、A 的顺序被存储。
这样接收者拉取消息时,也会看到乱序。
3. 服务端推送消息时乱序
即使消息已经按顺序存储,推送环节也可能因为网络问题丢失消息。
例如:
服务端依次推送 A、B、C |
如果客户端直接按到达顺序展示,就会看到 A、C、B,仍然乱序。
13.5.2 客户端发送消息
从消息发送者视角看,同一个会话中连续发送的消息,应该有序投递到服务端。
1. 每个会话建立独占长连接
客户端可以按照会话与服务端建立长连接。
一个会话 |
这样可以保证发送者在某个会话中发送的消息,有序到达后台机房网关。
2. 网关按会话 ID 固定路由到 IM 服务实例
为了保证从机房网关到 IM 服务内部的顺序,机房网关应该把同一个会话的消息请求发送到固定的 IM 服务实例上。
可以采用如下负载均衡策略:
hash(会话 ID) → 固定 IM 服务实例 |
这样,同一个会话的消息请求都会进入同一个 IM 服务实例,避免在服务实例之间乱序。
13.5.3 服务端存储消息
IM 服务与存储系统交互时,也要保证同一个会话的消息被有序写入会话消息链。
这意味着:
同一个会话的消息,必须由单个线程串行存储 |
比较简单的实现方式是使用消息队列,例如 Kafka。
1. 使用会话 ID 作为消息 Key 保证会话消息链有序
IM 服务收到新消息后,把消息投递到 Kafka,并以会话 ID 作为消息 Key。
同一个会话 ID |
因此,IM 服务可以拆分为两个子服务:
IM API: |
2. 使用用户 ID 保证用户消息链有序
会话消息服务确定消息要投递给哪些用户后,还要保证同一个目标用户的用户消息链中的消息也是有序的。
因此再引入一个按用户 ID 分区的消息队列。
会话消息服务 |
conversation_im_topic 和 user_im_topic 主题分别将会话ID和目标用户ID作为消息队列存储消息的Key,可以保证用户消息在会话消息链和用户消息链中的有序性。
至此,IM 服务被拆分为 3 个子服务,如下图所示:
IM API:接收消息,生成基础信息,按会话 ID 投递消息队列 |

13.5.4 服务端推送消息与客户端补偿
推送消息到客户端时,仍可能因为网络问题造成消息丢失,进而导致客户端收到乱序消息。
比如小王在线教不熟悉互联网的父亲老王怎么使用微信分享好友名片,小王顺序发送了 3条 消息。
消息 1:打开聊天框,点击加号打开功能面板 |
IM服务依次推送这3条消息,但是在推送过程中消息2丢失了,导致老王的客户端只收到消息1和消息3。由于客户端采用推拉结合模式获取消息,客户端之后在拉取消息 时收到了消息2,最后老王收到的消息顺序是消息1、消息3、消息2。
这样的上下文会让接收者无法正确理解操作步骤。
推拉结合模式的消息推送与消息拉取没有统一的消息排序规则,这是造成消息触达客户端时可能发生乱序的根本原因。
如果消息存储采用读扩散模式,那么消息推送和消息拉取都会基于会话消息链;如果消息存储采用写扩散模式,那么消息推送和消息拉取都会基于用户消息链。
我们在会话消息链和用户消息链的数据表中分别增加一个 Seq 属性来表示消息在链中的顺序,每当有一条新消息被插入会话消息链和用户消息链中时,都要保证新消息的 Seq 值一定比上一条消息的 Seq 值大,即 Seq 值要满足严格递增的特性。这样一来,一条消息的 Seq 就可以表示其在对应消息链中的准确顺序。
那么,如何保证 Seq 值严格递增呢?这件事情由将新消息写入会话消息链的会话消息服务和将新消息写入用户消息链的用户消息服务来保证。
- 对于会话消息链来说,会话消息服务在本地内存中为每个会话 ID 都保存最近一条消息的 Seq(初始值为 0)。每当收到一条新消息时,就将会话 ID 对应的 Seq 值加 1 并作为此消息的 Seq 值插入会话消息链中,这样可以保证按照消息的顺序递增 Seq 值。但是,如果会话消息服务有服务发布、扩缩容等导致服务实例重启的变更行为发生,那么本地内存中的 Seq 值会丢失。所以,我们要设计一个兜底策略:如果某会话 ID 对应的本地内存中的 Seq 值为 0,则先从会话消息链中获取最后一条消息的 Seq 值,作为本地内存中的 Seq 值。
- 用户消息链与会话消息链的原理非常相似,只不过在本地内存中换成了为每个用户 ID 保存最近一条消息的 Seq 值。
如此一来,将消息插入会话消息链和用户消息链中的流程改进如下图所示。

从图中可以看到,小王给老王发送的 3 条消息在两者的会话消息链中依次被赋予了 100、101、102 的 Seq 值,而在老王的用户消息链中,这 3 条消息依次被赋予了 10000、10001、10002 的 Seq 值。最后,服务端在处理消息触达目标用户客户端的逻辑时:
- 在读扩散模式下,在推送消息时,推送的数据除了消息本身,还包括消息在会话消息链中的 Seq 值;
- 在读扩散模式下,在拉取消息时,既拉取了消息本身,又拉取了消息在会话消息链中的 Seq 值;
- 在写扩散模式下,在推送消息时,推送的数据除了消息本身,还包括消息在用户消息链中的 Seq 值;
- 在写扩散模式下,在拉取消息时,既拉取了消息本身,又拉取了消息在用户消息链中的 Seq 值。
无论是读扩散模式还是写扩散模式,在拉取消息和推送消息时都会附带消息在对应消息链中的 Seq 值,而客户端在本地会以消息接收队列的形式记录已收到的消息 Seq 值,对应于服务端存储的会话消息链和用户消息链,如下图所示。

如果 IM 服务采用读扩散模式,那么客户端会为每个会话都维护一个消息接收队列;
如果 IM 服务采用写扩散模式,那么客户端只维护一个消息接收队列来表示用户消息链中已触达的消息。
无论采用哪种模式,消息接收队列最重要的都是要记录当前已经收到的 Seq 值连续递增、无空洞的最后一条消息的 Seq (local_seq),这样就可以很容易地发现推送的消息是否有乱序情况发生。假设此时客户端收到一条消息,此消息的 Seq 为 current_seq,那么:
- 如果 current_seq = local_seq + 1,则说明所收到的消息是连续的,客户端可以展示给用户;
- 如果 current_seq ≤ local_seq,则说明所收到的消息是重复的,客户端直接忽略即可;
- 如果 current_seq > local_seq + 1,则说明所收到的消息是乱序的,消息接收队列出现了 local_seq + 1 至 current_seq - 1 的消息空洞,所以客户端只暂存此消息,而不展示给用户。此时客户端主动发起补偿:拉取 Seq 为 local_seq+1 至 current_seq-1 的消息列表。
如上所述,local_seq 控制哪些消息可以被展示给用户。如果客户端收到的消息 Seq 与 local_seq 并不是连续递增的,则说明必有消息丢失,于是客户端主动拉取丢失的消息。由此可知,**客户端在实现推拉结合模式时,不仅要周期性地拉取消息,还要在有消息空洞时补偿拉取消息;**客户端在拉取到消息后,更新 local_seq 为最新的连续消息的最大 Seq 值。
下面还是以小王给老王连续发送消息的例子来说明客户端接收消息的流程。假设 IM 服务采用的是写扩散模式,也就是客户端基于用户消息链的消息接收队列来进行消息排序。
- 小王发送消息 1、消息 2、消息 3,在将这 3 条消息插入老王的用户消息链中时依次赋予其 Seq 值为 10000、10001、10002,服务端推送这 3 条消息给老王。
- 老王收到消息 1,客户端将 local_seq 更新为 10000。
- 消息 2 丢失,老王的客户端感知不到消息 2 的到来。
- 消息 3 到来,其 Seq 值为 10002,在老王的客户端此值不等于 local_seq+1,于是先不展示消息 3,而是从服务端拉取 Seq 值为 1002 的消息。
- 客户端获取到消息 2,最终 3 条消息连续,客户端展示消息 2 和消息 3,并更新 local_seq 为 10003。
最后,小王发送的 3 条消息被有序展示在老王的客户端。
本节总结
消息乱序可能发生在三个环节:
客户端发送:网络导致到达服务端乱序 |
对应解决思路:
客户端发送:每个会话使用长连接 |
13.6 会话管理与命令消息
目前我们只讨论了用户消息的收发问题,一条用户消息必然属于一个会话,会话本身也需要IM服务管理。
13.6.1 创建单聊会话
先从发送者视角看单聊会话的创建。
例如,用户 A 首次和用户 B 聊天:
用户 A 打开与用户 B 的聊天框 |
当用户 A 发出第一条消息时,客户端才请求服务端创建会话。
此时客户端会把以下内容传给服务端:
消息内容 |
服务端发现会话 ID 为 0,说明需要创建新会话。
于是需要依次创建如下数据:
1. 生成唯一会话 ID,在会话表中创建新会话 |
服务端将会话ID响应给客户端,表示会话创建成功、消息发送成功,但是此流程缺点明显,在一些场景中容易出现重复创建会话的情况。
重复创建会话的问题
场景一:消息发送失败后重发。
服务端存储消息时发生网络错误 |
场景二:双方同时发起聊天。
用户 A 打开与用户 B 的聊天框并发送消息 |
问题根源是:服务端没有简单办法判断两个用户之间是否已经存在单聊会话。
单聊会话 ID 的确定规则
解决方式是让会话 ID 直接反映单聊关系。
对任何单聊会话,都使用如下格式作为会话 ID:
{用户ID较小者}-{用户ID较大者} |
例如:
用户 A 的 ID = 222 |
这样,无论是用户 A 重发消息,还是用户 B 同时发送消息,IM 服务都会发现同一个单聊会话已经存在。
接收者视角的会话创建
接收者客户端收到消息后,会根据消息中携带的会话 ID 在本地查询是否已有该会话。
本地存在会话:直接展示消息 |
13.6.2 创建群聊会话
创建群聊会话时,发起者必须显式请求服务端,因此流程比单聊更简单。
例如,用户 A 选择向用户 B、用户 C、用户 D 发起群聊,则会直接向服务端发起创建群聊会话的请求,IM 服务只需要创建会话、设置会话与每个成员的关联关系即可。由于群聊不要求两个用户之间只能有一个群聊会话,所以不需要考虑用户B 同时向用户A、用户C、用户D发起群聊导致两个群聊会话重复的问题,谁发起了群聊就新建一个群聊会话。
接收者视角下,群聊会话的创建与单聊类似:客户端收到消息后,如果本地没有该会话,就根据会话 ID 拉取会话信息。
13.6.3 命令消息
在即时通信软件中,经常会出现一些并非普通聊天文本的事件。
例如:
好友发了一条消息,过一会儿被撤回 |
这些事件也依靠 IM 服务通知到客户端。
它们会被当作消息处理,但不是用户普通消息,而是命令消息。
命令消息用于:
控制消息展示 |
1. 消息表需要增加的字段
为了区分用户消息和命令消息,消息表中至少需要增加两个字段:
- msg_type:使用枚举类型,表示消息具体类型。如用户消息、撤回消息、邀请加群、移出群、退群、禁言等
- extra:使用字符串类型,命令消息所需要的额外参数被统一存储到这里,比如撤回消息事件需要指定被撤回的消息ID,邀请加群事件需要指定谁邀请谁,禁言事件需要指定谁禁言谁。
2. 群管理类命令消息
邀请加群、移出群、禁言这3种命令消息都需要指定事件发起者from_user和事件作用者to_user,即消息表中的extra字段需要存储如下参数:
{ |
客户端收到这些命令消息后,根据 msg_type 展示对应内容:
邀请加群:from_user 邀请 to_user 加入群聊 |
3. 退群命令消息
用户退群时,IM 服务产生的命令消息只需要指定退群者。
extra 字段可以保存:
{ |
客户端收到后,在群聊会话中展示:
user 退出了群聊 |
4. 撤回命令消息
用户选择撤回某条已发送的消息时,除修改消息表中的消息状态为“撤回”之外,IM 服务还应该产生一条指定消息ID的撤回命令,用于让消息在触达的客户端也实现撤回的效果。消息表中的extra字段如下:
{ |
在客户端无论是通过推送还是拉取接收到这条消息后,IM服务都会将客户端已展示的对应的消息内容删除并改写为“消息被撤回”。
13.7 消息回执
很多 IM 产品都支持消息回执功能。
消息回执指:
单聊中:发送者可以看到对方是否已读某条消息 |
13.7.1 上报已读消息
要知道一条消息是否已读,需要依赖消息接收者把已读事件上报给服务端。
关键问题是:
什么时候上报? |
如果一条消息刚展示给用户,客户端就立刻上报已读事件,会给服务端带来很大访问压力,尤其是群聊。
例如:
一个大群有 300 人活跃在线 |
实际上,消息是否已读通常不需要很强的实时性。
更合适的做法是:
消息接收者客户端: |
这样可以减少服务端压力。
13.7.2 记录已读消息
记录已读消息最直接的方式,是为每条消息和每个已读用户保存关联关系。
但这种方式会严重占用存储空间,实用性不高。
更好的做法是利用消息有序性,只记录:
每个用户在每个会话中最后一条已读消息的 Seq |
判断某条消息是否已读:
消息 Seq <= last_read_seq:已读 |
这种方式适合单聊,也适合群聊。
群聊有一个细节:
某个新成员在某个时刻被拉入群聊 |
last_read_seq 存储位置
我们在用户会话链中增加 last_read_seq 字段。
消息接收者读取某条消息后,客户端把已读事件上报给服务端,服务端更新用户会话链中对应的 last_read_seq。
消息发送者周期性检查自己发出的消息是否已读:
单聊会话: |
如下图所示,用户 A 在 4 人群聊会话 X 中发送了一条 Seq 为 100 的消息。
用户 B 读取后,上报 last_read_seq = 100 |

本节总结
消息回执不需要为每条消息保存所有已读用户关系。
更节省的方式是:
每个用户在每个会话中 |
判断已读状态时,只需要比较:
消息 Seq <= last_read_seq |
13.8 阶段性汇总:存储设计
在介绍完 IM 服务主要逻辑后,可以正式设计消息数据的存储。
本节涉及的表包括:
消息表 |
1. 消息表
消息表用于存储消息本身。
消息数据很少被修改,更多场景是根据消息 ID 获取消息数据。
因此,既可以使用分布式 KV 存储系统,也可以使用传统数据库。
如果使用分布式 KV:
Key:消息 ID |
如果使用传统数据库,消息表结构如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| msg_id | BIGINT | 消息 ID |
| conversation_id | VARCHAR(64) | 会话 ID |
| user_id | BIGINT | 消息发送者 ID |
| content | Text | 消息文本 |
| status | INT | 消息状态枚举,如 0 可见、1 屏蔽、2 撤回 |
| send_time | DATE | 消息发送时间 |
消息表使用 msg_id 字段作为唯一索引,以提高根据消息 ID 获取消息数据的效率。
2. 会话表
会话表用于管理会话数据,主要用途是根据会话 ID 获取会话信息。
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| conversation_id | VARCHAR(64) | 会话 ID |
| type | INT | 会话类型枚举,如 0 单聊、1 群聊、2 直播间、3 聊天室 |
| member | INT | 与会话相关的用户数量 |
| avatar | VARCHAR(128) | 用于群聊,表示群组头像 |
| announcement | Text | 用于群聊,保存群公告 |
| recent_msg_time | DATE | 此会话最新产生消息的时间 |
会话表使用 conversation_id 字段作为唯一索引。
conversation_id 使用 VARCHAR 类型,是为了满足 13.6.1 节中单聊会话 ID 使用字符串格式的需要,例如:
111-222 |
3. 会话消息链
会话消息链用于支持读扩散模式的消息存储,重点是保证会话内消息有序。
数据表 conversation_msg_list 的结构如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| conversation_id | VARCHAR(64) | 会话 ID |
| msg_id | BIGINT | 消息 ID |
| seq | BIGINT | 消息在会话中的序列号,用于保证消息顺序 |
会话消息链主要用于按照会话查询有序消息 ID 列表。
因此需要为 conversation_id 和 seq 创建联合索引:
sort_msg_idx(conversation_id, seq) |
作用:一方面可以保证通过会话ID获取到消息列表,另一方面可以保证消息列表按照 seq字段有序排列。
常见查询方式如下。
根据会话 X 的已读消息 read_seq 获取 N 条新消息:
SELECT * |
根据当前消息的 current_seq 向前查找 N 条历史消息:
SELECT * |
补偿拉取会话 X 中指定 Seq 区间 [seq_i, seq_j] 的消息:
SELECT * |
4. 用户消息链
用户消息链服务于写扩散模式,结构与会话消息链类似。
数据表 user_msg_list 的结构如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| user_id | BIGINT | 用户 ID |
| msg_id | BIGINT | 消息 ID |
| conversation_id | VARCHAR(64) | 会话 ID |
| seq | BIGINT | 消息在用户消息链中的序列号,用于保证消息顺序 |
用户消息链用于查询用户维度下有序的消息列表。
因此需要为 user_id 和 seq 创建联合索引:
sort_msg_idx(user_id, seq) |
按 Seq 查询消息列表的 SQL 与会话消息链类似,只是查询维度从会话 ID 换成用户 ID。
5. 用户会话链
用户会话链记录用户与会话的关联关系。
它至少承载以下职责:
根据会话 ID 获取会话参与用户列表 |
数据表 user_conversation_list 的结构如下:
| 字段名 | 类型 | 含义 |
|---|---|---|
| id | BIGINT | 自增主键,无特殊含义 |
| user_id | BIGINT | 用户 ID |
| conversation_id | VARCHAR(64) | 会话 ID |
| last_read_seq | BIGINT | 此会话中用户已读的最后一条消息 |
| notify_type | INT | 会话消息提醒类型:0 未屏蔽,正常提醒;1 屏蔽;2 强提醒 |
| istop | TINYINT | 会话是否被置顶展示 |
在推送消息、查看会话详情时,都需要获取某个会话的参与用户列表。
因此需要为 conversation_id 和 user_id 创建联合索引:
conversation_user_idx(conversation_id, user_id) |
根据会话 ID X 查询用户列表:
SELECT user_id |
即时通信中还有很多根据用户获取会话的场景:
查询用户是否属于某会话 |
因此还需要为 user_id 和 conversation_id 创建联合索引:
user_conversation_idx(user_id, conversation_id) |
查询用户 A 是否属于会话 X,或者查询用户 A 在会话 X 中的设置:
SELECT * |
如果用户切换登录设备,可能需要获取最近活跃会话列表。
我们可以:
根据 user_id 查询会话列表 |
13.9 高并发架构
即时通信是典型的读多写多场景。
在满足基本功能后,还要考虑海量用户下如何应对高并发请求。
13.9.1 发送消息
拥有上亿用户的即时通信软件,很容易出现大量用户同时发送消息的情况。
例如春节期间,可能有数百万用户同时发送拜年消息。
为了防止海量发送请求击垮 IM 服务,需要在发送链路中做高并发设计。
1. 消息队列既保证顺序,也用于削峰
在 13.5.3 节中,为了保证消息基于会话 ID 有序、基于用户 ID 有序,服务端存储流程引入了消息队列。
但消息队列还有另一层作用:
对高并发发送消息请求做削峰 |
IM API 接收消息后,把消息投递到消息队列就可以返回给用户。
后续消息存储和推送由 IM 服务内部按自己的处理速度平滑消费。
这实际上已经实现了异步写:
客户端发送消息 |
2. 消息堆积问题的解决方案
消息发送后会进入消息队列,由消费者异步消费并写入数据库。
如果出现以下情况:
消费者消费速度较慢 |
就会导致消息在队列中不断堆积,无法及时写入数据库,最终使消息不能及时触达接收者。
因此,需要从三个方向解决消息堆积问题:
提高消费并发能力 |
1. 增加 Topic 分区数,提高消费者并行消费能力
消息队列通常存在一个限制:
同一个消费组内 |
因此,如果 Topic 分区数太少,即使部署了很多消费者实例,也无法真正提高消费并发度。
解决办法是:
为消息队列的 Topic 设置足够多的分区 |
例如:
为 conversation_im_topic 设置 10000 个分区 |
本质上,这是一种针对高并发写场景的消息分片方案。
2. 批量消费消息,并进行写聚合,减少数据库写入压力
消费者可以采用批量消费模式,而不是一次只拉取一条消息。
例如:
一次从消息队列中拉取 500 条消息 |
由于同一个会话的消息会被投递到 conversation_im_topic 的同一个分区中,所以消费者拿到一批消息后,可以先按照会话进行聚合:
批量拉取消息 |
对于 user_im_topic 也类似:
批量拉取消息 |
这样可以减少数据库请求次数,提高消费者处理吞吐量,从而缓解消息堆积。
3. 客户端限制发送频次,从源头减少无效消息冲击
除了提升服务端消费能力,也可以在客户端对用户发送消息的频次进行限制。
如果用户在短时间内疯狂发送大量消息,大概率属于以下情况:
疯狂给某人"拍一拍" |
因此,可以在客户端设置发送阈值:
一定时间内发送消息数量超过阈值 |
这属于从消息生产源头进行限流,减少消息队列和服务端的压力。
13.9.2 数据缓存
IM 服务的主要数据包括:
消息表 |
为了防止高并发读消息请求击垮数据库,可以使用 Redis 缓存这些数据。
消息具有很强的时间属性,最近的消息更可能被频繁访问,所以缓存应该聚焦最近数据。
接下来我们具体来分析如何缓存数据。
1. 缓存消息表
最近消息更可能被频繁访问。
Redis 可以使用 String 对象存储 IM 服务刚收到的消息,并设置过期时间为 1 天。
Redis String |
这样可以保证 Redis 中缓存的是最近 1 天的消息。
2. 缓存会话表
最近创建的、最近有消息通信的会话更可能被频繁访问。
可以使用类似消息表的方式缓存最近会话。
但单聊会话只涉及两个用户,且没有太多可变元信息;用户在同一个设备上也很少访问单聊会话信息。所以建议 Redis 可以只缓存群聊会话。
3. 缓存用户会话链
用户会话链非常容易被频繁访问。
原因包括:
IM 服务下发消息时,要基于会话 ID 读取目标用户列表 |
对于最近活跃会话,用户会话链值得缓存。
Redis 可以这样存储:
List:存储每个会话的用户列表 |
4. 缓存会话消息链和用户消息链
会话消息链和用户消息链统称为消息链。
会话消息链:服务读扩散 |
用户查看历史消息的概率远低于读取最近消息,所以消息链缓存不需要存全部消息,只需要存最近收到的消息。
Redis 可以使用 ZSET 存储最近的消息 ID 列表:
Member:消息 ID |
这样既能缓存最近消息列表,又能用 Seq 保证消息有序。
数据库作为存储系统,Redis 作为缓存,两者结合后的结构如下图所示。

13.9.3 消息分级
不同会话参与人数不同,对消息有不同的触达成功率和实时性的要求,经过大致测算,我们把消息划分为如下几个优先级。
1. 单聊会话
单聊双方都非常在意消息是否顺利互动。
要求:
保证消息顺利触达 |
2. 小群:群成员少于 100 人
小群一般是好友圈子建立的群,或者围绕特定话题建立的群。
群成员也很在意群消息互动。
要求与单聊类似:
触达成功率要求高 |
3. 中等群组:群成员大于 100 人且小于 1000 人
这类群常见于公司部门的重要消息通知群,日常群聊参与人数不多。
要求:
重要的是要求消息触达每个成员 |
4. 大型群组:群成员大于 1000 人
这属于大型群组,类似交流社区。由于人多嘴杂,很多群成员大概率会屏蔽群消息,只有主动点击打开群会话才会真正阅读消息。
要求:
不要求群消息触达全部在线成员 |
消息分级后,可以为单聊、小群、中群、大群分别创建专用 IM 服务集群。集群之间不共享任何资源,如服务实例、消息队列、数据库、Redis。
这样做的收益:
- 单聊消息不会因为大群消息过多而堆积在消息队列中
- 大群消息击垮数据库,不会影响单聊和小群消息收发
- Redis 内存资源可以更多留给单聊和小群专用集群
- 推送系统优先保证单聊和小群消息推送,防止大群消息过度占用带宽
13.9.4 直播间弹幕模式
1. 直播间弹幕的场景特点
直播间弹幕是一种即时通信场景。
它指观众在直播间内实时发送短文本消息,并以滚动形式呈现在直播画面上。
弹幕既可以是观众和主播之间的聊天,也可以是观众之间的互动。
直播间弹幕看起来像群聊:
直播间相当于一个会话 |
但弹幕和传统 IM 群聊有明显差异。
直播间弹幕有以下特点:
- 只有看播用户有会话,这个会话就是用户正在观看直播的直播间。不存在一个用户同时观看多个直播的情况
- 用户可以随时进出直播间,群成员变动频繁
- 弹幕实时性要求高,因为主播需要即时回复弹幕
- 每个用户只需要接收看播期间的消息,不需要历史弹幕
- 直播间容易产生大量收发弹幕,形成过热会话
- 极端情况下允许丢失若干弹幕,发送者对其他用户是否能看到自己的发言不是很在意
- 弹幕数据不需要长期持久化,主播关播后可以不再保留
2. 消息存储:适合采用读扩散模式
这些特殊性使得直播间弹幕的 IM 服务设计应该有不同的思路。
首先,弹幕的消息存储非常适合采用读扩散模式,即弹幕只被存储到会话消息链中。
因为每个用户最多只有正在观看直播的直播间这一个会话,读扩散问题自然得到解决。
3. 用户会话链:直接使用 Redis 存储
看播用户频繁进出直播间,意味着用户会话链不仅会被高并发读取,也会被频繁修改。
如果继续使用数据库存储 + Redis 缓存的组合,很容易出现数据库和 Redis 数据不一致。
因此,直播间弹幕场景下,直接使用 Redis 存储更简单。
如下图所示,可以使用 Redis Hash 表示用户会话链,其中Field表示用户ID, Value表示此用户在直播间内的置,如是否被禁言、进房时间等信息。

4. 发送弹幕:利用消息队列削峰,必要时允许丢弃
所有的看播用户都会频繁地发送弹幕和读取弹幕列表。IM 服务已有消息队列,可以对发送弹幕请求做削峰,因此在正常情况下,发送弹幕问题不大。
但如果发送弹幕请求量超过消息队列处理能力,为了防止弹幕堆积,我们甚至可以直接按照比例丢弃弹幕,弹幕发送者的客户端只在本地展示这条弹幕,从发送者本人的视角来看,弹幕就像已经发送成功一样。
5. 读取弹幕:ZSET 直接存储弹幕数据
读取弹幕的请求量也很大。
虽然会话消息链使用Redis ZSET缓存最近的消息列表,但是这里的数据毕竟只有消息ID,想获取到完整的弹幕,还需要批量获取消息数据,因此会话消息链缓存的 ZSET 对象的Member不再适合用消息ID表示,而是应该直接使用弹幕数据。如下图所示。

这样一来,一个直播间的弹幕读取请求只需要访问一个 Redis ZSET 对象,就能具备较强的高并发读取能力。
如果读取请求量超过 Redis 单机处理能力,可以使用 Redis 主从架构分散读取压力。
13.10 本章小结:最终架构
在用户之间建立聊天关系的渠道是会话,它被用作消息收发的容器,消息能被发送、存储、接收的核心是我们介绍的那些模式,其中负责消息存储的模式有如下两种。
- 读扩散模式:消息被发送到会话消息链,用户在读取新消息时需要拉取全部相关会话。此模式适合会话数较少的场景。
- 写扩散模式:消息被分别发送到每个会话中用户的用户消息链。此模式更适合会话参与用户较少的场景,如单聊和小群。
负责消息收发的模式有如下三种。
- 推模式:每条消息都经过长连接被直接推送给在线用户,消息的实时性强,但易丢失。
- 拉模式:客户端主动轮询访问服务端获取新消息,消息不易丢失,但实时性一般。
- 推拉结合模式:既将新消息推送给在线用户,客户端又周期性地拉取消息,消息的实时性强,也不易丢失。
本章设计了一个既支持读扩散模式,又支持写扩散模式消息存储的 IM 服务,且采用推拉结合模式收发消息。
我们为数据库存储设计了多个数据表,它们分别负责如下事情。
- 消息表:存储消息本身。
- 会话表:存储会话元信息。
- 用户会话链:存储会话与用户的关联关系,以及每个用户在会话中的配置、权限、最近已读消息等。
- 会话消息链:服务于读扩散模式,存储会话中的消息 ID 列表。
- 用户消息链:服务于写扩散模式,存储各个用户的消息 ID 列表。
保证消息的有序性是即时通信的基本诉求,我们为消息链引入了递增的消息 Seq 来反映消息顺序,客户端维护消息链的接收队列,并通过查看当前收到的消息 Seq 在队列中是否连续来判断是否有消息丢失情况发生。如果消息 Seq 连续,则可以将消息展示给用户,否则补偿拉取空洞消息。
一条消息从发送到被用户接收经过了如下过程。
客户端在为会话与服务端建立的长连接上发出消息。
IM API 接收消息,为消息生成全局唯一的消息 ID。如果消息来自新会话,则进一步为消息生成会话 ID 并在会话表中创建新会话。然后,将消息按照会话 ID 有序投递到 conversation_im_topic 消息队列中。
会话消息服务消费 conversation_im_topic 中的最新消息,在消息表中插入新数据,并为消息产生递增的消息 Seq 后,将消息插入会话消息链中。
会话消息服务更新若干缓存数据。
① 将消息本体缓存到 Redis 中。
② 在会话表中更新会话的最新产生消息的时间;如果消息来自群聊,则将该群聊会话的会话元信息进一步缓存到 Redis 中。
③ 将消息插入会话消息链的 Redis 最近消息缓存中。
关于这里的补充解释请看后面。
会话消息服务获取与会话相关的用户列表,然后为每个用户按照用户 ID 将消息顺序转发到 user_im_topic 消息队列中。
用户消息服务消费 user_im_topic 中的最新消息,为消息产生递增的消息 Seq 后,将消息插入用户消息链中。
用户消息服务将消息更新到用户消息链的 Redis 最近消息缓存中。
将消息交给推送系统,由它把消息推送给目标用户。
接收者的客户端收到消息,在本地检查消息的 Seq 值是否等于接收队列中最后一条连续消息的 Seq + 1,如果是,则说明消息连续,客户端将消息展示给用户;如果大于它,说明出现消息空洞,需要补偿拉取消息;如果小于它说明是重复的,不用管。
客户端从服务端拉取消息,IM 服务先从 Redis 缓存中读取消息 ID 列表,如果未读取到,则从数据库的消息链中再次读取。
在读取到消息 ID 列表后,IM 服务将消息数据打包返回给客户端。
客户端将拉取到的消息补充到接收队列中,将连续消息展示给用户。
最后,IM 服务的完整架构如下图所示。
关于第 4 点的补充解释:
①:
这里缓存的是“消息表中的消息数据”,也就是消息正文和消息元信息
Key:msg_id
Value:消息内容、发送者、发送时间、消息状态、会话 ID 等作用是:后续客户端拉取消息时,通常会先从消息链中拿到一批消息 ID,然后再根据消息 ID 查询消息详情。为了避免每次都查消息表,所以把最近消息的完整内容缓存到 Redis 中。
②:
这里更新和缓存的是“会话表中的会话信息”,例如:
conversation_id
conversation_type
recent_msg_time
member
avatar
announcement如果消息来自群聊,则可以进一步把群聊会话信息缓存到 Redis 中。因为群聊会话通常包含群头像、群公告、成员数量等元信息,打开群聊或展示群聊列表时更容易被访问。
③:
这里缓存的是“会话消息链中的最近消息 ID 列表”,也就是某个会话最近产生了哪些消息,以及这些消息在会话中的顺序。
可以用 Redis ZSET 存储:
Key:conversation_msg_list:{conversation_id}
Member:msg_id
Score:seq例如:
conversation_msg_list:20001
10001 -> 1
10002 -> 2
10003 -> 3作用是:客户端拉取某个会话的新消息或历史消息时,可以先从 Redis 中按照 Seq 范围快速拿到消息 ID 列表;拿到消息 ID 后,再去第 ① 步的消息缓存中查询消息详情。