从0到100万用户的扩展
设计一个拥有上百万用户的系统是很有挑战性的,这将是一个不断优化、持续改进的过程。在本章中,我们先创建一个单用户的系统,然后逐渐将其扩展成可以服务上百万用户的系统。读完本章,你将掌握几个能帮助你破解系统设计面试难题的技巧。
本文节选自Alex所著《搞定系统设计:面试敲开大厂的门》,亚马逊2500人打出4.6分,豆瓣8.4分好书。
01
万里征途总是从第一步开始的,构建一个复杂系统也是如此。我们从简单的部分着手,先让所有的功能都在一个服务器上运行。图1-1展示了如何配置单台服务器,让一切都在其上运行,包括Web应用、数据库、缓存等。
研究请求流和流量源头有助于我们理解这个配置。我们先来看请求流(如图1-2所示)。
1.用户通过输入域名(例如api.mysite.com)来访问网站。通常,域名系统(DNS)是由第三方提供的付费服务,它并不是由我们的服务器来托管的。
2.IP地址被返回给网页浏览器或者移动应用。在图1-2所示的例子中,被返回的IP地址是15.125.23.214。
3.一旦获知IP地址,HTTP请求就被直接发送给Web服务器。
4.Web服务器返回HTML页面或者JSON响应来渲染页面。
接下来,我们研究一下流量源头。Web服务器的流量有两个源头:Web应用和移动应用。
—Web应用:它运用服务器端语言(Java、Python等)来处理业务逻辑、数据存储等;它还使用客户端语言(HTML和JavaScript)来展示内容。
—移动应用:HTTP是移动应用与Web服务器之间的通信协议。而JSON(JavaScript Object Notation)因其十分简单而被广泛用作数据传输时的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",
"646555-4567"
]
}
02
随着用户基数的增长,一台服务器已经无法满足需求,我们需要多台服务器:一台用于处理Web应用/移动应用的流量,另一台用作数据库(如图1-3所示)。把处理Web应用/移动应用流量(网络层)的服务器与数据库(数据层)服务器分开,我们就可以对它们分别进行扩展。
使用何种数据库
你可以选择传统的关系型数据库,也可以选择非关系型数据库。我们来看看它们的区别。
关系型数据库通常也叫作关系型数据库管理系统(RDBMS)或者SQL数据库,其中最流行的有MySQL、Oracle、PostgreSQL等。关系型数据库通过表和行来表示和存储数据。你可以使用SQL对不同的数据库表执行连接(join)操作。
非关系型数据库又叫作NoSQL数据库。流行的非关系型数据库有CouchDB、Neo4j、Cassandra、HBase、Amazon DynamoDB等。它们可以分为四类:键值存储、图存储、列存储和文档存储。非关系型数据库一般不支持连接操作。
对于大多数开发者而言,关系型数据库是最好的选择,因为它们已经有40多年的历史,而且一直表现不错。但如果它们无法满足你的特殊使用场景要求,你就需要考虑关系型数据库之外的选项。当需要满足如下条件时,非关系型数据库可能是一个正确的选择:
—你的应用只能接受非常低的延时。
—应用中的数据是非结构化的,或者根本没有任何关系型数据。
—只需要序列化(JSON、XML、YAML等格式)和反序列化数据。
—需要存储海量数据。
03
纵向扩展也叫作向上扩展,指的是提升服务器的能力(CPU、RAM等)。横向扩展也叫作向外扩展,指的是为你的资源池添加更多服务器。
当流量小的时候,纵向扩展是一个很好的选择,其主要优势是简单。不过,它有一些重大局限。
—纵向扩展是有硬性限制的,你不可能给一台服务器无限添加CPU和内存。
—纵向扩展没有故障转移和冗余。一旦一台服务器宕机,网站/应用也会随着一起完全不可用。
由于纵向扩展存在这些限制,因此对于大型应用来说,采用横向扩展更合适一些。
在我们前面的设计中,用户是直接连接到Web服务器的。一旦服务器离线,用户就无法访问网站了。还有一种场景是,非常多的用户同时访问Web服务器,达到了其负载上限,这时用户就会普遍感受到网站响应慢或者无法连上服务器。解决这些问题的最佳方法是使用负载均衡器。
01
负载均衡器会把输入流量均匀分配到负载均衡集里的各个Web服务器上。图1-4展示了负载均衡器是怎么工作的。
如图1-4所示,用户可以直接连接该负载均衡器的公共IP地址。这样设置后,Web服务器就再也不能被任何客户端直接访问了。为了提高安全性,服务器之间的通信使用私有IP地址。私有IP地址只可以被同一个网络中的服务器访问,在公网中是无法访问的。负载均衡器和Web服务器之间使用私有IP地址来通信。
增加了负载均衡器和一台Web服务器后,我们成功解决了网络层的故障转移问题,提升了网络层的可用性。具体细节如下:
—如果服务器1离线,所有的流量都会被路由到服务器2,从而避免整个网站宕机。我们可以之后再将一台新的“健康的”Web服务器添加到服务器池中,以平衡负载。
—如果网站流量增长非常快,两台服务器不足以处理这些流量,那么负载均衡器可以轻松地解决这个问题。只需要在服务器池中添加更多服务器,负载均衡器就会自动将请求发给新加入的服务器。
现在网络层看来已经不错了,那么数据层呢?目前的设计方案中只有一个数据库,所以无法支持数据库的故障转移和冗余。数据库复制是解决这些问题的常用技巧。
05
根据维基百科上的定义,“在很多数据库管理系统中,通常都可以利用原始数据库(Master,主库)和拷贝数据库(Slave,从库)之间的主从关系进行数据库复制。”。
主库通常只支持写操作,从库保存主库的数据副本且仅支持读操作。所有修改数据的指令,如插入、删除或更新等,都必须发送给主库来执行。在大部分应用中,对数据库的读操作远多于写操作,因此系统中从库的数量通常多于主库的数量。图1-5展示了一个主库搭配多个从库的例子。
数据库复制有如下优点:
—性能更好。在主从模式下,所有的写操作和更新操作都发生在主节点(主库)上,而读操作被分配到各个从节点(从库),因此系统能并行处理更多的查询,性能得到提升。
—可靠性高。如果有一台数据库服务器因自然灾害而损毁,比如遭遇台风或者地震,数据依然被完好保存,你不需要担心数据会丢失,因为这些数据已经被复制到处于不同地理位置的其他数据库服务器中。
—可用性高。由于不同物理位置的从库都复制了数据,因此即使一台数据库服务器宕机,你的网站依然可以运行,因为另一台数据库服务器里存储了数据。
前面讨论了负载均衡器是如何帮助提升系统可用性的,这里我们问一个同样的问题:如果有数据库服务器宕机了怎么办?图1-5所示的架构可以应对这种情况。
—如果只有一个从库,而它宕机了,则系统暂时会将读操作路由至主库。一旦发现有从库宕机,就会有一个新的从库来替代它。要是有多个从库可用,读操作会被重定向到其他正常工作的从库上;同样,也会有一个新的数据库服务器来替代宕机的那个。
—如果主库宕机,会有一个从库被推选为新的主库。所有的数据库操作会暂时在新的主库上执行。另一个从库会替代原来的从库并立即开始复制数据。在生产环境中,因为从库的数据不一定是最新的,所以推选一个新的主库会更麻烦。缺失的数据需要通过运行数据恢复脚本来补全。尽管还有别的数据复制方式可以解决数据缺失问题,比如多主复制或者循环复制,但是它们的设置更加复杂,本书不对这些内容进行讨论。感兴趣的读者可以进一步阅读相关参考资料。
图1-6展示了添加了负载均衡器和数据库复制之后的系统设计方案。
我们再来看一下现在的设计:
—用户从DNS获取负载均衡器的IP地址。
—用户通过这个IP地址连接负载均衡器。
—HTTP请求被转发到服务器1或者服务器2上。
—Web服务器在从库中读取用户数据。
—Web服务器把所有修改数据的操作请求都转发到主库上,包括写、更新和删除操作。
现在我们对于网络层和数据层都有了一定的理解,接下来可以提升加载和响应速度了。可以通过添加缓存层、把静态资源(JavaScript、CSS、图片、视频文件)转移到内容分发网络(CDN)上来实现加速。
06
缓存是临时的存储空间,用于存储一些很耗时的响应结果或者内存中经常被访问的数据,这样后续再访问这些数据时能更快。如图1-6所示,每次加载一个新网页,都要执行一个或者多个数据库请求来获取数据。不断向数据库发送请求会使应用的性能受到很大影响,而缓存可以缓解这种情况。
缓存层
缓存层是一个临时数据存储层,比数据库快很多。设置独立缓存层的好处有:提高系统性能,减轻数据库的工作负载以及能够单独扩展缓存层。图1-7展示了一种设置缓存层的方式。
当收到一个请求时,Web服务器首先检查缓存中是否有可用的数据:如果有,Web服务器就直接将数据返回给客户端;如果没有,就去查询数据库并把返回的响应存储在缓存中,再将其返回给Web服务器。这种缓存策略叫作通过缓存读(Read-through Cache)。根据数据的类型、大小和访问模式,可以采用不同的缓存策略。在网站Codeahoy上有一篇文章“Caching Strategies and How to Choose the Right One”,解释了不同的缓存策略是如何工作的。
大部分缓存服务器都为常见的编程语言提供了API,与其进行交互很简单。下面的代码段展示了典型的Memcached API:
SECONDS= 1
cache.set('myKey', 'hi there', 3600*SECONDS)
cache.get('myKey')
使用缓存时的注意事项
使用缓存时有以下几点需要注意:
—决定什么时候应使用缓存。如果对数据的读操作很频繁,而修改却不频繁,则可考虑使用缓存。因为被缓存的数据是存储在易变的内存中的,所以缓存服务器不是持久化数据的理想位置。比如,如果缓存服务器重启,其中的所有数据就会丢失。因此,重要的数据应该保存在持久性的数据存储中。
—过期策略。执行过期策略是好的做法。一旦缓存中的数据过期,就应该将其从缓存中清除。如果不设置过期策略,缓存中的数据会一直被保存在内存中。通常建议不要把过期时间设得太短,因为这样会导致系统不得不经常从数据库重新加载数据;当然,也不要设得太长,这样会导致数据过时。
—一致性:这关系到数据存储和缓存的同步。当对数据的修改在数据存储和缓存中不是通过同一个事务来操作的时候,就会发生不一致。当跨越多个地区进行扩展时,保持数据存储和缓存之间的一致性是很有挑战性的。如果你感兴趣,可以阅读Facebook的文章“Scaling Memcache at Facebook”。
—减轻出错的影响:单缓存服务器是系统中的一个潜在单点故障(Single Point Of Failure,SPOF)(如图1-8所示)。在维基百科中,单点故障的定义如下:“单点故障是指系统中的某一部分,如果它出现故障,整个系统就不能工作”。所以,推荐的做法是在不同的数据中心部署多个缓存服务器以避免单点故障。另一个推荐的做法是为缓存超量提供一定比例的内存,这样可以在内存使用量上升时提供一定的缓冲。
驱逐策略:一旦缓存已满,任何对缓存添加条目的请求都有可能导致已有条目被删除,这叫作缓存驱逐。LRU(Least-Recently-Used,最近最少使用)是最流行的缓存驱逐策略。也可以采用其他缓存驱逐策略,比如LFU(Least Frequently Used,最不经常使用)或者FIFO(First In First Out,先进先出),以满足不同的使用场景。
07
内容分发网络(Content Delivery Network,CDN)是由在地理上分散的服务器组成的网络,被用来传输静态内容。CDN中的服务器缓存了像图片、视频、CSS和JavaScript文件这一类的静态内容。
动态内容缓存是一个相对新的概念,不在本书讨论的范围内。它可以基于请求路径、查询字符串、cookie和请求头来缓存HTML页面。感兴趣的读者可以访问ASW的网站以了解更多内容。本书只讲解如何使用CDN缓存静态内容。
现在我们大致介绍一下CDN是如何工作的:当用户访问一个网站时,离用户最近的CDN服务器会返回静态资源。给人的直观感受是,离CDN服务器越远,网站加载内容就越慢。举个例子,如果CDN服务器在旧金山,那么洛杉矶的用户就比欧洲的用户更快获取网站内容。图1-9展示了CDN是如何缩短加载时间的。
图1-10展示了CDN的工作流。
1.用户A尝试通过请求图片的URL去获取image.png。这个URL的域名由CDN服务商提供。亚马逊和Akamai CDN上的图片URL大概是下面这个样子:
—https://mysite.cloudfront.net/logo.jpg
—https://mysite.akamai.com/image-manager/img/logo.jpg
2.如果CDN服务器的缓存中没有image.png,CDN服务器就会向数据源服务器请求这个文件。数据源服务器可以是Web服务器,或者线上存储,比如Amazon S3。
3.数据源服务器将image.png文件返回给CDN服务器,其中包括可选的HTTP头Time-to-Live(TTL,生存时间)。TTL描述了该图片文件应该被缓存多长时间。
4.CDN服务器缓存这个图片并将其返回给用户A。这个图片一直缓存在CDN服务器中,直到TTL到期。
5.用户B发送请求,要求获取这张图片。
6.只要TTL还没到期,CDN服务器的缓存就会返回该图片。
使用CDN时的注意事项
—花销:CDN是由第三方供应商来运营的,对数据在CDN中的进出都会收费。缓存不经常使用的内容,并不能给性能带来显著的好处,应该考虑把这些内容从CDN中移出。
—设置合理的缓存过期时间:对于时间敏感的内容,设置缓存过期时间是很重要的。这个时间不应该过长或过短。如果过长,内容会不够新。如果过短,可能导致频繁地将内容从数据源服务器重新加载至CDN。
—CDN回退:要好好考虑你的网站或应用如何应对CDN故障。如果CDN出现故障暂时无法提供服务,客户端应该有能力发现这个问题,并直接向数据源服务器请求资源。
—作废文件:以下操作均可以在文件过期之前将其从CDN中移除。
调用CDN服务商提供的API来作废CDN对象。
通过对象版本化来提供一个不同版本的对象。可以在URL中添加一个参数,比如版本号,来给一个对象添加版本。比如,在查询字符串中可以加入版本号2(image.png?v=2)。
图1-11展示了加入了CDN和缓存之后的系统设计方案。
1.静态资源(JavaScript代码、CSS文件、图片等)不再由Web服务器提供,而是从CDN中获取,以提高响应速度。
2.数据被缓存后,数据库的负载就减轻了。
08
现在是时候考虑横向扩展网络层了。为此,我们需要将状态(例如,用户会话数据)从网络层中移出。一个好的做法是将会话数据存储在持久性存储(如关系型数据库或NoSQL)中。集群中的每个Web服务器都可以经由数据库访问状态数据。这就是所谓的无状态网络层。
有状态架构
有状态的和无状态的服务器是有一些关键差异的。有状态的服务器处理客户端发来的一个个请求,并记下客户端的数据(状态)。无状态的服务器则不保存状态信息。
图1-12展示了一个有状态架构。
在图1-12所示的架构中,用户A的会话数据和个人资料图片会被存储到服务器1上。为了对用户A进行身份验证,必须将HTTP请求发给服务器1。如果将请求发给其他服务器,比如服务器2,由于服务器2上没有用户A的会话数据,因此身份验证就会失败。同理,用户B的所有HTTP请求必须发给服务器2;用户C的所有请求必须发给服务器3。
现在的问题是,如何将来自同一客户端的所有请求都发给同一个服务器。大部分负载均衡器都提供的黏性会话可以解决这个问题,但是会增加成本。这种方法使得添加或者移除服务器变得更加困难,同时也使得应对服务器故障变得更具挑战性。
无状态架构
图1-13展示了一个无状态架构。
在这个无状态架构中,用户的HTTP请求可以发给任意Web服务器,然后Web服务器从共享的数据存储中拉取数据。状态数据存储在共享数据存储而非Web服务器中。无状态的系统更加简单,更健壮,也更容易扩展。
图1-14展示了加入了无状态网络层后的系统设计。
如图1-14所示,我们把会话数据从网络层中移出,放到持久化存储中保存。共享数据存储可以是关系型数据库或者NoSQL(比如,Memcached、Redis)。选择NoSQL的原因是它容易扩展。自动扩展的意思是,基于网络流量自动地增加或者减少Web服务器。将状态数据从Web服务器中移除后,就很容易实现网络层的自动扩展了。
如果你的网站发展迅速,而且吸引了非常多的国际用户,要提高可用性以及在更广的地理区域提供更好的用户体验,让网站支持多数据中心就非常关键。
09
如果有某个数据中心出现严重的故障,可以把所有的流量转到另一个运转正常的数据中心。在图1-16所示的例子中,数据中心2(美国西部)发生了故障,全部流量被转至数据中心1(美国东部)。
要设置多数据中心,必须先解决如下技术难题:
—流量重定向。要有能把流量引导到正确数据中心的有效工具。geoDNS可以基于用户的地理位置把流量引导到最近的数据中心。
—数据同步。不同地区的用户可以使用不同的本地数据库或者缓存。在故障转移的场景中,流量可能被转到一个数据不可用的数据中心。常用的一个策略是在多个数据中心复制数据。Netflix工程博客上的文章“Active-Active for Multi-Regional Resiliency”说明了Netflix是如何实现多数据中心异步复制的。
—测试和部署:设置多数据中心后,在不同的地点测试你的网站/应用是很重要的。而自动部署工具则对于确保所有数据中心的服务一致性至关重要。
为了进一步扩展我们的系统,我们需要解耦系统中不同的组件,这样它们就可以单独扩展了。在现实世界中,很多分布式系统用消息队列来解决这个问题。
10
消息队列是一个持久化的组件,存储在内存中,支持异步通信。它被用作缓冲区,分配异步的请求。消息队列的基本架构很简单:输入服务(也称为生产者或发布者)创建消息,并把它们发布到消息队列中;其他服务或者服务器(也称为消费者或订阅者)与消息队列连接,并执行消息所定义的操作。这个模型如图1-17所示。
解耦使消息队列成为构建可扩展和可靠应用的首选架构。有了消息队列,当消费者无法处理消息时,生产者依然可以将消息发布到队列中;就算生产者不可用,消费者也可以从队列中读取消息。
考虑以下用例:你的应用支持修改图像,包括裁剪、锐化、模糊化等,这些任务都需要时间来完成。在图1-18中,Web服务器把图像处理的任务发布到消息队列。图像处理进程或服务(Worker)从消息队列中领取这个任务,并异步执行。生产者和消费者都可以独立地扩展。队列的规模变大以后,可以加入更多的Worker,以减少处理时间。如果队列在大部分时间中都是空的,就可以减少Worker的数量。
11
对于一个只有几台服务器的小网站,记录日志、收集指标和自动化只是锦上添花的实践而非必需的工作。但是当网站发展成为大企业提供服务的平台时,这些工作就是必需的了。
记录日志:监控错误日志非常重要,因为它可以帮助识别系统的错误和问题。你可以监控每个服务器的错误日志,也可以用工具把各个服务器的日志汇总到一个中心化的服务中,方便搜索和查看。
收集指标:收集不同类型的指标数据,有助于获得商业洞察力和了解系统的健康状态。
以下几个指标很有用:
—主机级别指标:CPU、内存、磁盘I/O等。
—聚合级别指标:比如整个数据库层的性能,整个缓存层的性能等。
—关键业务指标:每日活跃用户数、留存率、收益等。
自动化:当系统变得庞大且复杂时,就需要创建或者使用自动化工具来提高生产力。持续集成是一个很好的做法。在这种做法中,每次代码检入(check in)都需要通过自动化工具的审核,使团队能及时发现问题。同时,将构建、测试和部署等流程自动化,可以显著提高开发人员的生产力。
添加消息队列和各种工具
图1-19展示了更新后的系统设计,因为图书版面有限,只画了一个数据中心。
1.这个系统中包含一个消息队列,它使系统更加松散地耦合且更容易从故障中恢复。
2.它包含了记录日志、监控和收集指标的功能,以及自动化工具。
随着数据与日俱增,你的数据库过载变得越来越严重。是时候扩展数据层了。
12
数据库的扩展有两种方式:纵向扩展和横向扩展。
纵向扩展
纵向扩展又叫作向上扩展,就是为已有机器增加算力(CPU、内存、硬盘等)。业界有一些非常强劲的数据库服务器。亚马逊的RDS(关系型数据库服务)可以提供拥有24 TB内存的数据库服务器。这种性能强劲的数据库服务器可以存储和处理非常多的数据。举个例子,Stack Overflow的网站在2013年每个月有超过1000万的独立用户访问,但是它只有一个主数据库。然而,纵向扩展也有一些重大缺点:
—尽管可以给数据库服务器添加更多的CPU、内存等,但是硬件的能力总是有上限的。如果网站的用户基数很大,单服务器是不够的。
—更大的单点故障风险。
—总成本很高。强劲的服务器比一般的服务器贵很多。
横向扩展
横向扩展,也叫分片,就是添加更多服务器。图1-20对比了纵向扩展和横向扩展。
数据库分片是指把大数据库拆分成更小、更容易管理的部分(这些部分叫作Shard,分片)。每个Shard共享同样的数据库Schema,但是里面的数据都是这个Shard独有的。
图1-21展示了一个做了分片的数据库。根据用户ID,用户数据被分配到其中一个数据库服务器上。每次要访问数据时,就会用一个哈希函数来找对应的Shard。在我们的例子中,以user_id(用户ID)对4求余作为哈希函数。如果余数为0,那么Shard 0就被用来存储和获取数据;如果余数为1,就用Shard 1,依此类推。
图1-22展示了做过分片的数据库中的用户表示例。
实施分片策略时,要考虑的最重要的问题是选择什么分片键(Sharding Key)。分片键(也叫作分区键,Partition Key)由一个或者多个数据列组成,用来决定将数据分到哪个Shard。在图1-22所示的例子中,user_id被用作分片键。分片键可以把数据库查询路由到正确的数据库,使你高效地检索和修改数据。在选择分片键时,最重要的标准之一是选择一个可以让数据均匀分布的键。
分片是一种不错的扩展数据库的技术,但它还远不是一个完美的解决方案。它为系统引入了复杂性和新的挑战。
重分片数据:出现如下情况时,需要对数据重新分片。第一种是因为数据快速增长,单个Shard无法存储更多的数据。第二种是因为数据的分布不均匀,有些Shard的空间可能比其他的更快耗尽。当Shard被耗尽时,就需要更新用于分片的哈希函数,然后把数据移到别的地方去。我们会在第5章介绍一致性哈希算法,它是解决这个问题的常用技术。
名人问题:也叫作热点键问题。过多访问一个特定的Shard可能造成服务器过载。想象一下,把Katy Perry、Justin Bieber和Lady Gaga的数据都放在同一个Shard里,对于社交应用而言,这个Shard会因读操作太多而不堪重负。为了解决这个问题,我们可能需要为每个名人都分配一个Shard,而且每个Shard可能还需要进一步分区。
连接和去规范化(de-normalization):一旦数据库通过分片被划分到多个服务器上,就很难跨数据库分片执行连接(join)操作了。解决这个问题的常用方法就是对数据库去规范化,把数据冗余存储到多张表中,以便查询可以在一张表中执行。
在图1-23中,我们对数据库做了分片,以支持数据流量的快速增长;同时,将有些非关系型功能迁移到NoSQL数据库中,以降低数据库的负载。High Scalability网站上有一篇文章“What the Heck are You Actually Using NoSQL for?”介绍了很多NoSQL数据库的使用案例。
13
系统的扩展是一个迭代的过程,在本章所述内容的基础上继续迭代,可以帮我们走得更远。当网站或应用的用户数量超过100万时,就需要进行更多的调整和采用新的策略来扩展网站。比如,你可能需要优化系统,并把它解耦成更小的服务。本章所介绍的技术为你应对新挑战奠定了很好的基础。下面列出扩展系统以支持百万量级用户的几个技术要点,作为本章的总结:
—让网络层无状态。
—每一层都要有冗余。
—尽量多缓存数据。
—支持多个数据中心。
—用CDN来承载静态资源。
—通过分片来扩展数据层。
—把不同架构层分成不同的服务。
—监控你的系统并使用自动化工具。
恭喜你已经看到这里了。给自己一些鼓励。干得不错!
《搞定系统设计:面试敲开大厂的门》这本书目标是提供一个可靠的策略,帮助面试者回答系统设计问题。采取正确的策略且具备必要的知识,对面试的成功至关重要。