系统设计面试(一)从零扩展到数百万用户
本文非常干且长,建议收藏后观看。
从零扩展到数百万用户
设计一个支持数百万用户的系统具有挑战性,这是一个需要不断完善和无休止改进的旅程。在本章中,我们构建了一个支持单个用户的系统,并逐渐将其扩展为服务数百万用户。阅读本章后,你将掌握一些技巧,可以帮助你破解系统设计面试问题。
单服务器设置
千里之行始于足下,构建复杂系统也不例外。从简单的开始,一切都在单个服务器上运行。图 1-1 显示了单个服务器设置的图示,其中一切都在一台服务器上运行:Web 应用程序、数据库、缓存等。
要了解此设置,调查请求流和流量来源会很有帮助。让我们先看一下请求流程(图 1-2)。
- 用户通过域名访问网站,如api.mysite.com。通常,域名系统 (DNS) 是由第三方提供的付费服务,并非由我们的服务器托管。
- 互联网协议(IP)地址返回给浏览器或移动应用程序。在示例中,返回 IP 地址 15.125.23.214。
- 一旦获得 IP 地址,超文本传输协议 (HTTP) [^1] 请求将直接发送到你的 Web 服务器。
- Web 服务器返回 HTML 页面或 JSON 响应进行渲染。
接下来,让我们检查一下流量来源。Web 服务器的流量来自两个来源:Web 应用程序和移动应用程序。
- Web 应用程序:它使用服务器端语言(Java、Python 等)的组合来处理业务逻辑、存储等,并使用客户端语言(HTML 和 JavaScript)进行演示。
- 移动应用程序:HTTP 协议是移动应用程序和Web 服务器之间的通信协议。JavaScript Object Notation (JSON) 由于其简单性,是常用的 API 响应格式来传输数据。JSON 格式的 API 响应示例如下所示:
GET /users/12 - 检索 id = 12 的用户对象
{
"id": 12,
"firstName": "John",
"lastName": "Smith",
"address": {
"streetAddress": "21 2nd Street",
"city": "New York",
"state": "NY",
"postalCode": 10021
},
"phoneNumbers": [
"212 555-1234",
"646 555-4567"
]
}
数据库
随着用户群的增长,一台服务器是不够的,我们需要多台服务器:一台用于网络/移动流量,另一台用于数据库(图 1-3)。分离网络/移动流量(网络层)和数据库(数据层)服务器允许它们独立扩展。
使用哪些数据库?
你可以在传统的关系数据库和非关系数据库之间进行选择。让我们来看看他们的区别。
关系数据库也称为关系数据库管理系统 (RDBMS) 或 SQL 数据库。最流行的是 MySQL、Oracle 数据库、PostgreSQL 等。关系型数据库以表和行的形式表示和存储数据。你可以使用 SQL 跨不同的数据库表执行连接操作。
非关系型数据库也称为 NoSQL 数据库。流行的有 CouchDB、Neo4j、Cassandra、HBase、Amazon DynamoDB 等 [^2]。这些数据库分为四类:键值存储、图形存储、列存储和文档存储。非关系型数据库一般不支持连接操作。
对于大多数开发人员来说,关系数据库是最好的选择,因为它们已经存在了 40 多年,而且从历史上看,它们运行良好。但是,如果关系数据库不适合你的特定用例,那么在关系数据库之外进行探索至关重要。在以下情况下,非关系数据库可能是正确的选择:
- 你的应用程序需要超低延迟。
- 你的数据是非结构化的,或者你没有任何关系数据。
- 你只需要序列化和反序列化数据(JSON、XML、YAML 等)。
- 你需要存储大量数据。
垂直缩放与水平缩放
垂直扩展,称为“纵向扩展”,是指为你的服务器增加更多功率(CPU、RAM 等)的过程。水平扩展,称为“横向扩展”,允许你通过将更多服务器添加到资源池中来进行扩展。
当流量较低时,垂直扩展是一个不错的选择,垂直扩展的简单性是它的主要优势。不幸的是,它具有严重的局限性。
- 垂直缩放有硬性限制。向单个服务器添加无限的 CPU 和内存是不可能的。
- 垂直扩展没有故障转移和冗余。如果一台服务器出现故障,则网站/应用程序将完全崩溃。
由于垂直缩放的限制,水平缩放更适合大规模应用。
在之前的设计中,用户直接连接到 Web 服务器。如果网络服务器离线,用户将无法访问该网站。在另一种情况下,如果许多用户同时访问 Web 服务器,并且达到了 Web 服务器的负载限制,用户通常会遇到响应较慢或无法连接到服务器的情况。负载均衡器是解决这些问题的最佳技术。
负载均衡
负载平衡器在负载平衡集中定义的 Web 服务器之间平均分配传入流量。图 1-4 显示了负载平衡器的工作原理。
如图 1-4 所示,用户直接连接到负载均衡器的公共 IP。使用此设置,客户端将无法直接访问 Web 服务器。为了更好的安全性,私有 IP 用于服务器之间的通信。私有 IP 是只能在同一网络中的服务器之间访问的 IP 地址;但是,它无法通过 Internet 访问。负载均衡器通过私有 IP 与 Web 服务器通信。
在图 1-4 中,添加负载均衡器和第二个 Web 服务器后,我们成功解决了无故障转移问题并提高了 Web 层的可用性。详细说明如下:
- 如果服务器1 下线,所有的流量将被路由到服务器2。这样可以防止网站下线。我们还将向服务器池添加一个新的健康 Web 服务器以平衡负载。
- 如果网站流量快速增长,而两台服务器不足以处理流量,负载均衡器可以优雅地处理这个问题。你只需要在 Web 服务器池中添加更多服务器,负载均衡器就会自动开始向它们发送请求。
现在 Web 层看起来不错,那么数据层呢?当前设计只有一个数据库,因此不支持故障转移和冗余。数据库复制是解决这些问题的常用技术。让我们来看看。
数据库复制
主数据库通常只支持写操作。从库从主库获取数据的副本,只支持读操作。所有数据修改命令(如插入、删除或更新)都必须发送到主数据库。大多数应用程序需要更高的读写比率;因此,系统中从数据库的数量通常大于主数据库的数量。图 1-5 显示了一个主数据库和多个从数据库。
数据库复制的优点:
- 高性能:在主从模型中,所有的写入和更新都发生在主节点上;而读取操作分布在从节点上。此模型提高了性能,因为它允许并行处理更多查询。
- 可靠性:如果你的其中一个数据库服务器因自然灾害(例如台风或地震)而损坏,数据仍会保留。你无需担心数据丢失,因为数据会跨多个位置复制。
- 高可用性:通过跨不同位置复制数据,即使数据库处于脱机状态,你的网站仍然可以运行,因为你可以访问存储在另一个数据库服务器中的数据。
在上一节中,我们讨论了负载均衡器如何帮助提高系统可用性。我们在这里问同样的问题:如果其中一个数据库离线怎么办?图 1-5 中讨论的架构设计可以处理这种情况:
- 如果只有一个从库可用且下线,读操作将临时定向到主库。一旦发现问题,一个新的从数据库将替换旧的。如果有多个从数据库可用,读取操作将重定向到其他健康的从数据库。新的数据库服务器将替换旧的。
- 如果主数据库下线,从数据库将被提升为新的主数据库。所有数据库操作都将临时在新的主数据库上执行。一个新的从数据库将立即替换旧的进行数据复制。在生产系统中,升级新的主数据库更加复杂,因为从数据库中的数据可能不是最新的。需要通过运行数据恢复脚本来更新丢失的数据。尽管其他一些复制方法(例如多主复制和循环复制)可能会有所帮助,但这些设置更加复杂;他们的讨论超出了本书的范围。有兴趣的读者应参考列出的参考资料 [^4] [^5]。
图 1-6 显示了添加负载均衡器和数据库复制后的系统设计。
让我们看一下设计:
- 用户从DNS 获取负载平衡器的IP 地址。
- 用户使用此IP 地址连接负载平衡器。
- HTTP 请求被路由到服务器 1 或服务器 2。
- Web 服务器从从数据库读取用户数据。
- Web 服务器将任何数据修改操作路由到主数据库。这包括写入、更新和删除操作。
现在,你对 Web 和数据层有了深入的了解,是时候改进加载/响应时间了。这可以通过添加缓存层并将静态内容(JavaScript/CSS/图像/视频文件)转移到内容交付网络 (CDN) 来完成。
缓存
缓存是一个临时存储区域,将昂贵响应的结果或频繁访问的数据存储在内存中,以便更快地处理后续请求。如图 1-6 所示,每次加载新网页时,都会执行一个或多个数据库调用以获取数据。重复调用数据库对应用程序性能的影响很大。缓存可以缓解这个问题。
缓存层
缓存层是一个临时数据存储层,比数据库快得多。拥有单独的缓存层的好处包括更好的系统性能、减少数据库工作负载的能力以及独立扩展缓存层的能力。图 1-7 显示了缓存服务器的可能设置:
收到请求后,Web 服务器首先检查缓存是否有可用的响应。如果有,它将数据发送回客户端。如果没有,它会查询数据库,将响应存储在缓存中,然后将其发送回客户端。这种缓存策略称为通读缓存。根据数据类型、大小和访问模式,还可以使用其他缓存策略。之前的一项研究解释了不同的缓存策略是如何工作的 [^6]。
与缓存服务器交互很简单,因为大多数缓存服务器都提供通用编程语言的 API。以下代码片段显示了典型的 Memcached API:
SECONDS = 1
cache.set("myKey", "hi there", 3600 * SECONDS)
cache.get("myKey")
使用缓存的注意事项
以下是使用缓存系统的一些注意事项:
- 决定何时使用缓存。当数据被频繁读取但不经常修改时,考虑使用缓存。由于缓存数据存储在易失性内存中,因此缓存服务器不适合持久化数据。例如,如果缓存服务器重新启动,内存中的所有数据都会丢失。因此,重要的数据应该保存在持久数据存储中。
- 到期政策。实施过期策略是一种很好的做法。一旦缓存的数据过期,就会从缓存中删除。当没有过期策略时,缓存的数据会永久保存在内存中。建议不要将过期日期设置得太短,因为这会导致系统过于频繁地从数据库重新加载数据。同时,建议不要将过期日期设置得太长,因为数据可能会变得陈旧。
- 一致性:这涉及保持数据存储和缓存同步。由于数据存储和缓存上的数据修改操作不在单个事务中,因此可能会发生不一致。在跨多个区域进行扩展时,保持数据存储和缓存之间的一致性具有挑战性。有关详细信息,请参阅 Facebook [^7] 发表的题为“Scaling Memcache at Facebook”的论文。
- 缓解故障:单个缓存服务器代表潜在的单点故障 (SPOF),在 Wikipedia 中定义如下:“单点故障 (SPOF) 是系统的一部分,如果发生故障,将停止整个系统停止工作”[^8]。因此,建议使用跨不同数据中心的多个缓存服务器来避免 SPOF。另一种推荐的方法是按一定百分比过度配置所需的内存。随着内存使用量的增加,这提供了一个缓冲区。
-
- 驱逐策略:一旦缓存已满,任何将项目添加到缓存的请求都可能导致现有项目被删除。这称为缓存驱逐。最近最少使用(LRU)是最流行的缓存驱逐策略。可以采用其他驱逐策略,例如最不常用 (LFU) 或先进先出 (FIFO) 来满足不同的用例。
内容交付网络 (CDN)
CDN 是一个地理上分散的服务器网络,用于交付静态内容。CDN 服务器缓存静态内容,如图像、视频、CSS、JavaScript 文件等。
动态内容缓存是一个相对较新的概念,超出了本书的范围。它可以缓存基于请求路径、查询字符串、cookie 和请求标头的 HTML 页面。有关这方面的更多信息,请参阅参考资料 [^9] 中提到的文章。本书重点介绍如何使用 CDN 缓存静态内容。
以下是 CDN 在高层的工作方式:当用户访问网站时,离用户最近的 CDN 服务器将提供静态内容。直观地说,来自 CDN 服务器的用户越多,网站加载速度就越慢。例如,如果 CDN 服务器在旧金山,洛杉矶的用户将比欧洲的用户更快地获取内容。图 1-9 是一个很好的例子,展示了 CDN 如何缩短加载时间。
图 1-10 演示了 CDN 工作流程。
-
用户 A 尝试通过图片 URL 获取 image.png。URL 的域由 CDN 提供商提供。以下两个图像 URL 是用于演示图像 URL 在 Amazon 和 Akamai CDN 上的样子的示例:
- https://mysite.cloudfront.net/logo.jpg
- https://mysite.akamai.com/image-manager/img/logo.jpg
-
如果CDN服务器缓存中没有image.png,CDN服务器从源端请求文件,源端可以是Web服务器,也可以是Amazon S3等在线存储。
-
源端将 image.png 返回给 CDN 服务器,其中包括可选的 HTTP 标头 Time-to-Live (TTL),它描述了图像缓存的时间。
-
CDN 缓存图片并返回给用户 A。图片一直缓存在 CDN 中,直到 TTL 过期。
-
用户 B 发送请求以获取相同的图像。
-
只要 TTL 没有过期,图片就会从缓存中返回。
使用 CDN 的注意事项
- 成本:CDN 由第三方提供商运行,你需要为进出 CDN 的数据传输付费。缓存不常用的资产并没有带来显着的好处,因此你应该考虑将它们移出 CDN。
- 设置适当的缓存到期时间:对于时间敏感的内容,设置缓存到期时间很重要。缓存过期时间既不能太长也不能太短。如果太长,内容可能不再新鲜。如果太短,可能会导致内容从源服务器重复重新加载到 CDN。
- CDN 回退:你应该考虑你的网站/应用程序如何应对CDN 故障。如果出现临时 CDN 中断,客户端应该能够检测到问题并从源请求资源。
- 使文件失效:你可以通过执行以下操作之一在文件过期之前从 CDN 中删除文件:
- 使用CDN 供应商提供的API 使CDN 对象无效。
- 使用对象版本控制服务于对象的不同版本。要对对象进行版本化,你可以向 URL 添加参数,例如版本号。例如,将版本号 2 添加到查询字符串中:image.png?v=2。
图 1-11 显示了添加 CDN 和缓存后的设计。
- 静态资产(JS、CSS、图像等)不再由 Web 服务器提供服务。它们从 CDN 中获取以获得更好的性能。
- 通过缓存数据减轻数据库负载。
无状态网络层
现在是时候考虑水平扩展 Web 层了。为此,我们需要将状态(例如用户会话数据)移出 Web 层。一个好的做法是将会话数据存储在持久性存储中,例如关系数据库或 NoSQL。集群中的每个 Web 服务器都可以访问数据库中的状态数据。这称为无状态 Web 层。
有状态的架构
有状态服务器和无状态服务器有一些关键区别。有状态服务器会记住从一个请求到下一个请求的客户端数据(状态)。无状态服务器不保留任何状态信息。
图 1-12 显示了一个有状态架构的示例。
在图 1-12 中,用户 A 的会话数据和配置文件图像存储在服务器 1 中。要对用户 A 进行身份验证,必须将 HTTP 请求路由到服务器 1。如果将请求发送到服务器 2 等其他服务器,则身份验证将失败,因为服务器2 不包含用户 A 的会话数据。同样,来自用户 B 的所有 HTTP 请求都必须路由到服务器 2;来自用户 C 的所有请求都必须发送到服务器 3。
问题是来自同一个客户端的每个请求都必须路由到同一个服务器。这可以通过大多数负载均衡器 [^10] 中的粘性会话来完成;但是,这会增加开销。使用这种方法添加或删除服务器要困难得多。处理服务器故障也具有挑战性。
无状态架构
图 1-13 显示了无状态架构。
在这种无状态架构中,来自用户的 HTTP 请求可以发送到任何 Web 服务器,这些服务器从共享数据存储中获取状态数据。状态数据存储在共享数据存储中,并远离 Web 服务器。无状态系统更简单、更健壮且可扩展。
图 1-14 显示了带有无状态 Web 层的更新设计。
在图 1-14 中,我们将会话数据移出 Web 层并将它们存储在持久数据存储中。共享数据存储可以是关系数据库、Memcached/Redis、NoSQL 等。选择 NoSQL 数据存储是因为它易于扩展。自动缩放意味着根据流量负载自动添加或删除 Web 服务器。将状态数据从 Web 服务器中删除后,可以通过根据流量负载添加或删除服务器轻松实现 Web 层的自动缩放。
你的网站发展迅速,并在国际上吸引了大量用户。为了提高可用性并在更广泛的地理区域提供更好的用户体验,支持多个数据中心至关重要。
数据中心
图 1-15 显示了具有两个数据中心的示例设置。在正常操作中,用户通过 geoDNS 路由(也称为地理路由)到最近的数据中心,美国东部的拆分流量为 x%,美国西部的流量为 (100 – x)%。geoDNS 是一种 DNS 服务,允许根据用户的位置将域名解析为 IP 地址。
如果发生任何重大数据中心中断,我们会将所有流量引导至健康的数据中心。在图 1-16 中,数据中心 2(美国西部)离线,100% 的流量路由到数据中心 1(美国东部)。
实现多数据中心设置必须解决几个技术挑战:
- 流量重定向:需要有效的工具将流量引导到正确的数据中心。GeoDNS 可用于根据用户所在的位置将流量引导至最近的数据中心。
- 数据同步:不同地区的用户可以使用不同的本地数据库或缓存。在故障转移情况下,流量可能会被路由到数据不可用的数据中心。一种常见的策略是跨多个数据中心复制数据。之前的一项研究显示了 Netflix 如何实现异步多数据中心复制 [^11]。
- 测试和部署:使用多数据中心设置,在不同位置测试你的网站/应用程序非常重要。自动化部署工具对于保持所有数据中心的服务一致至关重要 [^11]。
为了进一步扩展我们的系统,我们需要解耦系统的不同组件,以便它们可以独立扩展。消息队列是许多现实世界分布式系统用来解决这个问题的关键策略。
消息队列
消息队列是一个持久的组件,存储在内存中,支持异步通信。它用作缓冲区并分发异步请求。消息队列的基本架构很简单。输入服务,称为生产者/发布者,创建消息,并将它们发布到消息队列。称为消费者/订阅者的其他服务或服务器连接到队列,并执行由消息定义的操作。模型如图 1-17 所示。
解耦使消息队列成为构建可扩展且可靠的应用程序的首选架构。使用消息队列,当消费者无法处理消息时,生产者可以将消息发布到队列中。即使生产者不可用,消费者也可以从队列中读取消息。
考虑以下用例:你的应用程序支持照片自定义,包括裁剪、锐化、模糊等。这些自定义任务需要时间才能完成。在图 1-18 中,Web 服务器将照片处理作业发布到消息队列。照片处理工作者从消息队列中获取作业并异步执行照片定制任务。生产者和消费者可以独立扩展。当队列的大小变大时,会增加更多的工作人员以减少处理时间。但是,如果队列大部分时间是空的,则可以减少工作人员的数量。
日志记录、指标、自动化
当使用在几台服务器上运行的小型网站时,日志记录、指标和自动化支持是很好的做法,但不是必需的。但是,既然你的网站已经发展为服务于大型企业,那么对这些工具进行投资是必不可少的。
日志记录:监控错误日志很重要,因为它有助于识别系统中的错误和问题。你可以监控每个服务器级别的错误日志,或使用工具将它们聚合到集中式服务中,以便于搜索和查看。
指标:收集不同类型的指标有助于我们获得业务洞察力并了解系统的健康状况。以下一些指标很有用:
- 主机级别指标:CPU、内存、磁盘 I/O 等。
- 聚合级别指标:例如,整个数据库层、缓存层等的性能。
- 关键业务指标:日活跃用户、留存率、收入等。
自动化:当系统变得庞大而复杂时,我们需要构建或利用自动化工具来提高生产力。持续集成是一个很好的实践,其中每个代码签入都通过自动化进行验证,让团队能够及早发现问题。此外,自动化构建、测试、部署过程等可以显着提高开发人员的生产力。
添加消息队列和不同的工具
图 1-19 显示了更新后的设计。限于篇幅,图中只展示了一个数据中心。
- 设计包含消息队列,有助于使系统更松耦合和故障恢复。
- 包括日志记录、监控、指标和自动化工具。
随着数据每天都在增长,你的数据库会变得更加过载。是时候扩展数据层了。
数据库扩容
数据库扩展有两种广泛的方法:垂直扩展和水平扩展。
垂直缩放
垂直扩展,也称为纵向扩展,是通过向现有机器添加更多功率(CPU、RAM、磁盘等)来进行扩展。有一些强大的数据库服务器。根据 Amazon Relational Database Service (RDS) [^12],你可以获得具有 24 TB RAM 的数据库服务器。这种强大的数据库服务器可以存储和处理大量数据。例如,stackoverflow.com 在 2013 年每月有超过 1000 万的独立访问者,但它只有 1 个主数据库 [^13]。然而,垂直缩放有一些严重的缺点:
- 你可以向数据库服务器添加更多 CPU、RAM 等,但存在硬件限制。如果你拥有庞大的用户群,单台服务器是不够的。
- 更大的单点故障风险。
- 垂直扩展的总体成本很高。强大的服务器要贵得多。
水平缩放
水平扩展,也称为分片,是添加更多服务器的做法。图 1-20 比较了垂直缩放和水平缩放。
分片将大型数据库分成更小、更易于管理的部分,称为分片。每个分片共享相同的模式,尽管每个分片上的实际数据对于该分片是唯一的。
图 1-21 显示了分片数据库的示例。用户数据根据用户 ID 分配给数据库服务器。每当你访问数据时,都会使用哈希函数来查找相应的分片。在我们的示例中,user_id % 4 用作散列函数。如果结果等于 0,则分片 0 用于存储和获取数据。如果结果等于 1,则使用分片 1。相同的逻辑适用于其他分片。
图 1-22 显示了分片数据库中的用户表。
实施分片策略时要考虑的最重要因素是分片键的选择。分片键(称为分区键)由一个或多个决定数据分布方式的列组成。如图 1-22 所示,“user_id”是分片键。分片键允许你通过将数据库查询路由到正确的数据库来有效地检索和修改数据。在选择分片键时,最重要的标准之一是选择一个可以均匀分布数据的键。
分片是一种很好的数据库扩展技术,但它远非完美的解决方案。它给系统带来了复杂性和新挑战:
-
「重新分片数据」:在以下情况下需要重新分片数据:
- 单个分片由于快速增长而无法再容纳更多数据。
- 由于数据分布不均匀,某些分片可能比其他分片更快地经历分片耗尽。当分片耗尽时,需要更新分片功能并移动数据。一致性散列,将在第 5 章讨论,是解决这个问题的常用技术。
- 「名人问题」:这也称为热点关键问题。对特定分片的过度访问可能会导致服务器过载。想象一下 Katy Perry、Justin Bieber 和 Lady Gaga 的数据最终都在同一个分片上。对于社交应用程序,该分片将被读取操作所淹没。为了解决这个问题,我们可能需要为每个名人分配一个分片。每个分片甚至可能需要进一步的分区。
- 「加入和反规范化」:一旦一个数据库被分片到多个服务器上,就很难跨数据库分片执行连接操作。一种常见的解决方法是对数据库进行非规范化,以便可以在单个表中执行查询。
在图 1-23 中,我们对数据库进行分片以支持快速增长的数据流量。同时,一些非关系功能被移动到 NoSQL 数据存储中以减少数据库负载。这是一篇涵盖 NoSQL [^14] 的许多用例的文章。
数百万用户及更多用户
扩展系统是一个迭代过程。重复我们在本章中学到的东西可以让我们走得更远。需要更多微调和新策略才能扩展到数百万用户之外。例如,你可能需要优化系统并将系统与更小的服务分离。本章中学到的所有技术都应该为应对新挑战提供良好的基础。为了结束本章,我们总结了我们如何扩展我们的系统以支持数百万用户:
- 保持网络层无状态
- 在每一层建立冗余
- 尽可能多地缓存数据
- 支持多个数据中心
- 在CDN中托管静态资产
- 通过分片扩展你的数据层
- 将层级拆分为单独的服务
- 监控你的系统并使用自动化工具
如果你看到这一步!说明文章对你是有帮助的,欢迎一键三连。