阅读时间:约 40-50 分钟 关键词:消息可靠性、ACK 机制、已读回执、未读计数、离线消息同步、写扩散、读扩散、IM 架构
一、引言:IM 系统的核心挑战
即时通讯(Instant Messaging,IM)是互联网最基础也最复杂的系统之一。从微信、钉钉到 Discord、Telegram,每天有数十亿条消息在这些系统中流转。表面上看,IM 只是"发消息、收消息"六个字,但背后涉及的工程挑战远超大多数 CRUD 系统。
一个合格的 IM 系统需要在四个核心维度上做到极致:
- 消息可靠性:消息不能丢、不能重复、不能乱序
- 已读/未读状态:发送者需要知道消息是否被对方看到
- 离线消息处理:用户不在线时消息如何暂存、如何同步
- 群聊扩散效率:一条消息如何在群组内高效触达成千上万成员
这四个问题不是独立的——它们相互交织,任何一个设计决策都会影响其他三个。本文从工程实践角度,逐一拆解每个问题的核心原理、主流方案和关键权衡。
二、消息可靠性:从发送到接收的全链路保障
2.1 消息可靠性的三层含义
在 IM 系统中,"消息可靠"包含三个递进的层次:
| 层次 | 含义 | 失败场景 |
|---|---|---|
| 发送可靠性 | 客户端发出的消息一定到达服务端 | 网络断开、超时 |
| 存储可靠性 | 服务端接收的消息一定持久化 | 进程崩溃、磁盘故障 |
| 投递可靠性 | 服务端存储的消息一定送达目标客户端 | 推送失败、客户端不在线 |
任何一个层次出问题,用户都会感知到"消息丢了"。
2.2 发送可靠性:ACK 确认机制
客户端发送消息到服务端的可靠性,核心依赖应用层 ACK 机制。
关键设计点:
- 消息 ID 由客户端生成(UUID),确保即使网络重试也能去重
- 服务端返回全局 msgId,作为后续操作(撤回、引用)的标识
- 客户端设置超时重试:通常 5-10 秒未收到 ACK 则重发,重试 3 次后标记失败
- 服务端通过 msgId 去重:收到相同 msgId 的消息直接返回缓存的 ACK

2.3 存储可靠性:写成功才算成功
服务端收到消息后,必须先持久化,再返回 ACK。这是不可妥协的原则。
典型的消息存储流程:
- 消息写入主库(同步写)
- 主库返回写入成功
- 服务端返回 ACK 给客户端
- 异步同步到从库/副本
如果先返回 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 单聊已读回执
单聊的已读回执是最简单的场景:接收者打开会话时,客户端向服务端上报"已读"。
实现方式:
- 用户 A 向用户 B 发送消息
{msgId: M1, seq: 100} - 用户 B 打开会话,客户端上报
lastReadSeq: 100 - 服务端更新 B 的
read_cursor = 100 - 服务端推送
read_receipt给 A 的在线设备 - 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 离线消息的生命周期
用户不在线时,发给他的消息需要暂存,等他上线后同步。一个完整的离线消息流程:
- 用户 A 向用户 B 发送消息(B 离线)
- 服务端写入消息存储,标记 B 未投递
- B 上线,建立长连接
- 客户端发起消息同步请求(携带本地最新 seq)
- 服务端返回 seq 之后的所有消息
- 客户端拉取完毕,ACK 确认
- 服务端标记投递完成

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、typemessage_delivery表(可选):msg_id、receiver_id、status(0=未投递, 1=已投递, 2=已读)、delivered_at
投递状态对每条消息每个接收者都有一条记录。对于群聊,这意味着 N 倍的写入放大——这也是为什么大群通常不维护精确投递状态的原因。
4.4 多端同步
现代 IM 中,用户通常有多个设备(手机、电脑、平板)。多端同步的核心是以服务端为真实数据源。
同步流程:
- 设备 A 发送消息 → 服务端存储
- 服务端推送通知给所有在线设备(含设备 B、C)
- 设备 B 收到推送 → 增量拉取消息
- 设备 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 | 选择依据 |
|---|---|---|---|
| 扩散模型 | 写扩散 | 读扩散 | 群大小:小群写扩散,大群读扩散 |
| 消息同步 | 增量同步 | 全量同步 | 增量为主,全量兜底 |
| 已读回执 | 实时推送 | 懒加载 | 小群实时,大群懒加载 |
| 未读计数 | 增量维护 | 临场计算 | 活跃会话增量,冷会话临场计算 |
| 存储策略 | 冷热分离 | 统一存储 | 成本敏感用冷热分离,简单场景统一存储 |
| 一致性 | 强一致 | 最终一致 | 消息写入强一致,状态同步最终一致 |
核心原则:
- 消息不丢比什么都重要:先写库再 ACK,不可妥协
- 会话内有序,会话间无序:没必要追求全局顺序
- 小群用写扩散,大群用读扩散:根据群规模自适应
- 能懒计算就不实时维护:未读计数、已读统计等,尽量在读取时计算
- 多端以服务端为准:避免端到端同步的复杂性
八、面试追问模块
以下问题从面试官视角设计,考察对 IM 系统设计的深入理解。建议在阅读完正文后,先尝试自己回答,再看参考答案。
Q1:如果让你设计一个支持 10 万人的超级群,消息发送延迟要求在 100ms 以内,你会怎么设计扩散模型?
参考答案:
10 万人的超级群,写扩散完全不可行(10 万次写入不可能在 100ms 内完成)。核心思路是读扩散 + 分层缓存:
- 消息只写一次:消息写入群消息队列(单次写入,<10ms)
- 热点消息多级缓存:最近 N 条消息缓存到 Redis(本地缓存 → Redis → MySQL)
- 在线用户直接推送:通过长连接推送消息通知(只推送通知,不含消息体),客户端收到通知后异步拉取
- 离线用户不做实时推送:等用户上线后增量同步
- 消息拉取走从库:读扩散的读操作打散到多个从库,避免主库压力
这样设计的发送延迟 = 单次写入(~5ms)+ 推送通知(<50ms),总计 <100ms。
Q2:已读回执在大群场景下,为什么微信不展示,而钉钉展示?钉钉是怎么实现的?
参考答案:
微信不展示群聊已读回执是产品选择,背后有技术原因:微信 500 人群如果展示已读/未读,每条消息的已读状态更新会产生 500 次写入,这是不可接受的。
钉钉展示已读/未读的实现:
- 不维护每条消息的已读列表,而是维护每个群成员的已读游标(
last_read_seq) - 用户打开群聊时,客户端上报当前浏览到的最新 seq,更新游标(1 次写入)
- 展示时,消息 seq 与各成员游标比较,计算已读/未读人数
- 对于大群,已读人数不需要实时精确,可以异步批量统计,延迟几秒甚至几十秒都能接受
- 已读人数的展示粒度可以降级:具体人数 → "大部分人已读" → "已读/未读"两个状态
Q3:离线消息数量很大时(比如用户一个月没上线),如何处理消息同步?
参考答案:
- 分页同步:客户端每次拉取有限条(如 100 条),用户滚动时再拉取下一页
- 按会话聚合:先同步各会话的"最新几条"和未读数,用户点进具体会话再拉取完整历史
- 消息压缩:连续的同类消息可以合并(如 100 条"你好"合并为一条)
- 时间线跳转:提供时间线跳转功能,让用户可以跳到特定日期,减少全量拉取的压力
- 冷热分离:近期消息(如 7 天内)保留在热存储,历史消息放冷存储,全量同步只涉及热存储
- 服务端限流:控制每个用户的同步 QPS,避免离线用户批量上线时冲击存储
Q4:什么是"消息扩散风暴"?如何防范?

参考答案:
消息扩散风暴是指:一条消息触发了大量后续操作,形成雪崩效应。典型场景:
- 10000 人群里发了一条消息,触发 10000 次推送
- 推送导致 10000 个客户端同时拉取消息
- 拉取操作打爆数据库
防范措施:
- 推送分级:大群消息不推送给所有在线成员,而是推送给"活跃用户"(如最近 30 分钟有操作的)
- 拉取限流:网关层对群消息拉取做限流,控制并发数
- 消息通知与消息体分离:推送只发通知("有新消息"),不包含消息体,客户端收到通知后决定是否拉取
- 缓存预热:热点消息提前缓存到 Redis,减少数据库访问
- 随机延迟:客户端收到推送后,随机延迟 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 篇。