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 {
    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 = (keys: Key[]): Promise;

class DataLoader {
  constructor(batchFn: BatchFn) {
    // todo
  }
  load(key: Key): Promise {
    // todo
  }
}

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

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

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

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

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

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

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

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

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

因此,修改如下:

export default class DataLoader {
  readonly batchFn: BatchFn;
  readonly storage: {
    key: Key;
    promise: Promise;
    resolve: ((entity: Entity) => void) | null;
  }[] = [];
  constructor(batchFn: BatchFn) {
    this.batchFn = batchFn;
  }
  async load(key: Key): Promise {
    let resolve = null;
    const promise = new Promise((res) => (resolve = res));
    this.storage.push({
      key,
      promise,
      resolve,
    });
    if (this.storage.length === 1) {
      setImmediate(() => this.doBatch());
    }
    return promise;
  }
  doBatch(): Promise {
    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 = (keys: K[]) => Promise;

interface PromiseMeta {
  resolve: ((entity: E) => void) | null;
  promise: Promise;
}

interface Options {
  immediate: boolean;
}

export default class DataLoader {
  readonly batchFn: BatchFn;
  readonly cache = new Map>();
  readonly options: Options = {
    immediate: true,
  };
  constructor(batchFn: BatchFn, options?: Options) {
    this.batchFn = batchFn;
    this.options = {
      ...this.options,
      ...options,
    };
  }
  async load(key: K): Promise {
    if (this.options.immediate) {
      if (this.cache.size === 0) {
        setImmediate(() => this.doBatch());
      }
    }

    let resolve = null;
    const promise = new Promise((res) => (resolve = res));
    this.cache.set(key, {
      promise,
      resolve,
    });

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

你可能感兴趣的:(Web,graphql,前端,N+1)