1. Scale From Zero To Millions Of Users #
从零到百万用户的扩展
这里,我们构建的系统最初只支持少量用户,然后逐渐扩展到支持数百万用户。
1.1 单服务器配置 #
最开始,我们会把所有东西都放在一台服务器上: Web应用、数据库、缓存等等。
图1.1:
图1.2:
图1.2中,请求流程是这样的:
- 用户通过DNS服务器查询网站的IP地址 (例如,api.mysite.com -> 15.125.23.214)。通常情况下,DNS由第三方提供,而不是自己托管。
- HTTP请求会直接从用户的设备发送到服务器 (通过其IP地址)。
- 服务器返回用于渲染的HTML页面或JSON数据。
访问网络服务器的流量来自Web应用或移动应用:
- Web应用使用服务器端语言(例如 Java、Python)来处理业务逻辑和数据存储。使用客户端语言(例如HTML、JavaScript)用于展示内容。
- 移动应用使用HTTP协议在移动设备和Web服务器之间进行通信。JSON用于格式化传输数据。
以下是一个JSON数据示例:
1GET /users/12 – Retrieve user object for id = 12
1{
2 "id":12,
3 "firstName":"John",
4 "lastName":"Smith",
5 "address":{
6 "streetAddress":"21 2nd Street",
7 "city":"New York",
8 "state":"NY",
9 "postalCode":10021
10 },
11 "phoneNumbers":[
12 "212 555-1234",
13 "646 555-4567"
14 ]
15}
1.2 数据库 #
随着用户的增长,将所有内容存储在单个服务器上是不够的。我们可以将数据库分离到另一台服务器上,以便它可以独立于Web层进行扩展:
图1.3:
1.2.1 选择使用哪种数据库 #
可以选择传统的关系型数据库或非关系型(NoSQL)数据库。
- 最流行的关系型数据库 - MySQL、Oracle、PostgreSQL。
- 流行的 NoSQL 数据库 - CouchDB、Neo4J、Cassandra、HBase、DynamoDB。
关系型数据库以表(tables)和行(rows)的形式表示和存储数据。可以连接(join)不同的表来表示聚合对象。
NoSQL数据库分为四类: 键值存储(key-value stores)、图存储(graph stores)、列存储(column stores)和文档存储(document stores)。NoSQL数据库通常不支持连接操作。
对于大多数用例来说,关系型数据库是最佳选择,因为它们出现的时间最长,并且在历史上运行良好。
但如果关系型数据库不满足场景要求,则可能需要考虑NoSQL数据库。
在以下情况下,NoSQL数据库可能是更好的选择:
- 应用需要超低延迟。
- 应用中的数据是非结构化的,或者根本不需要任何关系数据。
- 只需要序列化/反序列化数据 (JSON、XML、YAML等)。
- 需要存储海量数据。
1.3 垂直扩展 vs. 水平扩展 #
- 垂直扩展(vertical scaling),也叫纵向扩展、向上扩展(scale up)。指的是为服务器增加更多能力(CPU、内存等)。
- 水平扩展(horizontal scaling),也叫横向扩展,向外扩展(scale out)。指的是向资源池添加更多服务器。
当流量较小的时候,垂直扩展是一个很好的选择。简单是它的主要优点,但它有局限性:
- 垂直扩展是有硬性限制的。不可能向单个服务器添加无限的CPU和内存。
- 垂直扩展没有故障转移和冗余。如果服务器宕机,整个应用程序/网站也会随之宕机。
由于垂直扩展的局限性,水平扩展更适合大型应用程序。它的主要缺点是更难正确实现。
到目前为止的设计中,服务器宕机(例如由于故障或过载)意味着整个应用程序都会随之宕机。解决此问题的一个好方法是使用负载均衡器(load balancer)。
1.4 负载均衡器(Load balancer) #
负载均衡器将传入流量均匀分配到负载均衡集里的各个Web服务器上。
图1.4:
客户端连接到负载均衡器的公共IP。Web服务器不能被客户端直接访问。为了安全性,Web服务器拥有私有IP,负载均衡器可以访问这些私有IP。
通过添加负载均衡器和Web服务器,我们成功解决了Web层的故障转移问题。具体细节如下:
- 如果Server 1宕机,所有流量都将路由到Server 2。这可以防止网站离线。我们还将添加一台全新的服务器来平衡负载。
- 如果网站流量激增,并且两台服务器不足以处理这些流量,只需要在服务器池中添加更多服务器,负载均衡器就会自动将请求发给新加入的服务器。
Web层现在看起来很棒了。但是数据层呢?目前的设计方案中只有一个数据库,所以无法支持数据库的故障转移和冗余。数据库复制是解决这些问题的常用技巧。
1.5 数据库复制(Database replication) #
数据库复制通常可以通过主/从复制(master/slave replication)来实现。
📝 技术圈的反对种族歧视 master/slave replication现在更多被称作primary/secondary replication。
主数据库通常只支持写入。从数据库存储来自主数据库的数据副本,并且只支持读取操作。这种设置适用于大多数应用程序,因为通常读操作远多于写操作。可以通过添加更多从实例来轻松扩展。
图1.5:
数据库复制的优点:
- 更好的性能。能够并行处理更多读取查询。
- 可靠性高。如果一个数据库服务器损毁,数据仍然会被保存。
- 可用性高。只要有一个实例没有离线,数据就是可访问的。
那么如果一个数据库离线了会怎么样?
- 如果从数据库离线,读取操作将暂时路由到主数据库/其他从数据库。
- 如果主数据库宕机,一个从实例将被提升为新的主数据库。
图1.6: 展示了添加了负载均衡器和数据库复制之后的系统设计方案
以下是现在的设计:
- 用户从DNS获取负载均衡器的IP地址。
- 用户通过IP连接到负载均衡器。
- HTTP请求被路由到Server 1或Server 2。
- Web服务器在从库中读取用户数据,Web服务器把对数据的写操作请求都转发到主库上
接下来可以提升加载和响应速度了,通过添加缓存并将静态内容(JavaScript、CSS、图片、视频文件)转移到CDN(内容分发网络)来提高加载和响应速度。
1.6 缓存(Cache) #
缓存是一种临时存储,用于存储频繁访问的数据或很耗时的响应结果。
在我们的Web应用中,每次加载网页时,都要执行一个或者多个数据库请求来获取数据。可以使用缓存来缓解这种情况。
1.6.1 缓存层 #
缓存层是一个临时的数据存储层,从中获取结果比从数据库中获取要快得多。它也可以独立于数据库进行扩展。
设置独立缓存层的优点:
- 提高系统性能
- 减轻数据库的工作负载
- 可以单独扩展缓存层
图1.7:
图1.7的例子是一个直读式缓存(read-through cache): 服务器检查数据是否在缓存中可用。如果不可用,则从数据库中获取数据。
📝 常见的缓存读写策略
- Cache Aside(旁路缓存): 应用程序直接与数据库交互,缓存作为辅助存储。读取数据时,先查缓存,如果缓存未命中(Cache Miss),则从数据库读取数据,然后将数据放入缓存。写入数据时,先更新数据库,然后使缓存失效(通常是删除缓存)。
- Read Through(读穿透): 应用程序只与缓存交互,缓存服务负责与数据库的交互。读取数据时,应用程序请求缓存,如果缓存未命中,缓存服务会从数据库读取数据,然后将数据放入缓存并返回给应用程序。
- Write Through(写穿透): 应用程序只与缓存交互,缓存服务负责与数据库的交互。所有的写操作都经过缓存,每次向缓存中写数据时,缓存会把数据持久化到对应的数据库中去,且这两个操作在一个事务中完成。因此,只有两次都写成功了才是最终写成功了。坏处是有写延迟,好处是保证了数据的一致性。
- Write Behind(异步写回): 应用程序只与缓存交互。写入数据时,应用程序将数据写入缓存,然后立即返回。缓存服务异步地将数据批量写入数据库。
策略 读操作 写操作 一致性 性能(写) 实现复杂度 适用场景 Cache Aside 先查缓存,未命中查数据库并更新缓存 先更新数据库,然后使缓存失效 弱 中等 简单 读多写少 Read Through 只查缓存,缓存未命中由缓存服务负责更新 不直接写数据库,由缓存服务负责 较强 无 复杂 对数据一致性要求较高,且读操作频繁 Write Through 只查缓存 同时更新缓存和数据库 强 低 较复杂 对数据一致性要求非常高 Write Behind 只查缓存 只更新缓存,异步批量更新数据库 弱 高 较复杂 对数据一致性要求不高,但对写性能要求非常高
1.6.2 使用缓存的注意事项 #
- 何时使用: 通常在数据频繁读取但很少修改时很有用。缓存通常不会在重启时保留数据,因此它不是一个好的持久层。
- 过期策略: 控制缓存数据是否(以及何时)过期并从中删除。设置得太短会导致数据库将被频繁查询。设置得太长会导致数据过时。
- 一致性: 数据存储和缓存应该保持多大程度的同步?如果数据库中的数据发生更改,但缓存未更新,则会发生不一致。
- 缓解故障: 单个缓存服务器可能是单点故障(Single Point Of Failure, SPOF)。推荐的做法是在不同的数据中心部署多个缓存服务器以避免单点故障。另一个推荐的做法是为缓存超量提供一定比例的内存,这样可以在内存使用量上升时提供一定的缓冲。
- 驱逐策略: 缓存已满时,向缓存添加项目会发生什么?缓存驱逐策略控制这一点。常用策略: LRU、LFU、FIFO。
📝 缓存驱逐策略
- LRU(Least-Recently-Used) - 最近最少使用(常用)
- LFU(Least Frequently Used) - 最不经常使用
- FIFO(First In First Out) - 先进先出
1.7 CDN(内容分发网络) #
内容分发网络(Content Delivery Network,CDN)是由在地理位置上分散的服务器组成的网络,用于传递静态内容。CDN中的服务器缓存了像图片、视频、CSS和JavaScript文件这一类的静态内容。
图1.9:
图1.10: CDN的工作流程
- 用户尝试通过URL获取图像。URL由CDN提供,例如
https://mysite.cloudfront.net/logo.jpg
。 - 如果图像不在缓存中,CDN将向源服务器请求该文件(例如Web服务器、S3 存储桶等)。
- 源服务器会将图像连同可选的TTL(生存时间)参数返回给CDN,该参数控制静态资源的缓存时长。
- 只要在TTL范围内,后续用户将从CDN获取图像,而不会有任何请求到达源服务器。
1.7.1 使用CDN的注意事项 #
- 成本: CDN由第三方管理,需要为此支付费用。注意不要在其中存储不经常使用的内容。
- 缓存过期: 考虑适当的缓存过期时间。“太短”会频繁请求源服务器。“太长”内容不会及时更新。
- CDN回退: 要好好考虑你的应用如何应对CDN故障。如果CDN出现故障暂时无法提供服务,客户端应该有能力发现这个问题,并直接向源服务器请求资源。
- 失效: 可以通过调用CDN服务商或传递对象版本(例如,在查询字符串中可以加入版本号
image.png?v=2
)来完成。
图1.11: 展示了加入了CDN和缓存之后的系统设计方案
- 静态资源(JavaScript、CSS、图片等不再由Web服务器提供,而是从CDN中获取,以提高响应速度
- 数据被缓存后,数据库的负载就减轻了
1.8 无状态Web层 #
为了扩展我们的Web层,需要使其成为无状态的。
为了做到这一点,可以将用户会话数据存储在持久性数据存储中,例如我们的关系数据库或NoSQL数据库。
1.8.1 有状态架构 #
有状态服务器会记住客户端在不同请求之间的状态数据。无状态服务器则不会。
图1.12:
在图1.12中,用户与其会话数据存储所在的服务器耦合在一起。如果他们向另一台服务器发出请求,该服务器将无法访问用户的会话。
这可以通过粘性会话(sticky session)来解决,大多数负载均衡器都支持粘性会话,但它会增加开销。这种方法使得添加或者移除服务器变得更加困难,同时也使得应对服务器故障变得更具挑战性。
1.8.2 无状态架构 #
图1.13:
在这种情况下,服务器本身不存储任何用户数据。相反,它们将其存储在所有服务器都可以访问的共享数据存储中。
这样,来自用户的HTTP请求可以发给任意Web服务器。
图1.14展示了加入了无状态网络层后的系统设计:
用户会话数据存储可以是关系数据库或NoSQL数据存储(例如Memcached、Redis),NoSQL数据存储对于此类数据的扩展更容易。
注意在图1.14中将状态数据从Web服务器中移除后,Web服务器就具备了自动扩展能力,自动扩展指的是基于网络流量自动地增加或者减少Web服务器。
应用程序发展的下一步是支持多个数据中心。如果你的网站发展迅速,让网站支持多数据中心就非常关键,这可以提高可用性以及在更广的地理区域提供更好的用户体验。
1.9 数据中心 #
图1.15:
在图1.15中,正常情况下客户端根据IP地址被地理路由到最近的数据中心。如果发生中断,会将所有流量路由到健康的数据中心。
- 📝 GeoDNS(基于地理位置的域名服务) 是一种基于用户的地理位置将域名解析为不同IP地址的DNS服务。
- 📝 DNS轮询(DNS Round Robin) DNS轮询通过将多个IP地址分配给同一个域名,按照顺序(轮询)返回这些IP地址来实现负载均衡。每次DNS查询时,DNS服务器会返回不同的IP地址,轮流分配流量到不同的服务器上。
图1.16:
为了实现这种多数据中心设置,需要解决几个问题:
- 流量重定向: 用于将流量正确定向到正确数据中心的工具。在这种情况下可以使用GeoDNS。
- 数据同步: 如果发生故障转移,来自DC1的用户将转到DC2。一个挑战是他们的用户数据是否在那里。
- 测试和部署: 自动化部署和测试对于保持跨DC的部署一致性至关重要。
📝 不同地区的用户可以使用不同的本地数据库或者缓存。在故障转移的场景中,流量可能被转移到一个数据不可用的数据中心。常用的一个策略是在多个数据中心复制数据。Netflix工程博客上的文章“Active-Active for Multi-Regional Resiliency”说明了Netflix是如何实现多数据中心异步复制的。
《Active-Active for Multi-Regional Resiliency》是2013年的文章,那个时候还处于IaaS的云计算阶段,云原生,Kubernetes还没有被普及。
Netflix的异地多活设计:
- 每个区域都部署一整套服务
- 每个区域都能够独立运行
- 服务都是无状态的
- 数据层在不同区域之间的进行数据同步
- 支持多区域的自动部署工具
为了进一步扩展系统,我们需要解耦不同的系统组件,以便它们可以独立扩展。在现实世界中,很多分布式系统用消息队列来解决这个问题。
1.10 消息队列 #
消息队列是持久化组件,可实现异步通信。
📝 消息队列的作用: 解耦、异步与削峰
图1.17:
基本架构:
- 生产者创建消息。
- 消费者/订阅者订阅新消息并消费它们。
消息队列使生产者能够与消费者解耦。如果消费者宕机,生产者仍然可以发布消息,消费者稍后会收到该消息。
用例示例"图片处理":
- Web服务器将“照片处理任务”发布到消息队列。
- 可变数量的工作进程(可以向上或向下扩展)订阅该队列并处理这些任务(改图像,包括裁剪、锐化、模糊化等,这些需要时间来完成)。
图1.18:
1.11 记录日志、收集指标与自动化(Logging, Metrics, Automation) #
一旦的Web应用程序增长到一定程度,日志监控工具就至关重要。
- 日志记录: 监控错误日志非常重要,因为它可以帮助识别系统的错误和问题。你可以监控每个服务器的错误日志,也可以用工具把各个服务器的日志汇总到一个中心化的服务中,方便搜索和查看。
- 收集指标: 收集各种类型的指标有助于获得商业洞察力和了解系统的健康状态。
- 自动化: 持续集成是一个很好的做法。持续集成,例如自动化构建、测试、部署,可以及早发现各种问题,并提高开发人员的工作效率。
📝 关键指标
- 主机级别指标:CPU、内存、磁盘I/O等
- 聚合级别指标:比如整个数据库层的性能,整个缓存层的性能等
- 关键业务指标:每日活跃用户数、留存率、收益等
图1.19更新后的系统设计:
随着数据与日俱增,数据库过载变得越来越严重。是时候扩展数据层了。
1.12 数据库扩展 #
数据库扩展有两种方法: 垂直扩展和水平扩展。
1.12.1 垂直扩展 #
垂直扩展(vertical scaling),也叫纵向扩展、向上扩展(scale up)。
垂直扩展意味着向数据库节点添加更多物理资源——CPU、RAM、HDD 等。例如,在Amazon RDS中,可以以创建一个具有24 TB RAM的数据库节点,这种数据库可以处理大量数据。 例如,2013年的 Stack Overflow拥有1000万月独立访客,使用的是单个数据库节点。
垂直扩展也有一些缺点:
- 可以添加到节点的资源量存在硬件限制。硬件能力总有上限,用户和流量可能会一直增长。
- 更大的单点故障风险。
- 总体成本很高(功能强大的服务器价格很高)。
1.12.2 水平扩展 #
与其使用更大的服务器,不如添加更多服务器。
水平扩展也教横向扩展,就是添加更多服务器。
图1.20:
分片是一种数据库水平扩展类型,它将大型数据集分成较小的数据集。每个分片共享相同的schema,但实际数据不同。
一种对数据库进行分片的方法是基于某个键,该键使用模运算符在所有分片上均匀分布:
图1.21:
图1.22展示了做过分片的数据库中的用户表:
分片键(sharding key)也称为分区键(partition key)是使用分片时要考虑的最重要因素。特别是,应该以尽可能均匀地分配数据的方式选择键。
虽然分片这是一项有用的技术,但它给系统带来了许多复杂性:
- 重新分片数据: 如果单个分片变得太大,则需要执行此操作。如果数据分布不均匀,这种情况可能会很快发生。一致性哈希有助于避免移动过多数据。
- 名人问题(也称为热点键问题): 一个分片可能比其他分片更频繁地被访问,并可能导致服务器过载。我们可能不得不为某些名人使用单独的分片。
- 连接和反规范化: 很难跨分片执行连接操作。一种常见的解决方法是使用去规范化的表以避免进行连接。
图1.23展示了引入分片和用于某些非关系数据的NoSQL数据库后的架构:
在图1.23中,对数据库做了分片,以支持数据流量的快速增长;同时,将有些非关系型功能迁移到NoSQL数据库中,以降低数据库的负载。High Scalability网站上有一篇文章“What the Heck are You Actually Using NoSQL for?”介绍了很多NoSQL数据库的使用案例。
1.13 用户量达到甚至超过数百万 #
系统的扩展是一个迭代的过程。
目前所学的知识可以帮助我们走得很远,但我们可能需要应用更复杂的技术才能将应用程序扩展到数百万用户以上。
我们目前看到的这些技术可以为我们提供一个良好的基础。
以下是总结:
- 保持Web层无状态
- 在每一层构建冗余
- 缓存频繁访问的数据
- 支持多个数据中心
- 在CDN中托管静态资源
- 通过分片扩展数据层
- 将大型应用程序拆分为多个服务
- 监控你的系统并使用自动化