如何设计一个IM即时通讯系统?

2025年 阅读约 15 分钟 面试指南 · 系统设计

深入解析IM即时通讯系统设计:WebSocket长连接管理、消息可靠性(ACK+重试+去重+seqId)、消息存储(写扩散vs读扩散)、群聊设计(扩散写+批量推送),附面试模拟问答。

一句话总结

IM系统核心:WebSocket长连接(全双工通信)+ 消息可靠性(ACK+重试+seqId去重)+ 消息存储(单聊写扩散、群聊读扩散)。单聊:写扩散(一条消息写两份,各自拉取,简单可靠)。群聊:读扩散(一条消息写一份,群成员拉取,节省存储)。消息同步:seqId 递增序列号,客户端记录已收到的最大 seqId,增量拉取。

初级理解

IM 系统架构

# IM 系统分层 # 1. 接入层(Netty + WebSocket):管理长连接 # 2. 逻辑层:消息路由、存储、推送 # 3. 存储层:MySQL(消息)+ Redis(路由表+缓存) # 4. 推送层:在线推送(WebSocket)+ 离线推送(APNs/FCM) # 核心流程 # 用户A发消息给用户B # 1. A → 接入服务器:发送消息 # 2. 接入服务器 → 逻辑服务器:路由消息 # 3. 逻辑服务器:存储消息 + 查路由表(B在哪台服务器) # 4. 逻辑服务器 → B的接入服务器:转发消息 # 5. B的接入服务器 → B:推送消息

消息可靠性四要素

# 1. ACK 确认 # 客户端收到消息 → 回复 ACK(包含 msgId) # 服务端收到 ACK → 标记已送达 # 2. 重试机制 # 服务端发送消息 → 等待 ACK(超时 5s) # 未收到 ACK → 重试(最多 3 次) # 3 次失败 → 标记失败,走离线推送 # 3. 消息去重 # 每条消息有唯一 msgId # 客户端维护已收到 msgId 集合 # 重复消息直接丢弃 # 4. seqId 顺序号 # 每个用户的消息有递增 seqId # 客户端记录 lastSeqId # 断线重连后拉取 seqId > lastSeqId 的消息
一句话总结:ACK+重试保证送达,msgId去重,seqId保证顺序和增量同步。

中级深入

写扩散 vs 读扩散

# 写扩散(单聊推荐) # 一条消息写两份(发送方+接收方各自的消息表) # 用户拉取自己的消息表即可 # 优点:读取简单,各自独立 # 缺点:写入放大(群聊时严重) # 读扩散(群聊推荐) # 一条消息只写一份(群消息表) # 群成员拉取群消息表 # 优点:写入简单,节省存储 # 缺点:读取时需要查群消息表 # 实际方案:混合 # 单聊(1对1):写扩散 # 群聊(1对多):读扩散 # 超大群(万人大群):写扩散 + 异步推送

消息存储设计

# 单聊消息表(写扩散) CREATE TABLE msg_single ( id BIGINT PRIMARY KEY, seq_id BIGINT NOT NULL, # 用户维度递增 from_uid BIGINT, to_uid BIGINT, content TEXT, msg_type TINYINT, # 1文本 2图片 3语音 created_at DATETIME, INDEX idx_uid_seq (to_uid, seq_id) # 拉取消息 ); # 群聊消息表(读扩散) CREATE TABLE msg_group ( id BIGINT PRIMARY KEY, group_id BIGINT, from_uid BIGINT, content TEXT, msg_type TINYINT, created_at DATETIME, INDEX idx_group_time (group_id, created_at) ); # 用户消息同步位点 CREATE TABLE msg_sync ( user_id BIGINT PRIMARY KEY, last_seq_id BIGINT, # 单聊最大 seqId last_group_msg_id BIGINT # 群聊最大 msgId );
中级要点:单聊写扩散(一人一份),群聊读扩散(一份共享),混合使用。

高级拓展

群聊消息推送优化

# 万人大群推送挑战 # 问题:一条消息要推送给 10000 人 # 写扩散:10000 次写入,不可接受 # 读扩散:10000 人拉取,DB 压力大 # 优化方案 # 1. 读扩散 + 缓存 # 群消息写一份 → Redis 缓存最新消息 # 群成员从 Redis 拉取(减少 DB 压力) # 2. 在线批量推送 # 群成员中在线的直接推送 # 离线的走离线消息 # 3. 分页拉取 # 用户进入群聊 → 拉取最近 50 条 # 上滑 → 拉取更早的消息 # 4. 消息压缩 # 多条消息合并推送(如 100ms 内的消息)

已读/未读设计

# 单聊已读回执 # 1. 用户B查看消息 → 发送已读回执(包含最后一条消息的 seqId) # 2. 服务端更新已读位点 # 3. 推送已读回执给用户A # 群聊已读(显示多少人已读) # 1. 每条群消息记录已读人数 # 2. 用户查看消息 → 更新已读位点 # 3. 异步统计已读人数(不实时,延迟可接受) # 未读数计算 # 总消息数 - 已读消息数 = 未读数 # Redis 存储未读数(实时更新) # 或客户端本地计算

实战场景

场景:单聊消息发送

@Service public class MessageService { public void sendMessage(Long fromUid, Long toUid, String content) { # 1. 生成消息 Message msg = new Message(); msg.setMsgId(snowflake.nextId()); msg.setFromUid(fromUid); msg.setToUid(toUid); msg.setContent(content); # 2. 写扩散:存两份 long seqA = redis.incr("seq:" + fromUid); long seqB = redis.incr("seq:" + toUid); msgMapper.insert(msg, fromUid, seqA); # 发送方 msgMapper.insert(msg, toUid, seqB); # 接收方 # 3. 查路由表 String serverIp = redis.get("route:" + toUid); # 4. 在线 → 推送;离线 → 存离线消息 if (serverIp != null) { pushService.push(serverIp, msg); } else { offlineService.save(toUid, msg); } } }

面试模拟

面试官:IM系统怎么保证消息不丢不重?

你:四层保障:1)ACK确认:客户端收到后回复ACK,服务端超时重试(最多3次);2)消息去重:每条消息有唯一msgId,客户端去重;3)seqId顺序号:保证消息顺序,断线重连后增量拉取;4)离线消息:用户不在线时存DB,上线后拉取。

面试官:写扩散和读扩散怎么选?

你:单聊用写扩散(一条消息写两份),读取简单各自拉取;群聊用读扩散(一条消息写一份),节省存储。万人大群用读扩散+缓存优化。实际微信是写扩散(每人一份消息表),钉钉是读扩散(群消息共享)。