设计一个即时通讯(IM)系统

深入系统设计场景题高频约 11 分钟读完

一句话回答

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):

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 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。