GraphQL中的N+1问题

作者: Pursue | 来源:发表于2023-08-19 12:04 被阅读0次

    开篇

    原文出处

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

    相关文章

      网友评论

        本文标题:GraphQL中的N+1问题

        本文链接:https://www.haomeiwen.com/subject/ubrjmdtx.html