212|即时通讯系统设计:消息可靠性、已读/未读、离线消息、群聊扩散

架构设计与系统设计约 15 分钟阅读8,277 字符
知识库文档 213 / 251

阅读时间:约 40-50 分钟 关键词:消息可靠性、ACK 机制、已读回执、未读计数、离线消息同步、写扩散、读扩散、IM 架构


一、引言:IM 系统的核心挑战

即时通讯(Instant Messaging,IM)是互联网最基础也最复杂的系统之一。从微信、钉钉到 Discord、Telegram,每天有数十亿条消息在这些系统中流转。表面上看,IM 只是"发消息、收消息"六个字,但背后涉及的工程挑战远超大多数 CRUD 系统。

一个合格的 IM 系统需要在四个核心维度上做到极致:

  1. 消息可靠性:消息不能丢、不能重复、不能乱序
  2. 已读/未读状态:发送者需要知道消息是否被对方看到
  3. 离线消息处理:用户不在线时消息如何暂存、如何同步
  4. 群聊扩散效率:一条消息如何在群组内高效触达成千上万成员

这四个问题不是独立的——它们相互交织,任何一个设计决策都会影响其他三个。本文从工程实践角度,逐一拆解每个问题的核心原理、主流方案和关键权衡。


二、消息可靠性:从发送到接收的全链路保障

2.1 消息可靠性的三层含义

在 IM 系统中,"消息可靠"包含三个递进的层次:

层次 含义 失败场景
发送可靠性 客户端发出的消息一定到达服务端 网络断开、超时
存储可靠性 服务端接收的消息一定持久化 进程崩溃、磁盘故障
投递可靠性 服务端存储的消息一定送达目标客户端 推送失败、客户端不在线

任何一个层次出问题,用户都会感知到"消息丢了"。

2.2 发送可靠性:ACK 确认机制

客户端发送消息到服务端的可靠性,核心依赖应用层 ACK 机制。

关键设计点:

  • 消息 ID 由客户端生成(UUID),确保即使网络重试也能去重
  • 服务端返回全局 msgId,作为后续操作(撤回、引用)的标识
  • 客户端设置超时重试:通常 5-10 秒未收到 ACK 则重发,重试 3 次后标记失败
  • 服务端通过 msgId 去重:收到相同 msgId 的消息直接返回缓存的 ACK

文章配图
文章配图

2.3 存储可靠性:写成功才算成功

服务端收到消息后,必须先持久化,再返回 ACK。这是不可妥协的原则。

典型的消息存储流程:

  1. 消息写入主库(同步写)
  2. 主库返回写入成功
  3. 服务端返回 ACK 给客户端
  4. 异步同步到从库/副本

如果先返回 ACK 再写库,一旦服务端在"已 ACK 但未写库"之间崩溃,消息就永久丢失了。

存储选型考量:

  • 时序性:IM 消息天然按时间排序,适合基于时间分片的存储策略
  • 冷热分离:近期消息(如 7 天内)放热存储(MySQL/Redis),历史消息(7 天以上)放冷存储(HBase/TiKV 等)
  • 索引设计:核心查询是 (conversation_id, sequence_id) 范围扫描,联合索引即可高效覆盖

2.4 投递可靠性:推拉结合

消息存储到服务端后,需要投递给目标用户。这里有两种模式:

推送模式(Push):

  • 服务端通过长连接主动推送消息给在线用户
  • 优点:实时性高,延迟低
  • 缺点:用户不在线时推送失败,需要配合离线机制

拉取模式(Pull):

  • 客户端定期轮询服务端获取新消息
  • 优点:简单可靠
  • 缺点:延迟高,空轮询浪费资源

生产实践:推拉结合。用户在线时走推送,收到推送后客户端可以拉取补全(防止推送丢失);用户上线时走拉取,一次性拉取离线期间的所有消息。

文章配图
文章配图

2.5 消息去重与幂等

消息重复是分布式系统无法回避的问题。IM 系统中重复消息的来源:

来源 场景
客户端重试 发送消息后网络超时,客户端重发
服务端重试 推送消息失败后重试推送
MQ 重试 消息队列消费失败后重投

去重策略:

  • 客户端去重:每条消息携带客户端生成的唯一 ID(如 clientMsgId),服务端以此为去重键
  • 服务端去重:维护一个时间窗口(如 10 分钟)的去重集合,使用 Redis 的 SETNX 或布隆过滤器
  • 接收端去重:客户端本地维护已接收消息的 ID 集合,收到重复消息直接丢弃

2.6 消息顺序性

严格的消息顺序在分布式系统中代价极高。IM 系统的做法是:

  • 会话内有序:同一会话(单聊或群聊)内的消息保证顺序,通过会话级别的递增序列号(seq)实现
  • 会话间无序:不同会话之间的消息不保证全局顺序,也没有必要

服务端为每个会话维护一个自增的 seq,每条消息分配一个递增的 seq,客户端按 seq 排序展示。


三、已读/未读状态设计

3.1 单聊已读回执

单聊的已读回执是最简单的场景:接收者打开会话时,客户端向服务端上报"已读"。

实现方式:

  1. 用户 A 向用户 B 发送消息 {msgId: M1, seq: 100}
  2. 用户 B 打开会话,客户端上报 lastReadSeq: 100
  3. 服务端更新 B 的 read_cursor = 100
  4. 服务端推送 read_receipt 给 A 的在线设备
  5. A 的客户端显示"已读"

存储设计:

  • conversation_read_cursor 表:conversation_id、user_id、last_read_seq、updated_at
  • 每条消息的已读状态是计算出来的,不是存储的:msg.seq <= read_cursor 即视为已读

3.2 群聊已读统计

群聊的已读比单聊复杂得多——一个 500 人的群,不可能为每条消息存储 500 个已读状态。

方案一:写扩散记录已读人

为每条消息维护一个"已读用户列表":message_read_status: msg_id, read_users: [user1, user2, ...]。缺点:大群场景下每条消息都要更新大量记录,写放大严重。

方案二:群成员已读游标

为每个群成员维护一个"已读到哪条消息"的游标:group_read_cursor: group_id, user_id, last_read_seq。某个用户读了群消息,就更新他的游标。展示时,消息的 seq 与游标比较即可。

这种方式下,已读人数的计算变为:统计 group_read_cursor 中 last_read_seq >= msg.seq 的成员数。对于大群,不需要实时精确计算,可以异步统计或抽样展示。

文章配图
文章配图

3.3 会话级别的未读计数

未读计数是 IM 最核心的体验指标之一——那个红色的小数字直接影响用户打开 App 的频率。

计算方式:未读数 = 会话最新消息 seq - 用户在该会话的已读游标

关键挑战:未读计数需要在每次新消息到达时更新,对于活跃用户可能有数百个会话需要更新未读数。如果每次都实时计算,性能开销很大。

优化策略:

策略 说明
增量更新 新消息到达时,只更新对应会话的未读数 +1
懒计算 用户打开会话列表时才计算,平时不维护
缓存 未读数存 Redis,定期同步到 DB
游标偏移 不需要精确未读数,有个大致范围即可

生产实践:增量更新 + 懒计算混合。活跃会话用增量更新(Redis 维护),冷会话在用户打开时临时计算。钉钉和微信都采用类似策略。

3.4 扩散写 vs 拉取模式

"扩散写"在这里指的是:当用户 A 读了消息后,需要将"已读"这个状态通知给所有相关方。对于群聊,这涉及大量写操作。

两种模式对比:

维度 扩散写 拉取模式
实时性 高(实时推送) 中(按需拉取)
写放大 大(群聊 N 个成员 × 已读通知) 小(无写入)
读放大 小 大(每次展示都要查)
适用场景 小群(<100人) 大群(>100人)

微信的做法:微信群聊不展示已读/未读状态(只显示消息数),避免了这个问题。但钉钉需要展示已读/未读,采用写扩散 + 懒更新的混合策略。


四、离线消息系统

4.1 离线消息的生命周期

用户不在线时,发给他的消息需要暂存,等他上线后同步。一个完整的离线消息流程:

  1. 用户 A 向用户 B 发送消息(B 离线)
  2. 服务端写入消息存储,标记 B 未投递
  3. B 上线,建立长连接
  4. 客户端发起消息同步请求(携带本地最新 seq)
  5. 服务端返回 seq 之后的所有消息
  6. 客户端拉取完毕,ACK 确认
  7. 服务端标记投递完成

文章配图
文章配图

4.2 消息同步机制

增量同步(推荐):客户端请求 sync(from_seq=500, limit=100),服务端返回 seq 501-600 的消息列表。增量同步的核心是每个会话维护一个递增的 seq 序列。客户端本地记录已同步到的最大 seq,每次上线只拉取增量。

全量同步(兜底):当客户端本地 seq 与服务端差距过大(如换设备、清缓存),需要全量同步。全量同步通常分批拉取,避免一次性拉取过多数据。

4.3 离线消息存储

离线消息不应该单独存储——它和在线消息是同一份数据。区别只在于投递状态。

消息存储表设计:

  • messages 表:msg_id (PK)、conversation_id (INDEX)、sender_id、content、seq (会话内递增)、created_at、type
  • message_delivery 表(可选):msg_id、receiver_id、status (0=未投递, 1=已投递, 2=已读)、delivered_at

投递状态对每条消息每个接收者都有一条记录。对于群聊,这意味着 N 倍的写入放大——这也是为什么大群通常不维护精确投递状态的原因。

4.4 多端同步

现代 IM 中,用户通常有多个设备(手机、电脑、平板)。多端同步的核心是以服务端为真实数据源。

同步流程:

  1. 设备 A 发送消息 → 服务端存储
  2. 服务端推送通知给所有在线设备(含设备 B、C)
  3. 设备 B 收到推送 → 增量拉取消息
  4. 设备 C 离线 → 下次上线时增量拉取

关键设计:

  • 每个设备独立维护 last_sync_seq,各设备同步进度互不影响
  • 已读状态需要跨设备同步:设备 A 读了消息,设备 B 的未读计数也要更新
  • 消息撤回需要实时推送:不依赖客户端下次同步

文章配图
文章配图


五、群聊消息扩散模型

群聊是 IM 系统最复杂的场景,核心矛盾在于:一条消息需要触达 N 个群成员,而 N 可能从 2 到 200000。

5.1 写扩散模型(Inbox 模型)

原理:发送者的一条消息,复制 N 份写入每个接收者的收件箱。

用户 A 在 500 人群发消息:传统模式 1 次写入,写扩散 500 次写入(每个群成员一份)。

文章配图
文章配图

优点:

  • 每个用户的消息读取是独立的,互不干扰,读取性能极好
  • 消息撤回、删除等操作简单——从各自收件箱删除即可
  • 天然支持用户级别的已读/未读

缺点:

  • 写放大:500 人群聊,一条消息写 500 次
  • 存储放大:同一条消息存储 500 份
  • 大群发送延迟高:需要等所有写入完成

适用场景:小群(<500 人),如微信的群聊。

5.2 读扩散模型(Outbox 模型)

原理:消息只存储一份,群成员读取时从消息队列中拉取。

文章配图
文章配图

优点:

  • 写放大极低,一条消息只写一次
  • 存储经济,不会因群成员数增长而爆炸
  • 发送延迟低,与群大小无关

缺点:

  • 每个用户读取消息时都要访问群消息队列,读取放大
  • 消息撤回、删除需要通知所有已读用户
  • 个性化消息(如 @提醒)处理复杂

适用场景:大群(>500 人),如 Discord 的频道、Telegram 的超级群。

5.3 混合模型

生产系统普遍采用混合模型:根据群大小动态切换扩散策略。

文章配图
文章配图

  • 小群(<500人):写扩散——写放大可控,换取极致的读取性能和个性化体验
  • 大群(>500人):读扩散——牺牲一些读取性能,换取写入效率和存储经济
  • 超大群/频道(>10000人):读扩散 + 分页缓存——热点消息缓存,减少数据库压力

微信的实践:微信群聊最大 500 人,采用写扩散。每个用户有一个"收件箱",消息写入每个群成员的收件箱。这样的设计使得微信的消息读取极为高效,但限制了群规模。

钉钉的实践:群规模可达数千甚至上万,采用混合模型。小群写扩散,大群读扩散。群扩容时可以进行模式切换。

5.4 大群的特殊优化

对于万人大群,即使读扩散也有性能挑战:

热点消息缓存:最近 100 条消息缓存到 Redis,应对高频读取。使用消息 seq 范围作为缓存 key。

分页读取:客户端按 seq 范围分页拉取,每页 20-50 条,避免一次性拉取大量消息。

消息压缩:相邻时间内的消息合并,减少网络传输。相同发送者的连续消息合并展示。

读写分离:写入走主库,读取走从库。大群消息读取对延迟不敏感,可以读从库。

文章配图
文章配图


六、IM 系统整体架构

6.1 架构分层

文章配图
文章配图

6.2 各层职责

接入层(长连接网关):

  • 维护与客户端的 TCP/WebSocket 长连接
  • 协议解析、编解码
  • 心跳检测、连接管理
  • 消息的上下行转发

逻辑层:

  • 消息路由:单聊消息转发到目标用户,群聊消息分发到群成员
  • 会话管理:会话列表、未读计数、已读游标
  • 状态同步:多端同步、在线状态、已读状态
  • 推送服务:离线推送(APNs/FCM)、在线推送

存储层:

  • 消息 DB:消息主体存储,按会话+时间分片
  • Redis:热数据缓存、在线状态、未读计数
  • 冷存储:历史消息归档
  • 对象存储:图片、语音、视频等富媒体文件

6.3 长连接选型

长连接是 IM 系统的基石。主流方案:

方案 优点 缺点 代表
TCP 自研协议 极致性能,自定义程度高 开发成本高,H5 不支持 微信
WebSocket 标准协议,H5 原生支持 无离线推送能力 Slack
MQTT 轻量级,IoT 友好 功能有限 Facebook Messenger
gRPC Stream 双向流,HTTP/2 移动端支持一般 较新方案

生产实践:WebSocket 做主力通道,同时保留 HTTP 长轮询作降级。


七、总结:关键设计权衡

设计 IM 系统时,没有任何一个方案是银弹。核心权衡点:

权衡维度 选项 A 选项 B 选择依据
扩散模型 写扩散 读扩散 群大小:小群写扩散,大群读扩散
消息同步 增量同步 全量同步 增量为主,全量兜底
已读回执 实时推送 懒加载 小群实时,大群懒加载
未读计数 增量维护 临场计算 活跃会话增量,冷会话临场计算
存储策略 冷热分离 统一存储 成本敏感用冷热分离,简单场景统一存储
一致性 强一致 最终一致 消息写入强一致,状态同步最终一致

核心原则:

  1. 消息不丢比什么都重要:先写库再 ACK,不可妥协
  2. 会话内有序,会话间无序:没必要追求全局顺序
  3. 小群用写扩散,大群用读扩散:根据群规模自适应
  4. 能懒计算就不实时维护:未读计数、已读统计等,尽量在读取时计算
  5. 多端以服务端为准:避免端到端同步的复杂性

八、面试追问模块

以下问题从面试官视角设计,考察对 IM 系统设计的深入理解。建议在阅读完正文后,先尝试自己回答,再看参考答案。

Q1:如果让你设计一个支持 10 万人的超级群,消息发送延迟要求在 100ms 以内,你会怎么设计扩散模型?

参考答案:

10 万人的超级群,写扩散完全不可行(10 万次写入不可能在 100ms 内完成)。核心思路是读扩散 + 分层缓存:

  1. 消息只写一次:消息写入群消息队列(单次写入,<10ms)
  2. 热点消息多级缓存:最近 N 条消息缓存到 Redis(本地缓存 → Redis → MySQL)
  3. 在线用户直接推送:通过长连接推送消息通知(只推送通知,不含消息体),客户端收到通知后异步拉取
  4. 离线用户不做实时推送:等用户上线后增量同步
  5. 消息拉取走从库:读扩散的读操作打散到多个从库,避免主库压力

这样设计的发送延迟 = 单次写入(~5ms)+ 推送通知(<50ms),总计 <100ms。

Q2:已读回执在大群场景下,为什么微信不展示,而钉钉展示?钉钉是怎么实现的?

参考答案:

微信不展示群聊已读回执是产品选择,背后有技术原因:微信 500 人群如果展示已读/未读,每条消息的已读状态更新会产生 500 次写入,这是不可接受的。

钉钉展示已读/未读的实现:

  1. 不维护每条消息的已读列表,而是维护每个群成员的已读游标(last_read_seq)
  2. 用户打开群聊时,客户端上报当前浏览到的最新 seq,更新游标(1 次写入)
  3. 展示时,消息 seq 与各成员游标比较,计算已读/未读人数
  4. 对于大群,已读人数不需要实时精确,可以异步批量统计,延迟几秒甚至几十秒都能接受
  5. 已读人数的展示粒度可以降级:具体人数 → "大部分人已读" → "已读/未读"两个状态

Q3:离线消息数量很大时(比如用户一个月没上线),如何处理消息同步?

参考答案:

  1. 分页同步:客户端每次拉取有限条(如 100 条),用户滚动时再拉取下一页
  2. 按会话聚合:先同步各会话的"最新几条"和未读数,用户点进具体会话再拉取完整历史
  3. 消息压缩:连续的同类消息可以合并(如 100 条"你好"合并为一条)
  4. 时间线跳转:提供时间线跳转功能,让用户可以跳到特定日期,减少全量拉取的压力
  5. 冷热分离:近期消息(如 7 天内)保留在热存储,历史消息放冷存储,全量同步只涉及热存储
  6. 服务端限流:控制每个用户的同步 QPS,避免离线用户批量上线时冲击存储

Q4:什么是"消息扩散风暴"?如何防范?

文章配图
文章配图

参考答案:

消息扩散风暴是指:一条消息触发了大量后续操作,形成雪崩效应。典型场景:

  • 10000 人群里发了一条消息,触发 10000 次推送
  • 推送导致 10000 个客户端同时拉取消息
  • 拉取操作打爆数据库

防范措施:

  1. 推送分级:大群消息不推送给所有在线成员,而是推送给"活跃用户"(如最近 30 分钟有操作的)
  2. 拉取限流:网关层对群消息拉取做限流,控制并发数
  3. 消息通知与消息体分离:推送只发通知("有新消息"),不包含消息体,客户端收到通知后决定是否拉取
  4. 缓存预热:热点消息提前缓存到 Redis,减少数据库访问
  5. 随机延迟:客户端收到推送后,随机延迟 0-3 秒再拉取,削峰填谷

Q5:多端同时在线的消息同步,如何保证消息不丢、不重、不乱序?

参考答案:

核心原则:以服务端为唯一真实数据源。

不丢:

  • 客户端每次上线发起增量同步(sync from last_seq),拉取所有未收到的消息
  • 在线期间通过长连接推送,推送失败时标记,客户端下次心跳时补拉

不重:

  • 每条消息有全局唯一的 msgId(服务端生成)
  • 客户端维护已接收消息 ID 的本地集合,收到重复消息直接丢弃
  • 增量同步以 seq 为游标,天然去重(同一 seq 的消息不会拉两次)

不乱序:

  • 服务端为每个会话维护递增的 seq,消息写入时原子分配 seq
  • 客户端按 seq 排序展示,无论消息到达顺序如何
  • 消息到达顺序可能与 seq 不一致(网络原因),客户端做本地排序和补全(seq 有空洞时等待或主动拉取)

Q6:IM 系统的消息存储,如何设计冷热分离?

参考答案:

数据分层:

层级 时间范围 存储 访问延迟
热数据 0-7 天 MySQL + Redis <10ms
温数据 7-30 天 MySQL(只读副本) <50ms
冷数据 30 天+ HBase / TiKV / 归档 <200ms

迁移策略:

  • 定时任务每天凌晨扫描超过 7 天的消息,从热库迁移到温库/冷库
  • 迁移时保持消息 ID 不变,确保引用关系不断
  • 客户端查询历史消息时,根据时间范围路由到不同存储

索引优化:

  • 热数据表按月分表,索引精简
  • 冷数据存储按会话 ID + 时间范围组织,不需要复杂索引

本文是《Java 技术专家与架构师面试:250 个高频考点总览》系列的第 212 篇。