IM 收件箱机制(三)

作者:锅拌饭日期:2025/11/14

在IM中,有了长连接之后,如何完成服务端与客户端的数据同步也是很重要的一环。

通常会有两种方案,一个是服务端直接转发,一个是收件箱机制。我们以消息类型的数据为例。

服务端直接转发:

是服务端收到消息A,存储完成后,直接将消息A的具体内容通过长连接通道发送给客户端B。我们把这种方式叫做服务端直接转发。

收件箱:

服务端收到消息后,不直接转发该消息给客户端,而是将消息id、消息所在会话id推送给客户端的消息收件箱,客户端发现消息收件箱有数据后,择机通过长连接 or 短连接拉取消息具体内容。

服务端直接转发

服务端直接转发是最简单的,但是在实际操作运行中,会面临几个问题。

消息阻塞

首先,在富文本的场景中,一个富文本的消息大小可以达到1M。前面说过,长连接的通道是全双工的,允许同时双向通信。但是他的并发量只有1,所以在服务端向客户端传输消息A的时候,其他数据需要等待传输完成后才能再次传输。

如果用户当前在会话A中聊天,但是在会话B中,收到了上百条富文本消息,由于下行通道堵塞,会导致用户无法及时看到会话A中的消息。

ACK

在IM应用中,比实时性更优先的原则是数据真实性,即不能丢数据。所以需要有一套ACK机制,当客户端收到消息A并落库后,会给服务端发送一个回包,用于表示这条消息拉到了。

服务端直接转发方案,如果因为网络抖动原因,客户端没有收到该消息,则服务端还需要再次重试推送该消息,那么会有大量的网络带宽浪费,对服务端也有重试的成本。

消息聚合拉取

单次只拉取一条消息,对于客户端和服务端都是资源的浪费。将多条消息聚合拉取,能够节省网络资源和服务端计算资源。


消息优先级拉取

在一般的IM应用中,有很多用户不关心的群聊,但又不能退出群聊。所以会有会话分组或者折叠会话等功能。这些会话对消息实时性要求不是很高,如果服务端收到消息后直接转发该消息,会让客户端失去灵活拉取的主动权。

收件箱机制

基于前面提到的几个实际场景。结合信箱的原理,我们采用收件箱机制

在收件箱机制中,服务端收到消息后,不直接转发该消息给客户端,而是将消息id、消息所在会话id推送给客户端的消息收件箱,这个数据量很小。

客户端收到该推送后,将该消息id落库,并回包给服务端,让服务端感知到,该消息id,客户端已经收到了,服务端无需再次推送该消息了。

回包完成后,客户端会根据该消息的优先级,确认是否获取该消息id对应的消息体。也可以做聚合,批量拉取一系列消息体。

这里有人可能会疑惑,采用收件箱机制,会不会导致消息抵达实时性降低。实际上考虑到网络堵塞、消息聚合拉取等逻辑的存在,收件箱机制在业务优化后,可能比服务端直接转发效果更好。因为没有对应的A/B Test,无法确认两种方案在实时性上的区别。

采用收件箱机制后,在实际运用中,根据业务,会存在多个收件箱。比如消息收件箱、会话收件箱。在飞书中,还会有日程到期收件箱、文档变更收件箱等。

当然并不是所有服务端与客户端的通信都需要收件箱机制,根据业务复杂程度、信息数据量大小等,应当采用收件箱机制服务端直接转发相结合的方式。

我们以最复杂的业务,消息收件箱为例,查看一些技术细节。

首先,使用消息收件箱是为了保证消息有序、低延迟、数据不丢失

有序: 按照实际消息顺序获取,保证消息顺序

低延迟: 推与拉结合,保证实时性

数据不丢失: 通过ACK、重试,确保数据不丢失

消息收件箱

命令字

收件箱是一个概念上的东西,在实际开发中,客户端会和服务端约定一个命令字,比如10001,当服务端向客户端推送10001的时候,就代表当前数据是消息收件箱消息。当客户端收到10002时,就代表当前数据是会话收件箱消息。

一般的收件箱数据如下:

1
2{   // 推送命令字
3    "command": "10001",  
4    "data": {  
5        // 消息所在会话id
6        "conversation_id": "123456789_2",  
7        // 消息的序列号
8        "sequence_id": "123456789123456789"  
9    }  
10}
11
12

sequenceId

在上面的数据结构中,sequenceId是递增的(跨会话全局递增)。这个会有服务端保证,一般会分布式集群,会采用雪花算法(你可能听说过雪花算法)等方式生成,保证sequenceId的递增、唯一性。

sequenceId:

  1. 服务端每条消息入库前,都会生成sequenceId,该sequenceId是跨会话唯一、递增的。
  2. sequenceId的作用是用于标记收件箱数据位置,客户端基于sequenceId向服务端请求数据。
  3. 客户端每次请求会携带本地的lastSequenceId,服务端响应式会返回新的lastSequenceId+hasMore
  4. 客户端循环拉取,直到hasMore=false,确保本地数据完整。

当客户端收到10001推送后,会将该数据分发到对应的收件箱处理,即MessageEmailManager,在这里,会先对该数据落库,然后回包给服务端,表示客户端已经知道会话A中有消息123。服务端收到回包后,就不会再次推送消息123给客户端。

MessageEmailManager中,可以根据消息优先级,决定是否拉取该消息的具体内容,以及是否聚合拉取具体内容。

推拉结合

客户端在线时: 实时推送最新的sequenceId给客户端,客户端立即感知

登录/重连/冷启动时: 增量拉取,快速同步离线期间的数据

拉取失败时: 无限重试,确保拉取成功

整个流程图如下:

问题:为什么需要sequenceId?

思考这个问题:消息本身有个唯一的messageId,为什么不直接推送messageId给客户端,而是又新增了sequenceId专门用于推送呢?

首先,messageId也会保证全局的唯一性,在服务端用作消息的唯一标识,即数据库主key。

但是messageId存在两个缺陷:

  1. 以64位整型Long为例,一般会在messageId中固定前几位用于声明关键信息,如会话id等信息。
  2. messageId保证全局唯一,但是不保证消息是按照发送顺序递增的。

第二个缺陷,messageId不递增,导致它无法用于收件箱推送。对于消息推送来说,是推拉结合的,那么在冷启动的时候,需要上传本地已经获取到的消息:

  1. 如果使用messageId,那么就要把本地所有的messageId都发送给服务端。而且服务端还要做diff,才能计算出客户端需要的消息队列。
  2. 使用sequenceId,就简单很多。首先sequenceId是递增的,客户端只需要将本地的最新(or最大)sequenceId发送给服务端,服务端也只需要对比sequenceId大小,就可以计算出客户端需要的消息队列。

我们其实可以将sequenceId理解为版本号的概念,客户端只需要维护一个本地版本号,就可以和服务端同步剩余数据。

问题二:id会耗尽吗?

一般我们的id使用的都是Long类型,即64位整型的。那么思考一下,这个id会耗尽吗?

64 位有符号 Long 的最大值是 9223372036854775807,换算成亿为 92233720368.54775807 亿(约 9223 万亿)。

我们以国民级应用微信为例,假设日活有10亿,那么每个人要发送922万条消息才能耗尽这个id。我们把时间拉长到100年,那么每个人每年要发送9万条消息,每天发送250条消息。

上面的假设场景,是把所有极端情况拉满的,实际上大部分人在微信中一天内不会发送250条消息。

根据国外分析网站的数据wechat-statistics,微信每天会产生450亿条消息,那么Long型可以供使用20万天,即555年。

所以对于大部分应用,无需考虑id耗尽的问题。

消息空洞

上面几个原则很好理解,但是在实际开发中,还需要解决消息的空洞问题。

消息空洞: 即消息不连续,中间漏掉了消息

case1:网络抖动

想象这个场景,服务端依次收到了消息A消息B消息C。 依次通过推送告知客户端,有消息A消息B消息C三条新消息,需要客户端主动获取对应消息体内容。

但是由于网络抖动,消息B的推送丢失了。 客户端仅收到了消息A消息C,落库后回包给服务端。此时服务端虽然会重试推送消息B,但是用户此时正好在此会话中,需要展示消息A消息C。对于客户端来说,并不知道还有消息B,那么就会导致消息A消息C上屏展示了。这样就会对用户理解信息造成很大影响。

下图中,你的回复是图片+文字,但是对方只收到了“交配中”的文字,会让对方感觉很抽象。


case2:消息定位

一般来说,进入会话A,初始化只会拉取最新50条消息,用户滑动屏幕后才会触发加载。在卸载重装后,用户没有滑动屏幕加载更多消息,那么客户端SDK数据库中,只有最近50条消息。

用户通过搜索关键字,定位到了第10000条的消息。当用户在这第10000条消息滑动屏幕时,会调用loadMessage加载下一页的消息。

那么如何判断SDK数据库中的50条消息,是否能展示呢?

SDK怎么判断第10000条消息和最近50条消息是否连续呢?

我们从文字描述上看,此时肯定是不连续的,即出现了空洞。但对于SDK来说,一定要有一个可以量化的条件。

消息定位导致的消息空洞场景有很多:

用户在离线状态,群聊中产生了10000条消息,用户点击离线推送进入会话,也会和上面情景一样,产生消息空洞。

同理,点击被回复的消息/被引用的消息/被置顶的消息等,都有可能产生消息空洞。

如何保证消息连续性:

保证消息没有空洞,即连续性很重要。一般来说,服务端会在messageId的基础上,给每条消息计算出一个continueIdcontinueId连续递增的,从0、1、2、3一直到无穷大。

需要注意,continueId是绑定在具体的会话下面的,在这个会话下是连续递增的。

前面提到的sequenceId是跨会话的,在多个会话间是非连续递增的。

这样客户端就知道消息A消息C是不能上墙的,得主动拉取到消息B,才能一起上墙。

服务端连续递增id生成方案有很多,这里不多赘述 分布式ID生成器(CosId)设计与实现

在消息收件箱中,不关心消息是否是连续的,只关心消息是递增的。在99%的情况下,消息收件箱中的消息都是连续的。而且大部分会话,用户是极低概率进入的,而且即使进入,也很极低概率会滑动查看全部的历史消息。

所以,我们无需在收件箱中就保证消息连续性。只需要在用户看到这部分消息时,保证连续即可。一般来说,用户进入会话查看消息流程:

  1. 用户进入会话A后,客户端业务会调用loadMessage(lastMessageId, pageSize),其中,lastMessageId是描点id,pageSize是想要获取的消息数量。
  2. 客户端业务向客户端SDK查询从指定锚点开始的历史消息。
  3. 当客户端SDK从本地数据库捞出对应的消息列表后,需要check continueId是否是连续的
  4. 如果消息continueId连续,就直接返回给业务方。
  5. 如果不连续,客户端SDK需要发起网络请求,向服务端拉取补齐消息,最终返回给客户端业务。
  6. 这个过程会有预加载、loading等方式优化用户体验

消息中各种id

****含义作用
messageId消息的唯一id,具有全局唯一性,但是不保证按照发送顺序递增消息的主key
sequenceId全局唯一性,且是递增的,按照发送顺序一次递增用于客户端-服务端的消息同步
continueId单个会话内连续递增用于保证消息连续不空洞

IM 收件箱机制(三)》 是转载文章,点击查看原文


相关推荐


C#.NET WebAPI 返回类型深度解析:IActionResult 与 ActionResult<T> 的区别与应用
唐青枫2025/11/13

简介 核心概念对比 特性IActionResultActionResult<T>引入版本ASP.NET Core 1.0ASP.NET Core 2.1主要用途表示HTTP响应(状态码+内容)类型化HTTP响应返回值类型接口(多种实现)泛型类内容类型安全❌ 无编译时检查✅ 编译时类型检查OpenAPI/Swagger需手动添加 [ProducesResponseType]自动推断响应类型适用场景需要灵活返回多种响应的


Python 的内置函数 help
IMPYLH2025/11/11

Python 内建函数列表 > Python 的内置函数 help Python 的内置函数 help 详解 基本功能 help() 是 Python 的一个内置函数,主要用于查看对象、模块、函数、类等的帮助文档。这个功能对于了解 Python 的各种组件及其使用方法非常有用,特别是在开发过程中需要快速查看某个功能的用法时。 使用方法 直接调用 help() help() 启动交互式帮助系统,此时可以输入模块名、函数名等查看帮助信息,输入"quit"退出帮助系统。 查看特定对象的


Vue SSR 源码解析:ssrTransformSuspense 与 ssrProcessSuspense
excel2025/11/9

一、背景与概念说明 Vue 在服务端渲染(SSR)过程中,会对组件模板进行两阶段编译: 阶段一(Transform) :生成用于描述结构的中间表达(IR, Intermediate Representation)。 阶段二(Codegen) :将中间表达转换为最终的字符串拼接指令(例如 _push、_renderSlot)。 而 <Suspense> 组件是 Vue 3 的一个特殊机制,用于异步内容加载与占位渲染。 在 SSR 环境下,Vue 需要为 <Suspense> 生成可在服务端正确


C++中的多态:动态多态与静态多态详解
oioihoii2025/11/6

多态是面向对象编程的三大特性之一,C++提供了两种主要的多态形式:动态多态和静态多态。本文将详细解释它们的区别,并通过代码示例进行说明。 什么是多态? 多态(Polymorphism)指同一个接口可以表现出不同的行为。在C++中,这允许我们使用统一的接口来处理不同类型的对象。 动态多态(运行时多态) 动态多态在程序运行时确定调用哪个函数,主要通过虚函数和继承机制实现。 实现机制 使用虚函数(virtual function) 通过继承关系 运行时通过虚函数表(vtable)决定调用哪个函数


图的寻路算法详解:基于深度优先搜索(DFS)的实现
Seal^_^2025/11/2

图的寻路算法详解:基于深度优先搜索DFS的实现 一、寻路算法概述DFS寻路示例 二、算法核心思想数据结构设计 三、算法实现详解1. 核心数据结构2. 构造函数初始化3. DFS实现4. 路径查询方法 四、完整代码实现五、算法测试与应用测试代码输出结果 六、算法分析与优化时间复杂度分析空间复杂度优化方向 七、DFS寻路与BFS寻路对比八、实际应用场景九、总结 🌺The Begin🌺点点关注,收藏不迷路🌺


高并发压力测试:Llama-2-7b 在昇腾 NPU 的六大场景表现
2501_938774292025/10/30

以下是关于 Llama-2-7b 在昇腾 NPU 上进行高并发压力测试的六大场景表现分析,结合网络公开信息和技术逻辑整理而成: 场景一:文本生成吞吐量测试 在批量文本生成任务中(如问答、摘要),昇腾 NPU 通过异构计算架构优化模型并行度。实测数据显示,当并发请求数从 100 提升至 1000 时,吞吐量增长约 3.8 倍,但单请求响应时间增加 15%-20%,显存占用峰值达 80%。 关键指标: 吞吐量:1200 tokens/s(batch_size=32)延迟:50ms/toke


Swift 官方发布 Android SDK | 肘子的 Swift 周报 #0108
东坡肘子2025/10/28

📮 想持续关注 Swift 技术前沿? 每周一期《肘子的 Swift 周报》,为你精选本周最值得关注的 Swift、SwiftUI 技术文章、开源项目和社区动态。 📬 在 weekly.fatbobman.com 免费订阅 💬 加入 Discord 与中文 Swift 开发者深入交流 📚 访问 fatbobman.com 查看数百篇深度原创教程  一起构建更好的 Swift 应用!🚀 Swift 官方发布 Android SDK 10 月 24 日,Swift Android 工


大模型时代的广告营销变革与实践
京东零售技术2025/10/25

大模型时代的广告营销变革与实践 互联网领域,广告营销是一种核心业态,也是先进技术和研究成果的商业化进程最快的一种渠道。伴随生成式大模型的浪潮汹涌袭来,京东广告结合自身业务特性和电商零售的新业态,推出了自主研发的广告营销商业化场景大模型,并据此带来了一场深刻的技术和业务变革。 在2025年9月25日,京东JDD(京东全球科技探索者)大会的Oxygen 智能零售论坛上,京东广告团队做了题为《大模型时代的广告营销变革与实践》的报告。 核心观点 1. 通用大模型想解决营销领域问题需向垂类模型转型。 “全


【Java】基于 Tabula 的 PDF 合并单元格内容提取
Kida的躺平小屋2025/10/22

坑还是要填的,但是填得是否平整就有待商榷了(狗头保命...)。 本人技术有限,只能帮各位实现的这个地步了。各路大神如果还有更好的实现也可以发出来跟小弟共勉一下哈。 首先需要说一下的是以下提供的代码仅作研究参考使用,各位在使用之前务必自检,因为并不是所有 pdf 的表格格式都适合。 本次实现的难点在于 PDF 是一种视觉格式,而不是语义格式。 它只记录了“在 (x, y) 坐标绘制文本 'ABC'”和“从 (x1, y1) 到 (x2, y2) 绘制一条线”。它根本不“知道”什么是“表格”、“


猿辅导Java面试真实经历与深度总结(二)
360_go_php2025/10/22

​ 在面试中,掌握Java的基础知识和深入的理解是非常重要的。今天,我们来解析几个常见的Java面试问题,包括线程状态、线程池、深拷贝与浅拷贝、线程安全、Lock与Synchronized的区别,以及逃逸分析等话题。 1. 线程状态 Java中,线程有七种状态,它们是由 Thread.State 枚举类定义的。线程的状态随着程序的执行而发生变化,下面是七种状态的描述:​编辑 NEW:线程被创建,但尚未启动。 RUNNABLE:线程可以运行,或者已经正在运行。线程调度器选择合适的线程让它执行

首页编辑器站点地图

Copyright © 2025 聚合阅读

License: CC BY-SA 4.0