设计一个即时通讯(IM)系统
一句话回答
IM 系统分成接入层和逻辑层:接入层是长连接网关(WebSocket 或自定义 TCP 协议),只负责维持连接、心跳和收发包,并登记"用户连在哪台网关";逻辑层负责校验、分配会话内递增的序号(seq)、存储和投递。可靠投递靠 ACK + 超时重传 + 消息 ID 去重,但最终兜底的是按序号拉取:客户端记住每个会话收到的最大 seq,发现缺口、断线重连、换设备登录时都按 seq 拉取,离线消息和多端同步也由此解决。群聊在小群用写扩散、大群用读扩散;已读回执上报"已读到的 seq";心跳检测断线,重连用指数退避加随机抖动。
详细解析
第一步:澄清需求
- 功能:单聊、群聊(假设上限 500 人)、离线消息、多端同时在线并同步(手机、PC、Web)、历史消息漫游、已读回执
- 核心要求:消息不丢、不重,同一会话内不乱序;在线消息延迟低
- 量级估算(假设值):
- DAU 1000 万,峰值同时在线 300 万
- 每人每天发 50 条:5 亿条/天,平均约 5800 条/秒,峰值按 5 倍算约 3 万条/秒
- 单台网关维持 50 万个长连接(要压测确认):300 万 ÷ 50 万 = 6 台,留出冗余和故障切换的余量,部署 10~12 台
- 存储:每条按 500 字节(含索引)算,5 亿 × 500 字节 ≈ 250 GB/天,一年约 90 TB,要分片和冷热分离
第二步:整体架构
客户端(手机 / PC / Web)
│ 长连接:WebSocket 或自定义 TCP 协议,连上后先鉴权
▼
接入层:长连接网关集群(只管连接:鉴权、心跳、收发包,不含业务逻辑)
│ 上线时登记路由:用户 + 设备 → 网关节点(Redis,带过期时间,心跳时续期)
▼ RPC 或消息队列
逻辑层:消息服务
1 校验:好友关系、群成员、是否禁言;按发送方的 client_msg_id 去重
2 分配会话内递增的 seq,写入消息存储
3 回 ACK 给发送方(带服务端生成的 msg_id 和 seq)
4 查路由,把消息交给接收方所在的网关,网关推给客户端
5 接收方离线:消息已经存好,等上线后拉取;另发一条手机系统推送提醒
▼
存储:消息表(按会话分片)、会话表;Redis:路由、在线状态、seq 计数器
第三步:核心模块和数据模型
可靠投递:
发送方 服务端 接收方
│── 消息(client_msg_id) ──────>│ │
│ │ 去重、分配 seq、存储 │
│<── ACK(msg_id, seq) ─────────│ │
│ │── 推送(msg_id, seq) ─────────>│
│ │<── ACK ───────────────────────│
超时没收到 ACK:用同一个 client_msg_id 重发 超时没收到 ACK:重推几次,再不行等它来拉
- 上行:服务端按"发送方 + client_msg_id"去重,重复的请求直接返回第一次分配的 msg_id 和 seq,重发就是幂等的
- 下行:客户端按 seq 去重,重复的消息丢弃,但仍然回 ACK
- 兜底:推送只是为了快,完整性靠拉取。客户端只要知道自己缺了哪些 seq 就能补齐,推送丢了也不会丢消息
消息有序:
- 每个会话一个递增的 seq,由服务端分配;客户端按 seq 排序展示,而不是按到达顺序或发送方的本地时间(各端时钟不准)
- 发现缺口(收到 7,本地最大是 5)就拉取 6;缺口也可能只是消息还在路上,可以稍等片刻再拉
- seq 不能回退:用 Redis
INCR分配时,主从切换可能丢掉最近的自增,产生重复的 seq。可以像号段一样从数据库预取一段,或者存储时用"会话 ID + seq"的唯一索引兜底,冲突时把计数器调到库里的最大 seq 之后再分配 - 只需要会话内有序,不需要全局有序;经过消息队列时按会话 ID 选分区,见消息的顺序
离线消息和多端同步:每个设备各自保存每个会话的同步位点(已连续收到的最大 seq)。上线时先拉"会话列表 + 每个会话的最新 seq",和本地对比,只拉有新消息的会话,先拉最近一页展示,往前翻时再拉历史。自己在手机上发的消息,PC 端同样要收到,所以发送方的其他设备也是"接收方"。
存储选型:消息写多读少,按"会话 + seq 范围"查询,适合按会话 ID 分片的 MySQL,或者宽表存储(例如 HBase、Cassandra 这类),行键用"会话 ID + seq"。最近的消息缓存在 Redis,历史消息冷热分离、归档。
第四步:关键难点
群聊:写扩散还是读扩散:
| 写扩散 | 读扩散 | |
|---|---|---|
| 发送 | 消息存一份,给每个成员的收件箱各写一条索引 | 只写入群的消息列表 |
| 同步 | 每个用户一个收件箱、一个位点,拉一次拿到所有会话的新消息 | 每个群单独拉,要记住每个群的位点 |
| 写放大 | 群越大越严重 | 只写一次 |
| 适用场景 | 单聊、小群 | 大群、频道 |
已读回执:
- 单聊:接收方上报"在这个会话已读到 seq = X",发送方把 ≤ X 的消息都标为已读,每个会话每人只存一个数
- 群聊:每个成员存一个已读位点,某条消息的已读人数 = 已读位点 ≥ 它的 seq 的成员数,查看时再算;大群的代价高,通常只在小群开启。已读上报要合并、节流,不要每条都报
心跳与断线重连(协议细节见 WebSocket 的握手和保活):
- 客户端定时发应用层心跳,服务端连续几个周期收不到就关闭连接、删除路由;移动网络的 NAT 会回收空闲连接,心跳间隔要比它短,可以按网络情况自适应调整
- 断线后按指数退避重连,并加随机抖动:一台网关重启,几十万客户端同时重连,没有抖动就会一起压到其他网关上
- 重连成功后按 seq 拉取断线期间的消息;网关发版时先通知客户端分批迁移,而不是直接断开
第五步:扩展与优化
- 解耦:网关和逻辑层之间用消息队列,网关只管连接,两层各自扩容
- 撤回和编辑:发一条"撤回 seq = X"的控制消息,各端收到后处理,本身也走同样的 seq 和同步机制
- 安全:传输加密、敏感内容审核,对隐私要求高的产品做端到端加密
代码示例
客户端处理一个会话的推送:按 seq 去重、排序,发现缺口就拉取(JavaScript):
class ConversationSync {
constructor(pullAfter, onMessage) {
this.pullAfter = pullAfter // (seq) => Promise<消息数组>:拉取 seq 之后的消息
this.onMessage = onMessage // 按 seq 顺序交给界面展示
this.maxSeq = 0 // 已经连续收到的最大序号,持久化在本地
this.buffer = new Map() // 先到的、序号不连续的消息
this.pulling = null
}
// 收到服务端推送(不管是否重复,都要给服务端回 ACK,这里省略)
async onPush(msg) {
this.accept([msg])
if (this.buffer.size > 0) await this.sync() // 有缺口:中间的消息丢了或者还在路上
}
// 断线重连、启动时也调用:拉取本地最大序号之后的所有消息
async sync() {
this.pulling ??= this.pullAfter(this.maxSeq)
.then((msgs) => this.accept(msgs))
.finally(() => (this.pulling = null))
return this.pulling // 并发触发时复用同一次拉取
}
accept(msgs) {
for (const m of msgs) {
if (m.seq > this.maxSeq) this.buffer.set(m.seq, m) // seq <= maxSeq 是重复消息,丢弃
}
while (this.buffer.has(this.maxSeq + 1)) {
const next = this.buffer.get(this.maxSeq + 1)
this.buffer.delete(next.seq)
this.maxSeq = next.seq
this.onMessage(next)
}
}
}
const server = [1, 2, 3, 4].map((seq) => ({ seq, text: `消息${seq}` }))
const c = new ConversationSync(async (after) => server.filter((m) => m.seq > after), (m) => console.log(m.seq))
await c.onPush(server[0]) // 输出 1
await c.onPush(server[0]) // 重复,忽略
await c.onPush(server[2]) // 缺 2 → 拉取,输出 2 3 4
面试官可能追问
怎么知道用户连在哪台网关?网关挂了怎么办?
用户上线时,网关把"用户 + 设备 → 网关节点"写进 Redis,并设置过期时间,心跳时续期。消息服务投递前查这张路由表,把消息发给对应的网关。网关挂了,它上面的路由会因为不再续期而过期,客户端重连到其他网关后写入新的路由;这段时间里投递失败的消息按离线处理,客户端重连后按 seq 拉取,不会丢。
TCP 本身就是可靠的,为什么还要应用层 ACK?
TCP 只保证数据到达对方的内核缓冲区。消息到了网关,网关还没转发就崩溃了;或者推给客户端时,App 还没处理就被杀掉了,这些 TCP 都管不了。应用层 ACK 确认的是"客户端已经收到并保存",是端到端的确认。
msg_id 和 seq 有什么区别,只用一个行不行?
msg_id 是全局唯一的 ID(比如雪花 ID),用于去重和引用某条消息(撤回、回复);seq 是会话内连续递增的序号,用于排序和发现缺口。雪花 ID 只是趋势递增、不连续,客户端看到 ID 跳了也不知道是不是丢了消息,所以两者都需要。
500 人的群,一条消息要推 500 次,怎么优化?
只推在线的成员,离线的等它上线后拉取。在线成员按所在网关分组,每台网关只调用一次,带上成员列表,由网关在本地分发。更大的群只推一条"有新消息"的通知,客户端收到后按 seq 来拉(读扩散),避免把整条消息复制几千份。
易错点
- 按发送方的本地时间给消息排序,各端时钟不准,顺序就乱了
- 只靠推送,没有按 seq 拉取兜底,推送一丢消息就丢了
- 客户端重发时生成了新的消息 ID,服务端无法去重,对方收到两条
- 断线重连不加随机抖动,网关故障时引发重连风暴
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。