设计一个即时通讯系统

本文系统设计一个高可用即时通讯系统:需求澄清与量级估算、长连接接入(WebSocket/连接管理与心跳)、消息模型与存储(单聊/群聊、msgid 单调)、多端同步(seq 游标)、扩展性(群聊/频道读扩散与写扩散)、可靠性(ack 重传与已读回执),并给出架构图、数据表与伪代码。

即时通讯(IM)是移动互联网最基础的实时能力:聊天消息要在几百毫秒内送达对端,单聊、群聊、频道消息要可靠存储,用户换了手机还能拉回全部历史记录,多端登录时 PC 与手机的消息进度要同步。本文按照系统设计面试的标准答题结构,设计一个生产级的即时通讯系统,覆盖长连接接入、消息存储、多端同步、群聊扩展与可靠性保证。

一句话:IM 的两条主线是「实时送达」与「可靠存储」——长连接负责把消息秒级推给在线用户,离线消息与多端同步靠「按序存储 + 游标拉取」保证不丢不乱。

一、需求澄清与量级估算

1.1 需求澄清

面试官给出题目「设计一个即时通讯系统」后,先通过提问明确边界:

  • 聊天形态:单聊、群聊、频道/公众号、系统通知,覆盖哪些?
  • 消息类型:文本、图片、语音、视频、位置、文件,是否都需要?
  • 在线状态:是否需要显示在线/离线、正在输入?状态如何感知?
  • 多端同步:手机、PC、平板多端同时在线,消息进度如何对齐?
  • 历史消息:需要保存多久?支持搜索吗?
  • 可靠性:弱网下消息如何保证不丢?是否需要已读回执?
  • 规模:多少 DAU、多少峰值 QPS、平均在线连接数?

明确假设(面向面试的合理假设):

需求项假设
聊天形态单聊 + 群聊 + 频道
消息类型文本、图片、语音、视频、文件
在线状态在线/离线/离开,客户端上报
多端同步多端在线,按 seq 游标对齐
历史消息永久保存,支持按会话拉取
可靠性不丢消息 + 已读回执
规模1 亿 DAU、峰值消息 100 万条/秒

1.2 量级估算

指标估算值推导
DAU1 亿大型国民级 IM
日消息量500 亿条人均日发 50 条
峰值消息100 万条/秒晚高峰 + 群发场景
在线连接5000 万约 50% 用户在线
单连接带宽~5KB/s心跳 + 消息推送
消息存储日均 PB 级文本小、媒体大(另存 OSS)
送达时延P99 < 500ms在线推送目标
群聊峰值万人群广播单群单条消息扇出 1 万

一句话:1 亿 DAU、100 万条/秒的消息,决定了 IM 必须是「长连接网关 + 消息存储 + 推送」的三段式架构——连接与存储解耦,网关无状态可水平扩。

二、高层架构设计

   ┌─────────────────────────┐   ┌─────────────────────────┐
   │  用户 A (手机/PC 多端)    │   │  用户 B (手机/PC 多端)    │
   └────────────┬────────────┘   └────────────┬────────────┘
                │ WebSocket 长连接              │ WebSocket 长连接
                ▼                              ▼
   ┌─────────────────────────────────────────────────────────┐
   │              接入网关层 (Connection Gateway)              │
   │  ① 连接管理/心跳 ② 连接路由表(uid→conn) ③ 消息收发代理   │
   │  无状态,可水平扩展;用 Redis 维护全局在线路由           │
   └────────────┬────────────────────────────────┬───────────┘
                │ 上行消息(发消息)                  │ 下行推送(推消息)
                ▼                                 │
   ┌──────────────────────────────────────────────▼──────────┐
   │                     消息服务层 (Msg Service)               │
   │  ① 消息路由与 seq 分配 ② 消息持久化 ③ 在线/离线分发        │
   │  ④ 已读回执 ⑤ 群聊扇出                                 │
   └────────────┬────────────────────────────────────────────┘
                │
   ┌────────────▼────────────────────────────────────────────┐
   │  存储层                                                   │
   │  MySQL:会话/消息索引、用户关系                            │
   │  KV(Cassandra/TiKV):按 (session, seq) 的时序消息存储     │
   │  Redis:在线状态、连接路由、未读数                          │
   └────────────┬────────────────────────────────────────────┘
                │
   ┌────────────▼────────────────────────────────────────────┐
   │  离线与推送:用户离线 → 消息存离线库 + 走 APNs/厂商推送     │
   │  媒体:图片/语音/视频 存 OSS,消息体只存 URL               │
   └─────────────────────────────────────────────────────────┘

整体拆为四层:

  1. 接入网关层:维护海量 WebSocket 长连接,负责心跳、鉴权、连接路由。
  2. 消息服务层:消息路由、seq 分配、持久化、在线/离线分发、群聊扇出。
  3. 存储层:时序消息存储 + 会话索引 + 在线状态,KV 与 MySQL 分工。
  4. 离线与媒体:离线消息缓存 + 厂商推送 + 媒体对象存储。

2.1 长连接 vs 短轮询

实时送达的前提是「服务端能主动找到用户」,长连接是唯一可行解:

短轮询:客户端每秒问「有新消息吗」——1 亿 DAU 每秒 1 亿次查询,不可行
长轮询:HTTP 挂起 30 秒等消息——连接频繁重建,实时性差
WebSocket 长连接:一次握手后双向全双工——服务端可随时推送,最贴合 IM

选型:WebSocket(HTTP 兼容、穿透性好);弱网退化为「长轮询 + 厂商推送」兜底

一句话:IM 实时性的根基是长连接——连接建立后由服务端「主动推送」而非客户端「轮询拉取」,在线消息的送达时延因此压到毫秒级。

三、核心组件设计

3.1 连接管理与会话

网关维护每条连接的会话,核心是「连接路由表」与「心跳保活」:

连接生命周期:
  ① 客户端建连 → 鉴权(携带 token) → 绑定 uid
  ② 建立连接路由:redis hset conn:{uid} gateway_id, conn_id
  ③ 心跳:客户端每 30s 发 ping,网关 60s 未收到则判定离线
  ④ 断线重连:客户端自动重连,带上 last_seq 续拉未同步消息

路由表:
  conn:{uid} → {gateway_id, conn_id, platform, online}
  推送时先查路由表定位 uid 所在网关 → 网关找到连接 → 下发
多端策略:
  同 uid 多端(手机+PC)都建立连接,路由表存多行
  下行消息「推全端」,各自按 seq 对齐

3.2 消息模型与存储

每条消息需要唯一、有序、全局可定位的编号——msgid 与 会话内 seq:

CREATE TABLE message (
  msg_id     BIGINT PRIMARY KEY,    -- 全局唯一,服务端分配
  session_id BIGINT,                -- 会话(单聊=两人 hash,群聊=群 id)
  sender_id  BIGINT,
  msg_type   TINYINT,               -- 1文本 2图片 3语音 4视频 5文件
  content    TEXT,                  -- 文本内容 / 媒体 URL
  seq        BIGINT,                -- 会话内递增序号
  send_time  DATETIME,
  status     TINYINT                -- 0正常 1撤回 2被删除
);

-- 会话索引:记录会话成员与最后一条消息,用于会话列表
CREATE TABLE conversation (
  user_id    BIGINT,
  peer_id    BIGINT,                -- 对方/群 id
  last_msg_id BIGINT,
  unread_count INT,
  PRIMARY KEY (user_id, peer_id)
);
seq 分配:
  单聊会话:seq 在会话内严格递增,由消息服务统一分配
  群聊会话:群内每成员看到的 seq 一致(群消息共享序号)
  客户端拉取:SELECT * FROM message WHERE session_id=? AND seq > ? ORDER BY seq

要点:seq 是会话内单调递增的「水位线」,客户端记录自己已同步到的 max_seq,重连后从 max_seq+1 续拉——这就是多端同步与断点续传的数学基础。

3.3 多端同步:seq 游标

多端同步的核心是「每端各自记录拉取进度,不依赖对端状态」:

每个 (uid, device) 维护一个游标 checkpoint:
  cp:{uid}:{device} → last_seq(该端已同步的会话 seq)

同步流程:
  ① 上线/重连 → 读本地 cp
  ② 拉取 cp+1 之后的增量消息
  ③ 拉取完成 → 推进 cp
  ④ 新消息推送到达时顺带把对端 cp 更新(增量推送)

消息顺序保证:
  同一会话的推送与拉取都按 seq 有序
  客户端以 seq 为排序键,天然有序

一句话:IM 的「多端同步」不是一个复杂的同步协议,而是「每端一个单调游标 + 按 seq 增量拉取」——谁落后谁拉,游标永不回退,顺序永不乱。

3.4 群聊:读扩散 vs 写扩散

群聊是 IM 最大的扩展性挑战,关键在「一条消息如何到达群里所有人」:

写扩散(发消息时给每个成员都存一条):
  优点:读时只查自己的收件箱,简单
  缺点:万人群一条消息写 1 万条存储,写放大严重

读扩散(群里只存一条,成员按群 seq 拉取):
  优点:写一次,存储省
  缺点:读时要合并多群 + 查成员,读放大;大群拉取慢

折中(生产方案):
  小群(<500 人)→ 写扩散:体验好,存储可接受
  大群/频道 → 读扩散 + 在线推送扇出:离线只存「群游标」
;; 伪代码:大群读扩散发送
(defn send-group-msg [group-id sender content]
  (let [seq (next-seq group-id)]
    (msg-store/append! {:session_id group-id :seq seq
                        :sender_id sender :content content})
    ;; 在线成员直接推网关(连接路由批量查)
    (doseq [uid (online-members group-id)]
      (push! uid {:group_id group-id :seq seq :content content}))
    ;; 离线成员不逐条存,只推进群游标
    (mark-offline-cursor! group-id seq)))

结论:群聊没有银弹——小群写扩散保证拉取快,大群读扩散保证不写爆存储;「在线走推送、离线只记游标」是万人群既不丢消息又不放大写量的关键。

3.5 可靠性与已读回执

弱网下消息可能丢,必须有「确认-重传」机制:

上行可靠性(客户端 → 服务端):
  客户端发送时带 client_msg_id(客户端生成的唯一 id)
  服务端幂等:同 client_msg_id 只落一条,返回服务端 msg_id
  客户端收到 msg_id 才算发送成功,否则重发

下行可靠性(服务端 → 客户端):
  服务端推送后等客户端 ack(ack 携带已接收的 seq)
  未 ack 的消息 → 进入「待重推队列」,重连后按 seq 重推
  推送与拉取共用同一 seq 水位,天然对账

已读回执:
  客户端上报「已读到 seq=n」
  服务端更新会话已读水位 → 通知对方
  群聊已读只回执到「已读人数」级别,避免全量明细
;; 伪代码:上行幂等接收
(defn receive-msg [client-msg-id sender content]
  (if-let [existing (redis/get (str "cm:" client-msg-id))]
    {:msg_id existing}                             ; 重复消息,返回旧 id
    (let [msg-id (alloc-msg-id!)]
      (redis/set (str "cm:" client-msg-id) msg-id "NX" "EX" 86400)
      (msg-store/append! {:msg_id msg-id :sender_id sender :content content})
      {:msg_id msg-id})))

;; 伪代码:下行确认-重推
(defn on-ack [uid device seq]
  (advance-ack-cursor! uid device seq)
  (remove-pending uid device seq))                 ; 从待重推队列移除

一句话:IM 的可靠性是「幂等 + ack」:上行用 client_msg_id 幂等去重,下行用 seq ack + 重推 保证到达;只要 seq 不丢,消息就一条都不会丢。

四、深入权衡

4.1 在线状态如何感知

方案实时性代价适用
心跳超时判定秒级延迟心跳有带宽成本通用
客户端显式上报即时依赖客户端配合状态切换场景
在线路由 + 心跳秒级 + 精确定位Redis 压力可控生产标配

结论:在线状态不追求「绝对实时」,心跳超时判定(30s 内未心跳即离线)即可满足;精确到「在哪个网关」的连接路由由 Redis 维护,供推送寻址。

4.2 消息存储选型

数据存储理由
消息时序数据分布式 KV(Cassandra/TiKV)按 (session,seq) 顺序写、范围读
会话/关系/索引MySQL事务、低频 CRUD
在线状态/路由Redis高频读写、需快速失效
媒体文件OSS 对象存储大对象、低成本、CDN 分发

结论:消息是「写多读多的时序数据」,天然适合 KV 的 (partition_key, seq) 模型;MySQL 不适合做百万 TPS 的追加写,但适合做会话与关系的元数据存储。

4.3 群聊扇出的写放大

万人群若写扩散,一条消息 = 1 万次存储写,峰值 100 万条/秒会放大成 100 亿次/秒。权衡:

纯读扩散:写放大 = 1,读合并成本高
纯写扩散:读放大 = 1,写放大 = 群规模

工程方案:
  在线成员(约 60%)→ 网关推送,不落存储
  离线成员 → 只推进群游标(1 次写)
  实际写放大 ≈ 在线成员数,但推送走内存连接而非存储,代价可控

一句话:群消息的成本大头不在「存储」而在「扇出」——把扇出走内存推送、把落库压到每次一条,万人群才可能撑住;这正是 IM 网关层存在的意义。

五、总结

即时通讯系统的骨架是「长连接接入 + 可靠存储 + 游标同步」三件套:接入网关层用 WebSocket 维护海量长连接并维护连接路由,消息服务层为每条消息分配会话内单调 seq 并持久化到 KV 存储,多端通过「每端一个游标 + 按 seq 增量拉取」实现同步。可靠性上,上行用 client_msg_id 幂等去重、下行用 ack 确认重推,配合心跳与断线重连,保证「消息一条不丢、顺序一条不乱」。群聊与频道采用「小群写扩散 + 大群读扩散」折中,在线走推送、离线记游标,在体验与写放大之间取得平衡。最终,IM 对用户表现为「秒达、不丢、不乱、多端对齐」的可靠体验。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「design」更多文章

  1. 设计一个 API 网关系统
  2. 设计一个短视频系统
  3. 设计一个分布式缓存系统