如何设计一个日活5000万的聊天系统

本文首发于公众号:更AI (power_ai),欢迎关注,编程、AI干货及时送!

在本章中,我们将探讨聊天系统的设计。几乎每个人都使用聊天应用。图12-1展示了市场上一些最受欢迎的应用。

image-20230525202809605

聊天应用对不同的人扮演着不同的功能。明确具体需求至关重要。例如,当面试官考虑的是一对一的聊天时,你并不希望设计一个侧重于群聊的系统。探索功能需求很重要。

步骤1 - 理解问题并确定设计范围

确定要设计的聊天应用的类型非常重要。在市场上,有一对一的聊天应用如Facebook Messenger、微信和WhatsApp,有侧重于群聊的办公聊天应用如Slack,还有如Discord这样侧重于大群体互动和低延迟语音聊天的游戏聊天应用。

第一组澄清问题应该明确面试官在要求你设计聊天系统时具体想的是什么。至少,你应该弄清楚你应该侧重于设计一对一聊天应用还是群聊应用。你可能会问的一些问题如下:

候选人:我们应该设计什么样的聊天应用?一对一的还是基于群组的?
面试官:应该支持一对一和群聊。

候选人:这是移动应用吗?还是网页应用?还是两者都是?
面试官:两者都是。

候选人:这个应用的规模如何?是初创应用还是大规模的?
面试官:应该支持5000万的每日活跃用户(DAU)。

候选人:对于群聊,群成员的限制是多少?
面试官:最多100人。

候选人:对于聊天应用来说,什么功能是重要的?可以支持附件吗?
面试官:一对一聊天、群聊、在线指示器。系统只支持文本消息。

候选人:有消息大小限制吗?
面试官:是的,文本长度应该少于100,000个字符。

候选人:需要端到端加密吗?
面试官:现在不需要,但如果时间允许,我们会讨论这个问题。

候选人:我们应该保存多长时间的聊天记录?
面试官:永远。

在本章中,我们将专注于设计类似Facebook messenger的聊天应用,强调以下功能:

  • 一对一的聊天,传送延迟低
  • 小型群聊(最多100人)
  • 在线状态
  • 支持多设备。同一账户可以同时登录多个账户。
  • 推送通知

确定设计规模也很重要。我们将设计一个支持5000万DAU的系统。

步骤2 - 提出高层次设计并获得认同

为了开发出高质量的设计,我们应该对客户端和服务器之间的通信有基本的了解。在聊天系统中,客户端可以是移动应用或网页应用。客户端并不直接相互通信,而是每个客户端都连接到一个聊天服务,这个服务支持上述所有的功能。让我们关注基础操作。聊天服务必须支持以下功能:

  • 从其他客户端接收消息。
  • 为每条消息找到正确的收件人,并将消息转发给收件人。
  • 如果收件人不在线,则在服务器上为该收件人保留消息,直到她上线。

图12-2展示了客户端(发送者和接收者)与聊天服务之间的关系。

image-20230525202830712

当客户端打算开始聊天时,它使用一个或多个网络协议连接到聊天服务。对于聊天服务来说,网络协议的选择很重要。让我们和面试官一起讨论这个问题。

对于大多数客户端/服务器应用来说,请求是由客户端发起的。这对于聊天应用的发送端也是如此。在图12-2中,当发送者通过聊天服务向接收者发送消息时,它使用了经过时间检验的HTTP协议,这是最常见的网络协议。在这个场景中,客户端与聊天服务建立一个HTTP连接并发送消息,通知服务将消息发送给接收者。保持在线对此非常高效,因为保持在线的头部允许客户端与聊天服务保持持久连接。它还减少了TCP握手的数量。HTTP在发送端是一个好的选择,许多流行的聊天应用如Facebook [1] 最初就使用HTTP来发送消息。

然而,接收端稍微复杂一些。由于HTTP是由客户端发起的,所以从服务器发送消息并不简单。多年来,人们使用了许多技术来模拟服务器发起的连接:轮询,长轮询和WebSocket。这些都是系统设计面试中广泛使用的重要技术,让我们一起来看看它们。

轮询

如图12-3所示,轮询是一种客户端定期询问服务器是否有可用消息的技术。根据轮询的频率,轮询可能会耗费大量资源。它可能会消耗宝贵的服务器资源来回答一个大部分时间都是“否”的问题。

image-20230525202847465

长轮询

由于轮询可能效率低下,下一个进步就是长轮询(见图12-4)。

image-20230525202903376

在长轮询中,客户端会保持连接打开,直到有新的消息可用或达到超时阈值。一旦客户端收到新的消息,它会立即向服务器发送另一个请求,重新开始这个过程。长轮询有一些缺点:

  • 发送者和接收者可能不会连接到同一台聊天服务器。基于HTTP的服务器通常是无状态的。如果你使用轮询进行负载平衡,接收消息的服务器可能不会与接收消息的客户端建立长轮询连接。
  • 服务器无法很好地判断客户端是否已经断开连接。
  • 它是低效的。如果一个用户不常聊天,长轮询在超时后仍会进行周期性连接。

WebSocket

WebSocket是从服务器向客户端发送异步更新的最常见解决方案。图12-5显示了它的工作原理。

image-20230525202917789

WebSocket连接是由客户端发起的。它是双向的且持久的。它一开始是一个HTTP连接,可以通过一些明确定义的握手协议“升级”为WebSocket连接。通过这个持久的连接,服务器可以向客户端发送更新。即使存在防火墙,WebSocket连接通常也能正常工作。这是因为它们使用的是80端口或443端口,这些端口也是HTTP/HTTPS连接使用的。

前面我们说在发送方HTTP是一个不错的协议,但由于WebSocket是双向的,所以没有强烈的技术原因不在发送端也使用它。图12-6展示了如何在发送端和接收端都使用WebSocket(ws)。

image-20230525202932373

通过在发送和接收两端都使用WebSocket,它简化了设计,使得在客户端和服务器端的实现更为直接。由于WebSocket连接是持久的,因此在服务器端,高效的连接管理是至关重要的。

高级设计

刚才我们提到WebSocket被选为客户端和服务器之间的主要通信协议,它具有双向通信的特点,重要的是要注意,其他所有东西并不一定要使用WebSocket。实际上,聊天应用的大部分功能(注册、登录、用户资料等)都可以使用HTTP上的传统请求/响应方法。让我们深入一点,看看系统的高级组件。

如图12-7所示,聊天系统分为三个主要类别:无状态服务、有状态服务和第三方集成。

image-20230525202948036

无状态服务

无状态服务是传统的面向公众的请求/响应服务,用于管理登录、注册、用户资料等。这些是许多网站和应用的常见功能。

无状态服务位于负载均衡器后面,负载均衡器的工作是根据请求路径将请求路由到正确的服务。这些服务可以是单一的或个别的微服务。我们不需要自己建立许多这样的无状态服务,因为市场上有一些可以轻易集成的服务。我们将在深入探讨中更多讨论的一个服务是服务发现。其主要工作是给客户端提供一系列聊天服务器的DNS主机名,客户端可以连接到这些服务器。

有状态服务

唯一的有状态服务就是聊天服务。这个服务是有状态的,因为每个客户端都维护着一个到聊天服务器的持久网络连接。在这个服务中,只要服务器仍然可用,客户端通常不会切换到另一个聊天服务器。服务发现与聊天服务密切配合,以避免服务器过载。我们将在深入研究中详细讨论这个问题。

第三方集成

对于一个聊天应用,推送通知是最重要的第三方集成。即使应用程序没有运行,它也是一种在新消息到达时通知用户的方式。正确集成推送通知至关重要。有关更多信息,请参阅第10章设计一个通知系统。

可扩展性

在小规模上,上述所有服务都可以装入一个服务器。即使在我们设计的规模上,理论上也有可能将所有用户连接装入一个现代云服务器。服务器能够处理的并发连接数量最有可能成为限制因素。在我们的场景中,有100万并发用户,假设每个用户连接在服务器上需要10K的内存(这是一个非常粗略的数字,且非常依赖于语言选择),它只需要大约10GB的内存来在一个盒子里存储所有的连接。

如果我们提出一个所有内容都可以装入一个服务器的设计,这可能会在面试官的脑海中引起大红旗。没有技术人员会在一个单一的服务器上设计这样的规模。由于许多因素,单服务器设计是一个破局者。单点故障是其中最大的。

然而,从一个单服务器设计开始是完全可以的。只是要确保面试官知道这只是一个起点。将我们提到的所有内容放在一起,图12-8显示了调整后的高级设计。

image-20230525203007564

在图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聊天的消息表

图12-9显示了1对1聊天的消息表。主键是 message_id ,它用于确定消息顺序。我们不能依赖 created_at 来确定消息顺序,因为两条消息可能同时创建。

image-20230525203030794

群聊的消息表

图12-10显示了群聊的消息表。复合主键是 (channel_id, message_id). 在这里,频道和群组具有相同的含义。channel_id 是分区键,因为在群聊中的所有查询都在一个频道中进行。

image-20230525203043351

消息ID

如何生成 message_id 是一个值得探讨的有趣话题。Message_id 承担了确保消息顺序的责任。为了确定消息的顺序,message_id 必须满足以下两个要求:

  • ID必须是唯一的。
  • ID应该按时间排序,意味着新行的ID比旧的更高。

我们如何实现这两个保证呢?首先想到的是 MySql 中的“ auto_increment ”关键字。然而,NoSQL数据库通常不提供这样的特性。

第二种方法是使用像Snowflake [6] 这样的全局64位序列号生成器。这在“第7章:在分布式系统中设计一个唯一ID生成器”中进行了讨论。

最后一种方法是使用本地序列号生成器。本地意味着ID只在一个组内是唯一的。本地ID起作用的原因是,只需维护一对一频道或群组频道内的消息顺序就足够了。与全局ID实现相比,这种方法更容易实现。

步骤3 - 深入设计

在系统设计面试中,通常期望你能深入到高级设计中的某些组件。对于聊天系统,服务发现、消息流程和在线/离线指示器值得深入探讨。

服务发现

服务发现的主要角色是根据诸如地理位置、服务器容量等标准,为客户端推荐最好的聊天服务器。Apache Zookeeper [7] 是一个流行的开源服务发现解决方案。它注册所有可用的聊天服务器,并根据预定的标准为客户端选择最好的聊天服务器。

图12-11显示了服务发现(Zookeeper)的工作方式。

image-20230525203108930

  1. 用户A试图登录应用程序。
  2. 负载均衡器将登录请求发送到API服务器。
  3. 后端验证用户后,服务发现为用户A找到最好的聊天服务器。在此示例中,选择了服务器2,并将服务器信息返回给用户A。
  4. 用户A通过WebSocket连接到聊天服务器2。

消息流程

理解聊天系统的端到端流程是很有趣的。在本节中,我们将探讨1对1聊天流程、跨多个设备的消息同步以及群聊流程。

1对1聊天流程

图12-12解释了用户A向用户B发送消息时发生的情况。

image-20230525203124205

  1. 用户A向聊天服务器1发送聊天消息。
  2. 聊天服务器1从ID生成器获取消息ID。
  3. 聊天服务器1将消息发送到消息同步队列。
  4. 消息存储在键值存储中。
    5.a. 如果用户B在线,消息将转发到用户B连接的聊天服务器2。
    5.b. 如果用户B离线,推送通知(PN)服务器会发送推送通知。
  5. 聊天服务器2将消息转发给用户B。用户B和聊天服务器2之间存在持久的WebSocket连接。

跨多个设备的消息同步

许多用户拥有多个设备。我们将解释如何在多个设备之间同步消息。图12-13显示了消息同步的示例。

image-20230525203139220

在图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解释了这个流程。

image-20230525203156340

图12-14解释了用户A在群聊中发送消息时发生的情况。假设群里有3个成员(用户A,用户B和用户C)。首先,用户A的消息被复制到每个群成员的消息同步队列:一个用于用户B,另一个用于用户C。你可以把消息同步队列看作是接收者的收件箱。这种设计选择对小群聊很有好处,因为:

  • 它简化了消息同步流程,因为每个客户端只需要检查自己的收件箱就可以获取新消息。
  • 当群数量较小时,将副本存储在每个接收者的收件箱中并不太昂贵。

微信采用了类似的方法,它限制一个群的成员数量为500人[8]。然而,对于用户很多的群来说,为每个成员存储消息副本是无法接受的。

在接收者端,接收者可以从多个用户那里接收消息。每个接收者都有一个收件箱(消息同步队列),其中包含来自不同发送者的消息。图12-15说明了这个设计。

image-20230525203219073

在线状态

在线状态指示器是许多聊天应用程序的重要功能。通常,你可以在用户的个人资料图片或用户名旁边看到一个绿点。这一部分将解释这背后发生的事情。

在高级设计中,状态服务器负责管理在线状态并通过WebSocket与客户端通信。有几个流程会触发在线状态的改变。让我们来看看每一个。

用户登录

用户登录流程在“服务发现”部分已经解释过。在客户端和实时服务之间建立WebSocket连接后,用户A的在线状态和last_active_at时间戳会保存在KV存储中。用户登录后,状态指示器会显示用户在线。

image-20230525203233126

用户注销

当用户注销时,会经历图12-17所示的用户注销流程。在KV存储中的在线状态将被更改为离线。状态指示器显示用户已离线。

image-20230525203246006

用户断开连接

我们都希望我们的互联网连接是连续可靠的。然而,情况并非总是如此;因此,我们必须在我们的设计中处理这个问题。当用户从互联网断开时,客户端和服务器之间的持久连接就会丢失。处理用户断开连接的一种简单方法是将用户标记为离线,当连接重新建立时将状态改变为在线。然而,这种方法存在一个重大缺陷。用户在短时间内频繁断开和重新连接互联网是很常见的。例如,当用户通过隧道时,网络连接可能会断断续续。每次断开/重新连接都更新在线状态,会导致状态指示器变化太频繁,从而导致用户体验不佳。

我们引入心跳机制来解决这个问题。定期地,在线客户端向状态服务器发送一个心跳事件。如果状态服务器在一定时间内,比如从客户端开始的x秒内,接收到一个心跳事件,用户就被认为是在线的。否则,就是离线的。

image-20230525203259161

在图12-18中,客户端每5秒向服务器发送一个心跳事件。在发送了3个心跳事件后,客户端断开连接,并且在x = 30秒内没有重新连接(这个数字是任意选择的,以演示逻辑)。在线状态被改变为离线。

在线状态扩散

用户A的朋友们如何知道状态的变化?图12-19解释了其工作原理。状态服务器使用发布-订阅模型,其中每对朋友都维持一个通道。当用户A的在线状态发生变化时,它将事件发布到三个通道,即通道A-B、A-C和A-D。这三个通道分别由用户B、C和D订阅。因此,朋友们很容易获得在线状态的更新。客户端和服务器之间的通信是通过实时的WebSocket进行的。

image-20230525203311767

上述设计对于小用户群体是有效的。例如,微信使用了类似的方法,因为它的用户群体被限制在500人以内。对于较大的群体,通知所有成员在线状态变得昂贵且耗时。假设一个群有100,000个成员。每次状态改变将产生100,000个事件。为了解决性能瓶颈,一种可能的解决方案是只有当用户进入一个群或手动刷新好友列表时才获取在线状态。

第四步 - 总结

在本章中,我们介绍了一个支持一对一聊天和小群聊的聊天系统架构。WebSocket被用于客户端和服务器之间的实时通信。聊天系统包含以下组件:聊天服务器用于实时消息,状态服务器用于管理在线状态,推送通知服务器用于发送推送通知,键值存储用于聊天历史持久化和API服务器用于其他功能。

如果你在面试结束时还有额外的时间,这里有一些额外的讨论点:

  • 扩展聊天应用以支持媒体文件,如照片和视频。媒体文件的大小远大于文本。压缩、云存储和缩略图是值得讨论的有趣主题。
  • 端到端加密。Whatsapp支持消息的端到端加密。只有发送者和接收者可以阅读消息。对此感兴趣的读者应参考参考资料中的文章[9]。
  • 在客户端缓存消息可以有效地减少客户端和服务器之间的数据传输。
  • 提高加载时间。Slack建立了一个地理分布式网络,以缓存用户数据、频道等,以提高加载时间[10]。
  • 错误处理。
    • 聊天服务器错误。一个聊天服务器可能有数十万甚至更多的持久连接。如果一个聊天服务器离线,服务发现(Zookeeper)将为客户端提供一个新的聊天服务器,以建立新的连接。
    • 消息重发机制。重试和队列是重发消息的常见技术。

恭喜你走到这一步!现在给自己一个鼓励。干得好!

参考资料

[1] Facebook的Erlang: https://www.erlang-
factory.com/upload/presentations/31/EugeneLetuchy-ErlangatFacebook.pdf

[2] Messenger和WhatsApp每天处理600亿条信息:
https://www.theverge.com/2016/4/12/11415198/facebook-messenger-whatsapp-number-
messages-vs-sms-f8-2016

[3] 长尾效应: https://en.wikipedia.org/wiki/Long_tail

[4] 消息的底层技术: https://www.facebook.com/notes/facebook-
engineering/the-underlying-technology-of-messages/454991608919/

[5] Discord如何存储数十亿条消息: https://blog.discordapp.com/how-discord-
stores-billions-of-messages-7fa6ec7ee4c7

[6] 推特的雪花系统发布公告: https://blog.twitter.com/engineering/en_us/a/2010/announcing-
snowflake.html

[7] Apache ZooKeeper: https://zookeeper.apache.org/

[8] 从无到有:微信背景系统的演变(中文文章):
https://www.infoq.cn/article/the-road-of-the-growth-weixin-background

[9] 端到端加密: https://faq.whatsapp.com/en/android/28030015/

[10] Flannel: 应用级边缘缓存,让Slack更高效:
https://slack.engineering/flannel-an-application-level-edge-cache-to-make-slack-scale-
b8a6400e2f6b

你好,我是拾叁,7年开发老司机、互联网两年外企5年。怼得过阿三老美,也被PR comments搞崩溃过。这些年我打过工,创过业,接过私活,也混过upwork。赚过钱也亏过钱。一路过来,给我最深的感受就是不管学什么,一定要不断学习。只要你能坚持下来,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你还没什么方向,可以先关注我[公众号:更AI (power_ai)],这里会经常分享一些前沿资讯和编程知识,帮你积累弯道超车的资本。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值