5. 设计Facebook Messager
让我们设计一个像
Facebook Messenger
这样的即时消息服务,用户可以通过web
和移动界面相互发送文本消息。
1.什么是Facebook Messenger
Facebook Messenger
是一个软件应用程序,它为用户提供基于文本的即时消息服务。Messenger
用户可以通过手机和Facebook
网站与Facebook
好友聊天。
2.系统的要求和目标
Messenger
应满足以下要求:
功能性要求:
Messenger
应支持用户之间的一对一的聊天。Messenger
应跟踪其用户的在线/离线状态。Messenger
应支持聊天历史的持久化存储。
非功能性要求:
用户应该在最低到延迟性下有实时聊天的体验。
我们的系统应该是高度的一致性;用户应该能够在他们所有的设备上看到相同的聊天历史记录。
Messenger
的高可用性是必要的;为了保持一致性,可以容忍低可用性。
扩展性要求:
群聊:
Messenger
应支持多人在一个群中相互交谈。推送通知:
Messenger
应该能够在用户离线时通知用户新消息。
3.容量估算和限制条件
让我们假设,每天有500 millon
活跃用户,平均每个用户每天发送40条消息;相当于每天有20 billon
条消息。
存储估计:
让我们假设一条消息平均是100 bytes
,所以要存储一天的所有消息,需要2TB
的存储空间。
要存储5年的聊天历史,我们需要3.6 PB
的存储空间。
除了聊天信息,我们还需要存储用户的信息、消息的元数据(ID
、Timestamp
等)。要提到的是,上面的计算没有考虑数据压缩和复制。
带宽估计:
如果我们的服务每天获得2TB的数据,这将为我们提供每秒25MB
的入口数据。
由于每个传入的消息都需要发送给另一个用户,因此上传和下载都需要相同的25MB/s带宽。
高水平估计:
每天的总消息数:20 billion
每天存储: 2TB
储存5年: 3.6PB
输入数据: 25MB/s
输出数据: 25MB/s
4.高阶设计
在高阶层次上,我们需要一个聊天服务器,它将是核心部分,协调用户之间的所有通信。当用户想要向另一个用户发送消息时,他们将与聊天服务器建立连接并将消息发送到服务器;然后服务器将消息传递给另一个用户,并将其存储在数据库中。
详细的工作流程如下所示:
用户
A
通过聊天服务器向用户B
发送消息。服务器接收消息并向用户
A
发送确认。服务器将消息存储在其数据库中,并将消息发送给用户
B
。用户
B
接收消息并向服务器发送确认。服务器通知用户
A
消息已成功传递给用户B
。
5.详细组件设计
让我们先尝试构建一个简单的解决方案,其中所有的程序都在一台服务器上运行。在高阶设计中,我们的系统需要处理以下用例:
接收传入消息并传输、输出消息。
从数据库存储和检索消息。
记录哪些用户处于在线和离线状态,并将这些状态变更通知所有相关用户。
让我们逐一讨论这些场景:
a. 消息处理
我们如何有效地发送/接收信息?
要发送消息,用户需要连接到服务器并为其他用户发布消息。要从服务器获取消息,用户有两个选项:
1.Pull
:用户可以轮询服务器是否有新消息。
2.Push
:用户可以保持与服务器的连接处于连接状态,出现新消息时,服务器通知他们。
如果我们使用第一种方式,那么服务器需要跟踪仍在等待传递的消息,一旦接收消息的用户连接到服务器,请求新消息,服务器就可以返回所有挂起的消息。为了最大限度地减少延迟,他们必须非常频繁地检查服务器,如果没有挂起的消息,大多数情况下他们将得到一个空响应。这将浪费大量资源,而且看起来不是一个高效的解决方案。
如果我们使用第二种方式,即所有活跃用户都保持与服务器的连接状态,那么一旦服务器收到消息,它就可以立即将消息传递给目标用户。这样服务器就不需要跟踪挂起的消息,具有较低的延迟,因为消息会连接建立的时候立即传递。
客户端如何保持与服务器的连接?
我们可以使用HTTP
Long Polling
或WebSockets
。在Long Polling
中,客户端可以从服务器请求信息,服务器可以不立即响应。如果在轮询时服务器没有客户端的新数据,则服务器将保持请求打开状态并等待响应信息变为可用状态,而不是发送空响应。一旦有了新的消息,服务器就会立即向客户端发送响应,完成打开请求。在收到服务器响应后,客户端可以立即向另一个服务器发出请求以供将来更新。这在延迟性、吞吐量和性能方面都有很大的改进。长轮询请求可能会超时,也可能会与服务器的断开连接,在这种情况下,客户端必须打开一个新请求。
服务器如何跟踪所有打开的连接并高效地将消息重定向给用户?
服务器可以维护一个哈希表,其中key
是UserID
,value
是连接对象。因此,每当服务器收到某个用户的消息时,它都会在哈希表中查找该用户以找到连接对象,并在打开请求时发送消息。
当服务器收到离线用户的消息时会发生什么情况?
如果接收方已断开连接,服务器可以通知发送方传递失败。如果是临时断开连接,例如,接收方的长轮询请求刚刚超时,那么我们应该期待用户重新连接。在这种情况下,我们可以要求发送方重新发送消息。这种重试可以嵌入到客户端的逻辑中,这样用户就不必重新键入消息。服务器还可以将消息存储一段时间,并在接收方重新连接后重新发送。
我们需要多少个聊天服务器?
让我们计划在任何时候建立500 million
个连接。假设一个服务器可以在任何时候处理50K
并发连接,我们就需要10K
这样的服务器。
我们如何知道哪个服务器拥有与哪个用户的连接?
我们可以在聊天服务器之前引入一个负载均衡器;它可以将每个UserID
映射到服务器以重定向请求。
服务器应该如何处理“传递消息”请求?
服务器在收到新消息时需要执行以下操作:
1)将消息存储在数据库中
2)将消息发送给接收方
3)向发送方发送确认
聊天服务器将首先找到为接收方保持连接的服务器,并将消息传递给该服务器以将其发送给接收方。然后,聊天服务器可以将确认信息发送给发送方;我们不需要等待将消息存储在数据库中(这可以在后台发生)。存储消息将在下一节中讨论。
Messenger
如何维护消息的顺序?
我们可以为每条消息存储一个时间戳,即服务器接收消息的时间。这仍然不能确保客户端正确消息顺序。服务器时间戳无法确定消息的确切顺序的场景如下所示:
User-1
向User-2
的服务器发送消息M1
。服务器
T1
接收M1
。同时,
User-2
向User-1
的服务器发送消息M2
。服务器
T2
接收消息M2
,使得T2
>T1
。服务器向
User-2
发送消息M1
,向User-1
发送消息M2
。 所以User-1
会先看到M1
,然后是M2
,而User-2
会先看到M2
,然后是M1
。 要解决这个问题,我们需要为每个客户端的每条消息保留一个序列号。此序列号将确定每个用户的消息的确切顺序。使用此解决方案,两个客户端都将看到消息序列的不同视图,但此视图在所有设备上对它们都是一致的。
b. 从数据库存储和检索消息
每当聊天服务器收到新消息时,它都需要将其存储在数据库中。为此,我们有两种选择:
启动一个单独的线程,它将与数据库一起存储消息。
向数据库发送异步请求以存储消息。
在设计数据库时,我们必须牢记以下几点:
如何有效地使用数据库连接池。
如何重试失败的请求。
在何处记录那些即使重试也失败的请求。
如何在解决所有问题后重试这些记录的请求(重试后失败)。
我们应该使用哪种存储系统?
我们需要有一个数据库,可以支持高效率的小的更新,也可以快速获取一系列的记录。这是必需的,因为我们需要在数据库中插入大量的小的消息,并且在查询时,用户最感兴趣的是按顺序访问这些消息。
我们不能像MySQL
那样使用RDBMS
,也不能像MongoDB
那样使用NoSQL
,因为我们不能每次用户接收/发送消息时都从数据库中读/写一行。这不仅会给服务器带来高延迟,而且会在数据库上产生巨大的负载。
像HBase
这样的宽列数据库解决方案可以轻松满足我们的这两个需求。HBase是一个面向列的键值NoSQL
数据库,它可以针对一个键将多个值存储到多个列中。HBase
以Google
的BigTable
为模型,运行在Hadoop
分布式文件系统(HDFS
)之上。HBase将数据分组存储在内存缓冲区中,一旦缓冲区满了,它就会将数据转储到磁盘。这种存储方式不仅有助于快速存储大量的小的数据,而且可以通过键或扫描行的范围来获取行。HBase
也是一个高效的数据库,可以存储各种大小的数据,这也是我们的服务所需要的。
客户端应该如何有效地从服务器获取数据?
客户端应该在从服务器获取数据时分页。对于不同的客户端,页面大小可能不同,例如,手机屏幕较小,因此我们需要在屏幕中减少消息/对话的数量。
c.管理用户状态
我们需要跟踪用户的在线/离线状态,并在状态发生变化时通知所有相关用户。因为我们在服务器上为所有活跃用户维护一个连接对象,所以我们可以很容易地从中找出用户的当前状态。在任何时候都有500M
活跃用户的情况下,如果我们必须向所有相关的活跃用户广播每个状态的变化,这将消耗大量的资源。我们可以围绕这一点进行以下优化:
每当客户端启动该应用程序时,它都可以拉取其好友列表中所有用户的当前状态。
每当用户向另一个离线的用户发送消息时,我们都可以向发送者发送失败消息,并更新客户端的状态。
每当用户在线时,服务器总是可以延迟几秒钟来广播该状态,以查看用户是否立即离线。
客户端可以从服务器获取显示在用户视角中的那些用户的状态。这不应该是一个频繁的操作,因为服务器正在广播用户的在线状态,我们可以暂时容忍用户老的的离线状态。
每当客户端开始与另一个用户进行新的聊天时,我们都可以在该时间拉取这个状态。
Facebook Messenger
的详细组件设计
Facebook Messenger
的详细组件设计设计总结:
客户端将建立与聊天服务器的连接以发送消息;然后服务器将消息传递给请求的用户。所有活跃用户都将保持与服务器的连接状态以接收消息。每当新消息到达时,聊天服务器就会基于long pull
请求将其推送给接收用户。消息可以存储在HBase中,它支持快速小的更新和基于范围的搜索。服务器可以向其他相关用户广播用户的在线状态。客户端可以以较小的频率为在客户端视角中可见的用户获取状态更新。
6.数据分区
由于我们将存储大量数据(5
年3.6PB
),我们需要将其分发到多个数据库服务器上。我们的分区方案是什么?
基于UserID
的分区:
假设我们基于UserID
的散列进行分区,这样就可以将用户的所有消息保存在同一个数据库中。如果一个DB
分片是4TB
,那么
3.6PB/4TB~=900
分片5
年。为了简单起见,假设我们保留1K
个分片。因此,我们将通过“hash(UserID)%1000
找到分片编号,然后在那里存储/检索数据。这种分区方案还可以非常快速地为任何用户获取聊天历史。
一开始,我们可以从一个物理服务器上有多个碎片的较少的数据库服务器开始。因为我们可以在一台服务器上有多个数据库实例,所以我们可以很容易地在一台服务器上存储多个分区。散列函数需要理解这个逻辑分区方案,以便它能够映射一个物理服务器上的多个逻辑分区。
因为我们将存储无限的消息历史记录,所以我们可以从大量的逻辑分区开始,这些分区将映射到更少的物理服务器,并且随着存储需求的增加,我们可以添加更多的物理服务器来分发我们的逻辑分区。
基于MessageID
的分区:
如果我们将一个用户的不同消息存储在不同的数据库分片上,那么获取一段聊天的消息将非常慢,因此我们不应该采用这种方案。
7.缓存
我们可以将一些最近的消息(比如说最后15
条)缓存在一些最近的对话中这些对话在用户视图(比如说最后5
条)是可见的。因为我们决定将用户的所有消息存储在一个分片上,所以用户的缓存也应该保留在同一台机器上。
8.负载平衡
我们需要在聊天服务器前面设置一层负载均衡;它可以将每个UerID
映射到一个为用户保持连接的服务器,然后将请求定向到该服务器。类似地,我们的缓存服务器也需要负载均衡。
9.容错和复制
当聊天服务器出现故障时会发生什么?
我们的聊天服务器与用户保持连接。如果一台服务器宕机,我们是否应该设计一种机制将这些连接转移到其他服务器上?很难将TCP
连接故障转移到其他服务器;更简单的方法是在连接丢失时让客户端自动重新连接。
我们应该存储用户消息的多个副本吗?
我们不能只有用户数据的一个副本,因为如果保存数据的服务器崩溃或永久宕机,我们就没有任何机制来恢复该数据。为此,我们要么将数据的多个副本存储在不同的服务器上,要么使用诸如Reed-Solomon
编码之类的技术来分发和复制数据。
10.扩展性需求
a. 群聊
我们的系统中可以有单独的群聊对象,这些对象可以存储在聊天服务器上。group-chat
对象由GroupChatID
标识,还将维护属于该聊天的人员列表。负载均衡器基于GroupChatID
和服务器(这些服务器遍历所有聊天的用户以找到处理用户连接投送消息的服务器)定向每个群聊的消息,。
在数据库中,我们可以将所有组聊天记录存储在一个单独的表中,该表基于GroupChatID
进行分区。
b. 推送通知
在我们当前的设计中,用户只能向活跃用户发送消息,如果接收用户处于离线状态,我们会向发送用户发送失败消息。推送通知将使我们的系统能够向离线用户发送消息。
对于推送通知,每当有新消息或事件时,每个用户都可以从其设备(或web
浏览器)获取通知。每个制造商都维护一组服务器,用于将这些通知推送到用户。
为了在我们的系统中提供推送通知,我们需要设置一个通知服务器,该服务器将接收离线用户的消息并将它们发送到制造商的推送通知服务器,然后该服务器将它们发送到用户的设备。
Last updated