IM应用开发的两个消息库:同步库与存储库

引言

IM(Instant Messaging)应用开发的"终局问题"之一,是消息系统架构

不管你用 Netty、gRPC、WebSocket 还是裸 TCP,不管你用 MySQL、Redis 还是自研分布式存储——最终你都要面对两个核心问题:消息怎么同步到接收方的每个端消息怎么持久化,让用户随时翻出半年前的聊天记录

业界对这两个问题的解法,经过十几年演化,收敛到了一个经典的范式——“两个消息库”:一个消息同步库(Sync Store),一个消息存储库(Message Store / Archive Store)。

这个范式最早由阿里云 TableStore 团队在 2017 年前后系统性地提出并落地(参见《现代IM系统中消息推送和存储架构的实现》),至今依然是微信、钉钉、QQ 等主流 IM 产品在消息层面的核心架构基础。

本文围绕"两个消息库"展开讨论,从为什么需要两个库Timeline 模型写扩散 vs 读扩散物理存储选型工程中的取舍,希望能讲清楚这个看似简单实则充满权衡的设计问题。


一、传统架构:先同步,后存储

在分析现代架构之前,先看看"原始"做法。

传统架构是先同步后存储。流程大致是:

1
2
3
发送方 → 服务端 → (接收方在线?) → 是 → 在线推送 → 接收方
                   ↓ 否
                 离线库(暂存) → 接收方上线后拉取 → 删除离线消息

关键特征:

  • 服务端不持久化消息。消息同步到接收方后,服务端就丢弃了
  • 离线消息是临时缓存,接收方拉取后就删除
  • 不支持消息漫游(换个设备看历史消息?不存在)
  • 写放大几乎没有,但读放大严重(每条消息都要问:接收方在不在?发不发推送?)

这种架构在今天来看已经非常落伍,但它的思路相当直觉——消息嘛,传到了就行了,留着干嘛?

然而用户说:不行,我要在手机上看昨天聊的内容,晚上回家在 Mac 上接着看。这就引出了多端同步消息漫游的需求,传统架构完全做不到。


二、现代架构:先存储,后同步

现代架构的核心思想是先存储,后同步。消息从发送方发出,服务端不是先考虑"怎么传给接收方",而是先存起来。

1
2
3
发送方 → 服务端 → ①写入消息存储库 → ②写入消息同步库 → ③在线推送(优化路径)
                                                            ↓ 失败/离线
                                                        接收方主动拉取同步库

这里的核心变化是引入了两个独立的存储系统

2.1 消息存储库(Message Store)

  • 会话(Conversation / Session)组织,每个会话一个 Timeline
  • 保存该会话的全量消息,永不过期(理论上)
  • 用于支持消息漫游:用户在新设备上登录,从这里拉取任意会话的历史消息
  • 写入频率稳定:一次会话一条消息就是一次写入
  • 数据量最大:需支撑全量持久化

2.2 消息同步库(Sync Store)

  • 接收端(Device / Client)组织,每个接收端一个 Timeline
  • 保存待同步给这个端的所有消息(可能来自多个会话)
  • 用于支持多端同步:手机端、PC 端、Pad 端各自独立拉取各自的同步 Timeline
  • 写入频率高:一条消息可能触发 N 次写入(群聊 N 个成员)
  • 数据有生命周期:同步完毕后可以清理(取决于设计)

为什么要分成两个库?因为两者的访问模式完全不同

维度消息存储库消息同步库
主键会话 ID接收端 ID
访问模式按会话拉全量历史按设备拉增量消息
写入频率1 次/消息1~N 次/消息
数据量全量有状态的增量
TTL永久可淘汰
核心能力消息漫游多端同步

强行用一个库同时支撑这两种模式,要么读性能爆炸,要么写放大失控。


三、Timeline 模型:统一抽象

要想理解两个库的具体实现,首先要理解Timeline这个抽象模型。

Timeline 是一个有序消息队列,具有以下三个特性:

  1. 每一条消息都有一个顺序 ID(SeqId),后面的消息 SeqId 一定比前面的大
  2. 新消息永远追加到尾部,不插入中间
  3. 支持按 SeqId 随机定位和范围读取

这三条特性赋予了 Timeline 极强的表达能力:

  • 消息同步:接收方的每个端维护一个本地 cursor(即最新已同步的 SeqId),每次拉取时问"从 cursor+1 开始,给我最新的消息"即可。服务端无需维护每个端的状态
  • 消息漫游:直接从会话 Timeline 按 SeqId 范围批量读取
  • 多端独立进度:B1、B2、B3 三个端各自记录自己的 cursor,互不干扰

从这个角度看,消息同步库本质上是一个按设备维度组织的 Timeline 集合,消息存储库是一个按会话维度组织的 Timeline 集合


四、消息同步的选择:写扩散 vs 读扩散

这是 IM 架构中最经典的 trade-off 讨论。

4.1 读扩散(Fan-out on Read)

每条消息只写入存储库一次,各接收端主动去每个会话的 Timeline 拉取新消息。

场景写入次数
单聊1 次
群聊(N 人)1 次
  • 优点:写入极少,非常适合写密集型场景
  • 缺点:读放大严重。假设用户有 50 个活跃会话,每次同步需要拉 50 次。大量无效拉取(很多会话并没有新消息)。读写比从 10:1 放大到 100:1

4.2 写扩散(Fan-out on Write)

每条消息写入存储库 + 所有接收方的同步库

场景写入次数
单聊(A→B)1(存储)+ 2(A和B的同步库)= 3 次
群聊(N 人)1(存储)+ N 次
  • 优点:接收方只需拉一次自己的同步 Timeline,即可拿到所有未读消息。读压力极低
  • 缺点:写入放大严重,群聊场景尤其恐怖(万人大群每条消息写 1 万次)

4.3 主流选择:写扩散为主,混合为辅

IM 场景的特点是读多写少。一条消息产生一次,但会被读取多次(手机端读一次,PC 端读一次,历史翻看再读几次)。读写比约 10:1。

用读扩散,10:1 被放大到 100:1。用写扩散,读写比可以平衡到大约 30:30 — 虽然写变多了,但系统两端都不会触顶,整体吞吐更高。

现代 IM 系统(微信、钉钉)的普遍做法:

  • 默认使用写扩散,小群(<2000 人)走写扩散
  • 大群退化到读扩散,超过阈值后消息只写存储库,不写同步库。接收方在打开群聊时按需拉取
  • 读写混合模式:一个高级 IM 系统应该能根据群规模动态选择同步策略

五、物理存储选型

Timeline 是一个逻辑模型,落到底层需要具体的存储引擎。Timeline 对底层数据库是有要求的

需求说明
高并发写写扩散模式下,同步库面临巨大的写入压力
按序范围读按 SeqId 范围拉取消息是核心操作
随机定位按 SeqId 精确跳到某条消息
高可用 + 低成本消息数据量巨大,存储成本敏感

业界常见方案:

  • HBase / TableStore(阿里云):天然的 LSM-Tree 架构,按 rowkey 有序排列,range scan 性能极好。阿里系 IM 的经典选择
  • Cassandra / ScyllaDB:分区有序,写性能强悍,适合写扩散场景
  • TiKV:分布式 KV,支持有序扫描,适合自建
  • Redis(同步库):同步库可以用 Redis 的有序集合(ZSet),以 SeqId 为 score。带 TTL 自动淘汰。但数据量大了之后内存成本高,一般只用作同步库的缓存层
  • 自建 B+Tree 引擎:大厂的自研路线,极致优化

一个典型的部署模型:

1
2
同步库: Redis Cluster (主要) + 冷数据下沉到 KV Store
存储库: HBase / Cassandra (全量) + CDN 缓存热门消息

六、工程中的实际考量

理论讲完了,聊几个实际中遇到的痛点:

6.1 同步库的垃圾回收

同步库的数据有"保质期"——消息一旦被所有端确认已同步,就可以清理了。但实际上很难精确判断"所有端"。一般做法有:

  • 基于 TTL:给同步库 Timeline 设置过期时间(如 7 天/30 天),超时自动淘汰。接收方如果长期离线,上线后从存储库拉全量
  • 基于 ACK:每个端上报已同步的 SeqId,服务端计算最低水位,定期裁剪。实现复杂但更优雅

6.2 消息存储库的瘦身

存储库是永久存储,规模大了后面临几个问题:

  • 数据倾斜:某些高频用户/群聊的消息量远超平均值,单个 Timeline 过大。需做 Timeline 分裂(Split)
  • 冷热分离:近期消息放 SSD,远古消息放 HDD/OSS。常见策略是按时间分片,30 天前的数据迁移到廉价存储
  • 图片/文件分离:大文件不走消息库,单独的对象存储 + CDN,消息体里只存 URL

6.3 消息的有序性

我参与的一个项目里遇到了经典的"消息乱序"问题:

用户 A 在手机上连接 WebSocket 发了一条"你好",过了一秒又发了一条"在吗?"。结果 B 的手机上显示"在吗?“先到,“你好"后到。

原因分析:两条消息进了同步库后,由于 SeqId 生成器是分布式 ID(如雪花算法),虽然保证递增但不保证严格递增(即后分配的不一定大于先分配的)。再加上在线推送走的是推送通道(APNs / FCM),和主动拉取的路径不同,在线推送的成功率波动导致了乱序。

解决方案:在线推送不是同步路径,只是优化路径。接收方收到的在线推送消息先在本地暂存,等下一次主动拉取同步库,以同步库的 SeqId 顺序为准进行重排,再展示给用户。也就是说——“以拉为准”

6.4 SeqId 生成器

SeqId 是 Timeline 的灵魂。要求:

  • 全局唯一
  • 递增(但不要求严格连续)
  • 高性能(百万级 QPS)

常见方案:

  • 雪花算法(Snowflake):时间戳 + 机器 ID + 序列号。时钟回跳是大坑,需要额外处理
  • Redis INCR:按 Timeline 维度自增。简单可靠,但 Redis 性能在高并发下是瓶颈
  • 数据库自增序列:如 MySQL auto_increment,通过步长和 offset 做多节点。写吞吐受限于单库
  • 段锁(Segment):服务端预分配一段 SeqId 区间,用完了再去取。综合性能和复杂度最平衡的方案

七、总结:两个库的本质

“两个消息库"的本质,是将"数据持久化"和"数据分发"两个关注点解耦

  • 消息存储库解决**“数据在哪儿”**——持久化、可回溯、可漫游
  • 消息同步库解决**“数据怎么到终端”**——实时、高效、多端

两者共享 Timeline 这个抽象模型,但在物理实现、访问模式、生命周期上完全不同。

如果让我给 IM 架构新手一个建议:先理解 Timeline 模型,再理解写扩散和读扩散的取舍,最后才去纠结用什么数据库。底层存储可以换,但架构设计的思路一旦定下来,整个系统的上限和下限就都画好了。


参考

  • 阿里云 TableStore 团队,《现代IM系统中消息推送和存储架构的实现》
  • 掘金,《现代IM系统中聊天消息的同步和存储方案探讨》
  • 知乎,《IM 系统的架构设计——消息同步和存储》
CC BY-NC 4.0
最后更新于 2026-05-24