系统设计
嘿,欢迎来到本课程。希望本课程能给您带来良好的学习体验。
这门课程也可以在我的网站上找到,也可以在leanpub上找到电子书。如果觉得有帮助,请留下⭐作为鼓励!
目录
-
入门
-
第一章
-
第二章
-
第三章
-
第四章
- …
让我们设计一个类似Whatsapp 的即时通讯服务,类似于Whatsapp、Facebook Messenger和微信等服务。
Whatsapp 是一款为用户提供即时通讯服务的聊天应用。它是全球使用最广泛的移动应用之一,连接着 180 多个国家的 20 多亿用户。Whatsapp 也提供网页版。
我们的系统应满足以下要求:
让我们从估计和约束开始。
注意:请务必与面试官核对任何与规模或交通相关的假设。
假设我们有 5000 万日活跃用户 (DAU),平均每个用户每天向 4 个不同的人发送至少 10 条消息。这样,我们每天的消息量就达到了 20 亿条。
每天 20 亿个请求相当于每秒 24000 个请求。
如果我们假设每条消息平均为 100 字节,那么我们每天将需要大约 200 GB 的数据库存储空间。
由于我们的系统每天要处理 10.2 TB 的入口数据,因此我们需要每秒约 120 MB 的最低带宽。
以下是我们的高级估计:
类型 | 估计 |
---|---|
每日活跃用户(DAU) | 5000万 |
每秒请求数 (RPS) | 24K/秒 |
存储(每天) | 约10.2 TB |
储存(10年) | ~38 PB |
带宽 | ~120 MB/s |
这是反映我们要求的通用数据模型。
我们有下表:
用户
该表将包含用户的信息,例如name
、phoneNumber
和其他详细信息。
消息
顾名思义,该表将存储具有type
(文本、图像、视频等)属性的消息,content
以及用于消息传递的时间戳。消息还将具有相应的chatID
或groupID
。
聊天
该表基本上代表两个用户之间的私人聊天,并且可以包含多条消息。
用户聊天
该表将用户和聊天进行映射,因为多个用户可以有多个聊天(N:M 关系),反之亦然。
组
该表代表多个用户之间的组。
用户组
该表映射用户和组,因为多个用户可以成为多个组的一部分(N:M 关系),反之亦然。
虽然我们的数据模型看起来相当相关,但我们不一定需要将所有内容存储在单个数据库中,因为这会限制我们的可扩展性并很快成为瓶颈。
我们将数据拆分到不同的服务,每个服务拥有特定表的所有权。然后,我们可以将关系数据库(例如PostgreSQL)或分布式 NoSQL 数据库(例如Apache Cassandra)用于我们的用例。
让我们为我们的服务做一个基本的 API 设计:
此 API 将获取给定的所有聊天或群组userID
。
getAll(userID: UUID): Chat[] | Group[]
参数
用户 ID(UUID
):当前用户的 ID。
返回
结果(Chat[] | Group[]
):用户所属的所有聊天和群组。
channelID
获取给定(聊天或群组 ID)的用户的所有消息。
getMessages(userID: UUID, channelID: UUID): Message[]
参数
用户 ID(UUID
):当前用户的 ID。
频道 ID ( UUID
):需要从中检索消息的频道(聊天或群组)的 ID。
返回
消息(Message[]
):给定聊天或群组中的所有消息。
将用户消息发送到频道(聊天或群组)。
sendMessage(userID: UUID, channelID: UUID, message: Message): boolean
参数
用户 ID(UUID
):当前用户的 ID。
频道 ID ( UUID
):用户想要发送消息的频道(聊天或群组)的 ID。
消息(Message
):用户想要发送的消息(文本、图像、视频等)。
返回
结果(boolean
):表示操作是否成功。
将用户消息发送到频道(聊天或群组)。
joinGroup(userID: UUID, channelID: UUID): boolean
leaveGroup(userID: UUID, channelID: UUID): boolean
参数
用户 ID(UUID
):当前用户的 ID。
频道 ID ( UUID
):用户想要加入或离开的频道(聊天或群组)的 ID。
返回
结果(boolean
):表示操作是否成功。
现在让我们对我们的系统进行高层设计。
我们将使用微服务架构,因为它可以更轻松地水平扩展和解耦我们的服务。每个服务都拥有自己的数据模型。让我们尝试将系统划分为几个核心服务。
用户服务
这是一个基于 HTTP 的服务,用于处理与用户相关的问题,例如身份验证和用户信息。
聊天服务
聊天服务将使用 WebSocket 并与客户端建立连接,以处理聊天和群组消息相关功能。我们还可以使用缓存来跟踪所有活动连接(类似于会话),这将有助于我们判断用户是否在线。
通知服务
该服务只会向用户发送推送通知。具体细节我们将另行讨论。
在线服务
在线状态服务会记录所有用户的最后在线状态。我们将另行详细讨论。
媒体服务
此服务将处理媒体(图片、视频、文件等)的上传。我们将另行详细讨论。
服务间通信和服务发现怎么样?
由于我们的架构基于微服务,因此服务之间也会相互通信。通常,REST 或 HTTP 的性能表现良好,但我们可以使用更轻量、更高效的gRPC来进一步提升性能。
服务发现是我们需要考虑的另一件事。我们还可以使用服务网格,实现各个服务之间可管理、可观察且安全的通信。
注意:了解有关REST、GraphQL、gRPC 的更多信息以及它们之间的比较。
如何高效地发送和接收消息?我们有两种选择:
拉动模型
客户端可以定期向服务器发送 HTTP 请求,检查是否有新消息。这可以通过类似长轮询来实现。
推模型
客户端与服务器建立长连接,一旦有新数据可用,就会将其推送到客户端。我们可以使用WebSocket或服务器发送事件 (SSE)来实现这一点。
拉取模型不可扩展,因为它会在我们的服务器上产生不必要的请求开销,并且大多数情况下响应都是空的,从而浪费我们的资源。为了最大限度地降低延迟,使用WebSocket的推送模型是更好的选择,因为这样,只要与客户端的连接处于打开状态,我们就可以立即将数据推送到客户端,而不会有任何延迟。此外,WebSocket 提供全双工通信,而服务器发送事件 (SSE)则只是单向的。
注意:了解有关长轮询、WebSockets、服务器发送事件(SSE)的更多信息。
为了实现“上次查看”功能,我们可以使用心跳机制,客户端可以定期 ping 服务器以指示其活动状态。由于需要尽可能降低开销,我们可以将上次活动时间戳存储在缓存中,如下所示:
钥匙 | 价值 |
---|---|
用户 A | 2022年7月1日14:32:50 |
用户B | 2022-07-05T05:10:35 |
用户C | 2022-07-10T04:33:25 |
这将返回用户上次活动的时间。此功能将由状态服务结合Redis或Memcached作为缓存来处理。
另一种实现方法是跟踪用户的最新操作。一旦用户最近的活动超过某个阈值,例如“用户在过去 30 秒内未执行任何操作”,我们就可以将用户显示为离线,并使用最后记录的时间戳来记录上次上线时间。这更像是一种惰性更新方法,在某些情况下可能比心跳更新更有优势。
一旦在聊天或群组中发送消息,我们将首先检查收件人是否处于活动状态,我们可以通过考虑用户的活动连接和上次查看来获取此信息。
如果收件人不活跃,聊天服务将向消息队列中添加一个事件,其中包含额外的元数据,例如客户端的设备平台,稍后将用于将通知路由到正确的平台。
然后,通知服务将从消息队列中消费该事件,并根据客户端的设备平台(Android、iOS、Web 等)将请求转发到Firebase 云消息传递 (FCM)或Apple 推送通知服务 (APNS)。我们还可以添加对电子邮件和短信的支持。
我们为什么要使用消息队列?
由于大多数消息队列都提供尽力排序,这确保了消息通常按照发送的顺序传递,并且消息至少传递一次,这是我们服务功能的重要组成部分。
虽然这看起来像是一个典型的发布-订阅用例,但实际上并非如此,因为移动设备和浏览器各自都有处理推送通知的方式。通常,通知是通过 Firebase 云消息传递 (FCM) 或 Apple 推送通知服务 (APNS) 在外部处理的,这与我们在后端服务中常见的消息扇出不同。我们可以使用Amazon SQS或RabbitMQ之类的服务来支持此功能。
处理已读回执可能比较棘手,对于这种情况,我们可以等待客户端的某种确认 (ACK)来确定消息是否已送达,并更新相应的deliveredAt
字段。同样,我们会将消息标记为用户打开聊天后看到的消息,并更新相应的seenAt
时间戳字段。
现在我们已经确定了一些核心组件,让我们开始系统设计的初稿。
现在是时候详细讨论我们的设计决策了。
为了扩展数据库,我们需要对数据进行分区。水平分区(又称分片)是一个很好的第一步。我们可以使用以下分区方案:
上述方法仍然会导致数据和负载分布不均匀,我们可以使用一致性哈希来解决这个问题。
在消息传递应用中,我们必须谨慎使用缓存,因为用户期望获取最新数据,但许多用户会请求相同的消息,尤其是在群聊中。因此,为了防止资源使用量激增,我们可以缓存较旧的消息。
有些群聊可能包含数千条消息,通过网络发送这些消息效率极低。为了提高效率,我们可以在系统 API 中添加分页功能。这项功能对于网络带宽有限的用户非常实用,因为他们无需在需要时才检索旧消息。
使用哪种缓存驱逐策略?
我们可以使用Redis或Memcached等解决方案并缓存 20% 的每日流量,但哪种缓存驱逐策略最适合我们的需求?
对我们的系统来说,最近最少使用(LRU)策略可能是一个不错的选择。在这个策略中,我们首先丢弃最近最少使用的键。
如何处理缓存未命中?
每当出现缓存未命中时,我们的服务器可以直接访问数据库并使用新条目更新缓存。
有关详细信息,请参阅缓存。
众所周知,我们的大部分存储空间将用于存储媒体文件,例如图像、视频或其他文件。我们的媒体服务将处理用户媒体文件的访问和存储。
但是,我们可以在哪里大规模存储文件呢?嗯,对象存储就是我们想要的。对象存储将数据文件分解成称为对象的块。然后,它将这些对象存储在一个存储库中,该存储库可以分布在多个联网系统中。我们也可以使用分布式文件存储,例如HDFS或GlusterFS。
有趣的事实:一旦用户下载了媒体,Whatsapp 就会在其服务器上删除该媒体。
对于这种用例,我们可以使用Amazon S3、Azure Blob Storage或Google Cloud Storage等对象存储。
内容分发网络 (CDN)可以提高内容可用性和冗余度,同时降低带宽成本。通常,静态文件(例如图像和视频)由 CDN 提供。对于这种情况,我们可以使用Amazon CloudFront或Cloudflare CDN等服务。
由于我们将使用多种协议,例如 HTTP、WebSocket、TCP/IP,因此为每种协议分别部署多个 L4(传输层)或 L7(应用层)类型的负载均衡器将会非常昂贵。因此,我们可以使用支持多种协议的API 网关,而不会出现任何问题。
API Gateway 还可以提供其他功能,例如身份验证、授权、速率限制、节流和 API 版本控制,这些功能将提高我们服务的质量。
对于这种用例,我们可以采用Amazon API Gateway或Azure API Gateway等服务。
让我们识别并解决设计中的单点故障等瓶颈:
为了使我们的系统更具弹性,我们可以执行以下操作:
本文是我在 Github 上提供的开源系统设计课程的一部分。
嘿,欢迎来到本课程。希望本课程能给您带来良好的学习体验。
这门课程也可以在我的网站上找到,也可以在leanpub上找到电子书。如果觉得有帮助,请留下⭐作为鼓励!
入门
第一章
第二章
第三章
第四章