cswork / 系统设计

第 6 章 / 共 57 章 · 约 9 分钟

案例 4:设计即时通讯系统(类 WhatsApp)

本页目录
  1. 1. 需求澄清
  2. 2. 容量估算
  3. 3. 核心实体与接口
  4. 4. 高层架构
  5. 5. 深挖
  6. 6. 故障与扩展
  7. 7. 面试中常见的坑
  8. 8. 回答分级
  9. 9. 不同级别的期望
  10. 章节测验

先花 10 分钟自己设计:需求、核心实体、API 和高层架构,各写几行。写完或者跳过之后,下面的讲解才会展开。

先写你的思路

只存在这台设备上;登录后会存进你的账号。

聊天系统是面试里出现频率最高的题之一。表面上很简单:A 发一条消息,B 收到。真正要讲清楚的是三件事:长连接怎么管、消息怎么做到不丢、不乱、不重、用户离线后怎么补齐。下面按“需求 → 估算 → 接口 → 架构 → 深挖 → 故障”走一遍。

1. 需求澄清

功能需求(和面试官对齐后保留的范围):

  • 1 对 1 聊天和群聊(群上限先按 500 人,超大群单独讨论)
  • 对方在线时实时收到;离线的话,上线后能补齐
  • 消息状态:已发送 / 已送达 / 已读
  • 图片、视频等媒体消息
  • 同一账号多设备登录(手机 + 桌面)

明确不做:音视频通话、消息搜索、端到端加密的密码学细节(最后简单说它对架构的影响)。

非功能需求:

  • 低延迟:双方都在线时,投递 P99 在几百毫秒内
  • 不丢消息:服务器一旦回了“已发送”,这条消息必须已经持久化
  • 会话内有序:同一会话里所有人看到的顺序一致;不同会话之间不要求全局有序
  • 高可用:可用性优先,已读状态晚几秒可以接受

2. 容量估算

假设 5 亿 DAU,人均每天发 40 条:

指标估算
日消息量5 亿 × 40 = 200 亿条
平均写入 QPS200 亿 / 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)"]

一条消息的完整路径:

  1. A 的客户端生成 client_msg_id(UUID),通过长连接发出。
  2. Gateway 只管连接,原样转给 Message Service。
  3. Message Service 为该会话分配递增的 seq,以 (conv_id, client_msg_id) 为幂等键写入存储。
  4. 落库成功后回 accepted,A 的界面显示一个勾。
  5. 消息进入 Kafka。Fan-out Worker 查会话成员以及每台设备连在哪个网关:在线就直接推,设备回 ack 后 A 看到“已送达”;离线则发系统推送。
  6. 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 分配器的故障切换、多区域下会话归属区域的取舍,以及端到端加密对搜索、审核和扇出成本的影响

章节测验

6 道选择题
  1. 同一个群里两位成员几乎同时发消息。为什么不按客户端时间戳给消息排序?
  2. A 发送消息后等待超时,客户端重发;但第一次请求其实已经落库。怎样避免 B 看到两条相同的消息?
  3. 一个 20 万人的频道发出一条消息,比较合理的扇出方式是?
  4. 一台设备离线 3 小时后重连。判断它缺了哪些消息,可靠的依据是什么?
  5. 为什么要把网关层和消息业务逻辑拆开?
  6. 在宽列存储里,消息表的分区键用 (conv_id, month_bucket) 而不是只用 conv_id,主要原因是?

章节讨论

正在加载讨论…