首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

我是如何解决一个基于GraphQL网站的扩展性问题?

Vocal

Vocal是一个博客平台,内容广泛,包罗万象,甚至还有很多猫猫狗狗。

在Vocal发布内容,作者可以获得报酬。页面的每一次点击都会给作者一点酬劳,并且,作者还能接受其他用户的捐款。一些专业人士用这个平台展示自己的作品,但对大多数用户来说,这只是一个有趣的爱好,正好顺便赚些零花钱。

据悉,Vocal的母公司是Jerrick Media,而网站的开发工作是与悉尼一家名为Thinkmill的公司合作的。

背景

Thinkmill使用Next.js(基于React的Web框架)构建了一个网站,并与Keystone在MongoDB上提供的GraphQL API通信。Keystone是基于GraphQL的无头CMS库:你可以在JavaScript中定义一个schema,将其连接到数据存储,并获取自动生成的GraphQL API以访问数据。这是一个免费的开源软件项目,由Thinkmill提供商业支持。

Vocal V2

Vocal的初版赢得了关注。它吸引到喜欢自己的用户群,并且不断壮大,最后Jerrick Media请Thinkmill帮助开发V2版,并于去年9月成功发布。

V2版本主要涉及用户界面和功能变更,本文就不提这些内容了。而我所做的事情是:让新站点更加健壮,更具扩展能力。

数据库迁移

Thinkmill在使用MongoDB for Vocal时遇到一些可扩展性问题,因此决定将Keystone升级到V5版,以利用新版加入的Postgres支持。

如果你在技术领域从业时间较长,可能还记得00年代末的“NoSQL"风潮。当时的宣传:诸如Postgres之类的关系型(SQL)数据库的可扩展性不如MongoDB这样的“webscale”NoSQL数据库。

从技术上来说,这是正确的,但是NoSQL数据库的可扩展性来自各种可以有效处理的查询。简单的非关系型数据库(例如文档和键值数据库)有自己的用武之地,但是当用作应用的通用后端时,应用通常在超出关系型数据库理论上的扩展上限前就达到了数据库的查询能力极限。

Vocal的大多数数据库查询在MongoDB上都没什么问题,但是随着时间的流逝,越来越多的查询需要手动调整才能正常工作。

在技​​术需求方面,Vocal与Wikipedia很像,后者是世界上最大的网站之一。Wikipedia使用的是MySQL(或更确切地说是它的分叉,MariaDB)。当然,需要一些关键的工程设计才能适配Vocal的场景,但我认为在可预见的将来,关系型数据库不会成为Vocal扩展道路上的绊脚石。

我曾查过一个数据,托管的AWS RDS Postgres实例的成本不到旧的MongoDB实例的五分之一,但Postgres实例的CPU使用率仍低于10%,流量却比旧站点更多。这主要是由于一些重要的查询在文档数据库架构下向来效率低下所致。

迁移工作又是一个大话题,不过,基本上来说,一位Thinkmill开发人员使用MoSQL构建了一个ETL管道来完成繁重的任务。由于Keystone是一个FOSS项目,因此我也能够为其GraphQL到SQL的映射贡献一些性能改进。

对于这类问题,我推荐大家参考Markus Winand的SQL博客:使用Index LukeModern SQL。他的文笔很友好,非专业人士也能看得懂,同时提供了编写快速高效的SQL所需的大部分理论知识。剩下的理论知识,你可以找一本不错的数据库性能主题的教程来学习。

平台

架构

Vocal的V1版是几个Node.js应用的集合,它们运行在作为CDN的Cloudflare后面一个虚拟私有服务器(VPS)上。我推崇避免过度设计的理念,因此很喜欢这种架构。但到了V2版的开发工作开始时,很明显Vocal的流量已经不是这种简单架构能承受了的。处理高峰期的大量流量时,它给Thinkmiller的开发人员留的余地太少了,并且难以在线上安全部署更新。

下面是V2版的新架构:

Vocal V2的架构。请求通过CDN到达AWS中的一个负载均衡器。负载均衡器将流量分配给两个应用,“Platform”和“Website”。“Platform”是一个Keystone应用,负责在Redis和Postgres中存储数据。

基本上,两个Node.js应用被复制并放在一个负载均衡器后面,就这么简单。在SRE工作生涯中,我经常见到有工程师设想出比这个复杂很多的可扩展架构,但是我遇到过比Vocal规模大几个数量级的站点,这些站点仍然只是在数据库后端的负载平衡器之后复制服务。认真思考下,如果平台架构需要随着站点的成长而变得愈加复杂,那么它的可扩展性就不是很高。

提升网站可扩展性的重点是解决许多阻碍扩展的实现细节。

如果流量继续增长下去,Vocal的架构可能需要添加一些内容,但让它变得更加复杂的主要原因是新功能。例如,如果(出于某种原因)将来Vocal需要处理实时地理空间数据,那会是和博客帖子需求完全不同的技术怪兽,那时候应该就会有很多架构更改了。

大站点架构中的大多数复杂性来源于功能的复杂性。

如果你不知道如何让你的架构可扩展,我的建议是让它尽可能简单明了。修复非常简单的架构要比修复非常复杂的架构容易得多,也便宜得多。另外,过于复杂的架构更易出错,并且这些错误也更难以调试。

顺便说一句,Vocal碰巧被分成两个应用,但这并不重要。一个常见的扩展误区是,以可扩展性的名义过早将应用拆分为一些较小的服务,但拆分应用的位置选错了,从而导致了更多的可扩展性问题。Vocal作为单体应用扩展起来是没问题的,不过拆分的位置选得也挺好。

基础设施

Thinkmill有一些开发人员拥有AWS的使用经验,但它主要是一家开发公司,所以在部署新版时需要“搭把手”。我最终在AWS Fargate上部署了新版Vocal,这是Elastic Container Service(ECS)一个相对较新的后端。

在过去,许多人希望ECS成为一种简单的“将Docker容器作为托管服务运行”的产品,结果发现他们还是要构建和管理自己的服务器集群,于是大失所望。借助ECS Fargate,AWS可以管理集群。它支持运行Docker容器,并具有一些好用的基本功能,例如复制、运行状况检查、滚动更新、自动缩放和简单警报等。

一个很好的替代选项是像App Engine或Heroku这样的托管平台即服务(PaaS)。Thinkmill已经将它们用在一些简单的项目上,但其他项目需要更大的灵活性,所以还不能用它们。一些很大的站点也运行在PaaS上,但是Vocal规模已经大到了自定义云部署足够经济的程度。

另一个明显的选项是Kubernetes。Kubernetes比ECS Fargate的功能更多,但价格也昂贵得多——包括资源开销和维护所需的人员(例如常规节点升级)都更贵。一般来说,我不建议在没有DevOps专职人员的环境下使用Kubernetes。Fargate具有Vocal所需的功能,并让Thinkmill和Jerrick Media专注于网站改进工作,不用操心基础设施。

还有一个选项是“无服务器”函数产品,例如AWS Lambda或Google Cloud Functions。它们非常适合处理很少或毫无规律的流量,但是(正如我将解释的那样)ECS Fargate的自动缩放功能足以满足Vocal的后端需求。这些产品的另一个优点是,它们使开发人员可以在云环境中部署事物,而无需了解很多有关云环境的知识。代价是无服务器产品与开发过程以及测试和调试过程紧密耦合。Thinkmill内部已经拥有足够的AWS专业知识来管理Fargate部署,并且只要知道如何制作Node.js Express HelloWorld应用的开发人员就可以应对Vocal的开发工作,而无需了解有关无服务器函数或Fargate的任何知识。

ECS Fargate的明显缺点是供应商锁定。但是,避免供应商锁定就像避免停机一样是一种折衷。如果你担心迁移成本,那么花在平台独立性上的成本比迁移成本还多的话就没意义了。Vocal中特定于Fargate的代码总数少于500行。

最重要的是,Vocal应用代码本身与平台无关。它可以在普通的开发人员机器上运行,然后打包到一个Docker容器中,之后在几乎所有可以支持Docker容器的地方运行,包括ECS Fargate。

Fargate的另一个缺点是设置并不简单。像AWS中的大多数事物一样,它涉及VPC、子网、IAM策略等概念。所幸这类事物是相当静态的(不同于需要维护的服务器集群)。

构建可扩展的应用

如果你想轻松地运行规模巨大的应用,就有很多事情要做。遵循应用设计的十二要素原则是基础,这里不再赘述。

如果员工无法扩展运营的能力,那就没有必要构建“可扩展”的系统了,这就像将喷气发动机安装在独轮车上一样。让Vocal具备可扩展性的关键环节是设置诸如CI/CD和基础架构即代码之类的事物。同样,一些部署理念会让生产与开发环境大相径庭,所以不值得采用它们。生产与开发间的每一个差异都会减慢应用的开发速度,并可能导致错误。

缓存

缓存是一个很大的主题。我之前的一个演讲单挑出HTTP缓存讲了一下,但这还不够。本文中我会着重围绕GraphQL来展开。

首先,一歌重要的警告:每当遇到性能问题时,你可能会想:“是否可以把这个值放入缓存以供将来重用,从而提升性能?”微基准测试几乎总是会给你肯定的答案。但是,由于缓存一致性之类的问题,滥用缓存会让你的整个系统变得更慢。下面是我使用缓存前要思考一遍的问题清单:

  • 问问自己是否真的需要通过缓存解决性能问题
  • 再好好思考一遍(非缓存的性能调优技术往往更稳健)
  • 问问自己是否可以改善现有缓存来解决问题
  • 如果其他所有方法均失败,大概就可以添加新的缓存了

HTTP缓存系统是一直都在的,进而我们知道,在添加额外的缓存前应该设法充分利用HTTP缓存。

另一个很常见的陷阱是使用哈希映射或应用内部的某些内容进行缓存。它在本地开发中效果很好,但在大规模扩展时表现不佳。最好的办法是使用一个显式缓存库,要支持Redis或Memcached之类的可插入后端。

基础

HTTP规范中有两种类型的缓存:私有缓存和公用缓存。私有缓存是指不与多个用户共享数据的缓存,实际上是用户的浏览器缓存。剩下的就是公共缓存,其中包括你所控制的服务器(例如CDN或Varnish或Nginx之类的服务器)和非托管服务器(代理)。在当今的HTTPS世界中,代理缓存很少见,但在某些公司网络中也能见到。

缓存查找键通常基于URL,因此如果坚持使用“相同内容,相同URL;不同内容,不同URL”规则,缓存就不是什么大问题。换句话说,为每个页面提供一个canonical URL,并预防“聪明”的技巧从一个URL返回不同的内容。显然,这对GraphQL API端点有影响。

你的服务器可以使用自定义配置,但是配置HTTP缓存的主要方法是在Web响应上设置HTTP标头。最重要的标头是cache-control。以下内容表示,该行下的所有缓存可能将页面缓存最多3600秒(一小时):

代码语言:javascript
复制
cache-control: max-age=3600, public

对于用户特定的页面(例如用户设置页面),重要的是使用private换掉public,以告知公共缓存不要存储响应,并将其提供给其他用户。 另一个常见的标头是vary。这会告诉缓存响应是基于URL以外的因素而变化的。(它将HTTP标头添加到URL旁的缓存键中)这是一个非常笨的工具,所以我建议尽量改用良好的URL结构,但它的一个重要用例是告诉浏览器响应依赖登录cookie,以便它们在登录/注销时更新页面。

代码语言:javascript
复制
vary: cookie

如果页面可能会根据登录状态而变化,则你甚至在已注销的公共版本上也需要cahce-control: private(和vary: cookie),以确保响应不会混淆。

其他有用的标头包括etag和last-modified,这里就不介绍了。你可能还会见到一些旧的标头,例如expires和pragma: cache。早在1997年,HTTP/1.1就弃用它们了。

客户端标头

鲜为人知的是,HTTP规范允许在客户端请求中使用cache-control,以减少缓存时间并获得更新鲜的响应。

幸的是,浏览器似乎并未广泛支持大于0的max-age,但如果你有时在更新后需要一个新的响应,则no-cache会很有用。

HTTP缓存和GraphQL

如上所述,常规缓存键是URL。但GraphQL API通常只使用一个端点(我们称其为/api/)。如果希望一个GraphQL查询是可缓存的,则需要这个查询及其参数显示在URL路径中,例如/api/?query={user{id}}&variables={"x":99}(忽略URL转义)。这里的诀窍是将GraphQL客户端配置为使用HTTP GET请求进行查询(例如,为apollo-link-http设置useGETForQueries)。

突变是不能缓存的,因此它们仍需要使用HTTP POST请求。对于POST请求,缓存仅将/api/视为URL路径,但缓存将完全拒绝缓存POST请求。请记住:GET用于非突变查询,POST用于突变。在某些情况下,你可能希望避免在查询里用GET:因为查询变量可能包含敏感信息。URL有出现在日志文件、浏览器历史记录和聊天通道中的习惯,因此在URL中留下敏感信息往往不是什么好主意。无论如何,身份验证之类的事情都应该作为不可缓存的突变来完成,因此这种情况很少见,但也应该记住。

不幸的是,这里存在一个问题:GraphQL查询往往比REST API URL大得多。如果你仅启用基于GET的查询,将获得一些非常大的URL,可能大大超过了〜2000字节的限制,一些流行的浏览器和服务器是不接受它们的。一种解决方案是发送某种查询ID,而不是发送整个查询。(类似于/api/?queryId=42&variables={"x":99}.)Apollo GraphQL服务器支持两种方法来做这件事。

一种方法是从代码中提取所有GraphQL查询,并建立一个在服务端和客户端共享的查找表。它的一个缺点是让构建过程更加复杂,另一个缺点是它将客户端项目耦合到服务器项目上,这与GraphQL的卖点相悖。还有一个缺点是,代码的X版本可能会识别出一组与代码的Y版本不同的查询。这是一个问题,因为1)你的复制应用将在更新推出或回滚期间提供多个版本,并且2)客户端可能会使用缓存的JavaScript,即使你升级或降级服务器也是如此。

另一种方法被Apollo GraphQL称为自动持久查询(APQ)。对于APQ,查询ID是查询的哈希。客户端乐观地向服务器发出请求,通过哈希引用查询。如果服务器无法识别查询,则客户端会在POST请求中发送完整查询。服务器通过哈希存储该查询,以便将来可以识别它。

HTTP缓存和Keystone5

如上所述,Vocal使用Keystone 5生成其GraphQL API,而Keystone 5与Apollo lGraphQL服务器配合使用。我们在实践中如何设置缓存标头?

Apollo支持GraphQL schema上的缓存提示。好在Apollo会收集查询所涉及的所有内容的所有提示,然后自动计算适当的整体缓存标头值。例如,考虑以下查询:

代码语言:javascript
复制
query userAvatarUrl {
    authenticatedUser {
        name
        avatar_url
    }
}

如果name的最长期限为1天,而avatar_url的最长期限为1小时,则整个缓存的最长期限将是1小时,也就是最小的那个值。authenticatedUser取决于登录cookie,因此它需要一个private提示,该提示会覆盖其他字段上的public,因此生成的标头将是cache-control: max-age=3600, private

我将缓存提示支持添加到Keystone列表和字段。下面是一个从文档向待办事项列表演示中的字段添加缓存提示的简单示例:

代码语言:javascript
复制
const keystone = new Keystone({
  name: 'Keystone To-Do List',
  adapter: new MongooseAdapter(),
});
keystone.createList('Todo', {
  schemaDoc: 'A list of things which need to be done',
  fields: {
    name: {
      type: Text,
      schemaDoc: 'This is the thing you need to do',
      isRequired: true,
      cacheHint: {
        scope: 'PUBLIC',
        maxAge: 3600,
      },
    },
  },
});

另一个问题:CORS

跨域资源共享(CORS)规则与基于API的网站中的缓存会出现令人沮丧的冲突。

在深入探讨问题细节前,先来看最简单的解决方案:将主站点和API放在同一个域中。如果你的网站和API是从一个域提供的,则无需担心CORS规则(但你可能要考虑限制Cookie)。如果你的API是专门用于这个网站的,那么这就是最干净的解决方案,你可以愉快地跳过这一部分。

在Vocal V1中,网站(Next.js)和平台(Keystone GraphQL)应用位于不同的域(vocal.media和api.vocal.media)。为保护用户免受恶意网站的侵害,现代浏览器不允许一个网站与另一个网站交互。因此,在允许vocal.media向api.vocal.media发出请求前,浏览器将对api.vocal.media进行“飞行前”检查。这是一个使用OPTIONS方法的HTTP请求,该方法本质上会询问资源的跨域共享是否可行。在飞行前检查后,浏览器会发出一开始准备发出的正常请求。

“飞行前”检查也有自己的问题,它们是针对每个URL的。浏览器针对每个URL发出新的OPTIONS请求,并且服务器响应会应用于这个URL。服务器不能说vocal.media是所有api.vocal.media请求的可信来源。当所有内容都是对一个api端点的POST请求时,这并不是一个严重的问题,但是在为每个查询提供自己的GET-able URL之后,每个查询都会遭遇飞行前检查的延迟。

更令人沮丧的是,HTTP规范表示OPTIONS请求无法缓存,因此你会发现所有GraphQL数据都很好地缓存在用户旁边的CDN中,但是浏览器每次使用它时都得发出飞行前检查请求,一路发到原始服务器那里。

有几种解决方案(如果你不能单纯地使用一个共享域)。

如果你的API非常简单,则可以使用CORS规则的例外

可以将某些缓存服务器配置为忽略HTTP规范,并始终缓存OPTIONS请求(例如,基于Varnish的缓存和AWS CloudFront)。它并不会达到完全避免飞行前检查请求的性能水平,但总比默认设置要强。

另一个(确实很讨巧的)选项是JSONP。当心:如果你配置不好,可能会捅出安全漏洞。

让Vocal更好地利用缓存

在底层做好HTTP缓存工作后,我需要让应用更好地利用它。

HTTP缓存的局限是它在响应级别上是没有中间选项的。大多数响应都是可缓存的,但如果有一个字节不能缓存,那么所有缓存都用不了了。

作为博客平台,大多数Vocal数据都是很容易缓存的;但是在旧站点中,由于右上角的一个菜单栏,几乎没有哪个页面可以缓存。对于匿名用户,菜单栏将显示邀请用户登录或创建帐户的链接。对于已登录用户,这个菜单会变为用户头像和个人资料菜单。由于页面会根据用户登录状态而显示不同内容,因此无法将其缓存在CDN中。

Vocal的典型页面。该页面的大部分内容都是可轻松缓存的内容,但是在旧站点中,由于右上角有一个小菜单,因此实际上所有页面都不可缓存。

这些页面是由React组件的服务端渲染(SSR)生成的。解决方法是找出所有依赖登录cookie的React组件,并迫使它们仅在客户端延迟显示。现在,服务器返回带有占位符的完全通用页面,占位符用于登录菜单栏等内容。当页面加载到用户的浏览器中时,这些占位符将通过调用GraphQL API在客户端填充。通用页面可以安全地缓存在CDN中。

这种技巧不仅可以提高缓存命中率,而且还能帮助改善人们心理上感知的页面加载时间。黑屏甚至载入动画都会让我们感到不耐烦,但一旦出现第一块内容,它就会让我们分心数百毫秒。如果人们点击社交媒体上的Vocal帖子链接,而主要内容立即从CDN发送过来,那么很少有人会注意到,直到几百毫秒之后某些组件才能完全互动。

顺便说一句,为更快地将第一块内容呈现在用户面前,还有一个技巧是在生成SSR响应时对其进行流式处理,而不是等待整个页面渲染完毕后再发送。不幸的是,Next.js还不支持这种方法

拆分响应以提高可缓存性的想法也适用于GraphQL。使用一个请求查询多个数据的能力是GraphQL的典型优势,但如果响应的不同部分具有不同的可缓存性,则将它们拆分开来会更好。举一个简单的例子,Vocal的分页组件需要知道页面数以及当前页面的内容。最初,组件在一个查询中获取了这两个组件,但是由于页面总数在所有页面中都是恒定的,因此我将其设为单独的查询,以便对其缓存。

缓存的好处

缓存的明显好处是可以减少Vocal后端服务器上的负载。这当然很好,但是依靠缓存来增加容量是很危险的,因为当你终于有一天要删除缓存时还是需要一个备份计划。

改进的响应能力是启用缓存的更好理由。

其他一些好处可能不太明显。流量高峰往往是高度本地化的。如果拥有大量社交媒体关注者的某人分享了指向某个页面的链接,Vocal将会获得大量流量,但大部分流量只会访问该页面及其资产。这就是为什么缓存擅长吸收最庞大的流量峰值,让后端流量模式相对更平滑,更方便自动缩放来处理。

另一个好处是适度降级。即使后端由于某种原因陷入严重的麻烦,网站仍将通过CDN缓存为最受欢迎的部分提供服务。

其他性能改进

正如我经常说的,扩展的秘诀不是让事情变得复杂,而是让事情的复杂程度不超出需求,然后彻底修复所有阻止扩展的问题。

这里有一个提示:对于分布式系统中的调试难题,最困难的部分通常是获取正确的数据以查看正在发生的状况。很多时候,我陷入困境时只是在调来调去,靠猜想行事,而不是想着如何找到正确的数据。有时这是可行的,但它不适合处理棘手的问题。

一个相关的技巧是,你可以获取系统中每个组件的实时数据(甚至只是tail -F下的日志文件),把它们放在一个监视器中的几个窗口中,然后在另一个监视器中点击站点来看这些数据的变化,这样可以学到很多东西。比如说:“为什么切换这个复选框会在后端生成数十个数据库查询?”

这里有一个解决方法的示例。某些页面需要花费好几秒的时间来渲染,但仅在部署环境中,且仅在SSR中才会这样。监控仪表板没有显示任何CPU使用率高峰,并且应用没有在使用磁盘,这暗示该应用可能正在等待网络请求,也许正在等待后端。在开发环境中,我可以通过sysstat工具观察该应用的工作情况,以记录CPU/RAM/磁盘使用情况,以及Postgres语句日志记录和常规应用日志。Node.js支持使用bpftrace之类的工具来跟踪HTTP请求的探针,但因为一些很蠢的原因,它们在开发环境中不起作用,所以我在源代码中找到了探针,并使用请求日志记录构建了自定义的Node.js构建。我使用tcpdump记录网络数据,结果发现了问题:对于网站提出的每个API请求,都在对Platform建立一个新的网络连接。(如果这个方法还是找不到原因的话,我想我会在应用中添加请求跟踪。)

网络连接在本地计算机上速度很快,但在真实网络上花费的时间无法忽略不计。设置加密连接(例如在生产环境中)花费的时间甚至更长。如果你要向一台服务器(例如API)发出大量请求,则应该保持连接的启用状态并尽量重用它。浏览器会自动执行此操作,但是默认情况下,Node.js不会启用,因为它不知道你是否在发出更多请求。这就是为什么问题仅在SSR中出现的原因。像许多漫长的调试会话一样,最后的修复其实非常简单:只需配置SSR即可保持连接活跃。较慢页面的渲染时间大幅下降了。

如果你想了解更多关于此类内容的信息,我强烈建议你阅读《高性能浏览器网络手册》(可在线免费阅读),并遵循Brendan Gregg发布的指南

小结

实际上,我们还可以做很多事情来改善Vocal的性能,但我们并没有全部做完。在初创公司进行SRE工作与在大公司作为永久雇员进行SRE工作,这两者之间存在着很大的差异。我们有目标、预算和发布日期的约束,现在Vocal V2已经运行9个月,并且保持着健康的增长速度。

原文链接:

https://theartofmachinery.com/2020/06/29/scaling_a_graphql_site.html

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/leH29CkzMSVYIVhRW0Ta
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券