引言
IM(Instant Messaging)应用开发的"终局问题"之一,是消息系统架构。
不管你用 Netty、gRPC、WebSocket 还是裸 TCP,不管你用 MySQL、Redis 还是自研分布式存储——最终你都要面对两个核心问题:消息怎么同步到接收方的每个端?消息怎么持久化,让用户随时翻出半年前的聊天记录?
业界对这两个问题的解法,经过十几年演化,收敛到了一个经典的范式——“两个消息库”:一个消息同步库(Sync Store),一个消息存储库(Message Store / Archive Store)。
这个范式最早由阿里云 TableStore 团队在 2017 年前后系统性地提出并落地(参见《现代IM系统中消息推送和存储架构的实现》),至今依然是微信、钉钉、QQ 等主流 IM 产品在消息层面的核心架构基础。
本文围绕"两个消息库"展开讨论,从为什么需要两个库、Timeline 模型、写扩散 vs 读扩散、物理存储选型到工程中的取舍,希望能讲清楚这个看似简单实则充满权衡的设计问题。
一、传统架构:先同步,后存储
在分析现代架构之前,先看看"原始"做法。
传统架构是先同步后存储。流程大致是:
| |
关键特征:
- 服务端不持久化消息。消息同步到接收方后,服务端就丢弃了
- 离线消息是临时缓存,接收方拉取后就删除
- 不支持消息漫游(换个设备看历史消息?不存在)
- 写放大几乎没有,但读放大严重(每条消息都要问:接收方在不在?发不发推送?)
这种架构在今天来看已经非常落伍,但它的思路相当直觉——消息嘛,传到了就行了,留着干嘛?
然而用户说:不行,我要在手机上看昨天聊的内容,晚上回家在 Mac 上接着看。这就引出了多端同步和消息漫游的需求,传统架构完全做不到。
二、现代架构:先存储,后同步
现代架构的核心思想是先存储,后同步。消息从发送方发出,服务端不是先考虑"怎么传给接收方",而是先存起来。
| |
这里的核心变化是引入了两个独立的存储系统:
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 是一个有序消息队列,具有以下三个特性:
- 每一条消息都有一个顺序 ID(SeqId),后面的消息 SeqId 一定比前面的大
- 新消息永远追加到尾部,不插入中间
- 支持按 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 引擎:大厂的自研路线,极致优化
一个典型的部署模型:
| |
六、工程中的实际考量
理论讲完了,聊几个实际中遇到的痛点:
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 系统的架构设计——消息同步和存储》