2023年4月21日
我们如何使用 Prisma 将 Serverless 冷启动速度提升 9 倍
冷启动是阻碍 Serverless 应用提供快速用户体验的一大障碍——但它也是不可避免的。让我们探讨一下导致冷启动的原因,以及我们如何让每个使用 Prisma ORM 构建的 Serverless 应用运行得更快。
目录
赋能开发者,尽享 Serverless & Edge 的优势
在 Prisma,我们坚信 Serverless 和 Edge 应用的理念!这些部署范式具有巨大优势,使开发者能够以更具可伸缩性且成本更低的方式部署其应用。Vercel(支持 Next.js API 路由)或 AWS Lambda 等 Serverless 提供商就是很好的例子。
然而,这些范式也带来了新的挑战——尤其是在处理数据时!
这就是为什么在过去的几个月里,我们加大了对这些部署范式的关注,以帮助开发者在利用并享受 Serverless 和 Edge 技术优势的同时,构建数据驱动型应用。
我们从两个方面着手解决:
- 构建产品来解决这些生态系统带来的新挑战(例如 Accelerate,一个全球分布式数据库缓存)
- 改善 Prisma ORM 在 Serverless 和 Edge 环境中的体验
本文将介绍我们如何改进开发者在 Serverless 环境中构建数据驱动型应用时面临的一个主要问题:使用 Prisma ORM 时的冷启动。
令人头疼的冷启动 🥶
在 Serverless 环境中工作时,最常见的性能问题之一就是漫长的冷启动。但什么是冷启动呢?
不幸的是,这个术语存在很多歧义,并且经常被误解。通常,它描述的是当 Serverless 函数处理其第一个请求时,实例化其环境并执行其代码所需的时间。尽管这是基本的技??解释,但关于冷启动还有一些具体事项需要注意。
它们是无法避免的
冷启动是 Serverless 环境中不可避免的现实。Serverless 的主要“优势”在于,当流量增加时,你的应用可以无限扩展;当不使用时,它可以缩减到零。如果没有这种能力,Serverless 就不会是……Serverless!
如果一段时间没有请求,所有运行中的环境都会被关闭——这很好,因为这也意味着你不会产生费用。但这也意味着没有函数可以立即响应传入的请求。它们必须首先重新启动,这需要一点时间。
它们具有实际影响
冷启动不仅具有技术上的影响,还会给部署 Serverless 函数的企业带来实际问题。
为用户提供最佳体验至关重要,缓慢的启动性能可能会让用户望而却步。
来自 Cal.com 的 Peer Richelsen 最近在意识到他们的应用正遭受漫长冷启动的困扰后,在 Twitter 上寻求帮助。
最终,在 Serverless 环境中工作的开发者目标应该是尽可能缩短冷启动时间,因为漫长的冷启动会导致用户体验不佳。
它们比你想象的更复杂
尽管上述冷启动的解释相当直接,但重要的是要理解不同的因素都会导致冷启动。在接下来的几节中,我们将解释 Serverless 函数首次生成和执行时实际发生了什么。
注意:请记住,这是关于 Serverless 函数如何实例化和调用的通用概述。该过程的具体细节可能因你的云提供商和配置而异(我们主要以 AWS Lambda 为参考)。
我们将使用这个简单的 Serverless 函数作为示例来解释这些步骤:
步骤 1:启动环境
当函数接收到请求但当前没有可用实例时,你的云提供商会初始化执行环境,在该环境中运行你的 Serverless 函数。此阶段会发生多个步骤:
- 虚拟环境会根据你为 Serverless 函数分配的 CPU 和内存资源创建。
- 你的代码会以压缩包的形式下载,并解压到新环境的文件系统中。(如果你使用的是 AWS Lambda,任何关联的 Lambda 层也会被下载。)
- 运行时(即函数运行的特定语言环境)被初始化。如果你的函数是用 JavaScript 编写的,这将是 Node.js 运行时。
此后,函数仍未准备好处理请求。虚拟环境已就绪,所有代码都已到位,但运行时尚未处理任何代码。在调用处理程序之前,必须按照下一步所述初始化应用程序。
注意:函数的启动细节不可配置,由你的云提供商处理。你对此的工作方式没有太多发言权。
步骤 2:启动应用程序
通常,应用程序代码存在于两个不同的作用域:
- 处理程序函数外部的代码
- 处理程序函数内部的代码
在此步骤中,你的云提供商会执行处理程序外部的代码。处理程序内部的代码将在下一步中执行。
AWS Lambda 在运行上述函数时会记录以下内容:
你可以看到外部的 console.log("Executed when the application starts up!")
甚至在 AWS Lambda 记录实际的 START RequestId
之前就已经执行了。如果存在任何导入、构造函数调用或其他代码——它们也将在此时执行。
(当对函数进行热启动请求时,此行将不再被记录。处理程序外部的代码仅在冷启动期间执行一次。)
步骤 3:执行应用程序代码
在启动过程的最后部分,处理程序函数会被执行。它接收传入的 HTTP 请求(即请求头、请求体等...)并运行你已实现的逻辑。
上一步的 AWS Lambda 日志继续:
至此,函数的冷启动已结束,执行环境已准备好处理后续请求。
旁注:AWS Lambda 将执行处理程序内部代码所花费的时间记录为
Duration
,这发生在步骤 3。Init Duration
包括环境启动和应用程序启动,即步骤 1 和 2。
Prisma 如何影响冷启动
了解了什么是冷启动以及初始化 Serverless 函数的步骤后,我们现在将探讨 Prisma 在启动时间中扮演的角色。
-
Prisma Client 是一个独立于你的函数代码的 Node.js 模块,因此需要时间和资源加载到执行环境的内存中:整个函数归档文件需要从某个存储中下载,然后解压到文件系统中。所有 Node.js 模块都是如此,但这确实会增加冷启动时间,并且项目中使用的依赖越多,时间就越长——Prisma 可能是其中之一。
-
代码加载到内存后,还必须导入到处理程序文件中,并由 Node.js 解释器进行解释。对于 Prisma Client 来说,这通常意味着调用
const { PrismaClient } = require('@prisma/client')
。 -
当 Prisma Client 使用
const prisma = new PrismaClient()
实例化时,Prisma 查询引擎必须被加载并生成输入类型和函数等,以使客户端能够正常运行。它使用内部的 Schema Builder 来完成此操作。 -
最后,一旦虚拟环境准备好运行函数的首次调用,处理程序将开始执行你的代码。代码中的任何 Prisma 查询,例如
await prisma.user.findMany()
,如果尚未通过显式调用await prisma.$connect()
打开连接,将首先初始化与数据库的连接,然后执行查询并将数据返回给你的应用程序。
有了这些理解,我们可以继续解释我们如何改进 Prisma 对冷启动的影响。
启动性能提升 9 倍
在过去的几个月里,我们加大了工程投入,致力于解决这些冷启动问题,并自豪地说我们取得了显著的进展 🎉
总的来说,我们在构建 Prisma ORM 时一直遵循“让它能用,让它正确,让它快速”的哲学。自 2020 年 Prisma ORM 投入生产,并增加了对多种数据库的支持以及实现了广泛的功能集之后,我们终于开始专注于提高其性能。
为了说明我们的进展,请看下面的图表。第一张图表示我们在开始改进之前,一个具有相对较大 Prisma 模式(包含 500 个模型)的应用的冷启动持续时间:

之前
这下一张图展示了我们最近进行性能增强后,数据目前的样子:

之后
我们不会在这里粉饰太平,Prisma 的启动时间过去确实有很多不足之处,人们也理所当然地为此批评我们。
然而,正如你所看到的,我们现在的冷启动时间已经大大缩短了。这些进步源于我们对代码库的改进、对 Serverless 函数行为的发现以及最佳实践的应用。接下来的章节将更详细地描述这些内容。
新的基于 JSON 的线协议
下图与上面所示的之前的图相同:

之前
在此图中,Prisma Client 条形图中的蓝色部分表示函数首次调用期间运行 findMany
查询所花费的时间。该时间在内部条形图中分为两部分:紫色和红色。
我们很快意识到这张图并没有多大意义。运行查询所花费的大部分时间都用在了……没有运行查询!
这个紫色部分占据了 findMany
查询段的大部分时间,表示用于解析我们称之为 DMMF(数据模型元格式)的时间,DMMF 是一种内部结构,用于验证发送到 Prisma 查询引擎的查询。
红色部分表示实际运行查询所花费的时间。
这里根本问题在于 Prisma Client 使用了一种类似 GraphQL 的语言作为线协议与查询引擎进行通信。GraphQL 带来了一系列限制,迫使 Prisma Client 必须使用 DMMF(其大小可能达到兆字节级别的 JSON)来序列化查询。
如果你使用 Prisma 已久,可能会记得 Prisma 1 是一个更侧重于 GraphQL 的工具。当我们将 Prisma 重构为 Prisma 2 时,完全专注于成为一个纯粹的数据库 ORM,我们保留了这一部分架构,没有质疑它——也没有测量其性能影响。

Serhii 的发现
我们想到的解决方案是从头开始用纯 JSON 重构线协议,这使得 Prisma Client 和查询引擎之间的通信效率高得多,因为它不再需要 DMMF 来序列化消息。
重新设计线协议后,我们有效地从图中移除了整个紫色部分,留下了以下结果:

使用 JSON 协议
注意:如果你感兴趣,可以查看实际更改的拉取请求:prisma-engines#3624 和 prisma#17911。
查看 GitHub 上那些试用了新 JSON 线协议的用户给出的惊人反馈:

注意:基于 JSON 的线协议目前处于 预览阶段。一旦准备好投入生产,它将成为 Prisma Client 与查询引擎通信的默认方式。请尝试使用并 提交任何反馈,以帮助加快该功能普遍可用的进程。
将你的函数与数据库部署在同一区域
在我们切换到 JSON 协议后,图中那个令人分心的大块紫色部分消失了,我们可以专注于剩余的部分:

使用 JSON 协议
我们清楚地注意到浅红色和红色部分是接下来的主要优化对象。这些代表了 Prisma 触发的与实际数据库的通信。
无论何时托管需要访问传统关系型数据库的应用程序或函数,你都需要初始化与该数据库的连接。这需要时间并会带来延迟。你执行的任何查询也是如此。
目标是将时间和延迟降到最低。目前最好的方法是确保你的应用程序或函数与数据库服务器部署在同一地理区域。

你的请求到达数据库服务器的距离越短,连接建立的速度就越快。在部署 Serverless 应用程序时,这一点非常重要,因为不这样做可能造成的负面影响是巨大的。
不这样做可能会影响以下时间:
- 完成 TLS 握手
- 建立与数据库的安全连接
- 执行你的查询
所有这些因素都在冷启动期间被激活,因此会影响 Prisma 数据库对应用程序冷启动时间的影响。
令人尴尬的是,我们发现最初的几次测试中,AWS Lambda 的 Serverless 函数部署在 eu-central-1
,而 RDS PostgreSQL 实例托管在 us-east-1
。我们迅速纠正了这个问题,“之后”的测量结果清楚地显示了这可能对你的数据库延迟产生巨大影响,无论是连接的创建还是任何执行的查询。

数据库与函数位于同一区域
使用与你的函数不在尽可能近的数据库,将直接增加你的冷启动持续时间,并且在处理热请求期间稍后执行任何查询时也会产生相同的开销。
优化的内部模式构建
在前面显示的图中,你可能已经注意到内部条形图中的三个部分中,只有两个与数据库直接相关。另一个名为“Schema builder”的部分,以青色显示,则不是。这向我们表明,这一部分是潜在的改进领域。

数据库与函数位于同一区域
Prisma Client 条形图的绿色部分表示 Prisma Client 运行其 $connect
函数以建立与数据库连接所花费的时间。该部分在内部条形图中分为两块:青色和浅红色。
浅红色部分表示实际创建数据库连接所花费的时间,而青色部分则显示 Prisma 的查询引擎读取你的 Prisma 模式,然后用它生成用于验证传入的 Prisma Client 查询的模式所花费的时间。
之前生成这些项目的方式不够优化。为了缩短该部分的时间,我们解决了在那里发现的性能问题。
更具体地说,我们找到了方法来移除一段开销很大的代码,这段代码在查询引擎启动时,会在构建查询模式之前转换内部的 Prisma Schema。
我们现在还惰性地生成查询模式中许多类型名称的字符串。这产生了可衡量的差异。
伴随这一改变,我们还找到了优化 Schema Builder 内部代码以改善内存布局的方法,这带来了显著的性能(运行时)提升。
应用这些更改后,之前的请求看起来像这样:

经过 Schema Builder 增强后
注意青色部分显著缩短了。这是一个巨大的胜利,然而仍然存在一个青色部分,这意味着时间花费在了与数据库无关的事情上。我们已经确定了潜在的增强措施,可以将这部分时间缩短到接近(如果不是完全缩短到)零。
各项小改进
在此过程中,我们还发现了许多可以改进的较小效率问题。这样的问题有很多,所以我们不会一一赘述,但一个很好的例子是我们对平台检测例程进行的优化,该例程用于在 Linux 环境中搜索 OpenSSL 库(该改进的拉取请求可以在这里找到)。
这项增强平均可以缩短冷启动时间约 10-20 毫秒。虽然看起来不多,但这项增强以及我们所做的其他小改进累积起来,又节省了相当一部分时间。
旁注:关于 TLS 的发现
在此次行动中,我们还有一个值得注意的发现:通过 TLS 为数据库连接增加安全性,在你的数据库与 Serverless 函数托管在不同区域时,会对冷启动时间产生巨大影响。
TLS 握手需要与数据库进行一次往返。当你的数据库与函数托管在同一区域时,这会非常快,但如果它们相距遥远,则会非常慢。
Prisma Client 默认启用 TLS,因为这是连接数据库更安全的方式。因此,一些数据库与函数不在同一区域的开发者可能会发现,由于 TLS 握手导致冷启动时间增加。
下图显示了启用 TLS(第一部分)和禁用 TLS(通过在连接字符串中设置 sslmode=disable
)时的不同冷启动时间。

如果你的数据库与函数托管在同一区域,上述所示的 TLS 开销可以忽略不计。
Node 生态系统中的其他一些数据库客户端和 ORM 默认禁用 PostgreSQL 数据库的 TLS。在将 Prisma ORM 的性能与它们进行比较时,这可能会不幸地导致由于这种开箱即用安全性的差异而产生的性能印象。
我们建议将你的数据库和函数移动到同一区域,而不是为了提升性能而可能损害安全性。这样做既能保证数据库安全,又能带来更快的冷启动。
这仅仅是个开始
尽管我们在过去几个月取得了令人难以置信的进展,但这仅仅是个开始。
我们希望:
- 优化 Schema Builder(图中青色部分),通过可能在查询验证期间或采用惰性方式来完成部分工作,使其接近甚至达到 0。
- 优化 Prisma 的加载(图中黄色部分),这代表了加载 Prisma 所需的时间,并使其尽可能小。
- 将以上所有经验教训应用于 PostgreSQL 之外的其他数据库。
- 最重要的一点:研究 Prisma Client 查询的性能,并优化其执行时间,无论数据量大小。
随着我们不断改进 Prisma ORM 的性能,你可以在未来几周或几个月内期待本文的更新(当我们进一步提升冷启动性能时),甚至可能会有另一篇博文发布。
你可以提供帮助!
使 Prisma 在 Serverless 环境中的体验尽可能流畅是一个非常宏大的目标。尽管我们拥有一支优秀的团队致力于改善使用 Prisma Client 的 Serverless 函数的启动性能,但我们也意识到我们拥有庞大的开发者社区,他们渴望为此项目贡献自己的力量。
我们邀请你帮助改进 Prisma Client 的性能,特别是在 Serverless 启动时间方面。
你可以通过多种方式为实现提供世界一流 ORM 的目标做出贡献,使其可在 Serverless 环境和边缘计算中访问:
Prisma ORM 是一个开源项目,因此我们完全理解社区反馈和参与的重要性。我们欢迎反馈、批评、问题以及任何有助于推动 Prisma 造福每位开发者的建议。
不要错过下一篇!
订阅 Prisma 邮件列表