第 6 章 / 共 57 章 · 约 9 分钟
案例 4:设计即时通讯系统(类 WhatsApp)
先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。
先写你的思路
只存在这台设备上;登录后会存进你的账号。
聊天系统是面试里出现频率最高的题之一。表面上很简单:A 发一条消息,B 收到。真正要讲清楚的是三件事:长连接怎么管、消息怎么做到不丢、不乱、不重、用户离线后怎么补齐。下面按“需求 → 估算 → 接口 → 架构 → 深挖 → 故障”走一遍。
1. 需求澄清
功能需求(和面试官对齐后保留的范围):
- 1 对 1 聊天和群聊(群上限先按 500 人,超大群单独讨论)
- 对方在线时实时收到;离线的话,上线后能补齐
- 消息状态:已发送 / 已送达 / 已读
- 图片、视频等媒体消息
- 同一账号多设备登录(手机 + 桌面)
明确不做:音视频通话、消息搜索、端到端加密的密码学细节(最后简单说它对架构的影响)。
非功能需求:
- 低延迟:双方都在线时,投递 P99 在几百毫秒内
- 不丢消息:服务器一旦回了“已发送”,这条消息必须已经持久化
- 会话内有序:同一会话里所有人看到的顺序一致;不同会话之间不要求全局有序
- 高可用:可用性优先,已读状态晚几秒可以接受
2. 容量估算
假设 5 亿 DAU,人均每天发 40 条:
| 指标 | 估算 |
|---|---|
| 日消息量 | 5 亿 × 40 = 200 亿条 |
| 平均写入 QPS | 200 亿 / 86400 ≈ 23 万/秒 |
| 峰值 QPS | 按 3 倍 ≈ 70 万/秒 |
| 单条大小 | 文本 + 元数据约 200 B(媒体只存引用) |
| 日新增存储 | 200 亿 × 200 B ≈ 4 TB |
| 同时在线连接 | 按 30% 在线 ≈ 1.5 亿条长连接 |
最后一行最值得注意。单台网关调优后能扛几十万条长连接,1.5 亿连接就需要几百台网关。网关层的规模由连接数决定,而不是 QPS。
3. 核心实体与接口
实体:User、Device、Conversation、Membership、Message。单聊也当成只有两个成员的会话,这样单聊群聊走同一套逻辑。
客户端和服务器之间用 WebSocket 双向通信。HTTP 轮询要么延迟高、要么浪费请求;SSE 只能服务器单向推。主要事件:
客户端 → 服务器
send {client_msg_id, conv_id, type, body | media_ref}
ack {conv_id, seq} // 设备确认收到
read {conv_id, up_to_seq} // 已读到哪里
sync {cursors} // 重连后补齐
服务器 → 客户端
accepted {client_msg_id, conv_id, seq} // 已落库
message {conv_id, seq, sender, body...}
receipt {conv_id, user_id, delivered_seq | read_seq}
历史消息和媒体走普通 HTTP:GET /conversations/{id}/messages?before_seq=&limit= 分页拉取;POST /media 返回对象存储的预签名上传地址,客户端直传,消息里只放 media_ref。
4. 高层架构
查看图表源码
flowchart LR
accTitle: 即时通讯的高层架构
client["Client"] ==>|"WebSocket"| lb["L4 LB"] ==> gw["Gateway 集群"]
gw <--> reg[("Session Registry:device → gateway")]
gw --> ms["Message Service"] --> store[("Message Store(按 conv_id 分区)")]
ms -.->|"推给在线设备"| gw
ms --> kafka["Kafka"] --> fan["Fan-out Workers"]
fan -->|"在线:查 Registry,转给对应 Gateway"| gw
fan -->|"离线"| inbox["更新 inbox + 调 Push(APNs/FCM)"]
一条消息的完整路径:
- A 的客户端生成
client_msg_id(UUID),通过长连接发出。 - Gateway 只管连接,原样转给 Message Service。
- Message Service 为该会话分配递增的
seq,以(conv_id, client_msg_id)为幂等键写入存储。 - 落库成功后回
accepted,A 的界面显示一个勾。 - 消息进入 Kafka。Fan-out Worker 查会话成员以及每台设备连在哪个网关:在线就直接推,设备回
ack后 A 看到“已送达”;离线则发系统推送。 - B 打开会话后上报
read up_to_seq,已读回执沿同样路径回到 A。
5. 深挖
5.1 网关:有状态的部分越薄越好
Gateway 持有长连接,是整个系统里唯一有状态的组件。原则是让它尽量笨:只做鉴权、心跳、收发帧,业务逻辑都放到无状态的 Message Service。这样业务代码天天发布,也不会把用户连接踢掉。
- 心跳:客户端每 30 秒左右 ping 一次。Registry 里的路由记录带 TTL,靠心跳续期,网关宕机后记录自动过期。
- 重连风暴:一台网关挂掉,几十万客户端同时重连。客户端必须用指数退避 + 随机抖动,否则会把相邻网关一起压垮。
- 跨网关投递:Worker 查到 B 在 gateway-17 上,就通过 RPC 或 gateway-17 订阅的专属频道把消息送过去。
5.2 顺序:用服务器分配的序号,不用时间戳
各设备的时钟会漂移,按客户端时间戳排序,会出现回复排在问题前面的情况。做法是每个会话一个单调递增的 seq,由该会话所在分区的写入方分配。客户端按 seq 排序,发现中间有空洞就主动拉取。
分配器自己的故障要想清楚。直接用 Redis INCR conv:{id}:seq 最简单,但 Redis 主从是异步复制,主库宕机切换后计数器可能回退,同一个 seq 会被发两次,也可能跳号。可靠的做法是让计数器持久化:把 last_seq 存在会话所在分区的一行里,用条件更新递增;或者让同一个 conv_id 的写入都路由到一个分区 leader,leader 带任期号(epoch / fencing token),换主后旧 leader 的写入会被拒绝,新 leader 从已持久化的最大 seq 继续分配。跳号可以容忍(客户端来拉空洞时,服务器回复“这里没有消息”即可),重复的 seq 不能容忍。
只要求会话内有序。全局序号会让整个系统卡在一个计数器上。
5.3 不丢、不重:至少一次 + 幂等
A 发出消息后迟迟等不到 accepted,只能重发,而服务器可能其实已经存好了。分两层处理:
- 发送侧:
client_msg_id作幂等键,服务器遇到重复请求直接返回原来的seq。 - 接收侧:推送可能重复(推成功了但设备的 ack 丢了),客户端按
(conv_id, seq)去重。
至少一次投递加上两端幂等,用户看到的就是恰好一次。
5.4 离线同步:拉取保证正确,推送只负责快
这是这道题最容易讲错的地方。正确性不能依赖推送:推送会丢,会乱序,设备会断网。每台设备在本地记录自己同步到哪了,重连时发 sync,服务器返回游标之后的消息。
一个用户可能有上千个会话,逐个对比游标太慢。常见做法是给每个用户维护一条用户级变更流水(inbox):每条新消息在每个成员的 inbox 里追加一条引用,设备只带一个 inbox 游标,就能知道哪些会话有更新。
5.5 群聊扇出
| 群规模 | 策略 | 做法 |
|---|---|---|
| 几百人以内 | 写扩散 | 消息存一份,给每个成员的 inbox 追加引用并推送在线设备 |
| 上万人的频道 | 读扩散 | 只存一份,成员打开时自己拉,最多推一个“有新消息”的轻提醒 |
大群的已读回执也要降级,只显示“N 人已读”,不维护每个人的明细。
5.6 存储选型
消息数据写多、只追加、按“会话 + 序号范围”读取。宽列存储(Cassandra、HBase、ScyllaDB 这一类)很合适:
messages
partition key : (conv_id, month_bucket) -- 按月分桶,防止热门会话的分区无限变大
clustering key: seq DESC
columns : msg_id, sender_id, type, body, media_ref, created_at
按月分桶对低频会话不友好:一个几个月才聊一句的单聊,往前翻历史会扫到一串空桶。可以在会话元数据里维护一份非空桶列表;或者改成自适应分桶,比如按 seq 范围(seq / 10000)而不是自然月切分,before_seq 翻页还能直接算出该读哪个桶。
用户资料、群成员关系数据量小、需要事务,放关系型数据库即可。媒体文件进对象存储,前面加 CDN。
还有一个产品层面的取舍:服务器只当信箱(投递成功就删除,早期 WhatsApp 就是这个思路),还是当历史库(长期保存,支持多设备和换机恢复)。一旦要求多设备,基本只能选后者。
5.7 在线状态、多设备与加密
- 在线状态:用心跳维护
last_seen。不要每次上下线都广播给所有联系人,那是 N×M 的流量;只在用户打开某个聊天时订阅对方状态,并做去抖。 - 多设备:投递单位是设备,不是用户。每台设备有自己的游标和 ack。
- 端到端加密:服务器只看得到密文,服务端搜索和内容审核都做不了;每台设备有独立密钥,群消息要按设备分别加密,扇出成本更高。
6. 故障与扩展
- 网关宕机:客户端重连到别的网关。回过
accepted的消息都在库里,重连后sync补齐即可。 - 扇出积压:Kafka 在中间削峰;按
conv_id分区消费,同一会话仍然有序。 - 多地域:用户连就近机房;每个会话有一个归属地域负责分配
seq。跨地域会话多一跳延迟,换来顺序一致。
7. 面试中常见的坑
- 用客户端时间戳排序
- 只讲推送,不讲离线补齐
- 分不清已发送、已送达、已读各自由谁确认
- 所有群都用写扩散,没考虑超大群
- 把业务逻辑塞进网关,每次发布都断连
8. 回答分级
长连接与网关
- Bad:用 HTTP 轮询收消息,或者让网关同时承担业务逻辑。
- Good:选 WebSocket,网关只做鉴权、心跳和转发;用连接数而不是 QPS 估算网关数量。
- Great:Session Registry 的路由带 TTL、靠心跳续期,网关宕机后路由自动过期;重连用指数退避加抖动,避免重连风暴压垮其他网关;业务服务发版不会断开长连接。
顺序与不丢不重
- Bad:按客户端时间戳排序,或者以为 TCP 有序就等于消息有序。
- Good:每个会话由服务端分配递增的 seq;发送端带 client_msg_id 重试并去重,接收端按 (conv_id, seq) 去重。
- Great:分析 seq 分配器的故障:Redis 异步复制在故障切换后可能让计数回退,所以改用持久化计数或带 epoch 的分区 leader;明确“允许空洞、不允许重复”;说明至少一次投递加两端幂等,在用户看来就是恰好一次。
离线同步与群聊
- Bad:只靠推送保证送达,没有重连后的补齐路径;所有群都写扩散。
- Good:每台设备保存游标,重连后用 sync 拉取;小群写扩散,大频道读扩散。
- Great:用每个用户的 inbox 日志,让设备只凭一个游标就知道哪些会话有变化;大群把已读回执降级为计数;顺带说明端到端加密后服务端无法搜索和审核,群消息要按设备加密,扇出成本随之上升。
9. 不同级别的期望
| 级别 | 期望 |
|---|---|
| Mid(L4) | 在提示下给出 WebSocket 网关、消息服务和存储的完整链路,知道顺序按会话保证、离线设备要能补齐 |
| Senior(L5) | 主动讲清 seq 分配、两端去重和拉取与推送的分工,用连接数估算网关规模,按群大小选择扇出策略 |
| Staff(L6+) | 覆盖故障与演进:重连风暴、seq 分配器的故障切换、多区域下会话归属区域的取舍,以及端到端加密对搜索、审核和扇出成本的影响 |