CodeKarle:推特系统设计面试


一个典型的面试问题:“你将如何设计一个像 Twitter 这样的系统”。
 
让我们看一下开始的要求。
功能要求

  • 推文 - 应该允许您发布文本、图像、视频、链接等
  • Re-tweet - 应该允许你分享某人的推文
  • 跟随 - 这将是一种定向关系。如果我们跟随巴拉克奥巴马,他不必跟随我们回来
  • 搜索

非功能性要求

  • 重读 - twitter 的读写比非常高,所以我们的系统应该能够支持这种模式
  • 快速渲染
  • 快速推文。
  • 延迟是可以接受的——从前面的两个 NFR 中,我们可以理解系统应该是高可用的并且具有非常低的延迟。因此,当我们说延迟没问题时,我们的意思是几秒钟后就可以收到有关其他人的推文的通知,但内容的呈现应该几乎是即时的。
  • 可扩展- 平均每天在 Twitter 上每秒有 5k+ 条推文出现。在高峰时段,它很容易翻倍。这些只是推文,正如我们已经讨论过的那样,推特的读写比率非常高,也就是说,这些推文的读取次数会更高。这是每秒大量的请求。

 
那么我们如何设计一个系统来满足我们所有的功能需求而不影响性能呢?在讨论整体架构之前,让我们将用户分成不同的类别。这些类别中的每一个都将以稍微不同的方式处理。
  1. 知名用户:知名用户通常是拥有大量粉丝的名人、运动员、政治家或商界领袖
  2. 活跃用户:这些是在过去几个小时或几天内访问过系统的用户。在我们的讨论中,我们会将过去三天访问过 Twitter 的人视为活跃用户。
  3. 实时用户:这些是目前正在使用系统的活跃用户的子集,类似于 Facebook 或 WhatsApp 上的在线用户。
  4. 被动用户:这些是拥有活动帐户但在过去三天内未访问系统的用户。
  5. 非活动用户:可以说这些是“已删除”的帐户。我们并没有真正删除任何帐户,它更像是软删除,但就用户而言,该帐户不再存在。

现在,为简单起见,让我们将整个架构分为三个流程。我们将分别查看系统的入职流程、推文流程以及搜索和分析方面。
 
注册服务
我们有一个用户服务,它将在我们的系统中存储所有与用户相关的信息,并为登录、注册和任何其他需要用户相关信息的内部服务提供端点,提供GET APIs来获取用户的ID或电子邮件,POST APIs来添加或编辑用户信息,以及批量GET APIs来获取多个用户的信息。这个用户服务位于一个MySQL数据库的用户数据库之上。我们在这里使用MySQL,因为我们有有限数量的用户,而且数据是非常有关系的。另外,用户数据库将主要用于写,而与用户细节相关的读将由Redis缓存提供,它是用户数据库的一个图像。当用户服务收到一个带有用户ID的GET请求时,它将首先在Redis缓存中查找,如果该用户存在于Redis中,它将返回响应。否则,它将从用户数据库中获取信息,存储在Redis中,然后响应客户端。
 
用户关注的流程
与 "关注 "相关的请求将由Graph Service提供服务,Graph Service创建了一个用户在系统中的连接网络。Graph Service将提供API来添加关注链接,获得被某个用户ID关注的用户或关注某个用户ID的用户。该Graph Service位于用户Graph数据库之上,该数据库也是一个MySQL数据库。同样,关注链接不会经常变化,所以在Redis中缓存这些信息是有意义的。现在在关注流程中,我们可以缓存两个信息--谁是某个用户的关注者,以及谁是该用户的关注者。与用户服务类似,当Graph Service收到一个获取请求时,它将首先在Redis中进行查询。如果Redis有这些信息,它就会对用户作出回应。否则,它将从图数据库中获取信息,并将其存储在Redis中,然后回应给用户。
现在,有一些事情我们可以根据用户与Twitter的互动得出结论,比如他们的兴趣等等。因此,当此类事件发生时,分析服务会将这些事件放在Kafka中。

现在,还记得我们的直播用户吗?假设U1是一个关注U2的实时用户,U2发了一些推特。因为U1是直播,所以U1立即得到通知是合理的。这是通过用户直播Websocket服务实现的。这个服务与所有的直播用户保持着开放的连接,每当有直播用户需要被通知的事件发生时,就会通过这个服务发生。现在,基于用户与这个服务的互动,我们也可以跟踪用户在线的时间,当互动停止时,我们可以得出结论,用户不再是活的了。当用户下线时,通过websocket服务,一个事件将被发射到Kafka,Kafka将进一步与用户服务互动,并在Redis中保存用户最后的活动时间,其他系统可以使用这些信息来相应地修改他们的行为。
 
Tweet流程
现在,一条推特可以包含文本、图片、视频或链接。我们有一个叫做资产服务的东西,它负责上传和显示一条推文中的所有多媒体内容。我们已经在Netflix的设计文章中讨论了资产服务的细节,如果你有兴趣,可以去看看。

现在,我们知道推特有140个字符的限制,可以包括文本和链接。由于这个限制,我们不能在我们的推文中发布巨大的URL。这就是URL缩短器服务的作用。我们不打算讨论这项服务的工作细节,但我们已经在我们的Tiny URL文章中讨论过了,所以请务必查看。现在我们已经处理了链接,剩下的就是存储推文的文本,并在需要时获取它。这就是推文摄取服务的作用。当用户试图发布一条推文并点击提交按钮时,它会调用推文摄取服务,将推文存储在一个永久的数据存储中。我们在这里使用Cassandra,因为我们每天都会有大量的推文进来,而我们在这里需要的查询模式正是Cassandra最擅长的。要了解更多关于我们为什么使用数据库解决方案的信息,请查看我们关于选择最佳存储解决方案的文章。
现在,推文摄取服务,顾名思义,只负责发布推文,不提供任何GET API来获取推文。一旦有推文被发布,推文摄取服务就会向Kafka发送一个事件,说某某用户发布了一条推文ID。现在,在我们的Cassandra上面,有一个Tweet服务,它将提供API,通过tweet ID或用户ID获取tweet。

现在,让我们快速看一下用户方面的情况。在读取流程中,一个用户可以有一个用户时间线,即来自该用户的推文,或者一个主页时间线,即来自用户所关注的人的推文。现在,一个用户可能有一个巨大的他们所关注的用户列表,如果我们在显示时间线之前在运行时进行所有的查询,就会降低渲染速度。因此,我们对用户的时间线进行缓存。我们将预先计算活跃用户的时间线,并将其缓存在Redis中,所以活跃用户可以立即看到他们的时间线。这可以通过一个叫做Tweet处理器的东西来实现。

如前所述,当一条tweet被发布时,一个事件被触发到Kafka。Kafka将该事件传达给推文处理器,并为所有需要被通知到这条推文的用户创建时间线并进行缓存。为了找出需要被通知这一变化的关注者,推特服务与Graph Service进行交互。假设用户U1,其次是用户U2、U3和U4发布了一条tweet T1,那么tweet处理器将用tweet T1更新U2、U3和U4的时间线,并更新cache。

现在,我们只缓存了活跃用户的时间线。当一个被动的用户,比如说P1,登录到系统时会发生什么?这就是时间线服务的作用。请求将到达时间线服务,时间线服务将与用户服务交互,以识别P1是一个主动用户还是一个被动用户。现在,由于P1是一个被动用户,它的时间线没有被缓存在Redis中。现在,时间线服务将与Graph Service对话,以找到P1所关注的用户列表,然后查询推特服务以获取所有这些用户的推文,将它们缓存在Redis中,并回复给客户端。

现在我们已经看到了主动和被动用户的行为。我们将如何为我们的实时用户优化流程?正如我们之前所讨论的,当一条tweet被成功发布时,一个事件将被发送到Kafka。然后,Kafka将与推特处理器对话,后者为活跃用户创建时间线并将其保存在Redis中。但在这里,如果推文处理器发现需要更新的用户之一是一个实时用户,那么它将向Kafka发送一个事件,而Kafka现在将与我们之前简单讨论过的实时websocket服务进行交互。这个websocket服务现在将向应用程序发送一个通知,并更新时间线。

因此,现在我们的系统可以成功地发布不同类型的推文,并有一些内置的优化,以某种不同的方式处理主动、被动和直播用户。但它仍然是一个相当低效的系统。为什么呢?因为我们完全忘记了我们的著名用户 如果唐纳德-特朗普有7500万粉丝,那么每次特朗普推特上的东西,我们的系统都需要进行7500万次更新。而这仅仅是一个用户的一条推特。所以这个流程对我们的著名用户来说是行不通的。

Redis缓存只会在预先计算的时间线中缓存非著名用户的推文。时间线服务知道Redis只存储正常用户的推文。它与Graph Service交互,以获得我们当前用户(例如U1)所关注的著名用户列表,并从推文服务中获取他们的推文。然后,它将在Redis中更新这些推文,并添加一个时间戳,表明时间线的最后更新时间。当U1提出下一个请求时,它会检查Redis中针对U1的时间戳是否是几分钟前的。如果是的话,它将再次查询tweet服务。但如果时间戳是最近的,Redis将直接回复给应用程序。

现在我们已经处理了主动、被动、活生生的和著名的用户。至于不活跃的用户,他们已经是停用的账户,所以我们不需要担心他们。

现在,如果一个著名的用户关注另一个著名的用户,比方说唐纳德-特朗普和埃隆-马斯克,会发生什么?如果唐纳德-特朗普发推文,埃隆-马斯克应该立即得到通知,即使其他非著名用户没有被通知。这又是由推特处理器处理的。鸣叫处理器,当它从Kafa收到一个关于著名用户的新鸣叫的事件时,比方说,唐纳德-特朗普,更新关注特朗普的著名用户的缓存。

现在,这看起来是一个相当有效的系统,但也有一些瓶颈。比如Cassandra--它将承受巨大的负载,Redis--它需要有效地扩展,因为它完全存储在RAM中,还有Kafka--它又将收到疯狂的事件量。因此,我们需要确保这些组件是可以横向扩展的,对于Redis来说,不要存储旧的数据,这只是不必要地占用了内存。
 
现在来看看搜索和分析!

还记得我们在上一节讨论的推文摄取服务吗?当一条tweet被添加到系统中时,它会向Kafka发射一个事件。一个监听Kafka的搜索消费者将所有这些传入的推文存储到Elasticsearch数据库中。现在,当用户在搜索UI中搜索一个字符串时,它与搜索服务对话。搜索服务将与弹性搜索对话,获取结果,并回复给用户。

现在,假设一个事件发生了,人们在Twitter上发推特或搜索它,那么可以肯定的是,更多的人会去搜索它。现在我们不应该在elasticsearch上一次又一次地查询同样的东西。一旦搜索服务从elasticsearch获得一些结果,它将把它们保存在Redis中,生存时间为2-3分钟。现在,当用户搜索某些东西时,搜索服务将首先在Redis中查询。如果在Redis中找到了数据,它将回馈给用户,否则,搜索服务将查询elasticsearch,获得数据,将其存储在Redis中,并回馈给用户。这大大降低了elasticsearch的负载。

让我们再次回到我们的Kafka。将有一个连接到Kafka的spark流消费者,它将跟踪趋势关键词,并将它们传达给Trends服务。这可以进一步连接到一个趋势UI,使这些数据可视化。我们不需要为这些信息建立永久的数据存储,因为趋势是暂时的,但我们可以使用Redis作为短期存储的缓存。

现在你一定注意到我们的设计中大量使用了Redis。现在,尽管Redis是一个内存解决方案,但仍有一个选项可以将数据保存到磁盘。因此,在发生故障的情况下,如果一些机器坏了,你仍然有数据保存在磁盘上,以使它有更多的容错性。

现在,除了趋势之外,还有一些其他的分析可以进行,比如印度人在谈论什么。为此,我们将把所有传入的推文倾倒在一个Hadoop集群中,这可以为查询提供动力,如被转发最多的帖子等。我们还可以在Hadoop集群上运行一个每周的cron作业,它将拉入我们的被动用户的信息,并向他们发送每周的通讯,包括一些他们可能感兴趣的最新推文。这可以通过运行一些简单的ML算法来实现,这些算法可以根据用户以前的搜索和阅读来判断推文的相关性。新闻简报可以通过一个通知服务来发送,该服务可以与用户服务对话,以获取用户的电子邮件ID。