GraphQL 和 N+1 SQL 问题以及数据加载器

在本文中,我们将解释在实现高性能GraphQL服务器时无法回避的N+1 SQL问题。

太长不看

    • GraphQL は resolver を個別にかつ再帰的に実行していくため、 RDB のリレーションを効率的に先読みすることができません。そのため一般的に遅延読み込みを行います。

 

    Facebook 社は GraphQL で遅延読み込みするために dataloader という npm パッケージを公開しており、各種言語にその移植版のライブラリが存在しているので、それを使って N+1 SQL 問題を抑制しましょう。

(复习)什么是N+1 SQL问题?

N+1问题是指在一个SQL语句中获取了N条记录后,针对每个记录分别发出N个SQL语句来获取相关记录的情况。为了更好地理解,以下是一个伪代码示例:

# N 個の articles をフェッチする(SQL 1 つ)
articles = Article.all

users = articles.map do |article|
  # article ごとに user をフェッチする(SQL 1 つが N 回)
  article.user
end

当您运行此代码时,将执行以下SQL语句:

-- N 個の articles をフェッチする(SQL 1 つ)
SELECT * FROM articles;
-- article ごとに user をフェッチする(SQL 1 つが N 回)
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
SELECT * FROM users WHERE id = 4;
SELECT * FROM users WHERE id = 5;

就从日志的顺序来看,我觉得称之为1+N SQL更容易理解。但实际上,这就是N+1 SQL问题。执行SQL时会有各种开销。

    • RDB サーバと通信するための時間

 

    • RDBMS が SQL を解析する時間

 

    など

重复执行SQL不仅需要花费时间,而且可能会过度消耗运行数据库的服务器的CPU,从而影响其他会话。

要解决N+1个SQL问题,需要将重复执行N次的SQL合并为一个。通常有两种方法来执行合并后的SQL,取决于执行的时机。

只需要在解决N+1个SQL问题时,将重复执行的N次SQL合并为一个即可。这种情况下,根据执行合并后的SQL的时机,通常又可以分为两种方法。

先読み込み(Eager Loading): 必要になる前に読み込む

遅延読み込み(Lazy Loading): 必要なデータが分かったあとで読み込む

预读

先加载是在许多WAF中广泛支持的,包括Rails在内。

# N 個の articles をフェッチして(SQL 1 つ)
# 同時に N 個の users もフェッチする(SQL 1 つ)
articles = Article.includes(:user).all

users = articles.map do |article|
  # ロード済みなので SQL は発行されない
  article.user
end

在这种情况下,将执行以下SQL语句:

-- N 個の articles をフェッチする(SQL 1 つ)
SELECT * FROM articles;
-- 同時に N 個の users もフェッチする(SQL 1 つ)
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5);

延迟加载

要延迟加载,需要先声明所需的资源,然后一次性加载所需的数据。以下是使用 dataloader gem 的实现示例。

articles = Article.all

user_loader = Dataloader.new do |user_ids|
  result = {}
  # user_ids のデータをまとめてとってくる
  User.where(id: user_ids).each do |user|
    result[user.id] = user
  end
  user_ids.each do |user_id|
    # データが見つからなかったときは nil を使う
    result[user_id] ||= nil
  end
  result
end

promises = articles.map do |article|
  # 必要な user id を表明する。user_loader は promise を返し、SQL は実行しない
  user_loader.load(article.user_id)
end

# まとめた SQL が実行される
promises.first.sync #=> User

# すでに↑でロードされているので SQL は実行されない
promises.second.sync #=> User

这个时候运行的SQL如下:

-- N 個の articles をフェッチする(SQL 1 つ)
SELECT * FROM articles;
-- 同時に N 個の users もフェッチする(SQL 1 つ)
SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5);

预加载 vs 延迟加载

比较预加载和延迟加载。

    • 先読み込み

実装が簡単。データを使うときにはすでにデータがそこにあるので使うのも楽。
データを使う場所と、データを読み込む場所がコード上で離れる傾向があるため、開発をしていくうちに不要なデータを先読みし続けたり、逆に適切に先読みできておらず N+1 SQL が発生したりする。

遅延読み込み

必要なリソースの情報をためる特別な機構が必要なので実装が複雑。
実際に使うデータだけをロードすることが保証される。

长度各异。在GraphQL中会有什么样的情况呢?

执行GraphQL中的查询

让我们考虑一个具有以下 schema 的 GraphQL 服务器:

type Query {
  articles: [Article]
}
type Article {
  id: ID
  user: User
}
type User {
  id: ID
  name: String
}

假设对此服务器执行了以下查询:

{
  articles {
    user {
      id
      name
    }
    id
  }
}

在这种情况下,GraphQL会在完成查询字符串的解析处理等各种工作后,在内部进行以下操作以获取结果:

执行针对 Query 类型的 articles 字段的解析器… (1)针对解析器 (1) 的结果得到的 Article 数组的每个元素,作为输入执行针对 Article 类型的 user 字段的解析器… (2)

针对解析器 (2) 的结果得到的 User,作为输入执行针对 User 类型的 name 字段的解析器,并将其结果用作 name
针对解析器 (2) 的结果得到的 User,作为输入执行针对 User 类型的 id 字段的解析器,并将其结果用作 id

执行针对解析器 (1) 的结果得到的 Article 数组的每个元素,作为输入执行针对 Article 类型的 id 字段的解析器

请参考第 6. Execution 获取更详细信息。

以下是这里的重要点:

    • GraphQL のクエリは木構造になっているので、 GraphQL は実行時に再帰的に resolver を実行していく必要がある

 

    • 配列に要素ごとに resolver を実行するので、 resolver の中で単純に SQL を実行すると N+1 SQL 問題が発生する

 

    resolver はいつどこで実行されるのか、実行結果に対してどういう resolver が呼ばれるのか知らないので、 先読みをすることができない1

在GraphQL中,可以这样来表达:使用GraphQL时,N+1 SQL问题往往会出现,如果不使用延迟加载,要避免这个问题将会很困难。

使用GraphQL进行延迟加载

基于这样的背景,Facebook 公司作为 GraphQL 的开发者,发布了一个名为 dataloader 的库作为参考实现。如果你使用这个库和 Facebook 同样发布的 JavaScript 示例实现,你就能很容易地进行延迟加载。它的基本原理和我们之前提到的 dataloader gem 完全相同。

怎样教授延迟评估

顺便提一句,延迟加载如前所述是在宣告所有必要的数据后一次性解决,从而提高效率。GraphQL会将一个resolver的结果递归地传递给下一个resolver,但要实现延迟加载,GraphQL服务器必须知道是否可以直接使用resolver返回的值,还是需要进行延迟评估。

在JavaScript中,由于语言内置了Promise,因此处理起来非常方便。当resolver返回一个promise时,GraphQL会等待该promise被履行,然后才会进行后续评估。

在没有像JavaScript一样的内置功能的语言中,就需要显式地告诉GraphQL。例如,在graphql-ruby中,可以使用GraphQL::Schema.lazy_resolve来指定。

class MySchema < GraphQL::Schema
  lazy_resolve Promise, :sync
end

只要 graphql-ruby 的 resolver 返回了 Promise 类的实例,它就会将其推迟,并在没有其他任务时执行该实例的 sync 方法,以此来使用其返回值继续执行查询。

在Graph-gophers/dataloader的Go语言实现中,由于没有延迟评估机制,所以在创建struct时会内部创建一个Promise,并在解析器中进行评估。

总结

我介绍了在GraphQL中简单实现时经常遇到的N+1 SQL问题,以及如何使用延迟加载来解决该问题。

首先,延迟加载的实现虽然麻烦,但优点是易于优化。通过在GraphQL的框架上进行延迟加载,可以降低复杂性,并实现比普通实现更灵活、性能更好的API。

可以预读,但可能不会被使用。以本次例子为例,当解析 [文章] 时,可以预读用户,但如果查询是 {文章 { id }},那就会是多余的。↩

从名称上来看,它是将 JS 版的 dataloader 移植到 Ruby 的 dataloader gem。↩

广告
将在 10 秒后关闭
bannerAds