数据加载器模式
了解GraphQL应用中常见的性能问题,以及DataLoader模式如何帮助解决这些问题。
我们在这门课程中已经涵盖了大量内容。我们拥有一个可运行的后端,一个能够发送查询和变更请求的前端,甚至还实现了基于密码的身份验证来保护我们的应用。但这并不意味着我们已经掌握了 GraphQL 的全部知识!
在本章中,我们将深入探讨一些更高级的 GraphQL 主题。首先,我们将学习如何通过使用 DataLoaders 显著提升 GraphQL API 的性能。接着,我们会用几节课的时间了解如何更高效地在前端使用缓存。之后,我们还将学习如何为使用 GraphQL 查询的 React 组件编写测试。
性能问题
在本节中,我们将分析我们 GraphQL API 的性能。我们的 API 业务逻辑不多,主要功能是从数据库中查询数据,因此让我们看看实际执行时发生了什么。
为了观察具体行为,我们可以使用下面这段小代码片段,它会记录我们执行的每一次 MongoDB 查询。
1// server/src/mongo.js 2 3mongoose.set('debug', (collectionName, method, query) => { 4 logger.info(`${collectionName}.${method}, ${JSON.stringify(query)}`) 5})
这段代码利用 mongoose.set 方法,在 Mongoose 发送每条查询时调用指定函数。该函数接收三个参数:
collectionName:正在查询的 MongoDB 集合名称。method:查询方法,例如findOne。query:查询参数,比如我们要从数据库中获取的对象 ID。
以下是应用程序执行单个查询时输出的一个示例:
1[2021-11-09 22:24:27.009 +0000] INFO: users.findOne, {"_id":"3d6fad714086e3bfcfceac54"}
输出中的 users.findOne 表示应用程序向 users 集合发送了一个 findOne 查询,而 {"_id":"3d6fad714086e3bfcfceac54"} 是随查询一起发送的参数。这些参数说明我们希望获取具有该 ID 的单个用户。
现在,让我们看看当我们向应用发送如下 GraphQL 查询时,它会发出哪些数据库查询:
1query Query { 2 allProducts { 3 id 4 name 5 description 6 numberOfVotes 7 publishedAt 8 url 9 author { 10 id 11 fullName 12 } 13 categories { 14 id 15 name 16 slug 17 } 18 } 19}
该查询用于获取所有产品,并为每个产品获取其所属的所有分类信息,以及文章作者的相关信息。
让我们看看执行这个查询后的输出结果:
1[2021-11-09 22:24:26.975 +0000] INFO: products.find, {} 2[2021-11-09 22:24:26.978 +0000] INFO: products.find, {} 3[2021-11-09 22:24:27.009 +0000] INFO: users.findOne, {"_id":"3d6fad714086e3bfcfceac54"} 4[2021-11-09 22:24:27.010 +0000] INFO: categories.find, {"_id":{"$in":["24e7451df05ed5cd4cf1041b"]}} 5[2021-11-09 22:24:27.011 +0000] INFO: users.findOne, {"_id":"4b8373d016f277527198385b"} 6[2021-11-09 22:24:27.012 +0000] INFO: categories.find, {"_id":{"$in":["7561bfb015d8f536760cb671","a033a528b603fed46f861d4b"]}} 7[2021-11-09 22:24:27.013 +0000] INFO: users.findOne, {"_id":"3d6fad714086e3bfcfceac54"} 8[2021-11-09 22:24:27.014 +0000] INFO: categories.find, {"_id":{"$in":["a033a528b603fed46f861d4b"]}}
哇!仅一个 GraphQL 请求就触发了八次数据库查询。每次查询都会消耗资源,如数据库连接和内存,因此我们应尽可能减少查询次数。如果希望应用能支持更多用户,就必须降低应用发出的查询数量。
造成这种低效的原因是什么?问题出在 GraphQL 如何使用解析器(resolvers)构建响应的过程。回忆一下 GraphQL 服务器的执行流程。我们的服务器返回如下对象图:
首先,它调用 Queries.allProducts 解析器从 MongoDB 获取所有产品。这对应日志中的这一行:
1[2021-11-09 22:24:26.978 +0000] INFO: products.find, {}, {"projection":{}}
注意:你可能注意到
products.find在日志中出现了两次,这意味着我们发出了两次请求来获取所有产品。我们将在下一课末尾讨论这个问题,但目前先关注其余查询。
当 Apollo Server 获得产品列表后,它需要为每个产品获取作者信息。为此,它会为每个产品调用 Product.author 解析器,而该解析器又会单独发送一条查询来获取作者数据。
1const resolvers = { 2 Product: { 3 ... 4 author: async (product) => { 5 return User.findById(product.authorId) 6 }, 7 }, 8}
这对应三条类似以下的日志记录,每条对应一个产品:
1[2021-11-09 22:24:27.009 +0000] INFO: users.findOne, {"_id":"3d6fad714086e3bfcfceac54"}, {"projection":{}}
注意,我们的数据库中只有两个用户,但 Apollo Server 却向 users 集合发送了三次查询。不仅请求过多,而且重复获取相同的数据。
除了用户数据外,Apollo Server 还需要通过 Product.categories 解析器获取分类信息。由于每个产品的分类都需要获取,因此 Product.categories 解析器被调用了三次。
1const resolvers = { 2 Product: { 3 ... 4 categories: async (product, _, { categoriesLoader }) => { 5 const allIds = product.categoriesIds 6 return Category.find().where('_id').in(allIds) 7 } 8 }, 9}
和获取用户列表一样,这也导致了三条额外的查询,形式如下:
1[2021-11-09 22:24:27.012 +0000] INFO: categories.find, {"_id":{"$in":["7561bfb015d8f536760cb671","a033a528b603fed46f861d4b"]}}, {"projection":{}}
这种情况有多严重?
表面上看,这种情况似乎还不算太糟,但它会迅速失控。假设我们的 API 不再返回 3 个产品,而是要返回 20 个呢?那将需要执行 41 次查询——1 次获取产品列表,20 次获取每个产品的作者信息,再加 20 次获取每个产品的分类数据。
如果还有更多嵌套数据,且每个用户对象的构建还需要额外查询呢?我们可能最终需要发送 100 次甚至更多的查询才能处理一次请求!想象一下,如果有几百个并发用户同时访问,系统将完全无法承受。
如何解决?
当前方法的主要问题是:Apollo Server 为每个需要获取的对象分别发送独立查询。这被称为 N+1 问题。这里的 1 指的是获取初始记录列表,而 N 指的是为每个对象额外发起的查询。
理论上,解决这个问题的方法很直接:与其为每个产品单独查询作者,不如这样做:
- 获取应用需要返回的所有产品列表;
- 提取这些产品中所有作者的 ID;
- 发送一次查询,一次性获取所有作者数据。
虽然描述起来很简单,但实现起来并不容易,因为我们无法控制 Apollo Server 何时解析对象。那么,如何确保它能将多个独立查询合并成一批呢?这时,DataLoader 模式就能派上用场。
DataLoader 来拯救!
DataLoader 是一种源自 Facebook 的 GraphQL 模式。它允许将多个独立但针对同一数据库表的查询批量合并为一次查询。这能大幅减少 GraphQL API 发出的请求数量。更重要的是,DataLoader 与 GraphQL 无缝集成,使我们可以在不牺牲性能的前提下,依然使用独立的解析器来定义 API。
在本课程中,我们将使用 Node.js 的 graphql/dataloader 库,但其他编程语言和框架也有多种实现。例如,Apollo Server 和 Mongoose 都有现成的实现。不过,理解其工作原理至关重要,因此我们将在本课程中使用更低层级的库。
总结
尽管本节课主要是理论性的,但我们现在已经更清楚地认识到:如果对 GraphQL API 实现不当,可能会引发大量数据库查询。这会导致应用响应缓慢,严重影响用户体验。