设计像 WhatsApp 这样的实时聊天应用是最常见的系统设计面试问题之一。

在短时间内,你需要展示你不仅理解快乐路径,还理解当消息在不相连的用户之间通过移动网络飞行时可能出错的所有事情。

WhatsApp 每天为 20 亿 + 用户处理超过 1000 亿条消息。

我们将从头构建这个,从一个简单的版本开始,适用于几千用户,然后扩展它以处理数十亿用户在不可靠移动网络上发送消息。

这是一个实用指南: 我们不会对手头的困难部分视而不见或假装一切都有效。相反,我们将构建一些功能性的东西,识别它在哪里中断,并正确修复它。

第 1 部分涵盖内容

  • 需求和容量估计
  • 核心架构和数据模型
  • 为什么 WebSocket beats 其他协议用于实时消息
  • 服务发现和存在管理
  • 处理在线和离线消息传递
  • 多设备同步
  • 媒体文件上传而不杀死你的服务器

最后,你将不仅理解构建什么,还理解为什么做出每个决定,以及当你出错时会发生什么。

需求

核心需求

让我们具体说明”实时”在这里实际意味着什么。

功能需求

  • 用户可以在 1:1 聊天中发送和接收文本消息
  • 最多 100 参与者的群聊(我们稍后会看到为什么这个数字重要)
  • 用户离线时传递的消息排队 30 天
  • 媒体附件(图片、视频、音频剪辑)
  • 消息状态跟踪(已发送、已传递、已读)
  • 在线/离线状态和”最后 seen”

非功能需求

  • 低延迟传递(用户在线时低于 500ms)
  • 保证消息传递(消息不能只是消失)
  • 处理数十亿用户的高吞吐
  • 不在服务器上存储消息超过必要时间
  • 在单个组件故障时保持弹性

大多数人错过的棘手部分是离线需求。

为所有人在线设计很容易。但现实生活不是那样工作的……

容量估计

让我们运行数字,这样我们就不会构建一些在真实负载下崩溃的东西。

用户指标

  • 10 亿注册用户
  • 5 亿日活用户
  • 高峰时段 5000 万并发连接
  • 平均每用户每天 10-20 条消息

消息估计

  • 每日消息:5 亿用户 × 20 条消息 = 每天 100 亿条消息
  • 大约每秒 115,000 条消息平均
  • 但在高峰时段,我们需要乘以 3-5 倍。所以我们看每秒 350,000-500,000 条消息

存储计算

每条带元数据的消息平均约 1KB(文本内容、发送者/接收者 ID、时间戳、状态标志等)

  • 每日存储:1KB × 100 亿条消息 = 每天 10 TB
  • 年度存储:3.6 PB/年

但关键是: 大多数消息在几秒内传递并从服务器清除。我们不是永远存储所有内容。用户下载消息到他们的设备,我们在服务器上清除它们。

考虑 30 天未传递消息的保留和 10% 用户在服务器上保留历史。突然我们看 400-500 TB 活跃存储而不是 PB。

带宽

高峰时段 5000 万并发连接。

如果每个连接平均活跃消息 10KB/s,那是 500 GB/s 带宽。这就是为什么 WhatsApp 以在最小基础设施上运行而闻名。

他们在不发送不必要数据方面非常高效。

架构组件

让我们分阶段构建这个,从简单开始,只在需要时添加复杂性:

WhatsApp 架构

移动应用(客户端)

用户的手机。它维护到后端的持久连接,处理 UI,管理本地消息存储,并重试失败的操作。

负载均衡器

这坐在我们的聊天服务器前面,并在多个服务器之间分发传入连接。

把它想象成一个交通指挥,将车辆路由到不同车道。它将传入的 WebSocket 连接路由到聊天服务器,并使用粘性会话(意味着一旦你连接到服务器 A,你保持在服务器 A)。简单地说,用户在对话中途不在服务器之间跳跃。

它还监控服务器健康并停止向死服务器发送流量。

聊天服务器

聊天服务器维护到客户端的 WebSocket 连接(持久的双向通信通道保持打开),在用户之间路由消息,跟踪谁在线,并处理消息持久化。

每个服务器可以处理数十万同时连接。

消息队列

这将消息写入与传递解耦。

当聊天服务器收到消息时,它立即向发送者确认接收,然后异步将其推送到队列进行存储和传递。

我们可以使用像 Kafka 或 RabbitMQ 这样的工具,它们在这里工作良好。

消息存储服务

从队列消费并将消息写入数据库。

它还处理查询消息历史和管理保留策略(如 30 天后删除旧消息)。

消息数据库

消息的持久存储。

NoSQL 在这里工作良好(Cassandra、DynamoDB),因为我们需要高写入吞吐,我们的查询模式直接(按用户/对话/时间戳获取消息)。

我们每天写入数十亿条消息,所以我们需要一些可以处理海量写入量而不减速的东西。

用户连接缓存

内存存储(Redis)跟踪哪些用户在线、他们连接到哪个聊天服务器,以及他们的最后活动时间戳。

这使得路由决策快速。检查 Redis 花费微秒,而查询数据库花费毫秒。在规模上,这种差异很重要。

Blob 存储 + CDN

Blob 存储(如 AWS S3 或 Google Cloud Storage)存储媒体文件;它保存实际文件。

CDN(内容分发网络)在边缘位置缓存流行文件 worldwide,所以下载快速无论用户在哪里。它提供直接上传/下载路径,这样聊天服务器不会成为大文件传输的瓶颈。

通知服务

通过 APNs(iOS 的 Apple Push Notification Service)和 FCM(Android 的 Firebase Cloud Messaging)处理离线用户的推送通知。

当有人在你离线时给你发消息,这使你的手机震动。

存在服务

专门管理在线/离线状态的服务。从用户接收心跳,更新 Redis,并发布存在更改给感兴趣的订阅者。

为什么选择 WebSocket?

大多数消息系统使用 WebSocket。

让我们通过看看什么不起作用来理解为什么:

协议对比

轮询

客户端问,“有任何新消息吗?“每几秒。

这浪费带宽并增加延迟。

如果你每 2 秒检查一次,消息在你检查后到达,你等 2 秒看到它。乘以数百万用户不断询问更新即使没有新内容,你燃烧资源没有理由。

长轮询

客户端打开请求,服务器保持它打开直到有消息或超时。

长轮询比常规轮询好。

但它仍然有问题: 你不断打开和关闭连接。每条消息需要完整的 HTTP 握手(建立连接的来回)。

在规模上,这种开销杀死你。你的服务器花费更多时间管理连接生命周期而不是实际传递消息。此外,空闲 HTTP 连接仍然消耗服务器资源(连接状态的内存、线程池槽),没有 WebSocket 的效率好处。

WebSocket(我们使用)

一个持久的双向连接保持打开。客户端和服务器都可以随时推送数据。建立后最小开销。

为什么我们选择 WebSocket

连接在整个会话期间保持打开。当用户 A 发送消息给用户 B,它通过 A 的 WebSocket 连接流到服务器,服务器通过 B 的 WebSocket 连接立即推送它。

无轮询,无重复握手。只是数据双向流动。连接开销只在你打开应用时发生一次;之后只是消息内容。

WebSocket 权衡

  • 它需要支持它们的特殊负载均衡器(第 4 层负载均衡而不是第 7 层)
  • 它们在服务器上每个连接使用更多内存,因为你保持连接打开

但对于实时消息,延迟和带宽好处使其值得。

处理空闲 WebSocket 连接

当用户打开 WhatsApp 但几小时不发送任何消息时会发生什么?

连接保持打开但空闲!

我们用心跳处理这个:

  • 客户端每 30 秒发送轻量级 ping
  • 服务器用 Pong 响应

如果服务器 60 秒没有收到心跳,它假设连接死亡(网络问题、应用后台)并关闭它。这防止积累浪费服务器资源的僵尸连接。

空闲连接仍然消耗内存(连接状态、套接字缓冲区),但与即时消息传递的好处相比成本最小。

服务发现

在生产中,聊天服务器来来去去……它们崩溃、部署、扩展上下。所以系统如何跟踪什么可用?

问题

你的负载均衡器需要知道哪些聊天服务器健康。

聊天服务器需要知道哪些其他服务可用(消息存储服务实例、Redis 集群节点)。硬编码 IP 在实例短暂时不工作。

解决方案:服务注册表模式

我们使用服务注册表如 Consul 或 AWS Cloud Map。

这是它如何工作:

服务发现

注册

当聊天服务器启动时:

  1. 服务器在 IP 10.0.1.45:8080 启动
  2. 向 Consul 注册自己:
    • 服务名:“chat-server”
    • 实例 ID:“chat-server-abc123”
    • 健康检查端点:“/health”
    • 元数据:{region: “us-east”, capacity: 100000}
  3. 每 10 秒发送心跳
  4. 如果心跳停止,Consul 在 30 秒后标记它为不健康

发现

当负载均衡器需要可用服务器时:

  1. 查询 Consul:“给我所有健康的 chat-server 实例”
  2. Consul 返回列表:[10.0.1.45:8080, 10.0.1.67:8080, …]
  3. 负载均衡器更新路由表
  4. 监视更改(Consul 在更新时通知)

为什么我们选择这个

服务动态发现彼此。

  • 部署新服务器;它们自动注册
  • 杀死服务器,它自动移除
  • 无手动配置更新

健康检查

Consul 每 10 秒命中这个端点。

如果实例连续失败三次,它被标记为不健康。

权衡

服务发现添加另一个系统维护(Consul 集群需要高可用)。

但替代方案是手动实例管理,这不扩展并在你忘记更新配置时导致中断。

数据库模式

让我们设计实际支持我们用例的表:

数据库模式

Users 表(SQL - PostgreSQL 工作)

存储用户信息、认证详情、配置文件数据。

Messages 表(NoSQL - Cassandra)

存储消息内容、元数据、状态。

Groups 表(SQL)

存储群组信息、成员关系。

用户连接注册表(Redis)

user_id -> {
server_id: "chat-server-abc123",
last_seen: timestamp,
device_id: "phone-xyz"
}

消息收件箱(Redis)

user_id -> [
{message_id, timestamp, content, ...},
...
]

WebSocket 消息格式

现在让我们具体说明实际消息格式:

WebSocket 连接建立

客户端 → 服务器(初始握手 over WSS)

{
"type": "auth",
"payload": {
"user_id": "123",
"auth_token": "jwt_token_here",
"device_id": "phone-abc",
"platform": "ios",
"app_version": "2.24.5"
}
}

服务器 → 客户端(认证成功)

{
"type": "auth_success",
"payload": {
"session_id": "sess_xyz789",
"server_time": 1699189200000,
"unread_count": 47
}
}

发送消息

客户端 → 服务器

{
"type": "message",
"client_message_id": "client_abc123",
"payload": {
"receiver_id": "456",
"content": "Hey, are you free tomorrow?",
"media_url": null,
"reply_to": null,
"timestamp": 1699189200000
}
}

服务器 → 客户端(ACK)

{
"type": "message_ack",
"client_message_id": "client_abc123",
"payload": {
"message_id": "20241105120000000001",
"status": "sent",
"timestamp": 1699189200123
}
}

接收消息

服务器 → 客户端

{
"type": "message",
"payload": {
"message_id": "20241105120000000001",
"sender_id": "456",
"receiver_id": "123",
"content": "Yes, what time works for you?",
"media_url": null,
"timestamp": 1699189200123,
"status": "delivered"
}
}

客户端 → 服务器(传递 ACK)

{
"type": "delivery_ack",
"message_id": "20241105120000000001",
"timestamp": 1699189200456
}

已读回执

客户端 → 服务器(用户打开聊天)

{
"type": "read_receipt",
"message_ids": [
"20241105120000000001",
"20241105120000000002",
"20241105120000000003"
],
"timestamp": 1699189260000
}

服务器 → 原始发送者

{
"type": "read_receipt",
"payload": {
"message_ids": ["20241105120000000001", ...],
"read_by": "123",
"timestamp": 1699189260000
}
}

存在心跳

客户端 → 服务器(每 5 秒)

{
"type": "heartbeat",
"timestamp": 1699189200000
}

服务器 → 客户端

{
"type": "heartbeat_ack",
"server_time": 1699189200123
}

媒体上传请求

客户端 → 服务器(REST API)

POST /api/media/upload
{
"file_type": "image/jpeg",
"file_size": 2457600,
"conversation_id": "conv_123_456"
}

服务器 → 客户端

{
"upload_url": "https://s3.amazonaws.com/bucket/signed_url...",
"media_id": "media_xyz789",
"expires_in": 3600
}

群消息

客户端 → 服务器

{
"type": "group_message",
"client_message_id": "client_def456",
"payload": {
"group_id": "group_789",
"content": "Meeting at 3 PM",
"media_url": null,
"timestamp": 1699189200000
}
}

服务器扇出到所有成员,每个接收

{
"type": "group_message",
"payload": {
"message_id": "20241105120000000005",
"group_id": "group_789",
"sender_id": "123",
"content": "Meeting at 3 PM",
"timestamp": 1699189200123
}
}

本文为学习目的的个人翻译,译文仅供参考。

原文链接:WhatsApp System Design Interview

版权归原作者或原刊登方所有。本文为非官方译本;如有不妥,请联系删除。