Graphql中的N+1问题

开篇

原文出处

Graphql 是一种 API 查询语言和运行时环境,可以帮助开发人员快速构建可伸缩的 API。然而,尽管 Graphql 可以提供一些优秀的查询性能和数据获取的能力,但是在使用 Graphql 的过程中,开发人员也会遇到一些常见问题,其中最常见的一个问题是 N+1 问题。

什么是 GraphQL 中的 N+1 问题

在 GraphQL 中,N+1 问题指的是在一个查询语句中,某个字段需要通过 N 次额外查询来获取其关联的数据,导致查询效率低下的情况。这个问题的本质是由于 GraphQL 的数据模型本身的特性引起的。

在 GraphQL 中,查询语句可以包含多个字段,每个字段可能需要访问一个不同的数据源。当查询涉及到关联数据时,如果不做特殊处理,GraphQL 会逐个获取每个字段的数据,这可能会导致大量的额外查询,进而影响查询效率。

假设我们有一个电影网站,它有电影和演员两个实体,每部电影都有多个演员。我们可以用 GraphQL 定义如下的 schema:

type Movie {id: ID!title: String!actors: [Actor!]!
}type Actor {id: ID!name: String!age: Int!
}type Query {movies: [Movie!]!
}

现在,我们想要查询所有电影及其演员。我们可以像这样编写 GraphQL 查询:

query {movies {titleactors {name}}
}

在这个查询中,我们获取了所有电影的标题,以及每部电影的所有演员的名称。然而,如果我们没有采取任何措施来解决 N+1 问题,每个电影的演员都将需要单独查询。因此,如果我们有 100 部电影,就会产生 101 次查询(1 次获取电影,100 次获取演员),这会严重影响性能。

解决方案

Data loader

Data loader 是一个常用的解决 N+1 问题的工具,它可以将多个查询合并成一个查询,以减少查询次数。它的工作原理是在执行查询时,将多个相同类型的查询合并成一个批量查询,并将结果缓存起来,以便在需要时快速获取。Data loader 可以轻松地与 GraphQL 集成,并提供了许多可配置的选项,以便根据应用程序的需要进行优化。

下面是一个使用 data loader 的示例代码:

const DataLoader = require('dataloader')
const { actorsByMovieId } = require('./db')const actorsLoader = new DataLoader(async (movieIds) => {const actors = await actorsByMovieId(movieIds)const actorsMap = actors.reduce((acc, actor) => {acc[actor.movieId] = acc[actor.movieId] || []acc[actor.movieId].push(actor)return acc}, {})return movieIds.map((movieId) => actorsMap[movieId] || [])
})const resolvers = {Query: {movies: () => getMovies(),},Movie: {actors: (movie, args, context, info) => actorsLoader.load(movie.id),},
}

在上面的代码中,我们使用 data loader 来批量获取每个电影的演员。当 GraphQL 执行查询时,它将调用 load 函数,并将所有需要获取的电影 ID 传递给它。load 函数将所有电影 ID 作为参数,并从数据库中获取所有与这些电影相关的演员。然后,它将演员按电影 ID 分组,并将结果返回到 GraphQL 查询中。由于使用了 data loader,我们现在只需要进行一次查询来获取所有电影及其演员。

Join Monster

Join Monster 是一个解决 GraphQL N+1 问题的工具,它使用了 SQL 批量操作的思想。Join Monster 的主要思想是将多个 GraphQL 解析器的数据请求合并成一个 SQL 查询。这个 SQL 查询是经过优化的,只会查询数据库中需要的数据。同时,Join Monster 还使用了多级缓存来减少数据库的访问次数。

在代码层面,使用 Join Monster 时,我们需要先定义一个解析器,然后在 GraphQL 的 schema 中使用该解析器来查询数据。以下是一个使用 Join Monster 的示例代码:

const joinMonster = require('join-monster').default
const { GraphQLObjectType, GraphQLList } = require('graphql')
const db = require('./db')
const { UserType } = require('./userType')const CommentType = new GraphQLObjectType({name: 'Comment',fields: {id: { type: GraphQLInt },content: { type: GraphQLString },user: {type: UserType,resolve: (parent, args, context, resolveInfo) => {return joinMonster(resolveInfo, {}, (sql) => {return db.query(sql)})},},},
})const Query = new GraphQLObjectType({name: 'Query',fields: {comments: {type: new GraphQLList(CommentType),resolve: (parent, args, context, resolveInfo) => {return joinMonster(resolveInfo, {}, (sql) => {return db.query(sql)})},},},
})module.exports = new GraphQLSchema({ query: Query })

在上述代码中,我们定义了一个 CommentType,它包含了一个 user 字段,该字段使用 Join Monster 进行了解析。同时,我们还定义了一个 Query,该 Query 包含了 comments 字段,使用了 joinMonster 进行解析。在 resolve 函数中,我们将 Join Monster 的解析器传入,并在其中使用了 db.query 函数执行了查询。

假设我们有如下 GraphQL 查询:

{comments {idcontentuser {idname}}
}

在使用 Join Monster 之前,该查询需要进行 N+1 次 SQL 查询,每个 comment 对应一次查询,每个 user 对应一次查询。

在使用 Join Monster 之后,我们的查询只需要进行一次 SQL 查询。Join Monster 会根据 GraphQL 查询中的字段生成相应的 SQL 查询语句,并在数据库中执行该语句。以下是 Join Monster 生成的 SQL 语句的示例:

SELECT`Comment`.`id`,`Comment`.`content`,`User`.`id` AS `user.id`,`User`.`name` AS `user.name`
FROM`Comment`
LEFT JOIN`User`
ON`Comment`.`userId` = `User`.`id`

这个 SQL 查询语句会同时返回 comments 和它们对应的 users 的信息。由于只进行了一次 SQL 查询,Join Monster 大大减少了数据库访问的次数,从而提升了性能。

方案对比

方案优点缺点适用场景
dataloader可以自动处理 N+1 查询问题;可以使用缓存机制提高性能;比较成熟稳定,社区支持度高不能自动处理多层嵌套,对复杂查询支持不够好,需要手动编写基于 dataloader 嵌套查询适用于中小规模的项目,需要快速上手,提高开发效率的场景
join-monster可以自动生成高效的 SQL 查询,性能优秀; 可以自动处理多层嵌套的 N+1 查询问题依赖于 SQL 数据库,不适用于非 SQL 数据库场景(需要将 Graphql 当作 ORM)适用于需要高性能的场景,需要处理复杂查询场景

Data loader 的实现

考虑到 dataloader 比较好实现,且使用广泛,我们选取它进行简单的实现,以此更加深入的理解它是如何解决 N+1 问题的。

根据DataLoader的使用例子来看,DataLoader除了构造器以外,只有一个 load 方法,所以一个简单的 DataLoader 的声明如下:

type BatchFn = <Key, Entity>(keys: Key[]): Promise<Entity[]>;class DataLoader<Key, Entity> {constructor(batchFn: BatchFn<Key, Entity>) {// todo}load(key: Key): Promise<Entity> {// todo}
}

load 方法只是加入到 batch 的队列中,并不会立刻执行,执行条件是“没有地方调用 load 后“,才会执行整个 batch 队列的请求。于是有了一个小实现:

class DataLoader<Key, Entity> {readonly batchFn: BatchFn<Key, Entity>;readonly keys: Key[] = [];constructor(batchFn: BatchFn<Key, Entity>) {this.batchFn = batchFn;}async load(key: Key): Promise<void> {this.keys.push(key);if (this.keys.length === 1) {this.doBatch();//I hope it executes later}}doBatch(): Promise<Entity[]> {return this.batchFn(this.keys);}
}

代码很简单,只是遗留了一个问题,也是最重要的问题,如何让this.doBatch能够延迟行,延迟到所有的 load 同步方法调用完后。

此时就需要利用事件循环来改变它的执行顺序:

setImmediate(() => this.doBatch())

因为setImmediate会在回调阶段执行,因此会等到所有同步方法完成在执行。

一个DataLoader的最小实现就产生了:

class DataLoader<Key, Entity> {readonly batchFn: BatchFn<Key, Entity>;readonly keys: Key[] = [];constructor(batchFn: BatchFn<Key, Entity>) {this.batchFn = batchFn;}async load(key: Key): Promise<void> {this.keys.push(key);if (this.keys.length === 1) {setImmediate(() => this.doBatch());}}doBatch(): Promise<Entity[]> {return this.batchFn(this.keys);}
}

可是它的功能很局限,load 方法不能返回任何的值,Graphql 的 resolve 也就解析不了了。

因此,修改如下:

export default class DataLoader<Key, Entity> {readonly batchFn: BatchFn<Key, Entity>;readonly storage: {key: Key;promise: Promise<Entity>;resolve: ((entity: Entity) => void) | null;}[] = [];constructor(batchFn: BatchFn<Key, Entity>) {this.batchFn = batchFn;}async load(key: Key): Promise<Entity> {let resolve = null;const promise = new Promise<Entity>((res) => (resolve = res));this.storage.push({key,promise,resolve,});if (this.storage.length === 1) {setImmediate(() => this.doBatch());}return promise;}doBatch(): Promise<void> {const keys = this.storage.map(({ key }) => key);return this.batchFn(keys).then((entities) =>entities.forEach((entity, index) => {const { resolve } = this.storage[index];resolve && resolve(entity);}));}
}

doBatch将结果依次给到 load 当时挂载的 promise 上,这样以来 resolver 中的 promise 状态就会由 pending 转化为 fulfilled。

当然,为了考虑性能和健壮性,我们还可以继续扩展:

  • 增加缓存
  • 捕获异常
  • 支持手动执行 batch

最终完善如下(github repo):

type BatchFn<K, E> = (keys: K[]) => Promise<E[]>;interface PromiseMeta<E> {resolve: ((entity: E) => void) | null;promise: Promise<E>;
}interface Options {immediate: boolean;
}export default class DataLoader<K, E> {readonly batchFn: BatchFn<K, E>;readonly cache = new Map<K, PromiseMeta<E>>();readonly options: Options = {immediate: true,};constructor(batchFn: BatchFn<K, E>, options?: Options) {this.batchFn = batchFn;this.options = {...this.options,...options,};}async load(key: K): Promise<E> {if (this.options.immediate) {if (this.cache.size === 0) {setImmediate(() => this.doBatch());}}let resolve = null;const promise = new Promise<E>((res) => (resolve = res));this.cache.set(key, {promise,resolve,});return promise;}doBatch(): Promise<void> {const keys = [...this.cache.keys()];return this.batchFn(keys).then((entities) =>entities.forEach((entity, index) => {const promiseMeta = this.cache.get(keys[index]);if (promiseMeta) {const { resolve } = promiseMeta;resolve && resolve(entity);}})).catch(() => this.cache.clear());}dispatch(): Promise<void> {if (!this.options.immediate) {return this.doBatch();}throw new Error("Doesn't allow to dispatch given immediate is true!");}
}

最后

在本文中,我们深入探讨了 GraphQL 中的 N+1 问题。首先,我们介绍了 GraphQL 中常见的一些问题,例如查询过度嵌套和查询重复等。然后,我们详细介绍了 N+1 问题的定义及其出现的原因。接着,我们给出了具体的例子,并讨论了 N+1 问题对性能的影响。在解决 N+1 问题方面,我们列举了几种工具,包括 Batch loading、Data loader 和 Join Monster,并展示了它们在代码层面上的使用。我们还对这些工具的优缺点进行了比较和分析,并给出了最佳实践。

最后,我们介绍了一些避免 N+1 问题的最佳实践,例如避免嵌套查询、使用 GraphQL 片段和优化查询。这些实践可以帮助开发人员避免 N+1 问题并提高查询性能。

总的来说,N+1 问题是 GraphQL 中常见的性能问题之一,但是通过合适的工具和最佳实践,我们可以有效地解决它,提高查询性能,为用户提供更好的体验。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/44643.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

ajax-axios-url-form-serialize 插件

AJAX AJAX 概念 1.什么是 AJAX ? mdn 使用浏览器的 XMLHttpRequest 对象 与服务器通信 浏览器网页中&#xff0c;使用 AJAX技术&#xff08;XHR对象&#xff09;发起获取省份列表数据的请求&#xff0c;服务器代码响应准备好的省份列表数据给前端&#xff0c;前端拿到数据数…

PCTA 认证考试高分通过经验分享

作者&#xff1a; msx-yzu 原文来源&#xff1a; https://tidb.net/blog/0b343c9f 序言 我在2023年8月10日&#xff0c;参加了 PingCAP 认证 TiDB 数据库专员 V6 考试 &#xff0c;并以 90分 的成绩通过考试。 考试总分是100分&#xff0c;超过60分就算通过考试。试卷…

Mysql 使用JSON_SEARCH函数 判断多表查询时,某个拼接字段是否包含另外一个字段

场景 两个表管理查询 关联字段为A表id, B表的ids A表id是正常的整数, B的ids是id拼接成的字符类型, 格式是111,222,333这样的. A: B: id ids11 11,22,3322 33,44,5533 …

Dubbo 核心概念和架构

以上是 Dubbo 的工作原理图&#xff0c;从抽象架构上分为两层&#xff1a;服务治理抽象控制面 和 Dubbo 数据面 。 服务治理控制面。服务治理控制面不是特指如注册中心类的单个具体组件&#xff0c;而是对 Dubbo 治理体系的抽象表达。控制面包含协调服务发现的注册中心、流量管…

区块链碎碎念

现在的区块链早已过了跑马圈地的时代&#xff0c;现在还按照以前承接项目的方式做区块链只能是越来越艰难。经过几年的技术沉淀&#xff0c;做区块链项目的公司都已经没落的七七八八了。 区块链不是一个能够快速显现盈利能力的行业&#xff0c;相反这个行业目前的模式还是处于…

算法通关村第十关 | 快速排序

1.快速排序的基本过程 快速排序是分治法运用到排序问题的典型例子&#xff0c;基本思想是&#xff1a;通过一个标记pivot元素将n个元素的序列划分为左右两个子序列left和right&#xff0c;其中left中的元素都比pivot小&#xff0c;right的都比pivot的大&#xff0c;然后再次对l…

mybatis入门的环境搭建及快速完成CRUD(增删改查)

又是爱代码的一天 一、MyBatis的介绍 ( 1 ) 背景 MyBatis 的背景可以追溯到 2002 年&#xff0c;当时 Clinton Begin 开发了一个名为 iBATIS 的持久化框架。iBATIS 的目标是简化 JDBC 编程&#xff0c;提供一种更直观、易用的方式来处理数据库操作。 在传统的 JDBC 编程中&…

登录失败的 JSON 提示

登录失败的 JSON 提示 如果失败了&#xff0c;默认会跳转到Web工程根目录下的/login.jsp页面&#xff0c;可以配置factoryBean.setLoginUrl(“/myLogin”); 认证失败后会跳转到 setLoginUrl 这个方法指定的路径中 FormAuthenticationFilter 是 Shiro 框架中用于处理基于表单的身…

arm:day4

1. 实现三盏灯的点亮 .text .global _start_start: led1初始化函数LED_INIT: 1 通过RCC_AHB4_ENSETR寄存器&#xff0c;设置GPIOE F组控制器使能 0x50000A28[5:4]1ldr r0,0X50000A28ldr r1,[r0]orr r1,r1,#(0X3<<4)str r1,[r0] 2.1 通过GPIOE_MODER寄存器&#xff0c;…

Jmeter生成可视化的HTML测试报告

Jmeter也是可以生成测试报告的。 性能测试工具Jmeter由于其体积小、使用方便、学习成本低等原因&#xff0c;在现在的性能测试过程中&#xff0c;使用率越来越高&#xff0c;但其本身也有一定的缺点&#xff0c;比如提供的测试结果可视化做的很一般。 不过从3.0版本开始&…

【排序】选择排序

文章目录 选择排序时间复杂度空间复杂度稳定性 代码 选择排序 以从小到大为例进行说明。 选择排序就是定义出一个最小值下标&#xff0c;然后遍历整个剩下的数组选择出最小的放进最小值下标的位置。 时间复杂度 O(N) 遍历一次即可 空间复杂度 O(1) 稳定性 不稳定 代码 p…

HJ21 简单密码

描述 现在有一种密码变换算法。 九键手机键盘上的数字与字母的对应&#xff1a; 1--1&#xff0c; abc--2, def--3, ghi--4, jkl--5, mno--6, pqrs--7, tuv--8 wxyz--9, 0--0&#xff0c;把密码中出现的小写字母都变成九键键盘对应的数字&#xff0c;如&#xff1a;a 变成 2&…

微信小程序胶囊位置计算,避开胶囊位置

由于小程序在不同的手机上顶部布局会发生变化&#xff0c;不能正确避开胶囊位置&#xff0c;所以通过官方给出的胶囊信息&#xff0c;可以计算出胶囊位置&#xff0c;并避开 图示例&#xff1a; 此处思路是&#xff0c;获取胶囊底部位置&#xff0c;并拉开10个px 计算出来的…

vue实现穿梭框,ctrl多选,shift多选

效果图 代码 <template><div class"container"><!--左侧--><div><div class"title">{{ titles[0] }}</div><div class"layerContainer"><div v-for"item in leftLayerArray":key"…

【高阶数据结构】红黑树详解

文章目录 前言1. 红黑树的概念及性质1.1 红黑树的概念1.2 红黑树的性质1.3 已经学了AVL树&#xff0c;为啥还要学红黑树 2. 红黑树结构的定义3. 插入&#xff08;仅仅是插入过程&#xff09;4. 插入结点之后根据情况进行相应调整4.1 cur为红&#xff0c;p为红&#xff0c;g为黑…

局部变量可能会引发的错误---误判---用阶乘函数求强数的 Python 程序中遇到的问题

背景介绍 今天遇到这样一个额需求&#xff1a;用阶乘函数求强数的 Python 程序。所谓强数&#xff0c;就是一个特殊数字&#xff0c;满足其所有数字阶乘的和应等于数字本身的条件。 问题描述 def facorial_of_number(number):result 1for i in range(1, number 1):result *…

macOS - 安装 GNU make、cmake

文章目录 关于 cmake使用 brew 安装 关于 GNU make方式一&#xff1a;brew方式二&#xff1a;下载源码 关于 cmake 官网&#xff1a;https://cmake.org/ 使用 brew 安装 brew 安装 cmake: https://formulae.brew.sh/formula/cmake安装使用 brew : https://blog.csdn.net/lovec…

自己实现 SpringMVC 底层机制 系列之--实现任务阶段 2- 完成客户端浏览器可以请求控制层

&#x1f600;前言 本文是自己实现 SpringMVC 底层机制的第二篇之完成实现任务阶段 2- 完成客户端浏览器可以请求控制层 &#x1f3e0;个人主页&#xff1a;尘觉主页 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TabAxc96-1692497234107)(https://…

什么是LAXCUS分布式操作系统?

相较Linux、Windows&#xff0c;Laxcus是同时在多台计算机上运行的操作系统&#xff0c;处理大规模、高并发、高性能业务&#xff0c;其特点是资源共享和任务并行&#xff0c;并实现【数存算管】超融合一体化。环境中的资源&#xff1a;CPU、GPU、内存、硬盘、网络&#xff0c;…

机器学习/深度学习需要掌握的linux基础命令

很多深度学习/机器学习/数据分析等领域&#xff08;或者说大多数在Python环境下进行操作的领域&#xff09;的初学者入门时是在Windows上进行学习&#xff0c;也得益于如Anaconda等工具把环境管理做的如此友善 但如果想在该领域继续深耕&#xff0c;一定会与Linux操作系统打交…