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,一个全球分布式数据库缓存)
- 改善在 Serverless 和 Edge 环境中使用 Prisma ORM 的体验
本文将介绍我们如何改进开发者在 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')
。 -
当使用
const prisma = new PrismaClient()
实例化 Prisma Client 时,必须加载 Prisma Query Engine,它会生成输入类型和允许客户端正常运行的函数等内容。它使用内部 Schema Builder 来完成此操作。 -
最后,一旦虚拟环境准备好运行函数的首次调用,处理程序将开始执行您的代码。该代码中的任何 Prisma 查询,例如
await prisma.user.findMany()
,如果尚未通过显式调用await prisma.$connect()
打开连接,则会首先发起与数据库的连接,然后执行查询并将数据返回给您的应用程序。
有了这些了解,我们就可以继续解释我们如何改进 Prisma 对冷启动的影响。
启动性能提升了 9 倍
在过去的几个月里,我们加大了工程投入来解决这些冷启动问题,并自豪地说我们取得了巨大的进步 🎉
通常,在构建 Prisma ORM 时,我们一直遵循“先让它工作,再让它正确,最后让它快速”的理念。在 2020 年将 Prisma ORM 推向生产环境后,添加了对多个数据库的支持并实现了大量功能,我们终于开始着手改进其性能了。
为了说明我们的进展,请看下面的图表。第一个图表代表了在我们开始改进之前,具有相对较大的 Prisma schema(包含 500 个模型)的应用的冷启动时长

之前
下面的图表展示了我们在最近的性能增强工作之后,数据目前的表现

之后
我们不会在这里粉饰太平,Prisma 的启动时间确实曾不尽如人意,用户对此提出批评是理所当然的。
正如您所见,现在的冷启动时间*大大*缩短了。这方面的进展得益于我们对代码库的改进、关于 Serverless 函数行为的发现以及最佳实践的应用。接下来的部分将更详细地描述这些内容。
新的基于 JSON 的线协议
下面的图表与上面展示的“之前”图表是同一个

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

Serhii 的发现
我们想到的解决方案是完全重新设计线协议,使其基于纯 JSON,这使得 Prisma Client 和查询引擎之间的通信效率*大大*提高,因为它不再需要 DMMF 来序列化消息。
重新设计线协议后,我们有效地从图表中移除了整个紫色部分,剩下的如下

使用 JSON 协议后
注意:如果您有兴趣,可以查看 pull requests prisma-engines#3624 和 prisma#17911 以了解实际更改。
查看 GitHub 上尝试新 JSON 线协议的用户提供的这些令人惊叹的反馈

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

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

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

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

数据库与函数位于同一区域后
Prisma Client 条形的绿色部分代表 Prisma Client 运行其 $connect
函数以建立与数据库连接所花费的时间。Internals 条形中的此部分分为两个块:青色和浅红色。
浅红色部分代表实际创建数据库连接所花费的时间,青色部分显示 Prisma 的查询引擎读取您的 Prisma schema,然后使用它生成用于验证传入 Prisma Client 查询的 schema 所花费的时间。
以前生成这些项目的方式并未得到充分优化。为了缩短该部分的时间,我们解决了在那里发现的性能问题。
更具体地说,我们找到了在构建查询 schema 之前,查询引擎启动时删除转换内部 Prisma Schema 的昂贵代码的方法。
我们现在还延迟生成查询 schema 中许多类型的名称字符串。这产生了显著的差异。
除了这一更改之外,我们还找到了优化 Schema Builder 内的代码以改进内存布局的方法,从而带来了显著的性能(运行时)提升。
注意:如果您对我们所做的内存分配相关修复的具体细节感兴趣,请查看以下示例 pull requests:#3828,#3823
应用这些更改后,之前的请求如下所示

Schema Builder 增强后
请注意,青色部分明显缩短了。这是一个巨大的胜利,但仍然存在青色部分,这意味着时间花在了与数据库无关的事情上。我们已经确定了潜在的增强功能,可以将此部分的时间缩短到接近(甚至完全降到)零。
各种小改进
在此过程中,我们还发现了许多能够改进的较小效率问题。这类问题有很多,所以我们不会逐一介绍,但一个很好的例子是我们对用于在 Linux 环境中搜索 OpenSSL 库的平台检测例程进行的优化(该增强功能的 pull request 可以在这里找到)。
平均而言,此增强功能可以减少约 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 启动时间方面。
您可以通过多种方式为实现这一目标贡献力量,即提供一个可在 Serverless 和 Edge 环境中使用的世界一流 ORM
Prisma ORM 是一个开源项目,因此我们完全理解社区反馈和参与的重要性。我们热爱反馈、批评、问题以及任何可能帮助 Prisma 造福所有开发者的事物。
不要错过下一篇文章!
订阅 Prisma 新闻通讯