[GraphQL] N+1問題を解決するDataLoaderの仕組みとサンプル実装

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

GraphQLサーバのN+1問題とは

GraphQLのQueryは、取得したいデータのノードを辿って必要なデータを一度に取得できることが強みです。しかしその一方で、GraphQLサーバのデータアクセス層ではノードが多く・深くなればなるほど、データソースへのアクセス回数が増加します。例えば以下のようなクエリでは、純粋にデータアクセスを行うと、以下のようなSQLが発行されることが想像できます。これがN+1問題です。

{
  books(first: 10) {
    title
    author {
      name
    }
  }
}
# 最初にbookを10件取得したあと
SELECT * FROM books limit 10;

# Authorのリゾルバでauthorを1件ずつ取得する
SELECT * FROM authors WHERE id = ?; # ? = 1
SELECT * FROM authors WHERE id = ?; # ? = 2
SELECT * FROM authors WHERE id = ?; # ? = 3
SELECT * FROM authors WHERE id = ?; # ? = 4
SELECT * FROM authors WHERE id = ?; # ? = 5
SELECT * FROM authors WHERE id = ?; # ? = 6
SELECT * FROM authors WHERE id = ?; # ? = 7
SELECT * FROM authors WHERE id = ?; # ? = 8
SELECT * FROM authors WHERE id = ?; # ? = 9
SELECT * FROM authors WHERE id = ?; # ? = 10

通常のAPIであれば、テーブルをJOINして子要素を取得することを考えますが、GraphQLの場合はどのようなノードの組み合わせでQueryがリクエストされるか分かりません。全部を網羅的に取得するSQLにしてしまうと、要求されていないノードのデータまで取得することになり無駄が多くパフォーマンスが劣化しかねません。

DataLoader

そこで使用するのがDataLoaderというライブラリです。DataLoaderは、データアクセス層が効率的にデータ取得を行うための「バッチ処理機能」と「キャッシュ機能」を抽象化したものです。GraphQLサーバに限らず一般的にも使えるライブラリです。また、JavaScriptの実装はリファレンス実装とされており、他の多くの言語にも移植されています。

なお、本記事の説明はDataLoader: v2.0.0 時点の内容です。

バッチ処理関数

DataLoaderを使うには、DataLoaderのコンストラクタにバッチ処理用の関数を渡す必要があります。関数のシグネチャは以下のとおりです。K はレコードのキー項目となる値の型、 V は取得するレコードの型です。

type BatchLoadFn<K, V> =
  (keys: ReadonlyArray<K>) => PromiseLike<ArrayLike<V | Error>>;

https://github.com/graphql/dataloader/blob/master/src/index.d.ts#L73

冒頭のQueryを例にすると、以下のような関数になります。

const authorBatchLoadFn = (keys: string[]): Promise<(Author | Error)[]> => {
    // データソースがRDBであれば、以下のようなSQLを発行する
    // SELECT * FROM authors WHERE id in (keys);
}

またこの関数には、守らなければならないいくつかの制約があります。

  • keysの長さと、戻り値の配列の長さが等しいこと
  • keysの順序と、戻り値の配列の順序が等しいこと
    • データの一部が取得できない場合は、Errorオブジェクトを含めて返します

バッチ処理のスケジューラ

バッチ処理にするということは、いくつかの処理をまとめて遅延実行させるということになります。この遅延実行をコントロールする部分がスケジューラと呼ばれています。この部分がDataLoaderの最大の肝と言えると思います。

JavaScriptの実装では、ランタイムのイベントループをハックするような仕組みになっています。興味ある方はこちらをご参照ください。

https://github.com/graphql/dataloader/blob/master/src/index.js#L232

完全に理解できてないので雰囲気で説明すると、まずデータの遅延読み込みを行う#load() 関数はPromiseを返します。このPromiseはDataLoaderのインスタンス内のバッファにも格納されており、process.nextTick() を用いて、現在のコールスタックが終了した直後に解決(実際のデータ取得が行われる)ようになっています。

このスケジューラの挙動は、オプションで変更が可能です。また単位時間ごとに処理することも可能です。JavaScriptのようなイベントループの仕組みがない言語(ランタイム環境)では、単位時間ごとに処理を実行する仕組みが採用されるているようです。

インターフェイス

DataLoaderには「バッチ処理機能」と「キャッシュ機能」のための抽象的なインターフェイスが用意されています。できることはいたってシンプルです。

インターフェイス 説明
load(key) データを1件遅延取得します
loadMany(keys) データを複数件遅延取得します
clear(key) キャッシュを1件削除します
clearAll() キャッシュを全件削除します
prime(key, value) loadやloadManyを使ってデータを取得する前にキャッシュを作成します

Apollo ServerにDataLoaderを組み込んでみる

簡単なサンプルプロジェクトで実際の動作を確認してみます。プロジェクト構成などは以前の記事をご参照ください。本記事ではDataLoaderに関係する箇所を中心に記載します。

https://dev.classmethod.jp/articles/apollo-server-restdatasource-and-cache/

Schema定義

以下のスキーマを定義します。books Queryからスタートして、author→books→author→...とノードをループすることができます。

type Book {
  title: String
  author: Author
}

type Author {
  name: String
  books: [Book]
}

type Query {
  books: [Book]
}

ResolverとDataSourceの実装

Book.authorAuthor.books のリゾルバにDataLoaderを使用するように設定します。

import { ApolloServer, gql } from 'apollo-server'
import { DataSource } from 'apollo-datasource'
import DataLoader from 'dataloader'

const books = [
  {
    id: "0",
    title: 'The Awakening',
    authorId: "0",
  },
  {
    id: "1",
    title: 'City of Glass',
    authorId: "1",
  },
]

const authors = [
  {
    id: "0",
    name: 'Kate Chopin',
  },
  {
    id: "1",
    name: 'Paul Auster',
  },
]

class LibraryAPI extends DataSource {

  context
  authorLoader
  bookLoader

  constructor() {
    super()
        // DataLoaderのインスタンスを作成
    this.authorLoader = new DataLoader((ids: string[]) => this.getAuthorsByIds(ids))
    this.bookLoader = new DataLoader((ids: string[]) => this.getBooksByIds(ids))
  }

  initialize({ context, cache }) {
    this.context = context
  }

  async getBooks() {
    console.log('Call getBooks()')
    return books
  }

  // DataLoad関数
  async getAuthorsByIds(ids: string[]) {
    console.log('Call getAuthorsByIds()', ids)
    return authors.filter((author) => ids.includes(author.id))
  }

  // DataLoad関数
  async getBooksByIds(ids: string[]) {
    console.log('Call getBooksByIds()', ids)
    return books.filter((book) => ids.includes(book.id))
  }
}

const resolvers = {
  Query: {
    books: async (parent, args, { dataSources }, info) => {
      console.log("books resolver")
      return dataSources.libraryAPI.getBooks()
    },
  },
  Book: {
    author: async (parent, args, { dataSources }) => {
      console.log("author resolver")
      const author = dataSources.libraryAPI.authorLoader.load(parent['authorId'])
      return author
    }
  },
  Author: {
    books: async (parent, args, { dataSources }) => {
      console.log("author.books resolver")
      const bookIds = books.filter((book) => book.authorId === parent['id']).map((book) => book.id)
      const authorBooks = dataSources.libraryAPI.bookLoader.loadMany(bookIds)
      return authorBooks
    }
  }
}

const server = new ApolloServer({
  typeDefs,
  resolvers,
  debug: true,
  dataSources: () => {
    return {
      libraryAPI: new LibraryAPI()
    }
  },
  playground: true,
})

server.listen({ port: process.env.PORT || 4000 }).then(({ url }) => {
  console.log(`🚀  Server ready at ${url}`)
})

動作確認

サンプル1

Query

{
  books {
    title
    author {
      name
    }
  }
}

Result

{
  "data": {
    "books": [
      {
        "title": "The Awakening",
        "author": {
          "name": "Kate Chopin"
        }
      },
      {
        "title": "City of Glass",
        "author": {
          "name": "Paul Auster"
        }
      }
    ]
  }
}

サーバのログ

books resolver
Call getBooks()
author resolver
author resolver
Call getAuthorsByIds() [ '0', '1' ]

getAuthorsByIds() がまとめて1回実行されたことが分かります。

サンプル2

Query

{
  books {
    title
    author {
      name
      books {
        title
        author {
          name
        }
      }
    }
  }
}

Result

{
  "data": {
    "books": [
      {
        "title": "The Awakening",
        "author": {
          "name": "Kate Chopin",
          "books": [
            {
              "title": "The Awakening",
              "author": {
                "name": "Kate Chopin"
              }
            }
          ]
        }
      },
      {
        "title": "City of Glass",
        "author": {
          "name": "Paul Auster",
          "books": [
            {
              "title": "City of Glass",
              "author": {
                "name": "Paul Auster"
              }
            }
          ]
        }
      }
    ]
  }
}

サーバのログ

books resolver
Call getBooks()
author resolver
author resolver
Call getAuthorsByIds() [ '0', '1' ]
author.books resolver
author.books resolver
Call getBooksByIds() [ '0', '1' ]
author resolver
author resolver

getBooksByIds() がまとめて1回実行されたことが分かります。さらに末端のauthor resolverではDataLoaderのキャッシュが効いているため getAuthorsByIds() が実行されていない事がわかります。

サンプル3

前のサンプルでは getBooksByIds() が1回だけ実行されましたが、そもそもbooks resolverでbooksは読み込まれているのでこれをキャッシュしてあげるほうが良さそうです。loadする前にキャッシュさせるにはprimeというインターフェイスを利用します。

async getBooks() {
  console.log('Call getBooks()')
  // 以下を追加
  books.forEach((book) => {
    this.bookLoader.prime(book.id, book)
  })
  return books
}

そうして前のサンプルと同じQueryをリクエストするとサーバのログは以下のようになりました。

books resolver
Call getBooks()
author resolver
author resolver
Call getAuthorsByIds() [ '0', '1' ]
author.books resolver
author.books resolver
author resolver
author resolver

getBooksByIds() が実行されなくなったことが確認できました。

おわりに

DataLoaderを使うことでデータアクセス層で発生するN+1回のリクエストが解消されることが確認できました。スケジューラの挙動が理解できていないので、何かわからんけどうまくやってくれていてすごい という感想になってしまいます。悔しいのでもう少し調べたいと思います。