Vocal 是一个博客平台,内容广泛,包罗万象,甚至还有很多猫猫狗狗。
在 Vocal 发布内容,作者可以获得报酬。页面的每一次点击都会给作者一点酬劳,并且,作者还能接受其他用户的捐款。一些专业人士用这个平台展示自己的作品,但对大多数用户来说,这只是一个有趣的爱好,正好顺便赚些零花钱。
据悉,Vocal 的母公司是 Jerrick Media,而网站的开发工作是与悉尼一家名为 Thinkmill 的公司合作的。
Thinkmill 使用 Next.js(基于 React 的 Web 框架)构建了一个网站,并与 Keystone 在 MongoDB 上提供的 GraphQL API 通信。Keystone 是基于 GraphQL 的无头 CMS 库:你可以在 JavaScript 中定义一个 schema,将其连接到数据存储,并获取自动生成的 GraphQL API 以访问数据。这是一个免费的开源软件项目,由 Thinkmill 提供商业支持。
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 Luke 和 Modern 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 秒(一小时):
cache-control: max-age=3600, public
对于用户特定的页面(例如用户设置页面),重要的是使用 private 换掉 public,以告知公共缓存不要存储响应,并将其提供给其他用户。另一个常见的标头是 vary。这会告诉缓存响应是基于 URL 以外的因素而变化的。(它将 HTTP 标头添加到 URL 旁的缓存键中)这是一个非常笨的工具,所以我建议尽量改用良好的 URL 结构,但它的一个重要用例是告诉浏览器响应依赖登录 cookie,以便它们在登录 / 注销时更新页面。
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 会很有用。
如上所述,常规缓存键是 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 请求中发送完整查询。服务器通过哈希存储该查询,以便将来可以识别它。
如上所述,Vocal 使用 Keystone 5 生成其 GraphQL API,而 Keystone 5 与 Apollo lGraphQL 服务器配合使用。我们在实践中如何设置缓存标头?
Apollo 支持 GraphQL schema 上的缓存提示。好在 Apollo 会收集查询所涉及的所有内容的所有提示,然后自动计算适当的整体缓存标头值。例如,考虑以下查询:
query userAvatarUrl {
authenticatedUser {
name
avatar_url
}
}
如果 name 的最长期限为 1 天,而avatar_url
的最长期限为 1 小时,则整个缓存的最长期限将是 1 小时,也就是最小的那个值。authenticatedUser
取决于登录 cookie,因此它需要一个 private 提示,该提示会覆盖其他字段上的 public,因此生成的标头将是cache-control: max-age=3600, private
。
我将缓存提示支持 添加 到 Keystone 列表和字段。下面是一个从文档向待办事项列表演示中的字段添加缓存提示的简单示例:
const keystone = new Keystone({
name: 'Keystone To-Do List',
adapter: new MongooseAdapter(),
});
{
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)规则与基于 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。当心:如果你配置不好,可能会捅出安全漏洞。
在底层做好 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 个月,并且保持着健康的增长速度。
原文链接: