经典案例:实时即时通讯与聊天系统 (Chat System / 类似微信与 WhatsApp)¶
即时通讯系统(IM / Chat System)是现代互联网中最复杂、最具挑战性的系统之一。它要求在毫秒级延迟下,将消息从发送方稳定可靠地投递到接收方,并且需要应对海量用户的持续长连接、消息丢失、群聊爆炸扩散以及消息乱序等难题。
1. 初学者视角:为什么聊天系统不能用普通 HTTP 请求?¶
在传统的 Web 应用(如博客、新闻、电商浏览)中,客户端遵循“请求 - 响应 (Request - Response)”模型: - 用户点击刷新 \(\to\) 浏览器向服务器发送 HTTP 请求 \(\to\) 服务器返回数据。
但是在即时聊天场景中,接收方 Bob 并不知道 Alice 什么时候会给他发消息。如果仍然使用普通 HTTP:
graph LR
subgraph "传统 HTTP 短轮询 (严重浪费)"
C1[客户端 Bob] -->|每秒发送: 有新消息吗?| S1[服务器]
S1 -->|99% 情况下: 没有新消息| C1
end
subgraph "WebSocket 全双工长连接 (现代标准)"
C2[客户端 Bob] <-->|保持一条持续的 TCP 双向通道| S2[服务器]
S2 -.->|Alice 发来消息, 服务器主动推送!| C2
end
1.1 协议选型对比¶
| 机制 | 工作原理 | 致命缺陷 | 适用场景 |
|---|---|---|---|
| HTTP 短轮询 (Short Polling) | 客户端每隔 1~2 秒主动发起一次 HTTP 请求询问服务器是否有新消息。 | 海量资源浪费:99% 的请求都是空的,白白消耗移动端电量、服务器 CPU 与带宽;延迟取决于轮询间隔。 | 简单场景、低频更新(如扫码登录等待)。 |
| HTTP 长轮询 (Long Polling) | 客户端发起请求,服务器挂起请求不立即返回;直到有新消息或超时才返回,客户端收到后立即建立下一个长轮询。 | 连接频繁断开重连,HTTP Header 冗余开销大,服务器维护挂起请求资源开销大。 | 兼容老旧浏览器的替代方案。 |
| WebSocket (全双工长连接 - 工业首选) | 客户端通过 HTTP 握手升级为 WebSocket 协议,随后建立持久的 TCP 双向通道。客户端与服务端可随时互相推送。 | 服务端需要维持海量持久长连接(Connection-Oriented),需要高并发连接网关。 | 现代即时通讯、在线游戏、实时协作。 |
2. 需求分析与系统范围 (Requirements & Scope)¶
2.1 功能性需求 (Functional)¶
- 一对一单聊 (1-on-1 Chat):两名用户之间发送文本消息,毫秒级投递;
- 群聊 (Group Chat):支持最多 500 人的群组聊天;
- 消息状态反馈 (Message Status):支持“已发送 (Sent)”、“已送达 (Delivered)”、“已读 (Read)”状态流转;
- 在线状态显示 (Online Presence):显示好友当前是“在线”还是“离线”,以及“最后上线时间”;
- 多端同步与离线消息 (Multi-device & Offline Sync):用户离线时消息持久化存储,重新上线后自动拉取未读消息。
2.2 非功能性需求 (Non-Functional)¶
- 低延迟 (Low Latency):单聊端到端延迟控制在 \(100\text{ ms}\) 以内;
- 消息绝对不丢 (No Message Loss):哪怕遇到网络断线、服务器宕机,消息也绝不丢失(At-least-once 交付保证);
- 消息顺序性 (Message Ordering):同一个聊天会话内的消息必须严格按照发送时间先后顺序展示;
- 高并发连接支持 (High Concurrency):单集群支持千万级同时在线长连接。
3. 容量估算 (Back-of-the-Envelope Estimation)¶
- 日活跃用户 (DAU):设为 5 亿;
- 每人每日消息数:平均每人每天发送 40 条消息;
- 每日消息总量:\(5 \times 10^8 \times 40 = 2 \times 10^{10}\) 条/天 (约 200 亿条/天);
- 平均每秒消息量 (QPS):
\[
\text{Avg QPS} = \frac{2 \times 10^{10}}{86,400\text{ s}} \approx 230,000\text{ QPS}
\]
峰值 QPS(按 2 倍计算)约为 460,000 QPS。
- 并发长连接数 (Concurrent Connections):
- 假设高峰期有 10% 的活跃用户同时保持长连接在线:
\[
5 \times 10^8 \times 10\% = 50,000,000
\]
即约 5000 万同时在线长连接。
- 存储容量需求 (每日):
- 设一条文本消息平均大小为 \(100\text{ Bytes}\)(包含
message_id,sender_id,receiver_id,content,timestamp); - 每日新增文本存储:\(2 \times 10^{10} \times 100\text{ B} = 2\text{ TB}\)/天;
- 存储 5 年的历史记录约需:\(2\text{ TB} \times 365 \times 5 \approx 3.65\text{ PB}\)。
4. 高层架构设计 (High-Level Architecture)¶
由于长连接是有状态的 (Stateful),而业务逻辑(登录、拉取好友列表)是无状态的 (Stateless),因此架构上必须将两者严格分离:
graph TD
subgraph 客户端
Alice[用户 Alice]
Bob[用户 Bob]
end
subgraph "接入层 (有状态长连接池)"
GW1[WebSocket Gateway 1]
GW2[WebSocket Gateway 2]
end
subgraph 路由与状态中心
Zk[Redis / 路由中心: 记录 用户ID -> Gateway节点]
Presence[在线状态服务: Heartbeat]
end
subgraph "核心业务逻辑层 (无状态 HTTP / RPC)"
MsgService[消息分发与存储服务]
GroupService[群聊分发服务]
AuthService[认证与用户服务]
end
subgraph 数据存储层
RDBMS[(MySQL: 用户关系/群元数据)]
NoSQL[(HBase / Cassandra: 历史消息流水)]
end
Alice <==>|WebSocket 长连接| GW1
Bob <==>|WebSocket 长连接| GW2
GW1 --> MsgService
MsgService --> Zk
MsgService --> NoSQL
MsgService --> GW2
GW2 -.->|推送消息| Bob
GW1 -.->|上报心跳| Presence
各层职责分工:¶
- WebSocket Gateway 集群(接入层 - 有状态):
- 专门负责维持与手机端的海量 TCP/WebSocket 长连接;
- 极轻量,只做协议转换与消息透传,单台 64GB 内存服务器可支撑约 50万 ~ 100万 连接。
- 路由中心 (Session Router - Redis 集群):
- 记录全局路由映射表:
UserID -> Gateway_IP:Port。当需要给用户发消息时,先查路由表得知该用户连在哪台网关服务器上。 - 消息服务 (Message Service - 无状态):
- 处理消息持久化、敏感词过滤、安全审查、去重与顺序分配。
- 历史消息存储库 (NoSQL Key-Value / Wide-Column):
- 聊天记录读写模式为:大量的顺序写入,以及按时间范围分页读取(查看更早的聊天记录)。
- 工业界首选宽表数据库(如 Apache Cassandra, HBase, ScyllaDB)或 MongoDB,其 LSM-Tree 结构写入吞吐极高,且天然支持海量水平分片。
5. 核心难点深度剖析 (Deep Dive)¶
5.1 单聊消息的端到端投递流程 (1-on-1 Message Flow)¶
假设 Alice 正在给 Bob 发送一条消息:
sequenceDiagram
participant Alice as 客户端 Alice
participant GW1 as 网关 GW1 (Alice已连)
participant Svc as 消息分发服务
participant Router as 路由中心 (Redis)
participant DB as 消息存储库 (NoSQL)
participant GW2 as 网关 GW2 (Bob已连)
participant Bob as 客户端 Bob
Alice->>GW1: 1. 发送消息 (msg_id, to: Bob, text)
GW1->>Svc: 2. 转发消息
par 并行处理
Svc->>DB: 3. 异步持久化消息 (写入数据库)
Svc-->>GW1: 4. 返回 Server ACK (确认服务端已接收)
GW1-->>Alice: 5. 客户端显示打勾 (已发送)
end
Svc->>Router: 6. 查询 Bob 当前连在哪台网关?
alt Bob 在线 (连在 GW2)
Router-->>Svc: 返回 GW2 地址
Svc->>GW2: 7. 投递消息给 GW2
GW2->>Bob: 8. WebSocket 实时推送给 Bob
Bob-->>GW2: 9. Bob 客户端回复 Client ACK
GW2->>Svc: 10. 更新消息状态为"已送达"
else Bob 离线
Router-->>Svc: 返回离线状态
Svc->>Svc: 11. 触发第三方离线推送 (APNs / FCM)
end
5.2 群聊的“写扩散”与“读扩散”抉择 (Fan-Out on Write vs Read)¶
群聊是即时通讯中最容易拖垮系统的瓶颈。当群主发了一条消息,系统如何分发给群内的所有人?
graph TD
subgraph "写扩散 (Fan-out on Write)"
A1[群成员发消息] --> A2[为群里每个人复制一份消息<br>写入每个人的收件箱]
A2 --> A3[优点: 每个人读取极快, 只读自己收件箱<br>缺点: 500人群写放大500倍, 存储爆炸]
end
subgraph "读扩散 (Fan-out on Read - 群聊推荐)"
B1[群成员发消息] --> B2["只写一份消息到群消息表 (群发件箱)"]
B2 --> B3[群成员上线时, 拿着本地 last_msg_id<br>去群发件箱拉取大于该 ID 的增量消息<br>优点: 零写放大, 极大节省存储]
end
工业最佳实践:¶
- 普通单聊:采用写扩散(每个人一个收件箱,支持各自独立删除消息);
- 小群聊(\(\le 500\) 人):采用写扩散推送到在线用户的长连接网关,但落库存储采用读扩散(只存一份群消息记录,所有群成员共享同一个
group_id历史流水); - 超大万人群(如直播弹幕、全员大群):严格采用读扩散 + 丢弃降级策略。
5.3 消息可靠性保证:三层 ACK 机制 (At-Least-Once Delivery)¶
手机网络极其不稳定(进电梯、换基站)。如何确保消息“绝对不丢”? 通过三层确认应答机制(ACK):
- 第一层:Client \(\to\) Server ACK:
- Alice 发送后启动本地超时计时器;
- 若 5 秒内未收到服务器的
Server_ACK,说明网络丢包或网关断开,客户端自动重试; - 第二层:Server \(\to\) Receiver ACK:
- 服务器将消息推给 Bob 后,等待 Bob 客户端返回
Client_ACK; - 若超时未收到,服务器判定 Bob 网络异常断线,标记为未投递,转存离线消息池;
- 第三层:消息幂等去重 (Deduplication):
- 因为客户端可能超时重试,服务器可能会收到多条相同的消息。
- 解决方案:客户端生成全局唯一的
client_msg_id(通过 Snowflake 或 UUID),服务端使用 Redis / 数据库主键唯一约束进行幂等去重(INSERT IGNORE),确保同一条消息绝不会向接收方重复投递两次。
5.4 消息顺序性保障 (Message Ordering)¶
在聊天中,如果 Bob 问“吃了吗?”,Alice 回复“吃了”,但展示给别人看时顺序颠倒成了“吃了”在前、“吃了吗”在后,会产生巨大的误解。
为什么不能直接用手机客户端时间戳?¶
- 用户手机的本地时间可能根本不准(甚至用户手动修改了系统时钟);
- 跨时区通信时时间戳漂移严重。
解决方案:会话级单调递增 Sequence ID¶
- 在同一个聊天会话(单聊或群聊)中,由服务器统一生成严格单调自增的 64 位序号
Sequence ID; - 客户端在本地渲染消息时,严格按
Sequence ID升序排版展示; - 客户端还可以比对本地最新一条消息的
seq_id与接收到的seq_id: - 如果本地是 100,收到了 102,说明中间漏掉了 101 号消息!客户端立即主动向服务端发起拉取:“请求同步 101 号消息”,实现自我修复空洞。
5.5 在线状态与心跳保活机制 (Heartbeat & Presence)¶
服务器如何判断一个用户当前是“在线”还是“离线”?
- 不能仅依赖 TCP 断开事件:如果手机直接关机、拔出 SIM 卡或走进完全没有信号的地库,操作系统甚至来不及发送 TCP FIN 包,服务器的 TCP 连接可能还会傻傻挂起数十分钟;
- 心跳机制 (Ping-Pong):
- 手机客户端每隔 30 秒 发送一个极小的心跳包(Ping);
- 网关收到后回复 Pong,并刷新 Redis 中的键生存时间(TTL):
- 若用户掉线超过 60 秒未发心跳,Redis 键自动过期,系统自动判定该用户为“离线”状态。