“Designing a chat system like WhatsApp is the FizzBuzz of System Design.” 这是senior面试中一个比较高频的系统设计,本文将尝试从 0 到 1 设计一个生产级的实时消息系统。
需求分析:我们要设计什么?
在开始画架构图之前,先把需求理清楚。一个实时消息 App 通常需要支持:
核心功能(Core Features)
| 功能 | 描述 |
|---|---|
| 1v1 聊天 | 用户之间实时收发消息 |
| 群聊 | 多人群组,支持上百人 |
| 消息持久化 | 离线也能看到历史消息 |
| 已读回执 | 知道对方什么时候读了 |
| 在线状态 | 看到好友是否在线 |
| 消息推送 | 离线时收到推送通知 |
| 媒体消息 | 图片、语音、视频 |
非功能性需求(Non-Functional Requirements)
- 实时性:消息延迟 < 200ms
- 可用性:99.99% SLA,容灾切换
- 一致性:不丢消息、不重复
- 扩展性:支持从 1 万到 10 亿用户
整体架构:先画一张图
flowchart TB
subgraph Client["客户端"]
A["用户 A"]
B["用户 B"]
end
LB["Load Balancer"]
subgraph Gateway["WebSocket Gateway 集群"]
GW1["Gateway #1
(有状态)"]
GW2["Gateway #2
(有状态)"]
GWN["Gateway #N
(有状态)"]
end
subgraph Broker["Message Broker"]
K["Kafka / Redis Pub/Sub"]
end
subgraph Service["Service 层"]
CS["Chat Service
(消息存储/索引)"]
PS["Presence Service
(在线状态)"]
NS["Notification Service
(推送)"]
end
subgraph DB["Database"]
MySQL["MySQL"]
RedisDB["Redis"]
end
Client --> LB
LB --> GW1 & GW2 & GWN
GW1 & GW2 & GWN --> K
K --> CS & PS & NS
CS --> MySQL
PS --> RedisDB
NS --> Client
这个架构看起来复杂,但核心逻辑很清晰:Gateway 负责长连接,Service 负责业务逻辑,Broker 负责解耦。
核心组件详解
1. WebSocket Gateway:连接的入口
WebSocket 是实时消息的首选协议——建立一次连接,双方可以随时互相发送数据,不像 HTTP 那样每次都要”请求-响应”。
1 | # 简化的 WebSocket Handler |
Gateway 是有状态的——它需要知道每个用户连接在哪台机器上。这样当 A 发送消息给 B 时,我们可以找到 B 当前连接的 Gateway,直接推送过去。
2. Message Broker:解耦与分发
为什么需要 Message Broker(消息中间件)?
直接让 Gateway 调用 Database 写入消息不是不行,但有几个问题:
- 写入速度受限于 Database
- 如果有多个 Gateway,如何保证消息顺序?
- 群聊消息要发给 N 个人,每个都直接调用会很慢
所以我们引入 Broker:Gateway 只管把消息丢进队列,后续处理异步进行。
flowchart LR
G["Gateway"]
K["Kafka"]
S["Chat Service"]
D["Database"]
G --"publish: new_message"--> K
K --"consume"--> S
S --"写入"--> D
Kafka 是这个场景的热门选择——吞吐量大、持久化好、可以回溯。Redis Pub/Sub 也可以,但更适合小规模场景。
3. 消息存储:Database 设计
消息是核心数据,需要考虑:
- 写入速度——高峰期每秒百万消息
- 查询效率——快速拉取历史消息
- 分页——不能一次加载所有历史
1 | -- 消息表设计 |
分片策略:按 conversation_id 哈希分片,而不是按 user_id。因为查消息都是按”某个对话”查,按对话分片能保证一个对话的消息在一个分片上,查询效率最高。
消息流程:一次完整的发送
让我们走一遍完整的消息流程:
sequenceDiagram
participant A as 用户 A
participant GW_A as Gateway A
participant K as Kafka
participant CS as Chat Service
participant Redis
participant GW_B as Gateway B
participant B as 用户 B
A->>GW_A: 1. 发送消息 {"to": B, "content": "hi"}
GW_A->>K: 2. 写入 Kafka: "new_message"
K->>CS: 3. Chat Service 消费消息
CS->>CS: 4. 写入 MySQL,返回 message_id
Redis->>Redis: 5. 查找 B 在哪台 Gateway
GW_A->>GW_B: 6. 推送消息给 Gateway B
GW_B->>B: 7. 推送给用户 B
B->>GW_B: 8. ACK 已收到
GW_B->>GW_A: 9. 送达回执
GW_A->>A: 10. 显示"已送达"
核心步骤:
- Gateway 接收 → 验证 token,写入队列
- 异步持久化 → Service 消费后写入 DB
- 实时推送 → 查在线表,找到接收者的 Gateway,推送
- ACK → B 收到后发回确认,A 端显示”已送达”
群聊:复杂度上升一个量级
1v1 聊天是简单的——消息发给一个人。群聊呢?
挑战
- 100 人的群,A 发一条消息,要推给 99 个人
- 群成员可能分散在不同的 Gateway 上
- 有人离线,消息怎么处理?(等上线再推?or 跳过?)
- 群成员变化(有人加群/退群)如何同步?
解法:Fan-out
flowchart TB
Msg["新消息到达"]
Query["查询群成员列表
100 个 user_id"]
Check{检查每个成员
是否在线}
Push["推送到对应 Gateway"]
Offline["离线消息处理"]
Msg --> Query
Query --> Check
Check --"在线"--> Push
Check --"离线"--> Offline
Push --> GW1["Gateway #1"]
Push --> GW2["Gateway #2"]
Push --> GW3["Gateway #N"]
异步 Fan-out:不直接在 Gateway 做,而是把消息丢给一个专门的 Fan-out Service,它负责查询群成员、逐个推送。这样 Gateway 不会阻塞。
离线消息处理
如果用户不在线:
- 不处理——等用户自己打开 App 时从服务器拉取
- 离线推送——通过 APNs / FCM 发送推送通知
方案 2 更友好,但成本更高。通常的做法是:只对最近 N 条离线消息发推送,或者用户配置”开启离线通知”才发。
在线状态(Presence):谁在线?
这是一个看似简单但很容易踩坑的功能。
基础实现
1 | # 每次连接时 |
进阶:Last Seen
光知道”在不在线”不够,很多人想知道”他上次活跃是什么时候”。
1 | # 定期更新 last_seen |
坑点
心跳(Heartbeat):用户挂着 App 但没发消息,你怎么知道他还在?答案是心跳——定期发送 Ping,Gateway 收到后更新 Redis 里的时间。如果超过一定时间没收到心跳,就认为离线。
扩展性:如何支持 10 亿用户?
前面的架构能跑通,但要支持 10 亿用户,还需要:
1. 多 Region 部署
全球用户分布在美国、欧洲、亚洲。每个 Region 部署完整的服务,Region 之间通过专线同步消息。
flowchart LR
subgraph US["US Region"]
US_GW["Gateway"]
US_CS["Chat Service"]
US_DB["Database"]
end
subgraph EU["EU Region"]
EU_GW["Gateway"]
EU_CS["Chat Service"]
EU_DB["Database"]
end
subgraph Asia["Asia Region"]
Asia_GW["Gateway"]
Asia_CS["Chat Service"]
Asia_DB["Database"]
end
Sync["Cross-Region Sync"]
US <--> Sync
EU <--> Sync
Asia <--> Sync
2. 读写分离
99% 的请求是”读消息”,只有 1% 是”发消息”。所以:
- 多个 Read Replica 分担读取压力
- 只在 Primary 写入
3. 缓存策略
热点数据(最近的消息、群成员列表)放 Redis:
1 | # 读取消息时 |
进阶特性
已读回执
1 | # 用户 B 读了 A 的消息 |
消息反应(Emoji)
Emoji 反应不改变消息内容,只是附加一条”用户 X 对消息 Y 点了 ❤️”。实现很简单——单独一张表存反应。
阅后即焚
在消息表加一个 expires_at 字段,时间到了物理删除。或者更简单——客户端收到后显示,关闭对话后客户端删除。
总结
设计一个 WhatsApp 级别的消息系统,核心就三点:
- 长连接——WebSocket + Gateway 集群
- 消息可靠——Kafka 做缓冲,MySQL 持久化
- 实时推送——找到用户所在的 Gateway,直接推送
剩下的都是优化:群聊怎么快、离线怎么办、怎么支持更多人。这些问题没有标准答案——取决于你的业务规模、团队技术栈、运维能力。
参考来源: