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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容