2023 年 4 月 21 日

我们如何通过 Prisma 将 Serverless 冷启动速度提升 9 倍

对于使用 serverless 应用程序的快速用户体验来说,冷启动是一个巨大的障碍 —— 但同时也是不可避免的。让我们探讨一下导致冷启动的因素,以及我们如何使使用 Prisma ORM 构建的每个 serverless 应用程序都更快。

How We Sped Up Serverless Cold Starts with Prisma by 9x

目录

使开发者能够享受 Serverless 和 Edge 的优势

在 Prisma,我们坚信 serverless 和 edge 应用程序的前提!这些部署范例具有巨大的优势,并允许开发者以更可扩展且成本更低的方式部署其应用程序。诸如 Vercel (使用 Next.js API 路由) 或 AWS Lambda 等 Serverless 提供商就是很好的例子。

但是,这些范例也带来了新的挑战 —— 尤其是在处理数据时!

这就是为什么在过去的几个月中,我们增加了对这些部署范例的关注,以帮助开发者构建数据驱动的应用程序,同时利用并享受 serverless 和 edge 技术的优势。

我们正在从两个角度解决这个问题

  • 构建产品以解决这些生态系统带来的新挑战 (例如 Accelerate,一个全球分布式数据库缓存)
  • 改善在 serverless 和 edge 环境中使用 Prisma ORM 的体验

本文是关于我们如何改进开发者在使用 Prisma ORM 在 serverless 环境中构建数据驱动应用程序时面临的主要问题之一:使用 Prisma ORM 时的冷启动

可怕的冷启动 🥶

在 serverless 环境中工作时,最常见的性能问题之一是长时间的冷启动。但是什么是冷启动呢?

不幸的是,这个术语带有很大的歧义,并且经常被误解。但总的来说,它描述的是当 serverless 函数处理其第一个请求时,实例化其环境并执行其代码所需的时间。虽然这是基本的术语解释,但关于冷启动,还有一些具体的事情需要牢记。

它们是天生不可避免的

当在 serverless 环境中工作时,冷启动是一个不可避免的现实。serverless 的主要“优势”是,当流量增加时,您的应用程序可以无限扩展,而在不使用时,则可以缩减为零。如果没有这种能力,serverless 就不会是... serverless!

如果在一段时间内没有请求,则所有正在运行的环境都将关闭 —— 这很棒,因为这也意味着您不会产生任何成本。但这也意味着没有函数可以立即响应传入的请求。它们必须首先重新启动,这需要一点时间。

它们具有现实世界的影响

冷启动不仅具有技术上的影响,而且还为部署 serverless 函数的企业带来了实际问题。

为您的用户提供尽可能最佳的体验至关重要,而缓慢的启动性能可能会让用户望而却步。

来自 Cal.comPeer Richelsen 最近在意识到他们的应用程序正遭受长时间冷启动之苦后,转向了 Twitter

最终,在 serverless 环境中工作的开发者的目标应该是尽可能缩短其冷启动时间,因为长时间的冷启动可能会给他们的用户带来糟糕的体验。

它们比您想象的更复杂

尽管上面对冷启动的解释非常简单明了,但重要的是要了解不同的因素会导致冷启动。在接下来的几节中,我们将解释当 serverless 函数首次生成和执行时实际发生了什么

注意:请记住,这是关于 serverless 函数如何实例化和调用的总体概述。该过程的具体细节可能会因您的云提供商和配置而异 (我们主要使用 AWS Lambda 作为参考)。

我们将使用这个简单的 serverless 函数作为示例来解释这些步骤

步骤 1:启动环境

当函数收到请求但当前没有可用实例时,您的云提供商会初始化执行环境,serverless 函数将在其中运行。在此阶段会发生多个步骤

  1. 虚拟环境是使用您为 serverless 函数分配的 CPU 和内存资源创建的。
  2. 您的代码将作为存档下载并提取到新环境的文件系统中。(如果您使用的是 AWS Lambda,则任何关联的 Lambda 层 也会被下载。)
  3. 运行时 (即函数在其中运行的特定于语言的环境) 将被初始化。如果您的函数是用 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 在启动时间中扮演的角色。

  1. Prisma Client 是一个 Node.js 模块,它位于函数代码的外部,因此需要时间和资源才能加载到执行环境的内存中:整个函数存档需要从某个存储下载,然后提取到文件系统中。这对于所有 Node.js 模块都是如此,但是,它确实会增加冷启动,并且项目中使用越多的依赖项,冷启动就会增加 —— 而 Prisma 可能是其中之一。

  2. 一旦代码加载到内存中,它也必须导入到处理程序的文件中,并且必须由 Node.js 解释器解释。对于 Prisma Client,这通常意味着调用 const { PrismaClient } = require('@prisma/client')

  3. 当使用 const prisma = new PrismaClient() 实例化 Prisma Client 时,必须加载 Prisma Query Engine 并生成诸如输入类型和函数之类的东西,以使客户端能够正常运行。它使用内部 Schema Builder 来执行此操作。

  4. 最后,一旦虚拟环境准备好运行函数的初始调用,处理程序将开始执行您的代码。该代码中的任何 Prisma 查询,例如 await prisma.user.findMany(),将首先启动与数据库的连接 (如果尚未通过显式调用 await prisma.$connect() 打开),然后执行查询并将数据返回到您的应用程序。

通过这种理解,我们可以继续解释我们如何改进 Prisma 对冷启动的影响。

启动性能提升 9 倍

在过去的几个月中,我们增加了在解决这些冷启动问题上的工程努力,并自豪地说,我们已经取得了巨大的进步 🎉

总的来说,我们在构建 Prisma ORM 时一直遵循“先使其工作,再使其正确,再使其快速”的理念。在 2020 年为生产环境发布 Prisma ORM、添加对多个数据库的支持并实施广泛的功能集之后,我们终于专注于提高其性能。

为了说明我们的进展,请考虑下图。第一张图表示在我们开始努力改进之前,具有相对较大的 Prisma 模式 (具有 500 个模型) 的应用程序的冷启动持续时间

之前

下一张图是目前在性能增强方面做出近期努力后,数字的预览

之后

我们不会在这里粉饰情况,Prisma 的启动时间过去确实令人不满意,人们也因此正确地批评了我们。

正如您所看到的,我们现在的冷启动时间得多。这里的进步来自于对我们代码库的增强、关于 serverless 函数行为的发现以及应用最佳实践。接下来的部分将更详细地描述这些内容。

新的基于 JSON 的线路协议

下图与上面显示的之前的图相同

之前

在此图中,蓝色 部分的 Prisma Client 条表示在函数的初始调用期间运行 findMany 查询所花费的时间。该时间在 Internals 条中分为两个部分:紫色红色

我们很快意识到这张图没有多大意义。运行查询所花费的大部分时间... 没有运行查询

这个 紫色 部分占 findMany 查询部分的大部分,表示解析我们称之为 DMMF (数据模型元格式) 的时间,DMMF 是一种用于验证发送到 Prisma 的 query engine 的内部结构。

红色 部分表示实际运行查询所花费的时间。

这里的根本问题是,Prisma Client 使用类似 GraphQL 的语言作为线路协议与 query engine 通信。GraphQL 附带了一组限制,迫使 Prisma Client 使用 DMMF (可能达到兆字节的 JSON) 来序列化查询。

如果您使用 Prisma 很长时间了,您可能还记得 Prisma 1 是一个更加以 GraphQL 为中心的工具。当将 Prisma 重建为 Prisma 2 时,完全专注于成为纯数据库 ORM,我们保留了我们架构的这一部分,而没有质疑它 - 也没有衡量其性能影响。

Serhii 的启示

我们提出的解决方案是从头开始以纯 JSON 重新设计线路协议,这使得 Prisma Client 和 query engine 之间的通信更加高效,因为它不再需要 DMMF 来序列化消息。

在重新设计线路协议后,我们有效地从图中删除了整个 紫色 部分,使我们得到以下结果

使用 JSON 协议

注意:如果您有兴趣,可以查看 pull requests prisma-engines#3624prisma#17911,其中包含所做的实际更改。

查看 GitHub 上用户的精彩反馈,他们试用了新的基于 JSON 的线路协议

注意:基于 JSON 的线路协议目前处于 预览 状态。一旦它准备好用于生产环境,它将成为 Prisma Client 与 query engine 通信的默认方式。请试用它并提交任何反馈,以帮助加快此功能普遍可用的过程。

将您的函数托管在与数据库相同的区域中

在我们切换到 JSON 协议后,大的分散注意力的 紫色 部分从图中消失了,我们可以专注于剩余的部分

使用 JSON 协议

我们清楚地注意到 浅红色红色 部分是下一个大的候选者。这些表示 Prisma 触发的与实际数据库的通信。

每当您托管需要访问传统关系数据库的应用程序或函数时,您都需要启动与该数据库的连接。这需要时间和延迟。对于您执行的任何查询也是如此。

目标是将时间和延迟保持在绝对最小值。目前执行此操作的最佳方法是确保您的应用程序或函数部署在与数据库服务器相同的地理区域中。

您的请求到达数据库服务器的距离越短,建立连接的速度就越快。在部署 serverless 应用程序时,牢记这一点非常重要,因为这样做造成的负面影响可能非常重大。

不这样做会影响以下操作所需的时间

  • 完成 TLS 握手
  • 与数据库建立安全连接
  • 执行您的查询

所有这些因素都在冷启动期间被激活,因此在使用 Prisma 的数据库对应用程序的冷启动可能产生的影响中发挥作用。

令人尴尬的是,我们注意到我们在 eu-central-1 的 AWS Lambda 上使用 serverless 函数进行了最初的几次测试,而 RDS PostgreSQL 实例托管在 us-east-1 中。我们很快修复了该问题,“之后”的测量清楚地显示了这可能对数据库延迟产生的巨大影响,无论是对于连接的创建,还是对于之后执行的任何查询

数据库与函数位于同一区域

使用与您的函数尽可能不靠近的数据库将直接增加冷启动的持续时间,但也会在稍后处理热请求期间执行查询时产生相同的成本。

优化的内部模式构建

在之前显示的图中,您可能已经注意到,Internals 条上的三个部分中只有两个与数据库直接相关。另一个部分 “Schema builder”,以 青色 显示,则不然。这对我们来说是一个指标,表明该部分是一个潜在的改进领域

数据库与函数位于同一区域

Prisma Client 条的 绿色 部分表示 Prisma Client 运行其 $connect 函数以建立与数据库的连接所花费的时间。此部分在 Internals 条中分为两个部分:青色浅红色

浅红色 部分表示实际创建数据库连接所花费的时间,而 青色 部分显示 Prisma 的 query engine 花费的时间,读取您的 Prisma 模式,然后使用它来生成它用于验证传入的 Prisma Client 查询的模式。

之前生成这些项的方式并非尽可能地优化。为了缩短该部分,我们解决了我们可以在那里找到的性能问题。

更具体地说,我们找到了方法来删除在构建查询模式之前在启动 query engine 时转换内部 Prisma 模式的昂贵代码段。

我们现在还 惰性地生成查询模式中许多类型的名称的字符串。这产生了可衡量的差异。

随着该更改,我们还找到了优化 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 启动时间的上下文中。

有很多方法可以为提供世界一流的 ORM (可在 serverless 环境和边缘环境中访问) 的目标做出贡献

  • 提交您在使用 serverless 上的 Prisma 时发现的 问题
  • 有关于我们如何改进的想法吗?发起一个 讨论
  • 试用 jsonProtocol 预览功能并提供 反馈

Prisma ORM 是一个开源项目,因此我们完全理解社区反馈和参与的重要性。我们喜欢反馈、批评、问题以及任何可能有助于为每位开发者的利益推动 Prisma 前进的事物。

不要错过下一篇文章!

注册 Prisma 新闻通讯