第17章:设置后端系统-附近的好友
简介
本章重点介绍如何为一款允许用户共享位置并发现附近好友的应用程序设计可扩展的后端系统。附近.
与邻近性章节的主要区别在于,在本题中,位置会不断变化,而在上一题中,商家地址基本保持不变。
第一步:理解问题并明确设计范围
一些用于引导面试的问题:
•
C:在地理上,多近才算“附近”?
•
I:5英里,这个数值应当可配置
•
C:距离是按直线距离计算,还是考虑例如朋友之间有一条河流等因素?
•
I:是的,这是一个合理的假设
•
C:这款应用有多少用户?
•
I:10亿用户,其中10%使用附近好友功能
•
C:我们需要存储位置历史吗?
•
I:是的,这在例如机器学习等场景下可能很有价值
•
C:我们能否假设不活跃的好友会在10分钟后从该功能中消失
•
I:需要
•
C:我们需要担心GDPR等问题吗?
•
I:不,为了简化起见
Functional requirements
•
用户应在手机应用中查看附近的友人。每位好友都显示其与用户的距离及位置更新时间戳
•
附近好友列表应每隔几秒更新一次
Non-functional requirements
•
低延迟:及时接收位置更新至关重要,延迟不能过高
•
可靠性:偶尔出现数据点丢失可以接受,但系统应始终保持可用
•
最终一致性:位置数据存储无需强一致性。不同副本之间接收位置数据时存在几秒的延迟也是可以接受的
Back-of-the-envelope
用于估算潜在规模的一些参考数据:
•
附近好友是指5英里范围内的好友
•
位置更新间隔为30秒。由于人类步行速度较慢,因此无需过于频繁地更新位置。
•
平均每天有1亿用户使用该功能,其中并发用户占比10%,即1000万
•
平均每位用户有400位好友,所有好友均使用附近好友功能
•
应用每页展示20位附近的好友
•
位置更新QPS= 1000万 / 30 == 每秒约33.4万次更新
步骤2:提出高层次设计方案并获得认可
在探讨 API 与数据模型设计之前,我们先来研究一下将要采用的通信协议,因为它不像传统的请求-响应通信模式那样普遍。
High-level design
从宏观层面来看,我们希望在各节点之间实现高效的消息传递。这可以通过点对点协议来实现,但对于连接不稳定且功耗受限的移动应用而言,这种方式并不实用。
一种更实用的做法是使用共享后端作为向目标好友进行扇出的机制:

后端是做什么的?
•
接收所有活跃用户的定位更新
•
对于每一次定位更新,找出所有应当接收该更新的活跃用户,并将其转发给他们
•
如果好友之间的距离超出预设阈值,则不要转发位置数据
这听起来很简单,但真正的挑战在于如何为我们的业务规模设计系统。
我们首先从一个更简单的设计入手,随后在深入探讨中再讨论更为高级的方案:
•
负载均衡器:将流量均匀分配到 REST API 服务器以及双向 WebSocket 服务器上
•
REST API服务器:负责处理诸如管理好友、更新个人资料等辅助性任务。
•
WebSocket 服务器:有状态的服务器,负责将位置更新请求转发给相应的客户端。此外,它还负责在初始化时为移动端客户端预加载附近好友的位置信息(相关内容将在后文详述)。
•
Redis 位置缓存:用于存储每位活跃用户的最新位置数据。缓存中的每条记录都设置了过期时间(TTL)。当 TTL 到期时,该用户即被视为已不活跃,其数据将从缓存中移除。
•
用户数据库:存储用户及好友关系数据。此用途可选用关系型数据库或NoSQL数据库。
•
位置历史数据库:存储用户的位置数据历史,未必直接用于“附近的朋友”功能,而是用于追踪历史数据以支持分析目的
•
Redis 发布订阅:用作轻量级消息总线,为每个用户频道提供独立的主题,用于位置更新。
在上述示例中,WebSocket 服务器会订阅与其相连用户的频道,并在收到位置更新时将其转发给相应的用户。
Periodic location update
以下是周期性位置更新的流程:
•
移动端客户端向负载均衡器发送位置更新消息
•
负载均衡器将位置更新转发至该客户端对应的 WebSocket 服务器的持久连接
•
WebSocket 服务器将位置数据保存到位置历史数据库
•
位置数据会更新至位置缓存。WebSocket 服务器还会在内存中保存该用户的位置数据,以便后续进行距离计算
•
WebSocket 服务器通过 Redis 的发布/订阅机制在用户的频道中发布位置数据
•
Redis 的发布订阅机制会将位置更新广播给该用户频道的所有订阅者,即负责该用户好友相关业务的服务器
•
已订阅的 WebSocket 服务器接收位置更新,计算应将该更新发送给哪些用户,并将其发送出去
以下是同一流程的更详细版本:
平均而言,当一位用户平均拥有400位好友,且其中10%同时在线时,需要转发40条位置更新。
API Design
我们需要支持的 WebSocket 相关流程:
•
周期性位置更新——用户向 WebSocket 服务器发送位置数据
•
客户端接收位置更新——服务器发送好友的位置信息及时间戳
•
WebSocket 客户端初始化——客户端发送用户位置,服务器返回附近好友的位置信息
•
订阅新好友——WebSocket 服务器发送一个好友 ID,移动端客户端应负责追踪,例如在好友首次上线时
•
取消关注某位好友——WebSocket 服务器发送好友 ID,移动端客户端应根据该 ID 取消关注,例如当好友下线时
HTTP API——用于承担辅助性职责的传统请求/响应负载格式。
Data model
•
这是一个 GitHub 仓库页面,标题为“system-design-notes/01. Scaling at main · liquidslr/system-design-notes”。上下文。
•
位置历史表以关系型表格的形式存储相同的数据,包含上述的四列。由于 Cassandra 针对写密集型负载进行了优化,因此也可用于存储此类数据。
步骤3:设计深度剖析
让我们探讨如何对顶层设计进行扩展,使其在我们所瞄准的规模下也能正常运行。
How well does each component scale?
•
API 服务器:可通过自动伸缩组和复制服务器实例轻松实现水平扩展
•
WebSocket 服务器:我们可以轻松地对 WebSocket 服务器进行水平扩展,但在下线某台服务器时,需要确保能够优雅地关闭现有连接。例如,我们可以在负载均衡器中将某台服务器标记为“正在下线”,并在将其最终从服务器池中移除之前,停止向该服务器转发新的连接请求
•
客户端初始化:当客户端首次连接到服务器时,它会获取用户的关注列表,在 Redis 的 pubsub 上订阅这些用户的频道,从缓存中读取其位置信息,最后将数据转发给客户端
•
用户数据库:我们可以按 user_id 对数据库进行分片。此外,将用户及好友数据通过专门的服务与 API 对外提供,并由专门的团队负责维护,也可能是一个合理的选择
•
位置缓存:我们可以通过启动多个 Redis 节点轻松实现缓存的分片。此外,TTL 也限制了我们单次占用的最大内存。不过,我们仍然需要应对庞大的写入负载。
•
Redis 发布/订阅服务器:我们利用这样一个事实——如果某些频道已初始化但未被使用,则不会占用任何内存。因此,我们可以为所有使用附近好友功能的用户预先分配频道,从而避免在用户上线时临时创建新频道并通知活跃的 WebSocket 服务器等操作。
Scaling deep-dive on redis pub/sub component
为了维护所有的发布/订阅通道,我们需要大约200GB的内存。这可以通过使用两台各配备100GB内存的Redis服务器来实现。
鉴于我们每秒需要推送约1400万条位置更新,假设单台服务器每秒可处理约10万次写入,那么至少需要140台 Redis 服务器来承载这一负载。
因此,我们需要一个分布式Redis服务器集群来应对巨大的CPU负载。
为了支持分布式 Redis 集群,我们需要借助服务发现组件,例如 Zookeeper 或 etcd,以实时监控各节点的存活状态。
我们在服务发现组件中需要编码的数据如下:
WebSocket 服务器使用从 Zookeeper 获取的编码数据来确定特定通道所在的位置。为提高效率,每个 WebSocket 服务器都可以在内存中缓存哈希环数据。
在对服务器集群进行扩容或缩容时,我们可以设置一个每日任务,根据历史流量数据按需调整集群规模。此外,我们还可以适当超配集群资源,以应对突发的负载高峰。
Redis 集群可以被视为一种有状态的存储服务器,因为它为通道维护着一定状态,并且需要与订阅者进行协调,以便在集群中新部署的节点之间实现无缝切换。
在扩容过程中,我们必须留意一些潜在问题:
•
由于频道被重新分配,WebSocket 服务器将收到大量重订阅请求
•
在执行该操作期间,客户端可能会遗漏部分位置更新,这对于本问题而言是可以接受的,但我们仍应尽量减少此类情况的发生。建议在当日流量最低时段执行此类操作。
•
我们可以通过使用一致性哈希算法,在增减服务器时最大限度地减少需要迁移的通道数量
Adding/removing friends
每当有好友被添加或移除时,负责相关用户的 WebSocket 服务器都需要对该好友的频道进行订阅或取消订阅。
由于“附近的朋友”功能是更大应用的一部分,我们可以假设,在任何事件发生时,都可以在移动端客户端注册一个回调函数,客户端随后会向 WebSocket 服务器发送消息,以执行相应的操作。
Users with many friends
我们可以对用户的好友总数设定上限,例如,Facebook 的好友上限为5000人。
处理“whale”用户的 WebSocket 服务器端可能承受更高的负载,但只要我们拥有足够多的 WebSocket 服务器,就应当能够应对。
Nearby random person
如果面试官希望在设计中新增一项功能,即偶尔能在我们的附近好友地图上看到一位随机用户突然出现,该怎么办?
一种处理方式是根据地理哈希值,定义一组发布订阅频道:
在地理哈希范围内的任何用户都订阅相应的频道,以接收随机用户的定位更新:
我们还可以订阅多个地理哈希值,以应对某人虽处于某一区域但位于相邻地理哈希值边界的情况:
Alternative to Redis pub/sub
除了使用 Redis 实现发布/订阅之外,另一种选择是借助 Erlang——一种专为分布式计算应用而优化的通用编程语言。
借助它,我们可以启动数以百万计的小型 Erlang 进程,这些进程彼此之间相互通信。我们还能够在分布式 Erlang 应用程序中同时处理 WebSocket 连接和发布/订阅通道。
不过,使用Erlang的一个挑战在于,它是一种小众编程语言,因此很难找到优秀的Erlang开发人员。
步骤4:总结
我们成功设计了一套系统,支持了附近好友功能。
核心组件:
•
WebSocket 服务器:客户端与服务器之间的实时通信
•
Redis:快速读写位置数据 + 发布/订阅通道
我们还探讨了如何扩展RESTful API服务器、WebSocket服务器、数据层以及Redis发布/订阅服务器,并且研究了一种替代Redis发布/订阅的方案。此外,我们还实现了“随机附近的人”功能。