· 王思腾 · 系统架构 · 9 分钟阅读
系统设计:如何设计一个聊天系统(即时通讯 IM)
聊天系统看似简单(发消息、收消息),背后却要解决实时推送、消息有序、已读回执、离线消息、多端同步、海量连接等一系列难题。本文从收发消息的核心链路入手,讲清长连接网关、消息投递与确认、存储模型、群聊扇出,以及可靠性(不丢、不重、有序)的设计取舍。

微信、钉钉、Slack 的核心都是 IM(即时通讯)。表面看就是“把 A 的消息送到 B”,但要做到实时、不丢、不重、有序,还要支持离线、多端、群聊,复杂度远超一个普通 CRUD 系统。
一、核心需求
- 一对一聊天 + 群聊;
- 实时投递:对方在线时毫秒级收到;
- 消息可靠:不丢、不重复、同一会话内有序;
- 在线状态:在线 / 离线 / 正在输入;
- 离线消息:对方不在线时,上线后能拉到未读;
- 多端同步:手机、电脑、网页消息一致,已读状态同步;
- 支撑海量用户同时在线(百万级长连接)。
二、整体架构
IM 的关键分层:接入层维持长连接,逻辑层处理消息,存储层分“热消息”和“历史消息”。
flowchart TB
subgraph Clients["客户端"]
C1["手机"] --- C2["PC"] --- C3["Web"]
end
Clients <--> GW["接入层 · 长连接网关<br/>WebSocket / 自研 TCP 协议"]
GW <--> Logic["逻辑层<br/>认证 · 路由 · 会话 · 消息处理"]
Logic --> MQ["消息队列 Kafka"]
MQ --> Push["投递服务"]
Push --> GW
Logic --> Hot[("Redis<br/>在线状态 · 未读计数 · 最新消息")]
Logic --> Store[("消息存储<br/>HBase / Cassandra<br/>按会话分片")]
Push --> Third["第三方推送<br/>APNs / 厂商通道(离线时)"]三、长连接与接入层
聊天要实时,靠的是客户端与服务器之间的长连接(WebSocket,或移动端基于 TCP 的自研协议 + Protobuf):
- 连接建立后保持,服务器有消息就主动推(Push),而不是客户端反复轮询(Pull,浪费流量和电量);
- 单机维持长连接有上限(受文件描述符、内存限制,通常几万到几十万连接/机),所以接入层是无状态的连接网关集群,通过负载均衡摊开海量连接;
- 网关只负责“维持连接、收发包、转发”,不存业务状态——哪台网关上有哪个用户的连接,记录在 Redis 的“路由表”里(用户 → 网关实例)。
心跳机制:客户端定时发心跳包,网关检测到连接断了就清理路由表;NAT 网络下心跳还能防止连接被运营商回收。
四、一条消息的完整旅程
A 给 B 发消息“你好”:
sequenceDiagram
participant A as 客户端 A
participant GW as 网关
participant L as 逻辑层
participant DB as 消息存储
participant B as 客户端 B
A->>GW: 发送消息(本地生成 msgId)
GW->>L: 转发
L->>L: 校验、分配全局 seqId、落库
L->>DB: 持久化消息
L-->>A: 返回 ACK(服务端收到)
L->>B: B 在线则推送消息
B-->>L: 返回 ACK(已收到)
L->>A: 推送“已送达”回执
Note over L,B: B 不在线 → 存离线消息 + 走 APNs/厂商推送几个关键设计:
- 消息 ID 与序列号:客户端先生成全局唯一
msgId(去重用,防重复发送);服务端在会话维度分配单调递增的seqId(保证同一会话内消息有序、便于同步); - 两段 ACK:客户端→服务端 ACK 表示“服务器收到了”,接收方→服务端 ACK 表示“对方收到了”,据此显示“发送中 / 已送达 / 已读”;
- 投递失败重试:推送后没收到对方 ACK,会重试;客户端按
msgId去重,保证“至少一次投递 + 客户端去重 = 不重复”。
五、在线状态与离线消息
- 在线状态存 Redis:
用户 → 所在网关 / 在线状态。发消息时查路由表——在线就实时推,不在线就:- 消息落库(历史存储);
- 在 Redis 记未读计数;
- 通过 APNs(iOS)/ 厂商通道(安卓) 发系统级推送,让 App 没启动也能收到通知;
- 用户上线后,客户端带上本地“已同步到的最大 seqId”,向服务端拉取增量消息(
seqId > 本地最大的所有消息),完成离线补发和多端同步。
六、群聊:写扩散 vs 读扩散
群聊最大的难题是扇出(fan-out):一条群消息要让成百上千个成员看到。两种模型:
flowchart LR
subgraph W["写扩散(推模式)"]
W1["群消息"] --> W2["发消息时<br/>为每个成员复制一份<br/>写进各自收件箱"]
end
subgraph R["读扩散(拉模式)"]
R1["群消息"] --> R2["只存群会话一份"]
R3["成员读时<br/>拉取所有群的新消息合并"] --> R2
end- 写扩散(推模式):发消息时给每个成员的收件箱写一份。读很快(只拉自己的收件箱),但写放大严重——一个万人群发一条要写一万次;
- 读扩散(拉模式):消息只在群会话存一份,成员读取时拉取自己加入的所有群的新消息再合并。写轻松,但读放大、要合并排序。
实践中多为混合:小群用写扩散(成员少、写成本低、体验好),大群/超大群用读扩散(避免写爆)。
七、存储设计
消息数据量极大且写多读少、天然按时间有序,选型上:
- 热数据(在线状态、未读数、最近消息、会话列表)放 Redis,追求低延迟;
- 历史消息用 HBase / Cassandra 这类宽列存储:按
会话ID + seqId(时间)做 rowkey,天然适合“某会话按顺序翻页”的海量写入与顺序读;可水平分片扩展; - 消息不可变(发出后不修改),很适合这种追加写的存储。
八、可靠性小结
| 问题 | 解法 |
|---|---|
| 消息丢失 | 服务端落库后才回 ACK;推送失败重试;离线走第三方推送 |
| 消息重复 | 客户端生成唯一 msgId,接收端去重 |
| 消息乱序 | 会话维度单调递增 seqId,客户端按序排列 |
| 连接海量 | 无状态网关集群 + Redis 路由表 + 心跳保活 |
| 多端一致 | 以服务端 seqId 为准,上线拉取增量同步 |
九、小结
- IM 分三层:长连接网关维持海量连接、逻辑层处理路由与会话、存储层分 Redis 热数据和 HBase/Cassandra 历史消息;
- 实时靠长连接推送,离线靠消息落库 + 未读计数 + APNs/厂商通道,上线按 seqId 拉增量实现补发与多端同步;
- 可靠性靠“至少一次投递 + 唯一 msgId 去重 + 会话内 seqId 保序”;
- 群聊扇出要在写扩散(读快写重)和读扩散(写轻读重)之间取舍,大小群用混合策略;
- 消息不可变、按会话顺序写,适合宽列存储水平扩展。
一个“能聊天”的 demo 一天能写完,一个“不丢消息、百万在线”的 IM 要打磨数年——差别全在这些可靠性和扩展性的细节里。
本文由 AI 辅助创作,所有内容仅供交流与参考。



