开篇
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 {
title
actors {
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 {
id
content
user {
id
name
}
}
}
在使用 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 中常见的性能问题之一,但是通过合适的工具和最佳实践,我们可以有效地解决它,提高查询性能,为用户提供更好的体验。