在本章中,我们将探讨一个聊天系统的设计。几乎每个人都使用聊天应用。
图12-1展示了市场上一些最受欢迎的应用程序。
聊天应用对不同的人有不同的功能。确定确切的要求是极其重要的。例如,当面试官想要一对一的聊天时,你不希望设计一个专注于群聊的系统。探索特性需求是很重要的。
第一步-理解需求并且建立设计范围
就设计哪种类型的聊天应用达成一致是至关重要的。在市场上,有像Facebook Messenger、微信和WhatsApp这样的一对一聊天应用,像Slack这样专注于群聊的办公聊天应用,或者像Discord这样专注于大群互动和低语音聊天延迟的游戏聊天应用。
当面试官要求你设计一个聊天系统时,第一组澄清性问题应该明确面试官的想法。至少,弄清楚你应该专注于一对一聊天还是群聊应用。你可能会问以下一些问题:
应聘者:我们应该设计一个什么样的聊天应用?1对1还是小组?面试官:它应该支持一对一和群聊。
应聘者:这是一个移动应用程序吗?还是网页应用?还是两个?面试官:这两个。
应聘者:这个应用的规模是多少?创业应用还是大规模应用?面试官:它应该支持5000万日活跃用户(DAU)。
应聘者:对于群聊,群成员的限制是多少?面试官:最多100人 应聘者:最重要的功能是什么?它能支持附加发送吗?面试官:1对1聊天,群聊,在线指示。系统只支文本信息。
应聘者:邮件大小有限制吗?面试官:是的,文字长度应该少于100,000个字符。
应聘者:需要端到端加密吗?面试官:现在还不需要,但如果时间允许,我们会讨论的。
应聘者:我们应该保存多长时间的聊天记录?面试官:永远。
在本章中,我们专注于设计一个像Facebook messenger这样的聊天应用程序,重点是以下功能:
-
低交付延迟的一对一聊天
-
小群聊天(最多100人)
-
在线状态
-
多设备支持。同一个帐号可以同时登录多个设备。
-
推送通知 在设计尺度上达成一致也很重要。我们将设计一个支持5000万DAU的系统。
第二步-提出顶层设计并获取支持
要开发高质量的设计,我们应该了解客户机和服务器如何通信的基本知识。在聊天系统中,客户端可以是移动应用程序,也可以是web应用程序。客户端之间不直接通信。相反,每个客户机连接到一个聊天服务,该服务支持上面提到的所有功能。让我们把重点放在基本操作上。聊天服务必须支持以下功能:
-
接收来自其他客户端的消息。
-
为每封邮件找到合适的收件人,并将邮件转发给收件人。
-
如果收件人未在线,请将该收件人的邮件保存在服务器上,直到她在线。
客户端(发送方和接收方)与聊天服务的关系如图12-2所示。
当客户端打算开始聊天时,它使用一个或多个网络协议连接聊天服务。对于聊天服务,网络协议的选择很重要。让我们和面试官讨论一下。
对于大多数客户机/服务器应用程序,请求是由客户机发起的。对于聊天应用程序的发送端也是如此。在图12-2中,发送方通过聊天服务向接收方发送消息时,使用的是久经考验的HTTP协议,也是目前最常用的web协议。在此场景中,客户端打开与聊天服务的HTTP连接并发送消息,通知服务将消息发送给接收者。为此,keep-alive是有效的,因为keep-alive报头允许客户端与聊天服务保持持久连接。它还减少了TCP握手的次数。
HTTP在发送端是一个很好的选择,许多流行的聊天应用程序,如Facebook[1]最初使用HTTP来发送消息。
然而,接收端有点复杂。由于HTTP是客户端发起的,因此从服务器发送消息并不简单。多年来,许多技术被用于模拟服务器发起的连接:轮询、长轮询和WebSocket。这些都是在系统设计面试中广泛使用的重要技术,所以让我们逐一检查它们。
轮询
如图12-3所示,轮询是一种客户端定期询问服务器是否有可用消息的技术。根据轮询的频率,轮询的成本可能很高。它可能会消耗宝贵的服务器资源来回答一个大多数时候提供no作为答案的问题。
长轮询
由于轮询可能效率低下,下一个进程是长轮询(图12-4)。
在长轮询中,客户机保持连接打开,直到有实际可用的新消息或达到超时阈值。一旦客户端接收到新消息,它立即向服务器发送另一个请求,重新启动该进程。长轮询有几个缺点:
-
发送者和接收者可能不会连接到同一个聊天服务器。基于HTTP的服务器通常是无状态的。如果使用轮询进行负载平衡,则接收消息的服务器可能不会与接收消息的客户端建立长轮询连接。
-
服务器没有很好的方法来判断客户端是否断开连接。
-
效率低下。如果用户不经常聊天,长轮询仍然会在超时后定期建立连接。
WebSocket
WebSocket是将异步更新从服务器发送到客户端的最常见的解决方案。工作原理如图12-5所示。
WebSocket连接由客户端发起。它是双向和持久的。它以HTTP连接的形式开始它的生命,并且可以通过一些定义良好的握手“升级”为WebSocket连接。通过这个持久连接,服务器可以向客户端发送更新。即使有防火墙,WebSocket连接通常也能正常工作。这是因为它们使用HTTP/HTTPS连接也使用的端口80或443。
前面我们说过,在发送端,HTTP是一个很好的协议,但是由于WebSocket是双向的,没有强大的技术理由不使用它来发送。图12-6显示了WebSockets (ws)在发送端和接收端是如何使用的。
通过使用WebSocket发送和接收,它简化了设计,使客户端和服务器上的实现更加直接。由于WebSocket连接是持久的,因此有效的连接管理在服务器端是至关重要的。
顶层设计
刚才我们提到WebSocket被选为客户端和服务器之间双向通信的主要通信协议,重要的是要注意,其他所有内容都不必是WebSocket。事实上,聊天应用程序的大多数功能(注册、登录、用户配置文件等)都可以使用传统的HTTP请求/响应方法。让我们深入研究一下系统的高级组件。
如图12-7所示,聊天系统主要分为三大类:无状态服务、有状态服务和第三方集成。
无状态服务
无状态服务是传统的面向公众的请求/响应服务,用于管理登录、注册、用户配置文件等。这些是许多网站和应用程序的共同功能。
无状态服务位于负载平衡器后面,负载平衡器的工作是根据请求路径将请求路由到正确的服务。这些服务可以是整体的,也可以是独立的微服务。我们不需要自己构建许多这些无状态服务,因为市场上有一些服务可以很容易地集成。我们将深入讨论的一个服务是服务发现。它的主要工作是向客户端提供客户端可以连接到的聊天服务器的DNS主机名列表。
有状态服务
唯一有状态的服务是聊天服务。该服务是有状态的,因为每个客户机维护到聊天服务器的持久网络连接。在此服务中,只要服务器仍然可用,客户机通常不会切换到另一个聊天服务器。服务发现与聊天服务密切配合,以避免服务器过载。我们将深入探讨细节。
第三方集成
对于聊天应用来说,推送通知是最重要的第三方集成。这是一种在新消息到达时通知用户的方式,即使应用程序没有运行。适当整合推送通知非常重要。参考第10章设计通知系统了解更多信息。
对于聊天应用来说,推送通知是最重要的第三方集成。这是一种在新消息到达时通知用户的方式,即使应用程序没有运行。适当整合推送通知非常重要。参考第10章设计通知系统了解更多信息。
可扩展性
在小规模的情况下,上面列出的所有服务都可以放在一台服务器中。即使在我们设计的规模上,理论上也可以在一个现代云服务器中容纳所有用户连接。服务器可以处理的并发连接的数量很可能是限制因素。在我们的场景中,有1M个并发用户,假设每个用户连接在服务器上需要10K的内存(这是一个非常粗略的数字,并且非常依赖于语言选择),它只需要大约10GB的内存来保存一个机器上的所有连接。
如果我们提出一个所有东西都适合一个服务器的设计,这可能会在面试官的脑海中引起一个很大的危险信号。没有技术人员会在一台服务器上设计这样的规模。由于许多因素,单服务器设计是一个交易破坏者。单点故障是其中最大的。
但是,从单个服务器设计开始是完全可以的。只要让面试官知道这是一个开始。综上所述,调整后的高层设计如图12-8所示。
在图12-8中,客户端维护一个与聊天服务器的持久WebSocket连接,用于实时消息传递。
-
聊天服务器方便消息发送/接收。
-
状态服务器管理在线/离线状态。
-
API服务器处理一切,包括用户登录,注册,更改配置文件等。
-
通知服务器发送推送通知。
-
最后,键值存储用于存储聊天记录。当离线用户上线时,她将看到她以前的所有聊天记录。
储存
此时,我们已经准备好了服务器,服务正在运行,并且完成了第三方集成。技术栈的深处是数据层。数据层通常需要一些努力才能使其正确。我们必须做出的一个重要决定是决定使用正确类型的数据库:关系数据库还是NoSQL数据库?为了做出明智的决定,我们将检查数据类型和读/写模式。
在典型的聊天系统中存在两种类型的数据。第一个是通用数据,例如用户配置文件、设置、用户好友列表。这些数据存储在健壮可靠的关系数据库中。
复制和分片是满足可用性和可伸缩性需求的常用技术。
第二种是聊天系统所特有的:聊天历史数据。理解读/写模式非常重要。
-
聊天系统的数据量是巨大的。先前的一项研究[2]显示,Facebook messenger和Whatsapp每天处理600亿条消息。
-
只访问最近的聊天记录。用户通常不会查找以前的聊天记录。
-
虽然在大多数情况下查看最近的聊天记录,但用户可能会使用需要随机访问数据的功能,例如搜索,查看您的提及,跳转到特定消息等。数据访问层应该支持这些情况。
-
1对1聊天应用的读写比例约为1:1。
选择支持我们所有用例的正确存储系统是至关重要的。我们推荐键值存储的原因如下:
-
键值存储允许容易的水平扩展。
-
键值存储提供非常低的数据访问延迟。
-
关系数据库不能很好地处理长尾[3]数据。当索引变大时,随机访问的代价会很高。
-
键值存储被其他可靠的聊天应用程序所采用。例如,Facebook messenger和Discord都使用键值存储。Facebook messenger使用HBase [4], Discord使用Cassandra[5]。
数据模型
刚才,我们讨论了使用键值存储作为存储层。最重要的数据是消息数据。让我们仔细看看。
消息表为1对1聊天
1对1会话的消息表如图12-9所示。主键是message_id,它有助于确定消息序列。我们不能依赖created_at来决定消息序列,因为可以同时创建两个消息。
群组聊天的消息表
群聊消息表如图12-10所示。复合主键为(channel_id, message_id)。Channel和group在这里的意思是一样的。Channel_id是分区键,因为组聊天中的所有查询都在一个通道中操作。
Message ID
如何生成message_id是一个值得探索的有趣主题。Message_id负责确保消息的顺序。为了确定消息的顺序,message_id必须满足以下两个要求:
-
id必须唯一。
-
id应该按时间排序,这意味着新行比旧行具有更高的id。
我们怎样才能实现这两个保证呢?首先想到的是MySql中的“auto_increment”关键字。然而,NoSQL数据库通常不提供这样的特性。
第二种方法是使用全局64位序列号生成器,如Snowflake[6]。
这将在“第七章:在分布式系统中设计唯一ID生成器”中讨论。
最后一种方法是使用本地序列号生成器。 本地意味着id只在一个组内是唯一的。本地id之所以有效,是因为在一对一通道或组通道中维护消息序列就足够了。与全局ID实现相比,这种方法更容易实现。
第三步-底层实现
在系统设计面试中,通常期望您深入研究高级设计中的一些组件。对于聊天系统,服务发现、消息流和在线/离线指示器值得深入探索
服务发现
服务发现的主要作用是根据地理位置、服务器容量等标准为客户端推荐最佳聊天服务器。Apache Zookeeper[7]是一个流行的服务发现开源解决方案。它注册所有可用的聊天服务器,并根据预定义的标准为客户端选择最佳的聊天服务器。
Zookeeper服务发现的工作原理如图12-11所示。
-
用户A尝试登录应用。
-
负载平衡器将登录请求发送到API服务器。
-
后端对用户进行身份验证后,服务发现为用户A找到最佳的聊天服务器。本例中选择服务器2,并将服务器信息返回给用户A。
-
用户A通过WebSocket连接到聊天服务器2。
消息流
了解聊天系统的端到端流程是很有趣的。在本节中,我们将探讨1对1聊天流、跨多个设备的消息同步和组聊天流。
1对1消息
-
用户A向聊天服务器1发送聊天消息。
-
聊天服务器1从ID生成器获取消息ID。
-
聊天服务器1将消息发送到消息同步队列。
-
消息存储在键值存储中。
5.。如果用户B在线,留言将被转发到用户B所连接的聊天服务器2。
-
b。如果用户B离线,则从PN (push notification)服务器发送推送通知。
-
聊天服务器2将消息转发给用户B。用户B和聊天服务器2之间有一个持久的WebSocket连接。
跨多个设备的消息同步
许多用户拥有多个设备。我们将解释如何跨多个设备同步消息。消息同步示例如图12-13所示。
如图12-13所示,用户A有两台设备:手机和笔记本电脑。当用户A用手机登录聊天应用程序时,它与聊天服务器1建立WebSocket连接。类似地,在笔记本电脑和聊天服务器1之间有一个连接。
每个设备都维护一个名为cur_max_message_id的变量,该变量跟踪设备上最新的消息ID。满足以下两个条件的消息被视为新闻消息:
-
收件人ID等于当前登录的用户ID。
-
键值存储中的消息ID大于cur_max_message_id。
在每个设备上使用不同的cur_max_message_id,消息同步很容易,因为每个设备都可以从KV存储获取新消息。
小群组聊天流
与一对一聊天相比,群聊的逻辑更加复杂。流程如图12-14和12-15所示。
如图12-14所示,用户A在群中发起消息。假设组中有3个成员(用户A,用户B和用户C)。首先,来自用户A的消息被复制到每个组成员的消息同步队列中: 用户B一个,用户C一个。您可以将消息同步队列视为接收者的收件箱。这种设计选择对于小群聊天很好,因为:
-
它简化了消息同步流程,因为每个客户端只需要检查自己的收件箱就可以获得新消息。
-
当群组数量很小时,在每个收件人的收件箱中存储一份副本不会太昂贵。
微信采用了类似的方法,它将群组限制在500人以内[8]。但是,对于有很多用户的组,为每个成员存储消息副本是不可接受的。
在接收方,一个接收方可以接收多个用户的消息。每个接收方都有一个收件箱(消息同步队列),其中包含来自不同发送方的消息。其外观设计如图12-15所示。
在线状态
在线状态指示器是许多聊天应用程序的基本功能。通常,你可以在用户的头像或用户名旁边看到一个绿点。本节解释幕后发生的事情。
在高级设计中,在线服务器负责管理在线状态,并通过WebSocket与客户端通信。有一些流将触发在线状态更改。让我们逐一考察一下。
用户注册
“服务发现”一节解释了用户登录流程。在客户端和实时服务之间建立WebSocket连接后,用户a的在线状态和last_active_at时间戳被保存在KV存储中。在线状态指示灯显示用户登录后处于在线状态。
用户退出
当用户注销时,需要执行如图12-17所示的用户注销流程。KV存储的在线状态变为离线。状态指示灯表示用户离线。
用户断开连接
我们都希望我们的互联网连接是稳定可靠的。然而,情况并非总是如此;因此,我们必须在设计中解决这个问题。当用户与internet断开连接时,客户端与服务器之间的持久连接将丢失。处理用户断开连接的一种简单方法是将用户标记为脱机,并在连接重新建立时将状态更改为联机。然而,这种方法有一个主要缺陷。用户在短时间内频繁断开和重新连接互联网是很常见的。例如,当用户通过隧道时,网络连接可以打开或关闭。在每次断开/重新连接时更新在线状态会使状态指示器频繁更改,从而导致糟糕的用户体验。
我们引入了一个心跳机制来解决这个问题。在线客户端定期向在线服务器发送心跳事件。如果状态服务器在一定时间内接收到一个心跳事件,比如来自客户机的x秒,则认为用户在线。否则为离线状态。
在图12-18中,客户端每5秒向服务器发送一次心跳事件。发送3个心跳事件后,客户端断开连接,并且在x = 30秒内没有重新连接(这个数字是任意选择的,以演示逻辑)。在线状态变为离线状态。
在线状态广播
用户A的朋友是如何知道状态变化的?原理如图12-19所示。
状态服务器使用发布-订阅模型,其中每个朋友对维护一个通道。当用户A的在线状态发生变化时,将事件发布到A- b、A- c和A- d三个通道。这三个通道分别由用户B、C和D订阅。因此,朋友们很容易获得在线状态更新。客户端和服务器之间的通信是通过实时WebSocket实现的。
上述设计对于小用户组是有效的。例如,微信使用了类似的方法,因为它的用户群上限为500。对于较大的群组,通知所有成员在线状态既昂贵又耗时。假设一个组有10万名成员。
每次状态变化将产生100,000个事件。为了解决性能瓶颈,一种可能的解决方案是仅在用户进入组或手动刷新好友列表时获取在线状态。
第四步-总结打包
在本章中,我们介绍了一个支持一对一聊天和小组聊天的聊天系统架构。WebSocket用于客户端和服务器之间的实时通信。聊天系统包含以下组件:用于实时消息传递的聊天服务器、用于管理在线状态的状态服务器、用于发送推送通知的推送通知服务器、用于聊天历史记录持久性的键值存储以及用于其他功能的API服务器。
如果你在面试结束时有额外的时间,这里有一些额外的谈话要点:
-
扩展聊天应用程序,以支持媒体文件,如照片和视频。媒体文件的大小明显大于文本。压缩、云存储和缩略图都是有趣的话题。
-
端到端加密。Whatsapp支持对消息进行端到端加密。只有发送方和接收方可以读取消息。有兴趣的读者可以参考参考资料[9]中的文章。
-
在客户端缓存消息可以有效地减少客户端和服务器之间的数据传输。
-
改善加载时间。Slack构建了一个地理分布的网络来缓存用户的数据、通道等,以获得更好的加载时间[10]。
-
错误处理。
-
聊天服务器错误。到聊天服务器的持久连接可能有数十万甚至更多。如果聊天服务器离线,服务发现(Zookeeper)将为客户端提供一个新的聊天服务器来建立新的连接。
-
消息重发机制。重试和排队是重发消息的常用技术。