系统设计:从零用户扩展到百万用户
Posted powerai
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了系统设计:从零用户扩展到百万用户相关的知识,希望对你有一定的参考价值。
设计一个支持百万用户的系统是具有挑战性的,这是一段需要不断改进和不断提升的旅程。在本章中,我们将构建一个支持单个用户的系统,并逐渐扩展以服务于数百万用户。阅读本章后,您将掌握一些技巧,帮助您解决系统设计面试问题。
AI不会取代你,使用AI的人会。欢迎关注我的公众号:更AI。以程序员的视角来看AI能带给我们什么~
单服务器设置
千里之行始于足下,构建一个复杂的系统也是如此。为了从简单的东西开始,我们将所有内容都运行在一个单独的服务器上。图1显示了一个单服务器设置的示意图,其中所有内容都在一个服务器上运行:Web应用程序、数据库、缓存等。
为了理解这个设置,有助于调查请求流程和流量来源。让我们首先看一下请求流程(图1-2)。
- 用户通过域名访问网站,例如api.mysite.com。通常,域名系统(DNS)是由第三方提供的付费服务,而不是由我们的服务器托管。
- Internet协议(IP)地址返回给浏览器或移动应用。在本例中,返回的IP地址是15.125.23.214。
- 一旦获得IP地址,超文本传输协议(HTTP)[1]请求将直接发送到您的Web服务器。
- Web服务器返回HTML页面或JSON响应进行渲染。
接下来,让我们来看一下流量来源。流向您的Web服务器的流量来自两个来源:Web应用程序和移动应用程序。
- Web应用程序:它使用一组服务器端语言(Java、Python等)来处理业务逻辑、存储等,以及客户端语言(HTML和JavaScript)来进行展示。
- 移动应用程序:HTTP协议是移动应用程序与Web服务器之间的通信协议。由于其简单性,JavaScript对象表示法(JSON)通常用于传输数据的API响应格式。下面是一个以JSON格式显示的API响应示例:
GET /users/12 – 检索id为12的用户对象
数据库
随着用户群体的增长,一个服务器已经不够用了,我们需要多台服务器:一台用于处理网站和移动端的流量,另一台用于数据库(图1-3)。将网站和移动端流量(Web层)与数据库(数据层)服务器分离,可以使它们能够独立扩展。
使用哪种数据库?
您可以选择传统关系型数据库或非关系型数据库。让我们来看看它们的区别。
关系型数据库也被称为关系数据库管理系统(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服务器的负载限制,用户通常会遇到响应变慢或无法连接到服务器的问题。负载均衡器是解决这些问题的最佳技术。
负载均衡器
负载均衡器会将传入的流量均匀分配给在负载均衡集合中定义的Web服务器。图1-4展示了负载均衡器的工作原理。
如图1-4所示,用户直接连接负载均衡器的公共IP。通过这种设置,Web服务器不再能直接被客户端访问。为了更好的安全性,私有IP用于服务器之间的通信。私有IP是一个只能在同一网络中的服务器之间访问的IP地址,无法通过互联网访问。负载均衡器通过私有IP与Web服务器进行通信。
在图1-4中,当负载均衡器和第二个Web服务器添加后,我们成功解决了故障切换的问题,并提高了Web层的可用性。具体细节如下:
- 如果服务器1下线,所有流量将被路由到服务器2。这样可以防止网站宕机。我们还可以向服务器池中添加一个新的健康Web服务器来平衡负载。
- 如果网站流量迅速增长,两个服务器无法处理流量,负载均衡器可以优雅地解决这个问题。您只需要向Web服务器池添加更多服务器,负载均衡器将自动开始将请求发送给它们。
现在Web层看起来很好,那数据层呢?当前的设计只有一个数据库,因此不支持故障转移和冗余。数据库复制是解决这些问题的常见技术。让我们来看一下。
数据库复制
引用自维基百科:“数据库复制可以在许多数据库管理系统中使用,通常在原始数据库(主数据库)和副本(从数据库)之间建立主/从关系。” [3]
主数据库通常仅支持写操作。从数据库从主数据库获取数据的副本,仅支持读操作。所有的插入、删除或更新等修改数据的命令必须发送到主数据库。大多数应用程序需要更高比例的读操作与写操作,因此系统中从数据库的数量通常大于主数据库的数量。图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:
使用缓存的注意事项
以下是使用缓存系统时应考虑的几个问题:
-
决定何时使用缓存。在数据经常被读取但很少被修改时,考虑使用缓存。由于缓存数据存储在易失性内存中,缓存服务器不适合用于持久化数据。例如,如果缓存服务器重新启动,内存中的所有数据都会丢失。因此,重要的数据应保存在持久化数据存储中。
-
过期策略。实施过期策略是一个好的做法。一旦缓存数据过期,它将从缓存中删除。当没有过期策略时,缓存数据将永久存储在内存中。建议不要将过期日期设置得太短,否则系统会过于频繁地从数据库重新加载数据。同时,也不建议将过期日期设置得太长,以免数据变得陈旧。
-
一致性:这涉及保持数据存储和缓存的同步。由于数据存储和缓存上的数据修改操作不在单个事务中,所以可能发生不一致。在跨多个地区进行扩展时,保持数据存储和缓存之间的一致性是具有挑战性的。有关详细信息,请参考Facebook发表的题为《Scaling Memcache at Facebook》的论文[7]。
-
减轻故障:单个缓存服务器代表潜在的单点故障(SPOF),维基百科对其的定义如下:“单点故障(SPOF)是系统的一部分,如果它发生故障,将导致整个系统停止工作”[8]。因此,建议在不同的数据中心中使用多个缓存服务器,以避免单点故障。另一个推荐的方法是通过一定百分比进行过量配置所需的内存。这样可以提供一个缓冲区,以应对内存使用量增加的情况。
-
淘汰策略:一旦缓存已满,任何向缓存中添加项的请求都可能导致现有项被移除。这被称为缓存淘汰。最近最少使用(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是用来演示Amazon和Akamai CDN上图像URL的样例:
- 如果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层的时候了。为此,我们需要将状态(例如用户会话数据)从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、DISK等)来进行扩展。有一些功能强大的数据库服务器。根据亚马逊关系数据库服务(RDS)[12],你可以获得一台具有24 TB RAM的数据库服务器。这种强大的数据库服务器可以存储和处理大量的数据。例如,2013年的stackoverflow.com每月有超过1000万的独立访问者,但它只有1个主数据库[13]。然而,垂直扩展也存在一些严重的缺点:
- 你可以为数据库服务器添加更多的CPU、RAM等,但存在硬件限制。如果你有大量的用户,单个服务器是不够的。
- 单点故障的风险增加。
- 垂直扩展的总体成本较高。强大的服务器更加昂贵。
水平扩展
也称为分片,是添加更多服务器的做法。图1-20比较了垂直扩展和水平扩展。
分片将大型数据库分割成更小、更易管理的部分,称为分片。每个分片共享相同的模式,尽管每个分片上的实际数据是唯一的。
图1-21展示了分片数据库的示例。用户数据根据用户ID分配到数据库服务器上。每当你访问数据时,都会使用散列函数来找到相应的分片。在我们的示例中,user_id % 4被用作散列函数。如果结果等于0,则使用分片0来存储和获取数据。如果结果等于1,则使用分片1。其他分片的逻辑也是相同的。
图1-22展示了分片数据库中的用户表。
在实施分片策略时,最重要的因素是选择分片键。分片键(也称为分区键)由一个或多个列组成,用于确定数据的分布方式。如图1-22所示,“user_id”是分片键。分片键允许你通过将数据库查询路由到正确的数据库来高效地检索和修改数据。在选择分片键时,最重要的一个标准是选择一个能够均匀分布数据的键。
分片是扩展数据库的一种很好的技术,但远非完美的解决方案。它给系统引入了复杂性和新的挑战:
重新分片数据:当1)单个分片由于快速增长而无法再容纳更多数据时,需要重新分片数据。2)某些分片可能由于不均匀的数据分布而更快地耗尽分片。当分片耗尽时,需要更新分片函数并移动数据。一种常用的解决此问题的技术是一致性哈希,将在第5章中讨论。
热点键问题:也称为明星问题。对特定分片的过度访问可能导致服务器超载。想象一下,Katy Perry、Justin Bieber和Lady Gaga的数据都最终存储在同一个分片上。对于社交应用来说,该分片将被读操作淹没。为了解决这个问题,我们可能需要为每个名人分配一个分片。甚至每个分片可能还需要进一步分区。
连接和去规范化:一旦数据库被分片到多个服务器上,执行跨数据库分片的连接操作就变得困难。一个常见的解决方法是对数据库进行去规范化,以便可以在单个表中执行查询。
在图1-23中,我们对数据库进行分片以支持快速增长的数据流量。与此同时,一些非关系型功能被移到NoSQL数据存储中,以减轻数据库负载。这是一篇涵盖了NoSQL许多使用案例的文章[14]。
超过数百万用户的规模
系统的扩展是一个迭代的过程。根据本章学到的知识进行迭代可能会使我们走得更远。为了超越数百万用户,需要更多的优化和新策略。例如,您可能需要优化您的系统并将系统解耦为更小的服务。本章学到的所有技术应该为应对新的挑战提供了良好的基础。为了总结本章,我们提供了如何扩展我们的系统以支持数百万用户的摘要:
- 保持 Web 层无状态
- 在每个层面上构建冗余性
- 尽可能地缓存数据
- 支持多个数据中心
- 在 CDN 中托管静态资源
- 通过分片扩展数据层
- 将层级拆分为独立的服务
- 监控您的系统并使用自动化工具
恭喜您取得了如此大的进展!现在给自己一个鼓励。做得好!
AI不会取代你,使用AI的人会。欢迎关注我的公众号:更AI。以程序员的视角来看AI能带给我们什么~
参考资料
[1] 超文本传输协议(Hypertext Transfer Protocol): https://zh.wikipedia.org/wiki/超文本传输协议
[2] 你是否应该超越关系型数据库?:
https://blog.teamtreehouse.com/should-you-go-beyond-relational-databases
[3] 复制(Replication): https://zh.wikipedia.org/wiki/复制_(计算机)
[4] 多主复制(Multi-master replication):
https://zh.wikipedia.org/wiki/多主复制
[5] NDB 集群复制:多主和环形复制:
https://dev.mysql.com/doc/refman/5.7/en/mysql-cluster-replication-multi-master.html
[6] 缓存策略及如何选择合适的策略:
https://codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/
[7] R. Nishtala,“Facebook 的缓存扩展”,第十届 USENIX 网络系统设计与实现研讨会(NSDI \'13)。
[8] 单点故障(Single point of failure): https://zh.wikipedia.org/wiki/单点故障
[9] Amazon CloudFront 动态内容传送:
https://aws.amazon.com/cloudfront/dynamic-content/
[10] 配置经典负载均衡器的黏性会话:
https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-sticky-sessions.html
[11] 多区域容错性的主动-主动(Active-Active):
https://netflixtechblog.com/active-active-for-multi-regional-resiliency-c47719f6685b
[12] Amazon EC2 高内存实例:
https://aws.amazon.com/ec2/instance-types/high-memory/
[13] 运行 Stack Overflow 的所需条件:
http://nickcraver.com/blog/2013/11/22/what-it-takes-to-run-stack-overflow
[14] 你到底在使用 NoSQL 做什么:
http://highscalability.com/blog/2010/12/6/what-the-heck-are-you-actually-using-nosql-for.html
本文翻译自《System Design Interview: An Insider’s Guide》第一章,如有侵权,请联系本人删除
爬取百万数据的采集系统从零到整的过程
目录
前言:记录下在上家公司负责过的一个采集系统从零到整的过程,包括需求,分析,设计,实现,遇到的问题及系统的成效,系统最主要功能就是可以通过对每个网站进行不同的采集规则配置对每个网站爬取数据,目前系统运行稳定,已爬取的数据量大概在600-700万之间(算上一些历史数据,应该也有到千万级了),每天采集的数据增量在一万左右,配置采集的网站1200多个,这个系统其实并不大,但是作为主要的coding人员(基本整个系统的百分之八十的编码都是我写的),大概记录一下系统的实现,捡主要的内容分享下,最后在提供一些简单的爬虫demo供大家学习下
需求
数据采集系统:一个可以通过配置规则采集不同网站的系统
主要实现目标:
- 针对不同的网站通过配置不同的采集规则实现网页数据的爬取
- 针对每篇内容可以实现对特征数据的提取
- 定时去爬取所有网站的数据
- 采集配置规则可维护
- 采集入库数据可维护
分析
第一步当然要先分析需求,所以在抽取一下系统的主要需求:
- 针对不同的网站可以通过不同的采集规则实现数据的爬取
- 针对每篇内容可以实现对特征数据的提取,特征数据就是指标题,作者,发布时间这种信息
- 定时任务关联任务或者任务组去爬取网站的数据
再分析一下网站的结构,无非就是两种;
一个是列表页,这里的列表页代表的就是那种需要在当前页面获取到更多别的详情页的网页链接,像一般的查询列表,可以通过列表获取到更多的详情页链接。
一个是详情页,这种就比较好理解,这种页面不需要在这个页面再去获得别的网页链接了,直接在当前页面就可以提取数据。
基本所有爬取的网站都可以抽象成这样。
设计
针对分析的结果设计实现:
- 任务表
- 每个网站可以当做一个任务,去执行采集
- 每个网站可以当做一个任务,去执行采集
- 两张规则表
- 每个网站对应自己的采集规则,根据上面分析的网站结构,采集规则又可以细分为两个表,一个是包含网站链接,获取详情页列表的列表采集规则表,一个针对是网站详情页的特征数据采集的规则表 详情采集规则表
- url表
- 负责记录采集目标网站详情页的url
- 定时任务表
- 根据定时任务去定时执行某些任务 (可以采用定时任务和多个任务进行关联,也可以考虑新增一个任务组表,定时任务跟任务组关联,任务组跟任务关联)
- 数据存储表
- 这个由于我们采集的数据主要是招标和中标两种数据,分别建了两张表进行数据存储,中标信息表,招标信息表
实现
框架
基础架构就是:ssm+redis+htmlunit+jsoup+es+mq+quartz
java中可以实现爬虫的框架有很多,htmlunit,WebMagic,jsoup等等还有很多优秀的开源框架,当然httpclient也可以实现。
为什么用htmlunit?
htmlunit 是一款开源的java 页面分析工具,读取页面后,可以有效的使用htmlunit分析页面上的内容。项目可以模拟浏览器运行,被誉为java浏览器的开源实现
简单说下我对htmlunit的理解:
- 一个是htmlunit提供了通过xpath去定位页面元素的功能,利用xpath就可以实现对页面特征数据进行提取;
- 第二个就在于对js的支持,支持js意味着你真的可以把它当做一个浏览器,你可以用它模拟点击,输入,登录等操作,而且对于采集而言,支持js就可以解决页面使用ajax获取数据的问题
- 当然除此之外,htmlunit还支持代理ip,https,通过配置可以实现模拟谷歌,火狐等浏览器,Referer,user-agent,是否加载js,css,是否支持ajax等。
XPath语法即为XML路径语言(XML Path Language),它是一种用来确定XML文档中某部分位置的语言。
为什么用jsoup?
jsoup相较于htmlunit,就在于它提供了一种类似于jquery选择器的定位页面元素的功能,两者可以互补使用。
采集
采集数据逻辑分为两个部分:url采集器,详情页采集器
url采集器:
- 只负责采集目标网站的详情页url
详情页采集器:
- 根据url去采集目标url的详情页数据
使用htmlunit的xpath,jsoup的select语法,和正则表达式进行特征数据的采集。
这样设计目的主要是将url采集和详情页的采集流程分开,后续如果需要拆分服务的话就可以将url采集和详情页的采集分成两个服务。
url采集器与详情页采集器之间使用mq进行交互,url采集器采集到url做完处理之后把消息冷到mq队列,详情页采集器去获取数据进行详情页数据的采集。
遇到的问题
数据去重:
- 在采集url的时候进行去重
- 同过url进行去重,通过在redis存储key为url,缓存时间为3天,这种方式是为了防止对同一个url进行重复采集。
- 通过标题进行去重,通过在redis中存储key为采集到的标题 ,缓存时间为3天,这种方式就是为了防止一篇文章被不同网站发布,重复采集情况的发生。
数据质量:
由于每个网站的页面都不一样,尤其是有的同一个网站的详情页结构也不一样,这样就给特征数据的提取增加了难度,所以使用了htmlunit+jsoup+正则三种方式结合使用去采集特征数据。
采集效率:
由于采集的网站较多,假设每个任务的执行都打开一个列表页,十个详情页,那一千个任务一次执行就需要采集11000个页面,所以采用url与详情页分开采集,通过mq实现异步操作,url和详情页的采集通过多线程实现。
被封ip:
对于一个网站,假设每半小时执行一次,那每天就会对网站进行48次的扫描,也是假设一次采集会打开11个页面,一天也是528次,所以被封是一个很常见的问题。解决办法,htmlunit提供了代理ip的实现,使用代理ip就可以解决被封ip的问题,代理ip的来源:一个是现在网上有很多卖代理ip的网站,可以直接去买他们的代理ip,另一种就是爬,这些卖代理ip的网站都提供了一些免费的代理ip,可以将这些ip都爬回来,然后使用httpclient或者别的方式去验证一下代理ip的可用性,如果可以就直接入库,构建一个自己的代理ip库,由于代理ip具有时效性,所以可以建个定时任务去刷这个ip库,将无效ip剔除。
网站失效:
网站失效也有两种,一种是网站该域名了,原网址直接打不开,第二种就是网站改版,原来配置的所有规则都失效了,无法采集到有效数据。针对这个问题的解决办法就是每天发送采集数据和日志的邮件提醒,将那些没采到数据和没打开网页的数据汇总,以邮件的方式发送给相关人员。
验证码:
当时对一个网站采集历史数据采集,方式也是先通过他们的列表页去采集详情页,采集了几十万的数据之后发现,这个网站采不到数据了,看页面之后发现在列表页加了一个验证码,这个验证码还是属于比较简单的就数字加字母,当时就想列表页加验证码?,然后想解决办法吧,搜到了一个开源的orc文字识别项目tess4j(怎么使用可以看这),用了一下还可以,识别率在百分之二十左右,因为htmlunit可以模拟在浏览器的操作,所以在代码中的操作就是先通过htmlunit的xpath获取到验证码元素,获取到验证码图片,然后利用tess4j进行验证码识别,之后将识别的验证码在填入到验证码的输入框,点击翻页,如果验证码通过就翻页进行后续采集,如果失败就重复上述识别验证码操作,知道成功为止,将验证码输入到输入框和点击翻页都可用htmlunit去实现
ajax加载数据:
有些网站使用的是ajax加载数据,这种网站在使用htmlunit采集的时候需要在获取到HtmlPage对象之后给页面一个加载ajax的时间,之后就可以通过HtmlPage拿到ajax加载之后的数据。
- 代码:webClient.waitForBackgroundJavaScript(time); 可以看后面提供的demo
demo
一个简略爬虫的代码实现:
@GetMapping("/getData")
public List<String> article_(String url,String xpath){
WebClient webClient = WebClientUtils.getWebClientLoadJs();
List<String> datas = new ArrayList<>();
try {
HtmlPage page = webClient.getPage(url);
if(page!=null){
List<?> lists = page.getByXPath(xpath);
lists.stream().forEach(i->{
DomNode domNode = (DomNode)i;
datas.add(domNode.asText());
});
}
}catch (Exception e){
e.printStackTrace();
}finally {
webClient.close();
}
return datas;
}
上面的代码就实现了采集一个列表页
- url就是目标网址
- xpath就要采集的数据的xpath了
爬一下博客园
请求这个url:http://localhost:9001/getData?url=https://www.cnblogs.com/&xpath=//*[@id="post_list"]/div/div[2]/h3/a
- url:传的是博客园首页的地址;
- xpath:传的是获取博客园首页的博客列表的标题
网页页面:
采集回的数据:
再爬一下csnd
再次请求:http://localhost:9001/getData?url=https://blog.csdn.net/&xpath=//*[@id="feedlist_id"]/li/div/div[1]/h2/a
- url:这次传是csnd的首页;
- xpath:传的是获取csdn首页的博客列表的标题
网页页面:
采集回的数据:
采集步骤
通过一个方法去采集两个网站,通过不同url和xpath规则去采集不同的网站,这个demo展示的就是htmlunit采集数据的过程。
每个采集任务都是执行相同的步骤
- 获取client -> 打开页面 -> 提取特征数据(或详情页链接) -> 关闭cline
不同的地方就在于提取特征数据
优化:利用模板方法设计模式,将功能部分抽取出来
上述代码可以抽取为:一个采集执行者,一个自定义采集数据的实现
/**
* @Description: 执行者 man
* @author: chenmingyu
* @date: 2018/6/24 17:29
*/
public class Crawler {
private Gatherer gatherer;
public Object execute(String url,Long time){
// 获取 webClient对象
WebClient webClient = WebClientUtils.getWebClientLoadJs();
try {
HtmlPage page = webClient.getPage(url);
if(null != time){
webClient.waitForBackgroundJavaScript(time);
}
return gatherer.crawl(page);
}catch (Exception e){
e.printStackTrace();
}finally {
webClient.close();
}
return null;
}
public Crawler(Gatherer gatherer) {
this.gatherer = gatherer;
}
}
在Crawler 中注入一个接口,这个接口只有一个方法crawl(),不同的实现类去实现这个接口,然后自定义取特征数据的实现
/**
* @Description: 自定义实现
* @author: chenmingyu
* @date: 2018/6/24 17:36
*/
public interface Gatherer {
Object crawl(HtmlPage page) throws Exception;
}
优化后的代码:
@GetMapping("/getData")
public List<String> article_(String url,String xpath){
Gatherer gatherer = (page)->{
List<String> datas = new ArrayList<>();
List<?> lists = page.getByXPath(xpath);
lists.stream().forEach(i->{
DomNode domNode = (DomNode)i;
datas.add(domNode.asText());
});
return datas;
};
Crawler crawler = new Crawler(gatherer);
List<String> datas = (List<String>)crawler.execute(url,null);
return datas;
}
不同的实现,只需要去修改接口实现的这部分就可以了
数据
最后看一下利用采集系统采集的数据。
效果
效果还是不错的,最主要是系统运行稳定:
- 采集的历史数据在600-700万量级之间
- 每天新采集的数据增量在一万左右
- 系统目前配置了大约1200多个任务(一次定时的实现会去采集这些网站)
数据
系统配置采集的网站主要针对全国各省市县招投标网站(目前大约配置了1200多个采集站点)的标讯信息。
采集的数据主要做公司标讯的数据中心,为一个pc端网站和2微信个公众号提供数据
- 网址:http://www.bid-data.com
- 公众号:爱招标,中标喽(欢迎关注,掌握一手标讯信息)
以pc端展示的一篇采集的中标的数据为例,看下采集效果:
- http://www.bid-data.com/bid_MQKHG001TD6.html
采集的详情:
特征数据的提取:
本文只是大概记录下这个采集系统从零到整的过程,当然其中还遇到了很多本文没提到的问题。
欢迎转载,转载请保留出处 陈明羽:https://www.cnblogs.com/cmyxn/p/9376256.html
关注关注我的公众号啊
以上是关于系统设计:从零用户扩展到百万用户的主要内容,如果未能解决你的问题,请参考以下文章