1. 系统的要求和目标
1.1 功能要求
- 对话:系统应支持用户之间的一对一和群组对话。
- 确认消息:系统应支持消息传递确认,如已发送、已送达、已读。
- 共享:系统应支持媒体文件的共享,例如图像、视频和音频。
- 聊天存储:系统必须支持用户离线时聊天消息的持久存储,直到消息成功传递。
- 推送通知:一旦离线用户的状态变为在线,系统应该能够向其通知新消息。
1.2 非功能性需求
- 低延迟:用户应该能够以低延迟接收消息。
- 一致性:消息应该按照发送的顺序传递。此外,用户必须在所有设备上看到相同的聊天历史记录。
- 可用性:系统应该具有高可用性。然而,一致性比可用性更重要。
- 安全性:系统必须通过端到端加密来保证安全。我们需要确保只有通信双方才能看到消息的内容。中间的任何人,甚至我们作为服务所有者,都不应有权访问。
- 可扩展性:系统应该具有高度可扩展性,以支持每天不断增加的用户和消息数量。
2. 高层系统设计
2.1 通讯方式
首先,我们需要了解客户端和服务器如何通信。在聊天系统中,客户端可以是移动应用程序或 Web 应用程序。客户端之间不直接通信。每个客户端都连接到一个聊天服务,该服务支持我们之前讨论的所有功能:
- 接收来自其他客户端的消息。
- 为每条消息找到正确的收件人,并将消息转发(传递)给收件人。
- 如果收件人不在线,则需要在服务器上保留该收件人的消息,直到他们在线为止。
由于 HTTP 是客户端发起的,我们无法真正从服务器向接收者发送消息,因此我们需要考虑用于模拟服务器发起的连接的其他技术:轮询、长轮询和 WebSocket。
- 轮询:是客户端定期向服务器请求数据,产生大量请求,效率低下。
- 长轮询:服务器保持连接打开,直到有新数据可用,从而减少请求数量和延迟。
- WebSocket:是一种双向通信协议,可通过单个长期连接实现客户端和服务器之间的实时通信,从而提供最低的延迟。它是从服务器向客户端发送异步更新的最常见解决方案。
两个客户端之间的通信步骤如下:
- 用户A和用户B创建与聊天服务器的通信通道。
- 用户A向聊天服务器发送消息。
- 当收到消息时,聊天服务器会向用户 A 回复确认消息。
- 如果接收者的状态为离线,则聊天服务器将消息发送给用户B,并将消息存储在数据库中。
- 用户 B 向聊天服务器发送确认消息。
- 聊天服务器通知用户A消息已成功发送。
- 当用户 B 阅读消息时,应用程序通知聊天服务器。
- 聊天服务器通知用户A用户B已阅读消息。
对于客户端-服务器聊天通信,WebSocket 优于 HTTP(S) 协议,因为 HTTP(S) 不会保持连接打开以供服务器频繁向客户端发送数据。使用 HTTP(S) 协议时,客户端不断向服务器请求更新,这会占用大量资源并导致延迟。WebSocket 在客户端和服务器之间维护持久连接。只要数据可用,该协议就会立即将数据传输到客户端。它提供了双向连接,用作将异步更新从服务器发送到客户端的通用解决方案。
其他一切非聊天内容不一定都是通过 WebSocket协议。事实上,聊天应用程序的大多数功能(注册、登录、用户配置文件等)都可以使用基于 HTTP 的传统请求/响应方法。
2.2 高级组件
聊天系统分为三大类:无状态服务、有状态服务、第三方集成。
- 无状态服务:是传统的基于HTTP的请求/响应服务,用于管理登录、注册、用户配置文件等。它们位于负载均衡服务后面,负载均衡服务的工作是根据请求路径将请求路由到正确的服务。这些服务可以是整体的或单独的微服务。我们不需要自己构建许多无状态服务,因为市场上有可以轻松集成的服务。我们将深入讨论的一项服务是服务发现。它的主要工作是向客户端提供客户端可以连接的聊天服务器的 DNS 主机名列表。
- 有状态服务:唯一有状态的服务是聊天服务。该服务是有状态的,因为每个客户端都维护与聊天服务器的持久网络连接。在此服务中,只要服务器仍然可用,客户端通常不会切换到另一个聊天服务器。服务发现与聊天服务密切配合,以避免服务器过载。
- 第三方集成:用于推送通知,以便在新消息到达时通知用户,即使应用程序未运行也是如此。
2.3 存储
我们需要考虑使用哪种类型的数据库:关系数据库还是NoSQL。在典型的聊天系统中,我们将有两种类型的数据。
第一个是通用数据,例如用户个人资料、设置和用户好友列表。这些数据应该存储在可靠的关系数据库中。我们需要实现复制和分片来满足可用性和可扩展性要求。
第二个是聊天系统特有的:聊天历史数据。
- 对于聊天系统来说,数据量非常巨大(例如Facebook 每天有 600 亿条消息)。
- 仅经常访问最近的聊天记录。用户通常不会查找旧聊天记录。
- 尽管在大多数情况下都会查看最近的聊天历史记录,但用户可能会使用需要随机访问数据的功能,例如搜索、查看您的提及、跳转到特定消息等。
- 对于 1 对 1 聊天应用程序来说,读写比率约为 1:1。
我认为NoSQL的键值存储非常适合这里,因为:
- 键值存储允许轻松水平扩展。
- 键值存储提供非常低的数据访问延迟。
- 关系数据库不能很好地处理数据的长尾。当索引变大时,随机访问的成本很高。
- 键值存储已被其他经过验证的可靠聊天应用程序采用。例如,Facebook Messenger 和 Discord 都使用键值存储。
2.4 数据模型
2.4.1 1对1聊天的消息表
主键是message_id,它有助于决定消息的顺序。我们不能依靠消息建立的时间戳来决定消息顺序,因为可以同时创建两条消息。
2.4.2 群聊消息表
复合主键是(channel_id,message_id)。其中channel_id是群组ID。
2.4.3 message_id
message_id负责消息的顺序,其具有以下特性:
- 必须是唯一的。
- 应可按时间排序,这意味着新行的 ID 高于旧的。
如何才能实现这两个保证呢?第一个想法是MySql中的"auto_increment"关键字。然而,NoSQL 数据库通常不提供这样的功能。第二种方法是使用全局 64 位序列号生成器,例如雪花算法(Snowflake)。
2.5 API设计
2.5.1 发送消息
此 API 用于通过对 /messages 进行 POST调用,将聊天消息从发送方发送到接收方。
sendMessage(sender_ID、reciever_ID、type、text = none、media_object = none、document = none )
- sender_ID: 发送消息的用户的唯一标识符。
- receiveer_ID: 接收消息的用户的唯一标识符。
- type: 表示发送方发送的是文本信息、媒体文件、文档(默认消息类型为文本)。
- text: 包含必须作为消息发送的文本。
- media_object: 要发送的媒体文件。
- document: 要发送的文档文件。
2.5.2 获取消息
通过调用该API,用户可以在离线后上线时获取所有未读消息。
getMessage(user_Id)
- user_id: 代表必须获取所有未读消息的用户的唯一标识符。
2.5.3 上传媒体或文档文件
通过 uploadFile 向 /v1/media 发出POST请求来上传媒体文件。成功会返回一个文件ID。假设可上传媒体的最大文件大小为 16 MB,而文档的限制为 100 MB。
uploadFile(file_type, file)
- file_type: 上传的文件类型。
- file: 上传的文件。
2.5.4 下载文档或媒体文件
downloadFile(user_id, file_id)
- user_id: 下载文件的用户唯一标识符。
- file_id: 文件的唯一标识符。它是在通过uploadFile() 调用上传文件时生成的。downloadFile() 调用通过此标识符下载媒体文件。客户端可以通过向服务器提供文件名来找到file_id。
3. 详细设计
3.1 WebSocket服务器
一台 WebSocket 服务器肯定不足以处理数十亿台设备,因此应该有足够的服务器来处理这个问题。这些服务负责为每个在线用户提供一个端口。因此,我们需要一个WebSocket 管理服务,它基本上位于数据存储集群(Redis)之上。
3.2 服务发现
此外,我们还需要根据客户的地理位置、服务器容量等为客户推荐最好的聊天服务器。Apache Zookeeper 是一种流行的开源服务器。它注册所有可用的聊天服务器,并根据预定义的标准为客户端选择最佳的聊天服务器。
- 用户A尝试登录应用程序。
- 负载均衡器向API服务器发送登录请求。
- 后端对用户进行身份验证后,服务发现为用户 A 找到最佳的聊天服务器(服务器 2),并将服务器信息返回给用户 A。
- 用户A通过WebSocket连接到聊天服务器2。
3.3 发送或接收消息
WebSocket 服务器还需要与另一个消息服务进行通信。消息服务基本上是数据库集群顶部的消息存储库。它充当与数据库交互的其他服务的数据库接口。它从数据库中存储和检索消息,并在特定时间(我们可以设置)后删除它们。并且,它公开 API 以通过各种过滤器接收消息,例如用户 ID、消息 ID 等。
现在,如果用户 A 想要向用户 B 发送消息。由于我们有多个 WebSocket 服务器,这些用户可以连接到不同的服务器。那么这是如何工作的:
- 用户A与其所连接的WebSocket服务器进行通信。
- 与用户A连接的 WebSocket 服务器识别用户 B 连接的websocket服务器。如果用户 B 在线,WebSocket 管理服务会向用户 A 的 WebSocket 服务器告知用户 B 已与其 WebSocket 服务器连接的消息。
- 同时,WebSocket服务器将消息发送到消息服务并存储在数据库中(以防用户B离线)。因为,要处理的消息是先进先出的策略,当消息传递到接收者时,它们将从数据库中删除。
- 现在,用户A的WebSocket服务器已经知道用户B与自己的WebSocket服务器连接的信息。两个用户都与 WebSocket 管理服务通信以查找对方的 WebSocket 服务器。
- 如果用户B离线,消息将保留在数据库中。每当他们上线时,所有发送给用户 B 的消息都会通过推送通知传递。否则,这些消息将在 30 天后永久删除。
如果两个用户之间存在连续对话,则会对 WebSocket 管理服务进行多次调用。为了最大限度地减少延迟并减少这些调用的数量,我们可以向每个 WebSocket 服务器添加一个缓存,如下所示:
- 如果两个用户都连接到同一服务器,则可以避免对 WebSocket 管理服务的调用。
- 它还可以缓存有关哪个用户连接到哪个 WebSocket 服务器的最近对话的信息。
我们还应该考虑过期策略:WebSocket 服务器应该缓存信息多长时间?如果用户断开连接并连接到另一台服务器,缓存中的数据将变得过时。
在这种情况下,信息将在 WebSocket 管理服务中更新,而 WebSocket 管理服务将验证 WebSocket 服务器使用的缓存中的数据,并将更新的数据发送到相应的缓存。因此,缓存中的信息将保留在那里,直到收到来自 WebSocket 管理服务的无效信号。
3.4 支持群组消息
WebSocket 服务器不跟踪组,它们只跟踪活动用户。但在群组中,一些用户可能在线,而另一些用户可能离线。我们需要考虑组消息的其他组件,负责将消息传递给组中的每个用户:
- 群组消息处理程序
- 群消息服务
- 采用Kafka做为消息队列
Kafka是一个开源分布式事件流平台,用于构建实时数据管道和流应用程序。它旨在实时处理大量数据。它提供了一个发布-订阅消息系统,允许应用程序以容错、可扩展且可靠的方式发送和接收消息。Kafka 基于分布式架构,由多个节点或代理组成,这些节点或代理协同工作形成一个集群。生产者可以向 Kafka主题发送消息,消费者可以从这些主题中读取消息。Kafka还支持流处理,可以实时处理数据流。Kafka在业界广泛用于构建实时数据管道和流应用程序,它已成为大数据生态系统中的重要工具。
用户 A 希望向具有群组送消息 - 例如 Group/A:
- 由于用户 A 连接到 WebSocket 服务器,因此它向 Group/A 的消息服务发送消息。
- 群组消息处理程序与群组服务通信以检索群组Group/A用户的数据。
- 消息服务将消息连同有关该组的其他特定信息发送到 Kafka。该消息保存在那里以供进一步处理。在 Kafka 术语中,一个群组可以是一个主题(topic),发送者和接收者可以分别是生产者和消费者。
- 现在,群组服务将每个群组中用户的所有信息保存在系统中。它拥有每个组的所有信息,包括用户ID、群组ID、状态、组图标、用户数量等。该服务驻留在 MySQL 数据库集群之上,具有按地理位置分布的多个辅助副本。Redis 缓存服务器还用于缓存来自 MySQL 服务器的数据。地理分布的副本和 Redis 缓存都有助于减少延迟。
- 最后,组消息处理程序遵循与 WebSocket 服务器相同的流程,并将消息传递给每个用户。
为什么在我们的聊天应用程序中使用 SQL 数据库:
- 结构化数据:聊天服务需要结构化数据来管理用户对话、用户配置文件和消息历史记录。SQL 数据库提供了一种结构化的数据组织方法,使其易于查询和管理。
- 一致性:我们的服务还要求数据存储和检索的一致性,以确保消息可靠且一致地传递。即使多个用户同时访问数据,SQL 数据库也能保证数据的一致性。
- 可扩展性:随着用户和消息数量的不断增加,轻松扩展的能力非常重要。SQL 数据库提供了跨多个服务器和节点分片数据的能力,从而更容易水平扩展。
- 可靠性:我们的聊天应用程序需要可靠且 24/7 可用。
分片(水平扩展)是添加更多服务器的做法。分片将大型数据库分成更小、更容易管理的部分,称为分片。每个分片共享相同的架构,但每个分片上的实际数据对于该分片来说是唯一的。用户数据根据用户ID分配到数据库服务器。每当您访问数据时,都会使用哈希函数来查找相应的分片。在我们的示例中,使用user_id % 4 作为哈希函数。如果结果等于 0,则使用分片 0 来存储和获取数据。如果结果等于 1,则使用分片1。同样的逻辑也适用于其他分片。
3.5 用户服务
3.6 媒体文件
通常,WebSocket 服务器是轻量级的,不支持繁重的逻辑,例如处理媒体文件的发送和接收。所以我们需要添加另一个服务——文件服务,它将负责发送和接收媒体文件。压缩和加密的文件将被发送到文件服务以将文件存储在 blob 存储上。如果文件服务收到对某些特定内容的大量请求,则内容会加载到 CDN 上。
3.6.1 对于发送媒体文件
- 首先应在设备端对其进行压缩和加密。
- 然后,压缩和加密的文件被发送到文件服务,以将文件存储在 blob 存储上。文件服务分配一个与发件人关联的 ID。文件服务还可以为每个文件提供一个哈希值,以避免 Blob 存储上的内容重复。例如,如果用户想要上传 Blob 存储中已有的图像,则不会上传该图像。相反,相同的 ID 会转发给接收者。
- 文件服务通过消息服务将媒体文件的ID发送给接收者。接收方使用 ID 从 Blob 存储下载媒体文件。
- 如果文件服务收到对某些特定内容的大量请求,则内容会加载到 CDN 上。
Blob存储:是非结构化数据的存储解决方案。我们可以在那里存储照片、音频、视频或其他多媒体项目。每种类型的数据都存储为blob。它遵循平面数据组织模式,其中没有目录、子目录等。它由具有称为一次写入多次读取 (WORM)的特定业务需求的应用程序使用,该需求规定数据只能写入一次,并且任何人都无法更改它。
4. 最终设计
4.1 非功能性需求
我们对此设计的非功能性要求是低延迟、一致性、可用性和安全性。让我们想想如何在我们的系统中实现这些要求:
- 低延迟:
我们可以在各个层面上最小化系统的延迟
- 我们可以通过地理上分布的 WebSocket 服务器以及与其关联的缓存来做到这一点。
- 我们可以在 MySQL 数据库集群之上使用 Redis 缓存集群。
- 我们可以使用 CDN 来频繁共享文档和媒体内容。
- 一致性:系统还借助严格排序的 FIFO 消息队列,提供消息的高一致性。但是,消息的排序需要 Sequencer(分配唯一序列号或时间戳的组件或算法)为每条消息提供 ID。此 ID 号可帮助系统识别消息发送的顺序,即使消息到达时顺序不正确。对于离线用户,Mnesia 数据库将消息存储在队列中。用户上线后,消息将按顺序发送。
- 可用性:如果我们有足够的 WebSocket 服务器并跨多个服务器复制数据,则系统可以实现高可用性。当用户由于 WebSocket 服务器中的某些故障而断开连接时,会话将通过负载均衡器与不同的服务器重新创建。此外,消息按照主从复制模型存储在数据存储集群(Mnesia 常用于消息系统)上,从而提供高可用性和持久性。
- 安全性:系统还提供端到端的加密机制,保证用户之间聊天的安全。
- 可扩展性:由于高性能工程(意味着如果我们的系统是使用高性能工程原理设计和开发的),可扩展性可能不是一个重大问题。然而,我们提出的系统是灵活的,因为随着负载的增加或减少,可以添加或删除更多服务器。
5. 设计权衡
5.1 一致性和可用性之间的权衡
根据 CAP 定理,系统可以在发生网络分区时提供其中之一或另一个。显然,在我们的系统中,消息的正确排序至关重要。否则,用户之间交流的信息的上下文可能会发生显著变化。因此,我认为如果发生网络分区,我们系统的可用性可能会受到影响。
CAP定理(也称为 Brewer 定理)指出,分布式数据库系统只能保证这三个特性中的两个:一致性、可用性和分区容错性。
5.2 延迟和安全性之间的权衡
低延迟是系统设计中为用户提供实时体验的重要因素。然而,另一方面,如果不加密,通过我们的聊天应用程序共享信息或数据可能会不安全。缺乏适当的安全机制会使数据容易受到未经授权的访问。因此,我们可以接受优先考虑消息安全传输和低延迟的权衡。例如,在涉及多媒体的通信的情况下,在发送方设备上近乎实时地加密它们并在接收方上解密它们可能会给设备带来负担,从而导致延迟。
6. 资源估算
我们需要估计存储容量、带宽和服务器数量来支持如此大量的用户和消息。
6.1 存储估算
例如,WatsUp 每天共享的消息超过 1000 亿条,我们根据这个数字来估算存储容量。假设每条消息平均占用 100 字节,我们的服务器只会将消息保留 30 天。因此,如果用户在这些天内没有连接到服务器,这些消息将从服务器中永久删除。
1000 亿/天 * 100 字节 = 10 TB/天
30 天的存储容量约为:
30 * 10 TB/天 = 300 TB/月
除了聊天消息之外,我们还有媒体文件,每条消息占用超过 100 字节。我们还必须存储用户的信息和消息的元数据——例如时间戳、ID 等。在此过程中,我们还需要加密和解密来实现安全通信(因此我们需要存储加密密钥和相关元数据)。因此,准确地说,我们每月需要超过 300 TB,但为了简单起见,我们还是坚持每月 300 TB 这个数字。
6.2 带宽估计
由于我们的服务每天将获取 10TB 数据,因此我们需要将其除以 86400(一天中的秒数),这将为我们提供116 Mb/s 的传入带宽。
10 TB / 86400 ≈ 116 MBps
为了简单起见,我们暂时忽略图像、视频、文档等媒体内容。
我们还需要相同数量的传出带宽,因为来自发送方的相同消息需要传递到接收方:
总带宽: 116 * 2 = 232 MBps
6.3 服务器数量估计
让我们开始估计服务器数量。假设我们的系统在单个服务器(例如 WhatsApp)上处理大约 1000 万个连接,每天的总连接数为 20 亿:
服务器数量 = 每日总连接数 / 每台服务器的连接数量
20亿 / 1000万 = 200台服务器
7. 总结
本文展示了如何设计一款实时聊天系统的架构,包括:
- 如何设计通讯协议类型
- 服务器端架构
- 消息存储结构
- 关键的过程如何处理
- 如何权衡一些冲突
- 如何估算所需资源