Node.js で DataLoader を使って GraphQL の N+1 問題を解決する

目次

概要

こんにちは、6 月にハイヤールーに入社した @okarin です。今回は GraphQL のサーバーサイドを開発している際に発生した N+1 問題を、 graphql/dataloader を使って解決することができたので、記事にまとめました。

なぜ GraphQL で N+1 問題が起こるのか

まず、スキーマが以下のように定義されているとします。

type Author {
  id: Int
  name: String
  avatar: name
  posts: [Post!]!
}

type Post {
  id: Int
  title: String
  content: String
  author: Author
}

type Query {
  posts: [Post!]!
  author(id: Int!): Author
}

このようにスキーマが定義されているとき、 Post の一覧を表示するページで、 Author の情報も取得したいとします。そうすると、 query は以下のようになります。

query {
  posts {
    title
    author {
      name
      avatar
    }
  }
}

このとき、 PostAuthor の情報がそれぞれ異なる DB テーブルに保存されている、または異なるマイクロサービスから取得するようになっているとします。すると、それぞれの Post に対して Author の情報を取得するリクエストを個々に送るようになってしまいます。したがって、仮に Post を 10 件取得したとすると、各 Post に紐づく Author のデータを取得しようとするので、データベースやマイクロサービスなどに対するリクエストが 10 回発生します。このように、意図しない N+1 問題が容易に発生してしまいます。擬似的なコードで表現すると、以下のようになります。

Query.posts() -> posts
for each post in posts
  Post.Author() -> author (ここで Post が10件あると、リクエストが10回発生する)
  Author.name() -> name
  Author.avatar() -> avatar

この問題は、 DataLoader を使って解決することができます。具体的には、リクエストを逐次的に処理するのではなく、一定時間待ってから、1 つのリクエストにまとめて処理します。この仕組みに関しては後述します。

DataLoader の使い方

ここでは簡単な例をみていきます。以下のように DataLoader インスタンスを生成して、 load 関数を実行するだけで、複数発生するはずだったリクエストが 1 つにまとめられます。

// getUsers は実際は DB へのリクエスト、または他のマイクロサービスなどへのリクエストになる
// 今回は簡単のため、適当なデータを返す
const getUsers = async (userIds: readonly number[]) => {
  return userIds.map((userId): User => ({ name: `user${userId}` }));
}

// DataLoader のコンストラクタの引数には、
// load 関数などで渡す値の配列を引数にしてバッチ処理を行う関数を渡す
const UserLoader = new DataLoader<number, User>(userIds => getUsers(userIds));

// 以下の3つのリクエストがまとめて1つのリクエストとして送られる
const promise1 = UserLoader.load(1);
const promise2 = UserLoader.load(2);
const promise3 = UserLoader.load(3);

const [user1, user2, user3] = await Promise.all([promise1, promise2, promise3]);

複数の userId を渡したいときには、 loadMany を使います。このときは返り値の型が (User | Error)[] のようになるので、ハンドリングが必要となります。

const users123OrError = await UserLoader.loadMany([1, 2, 3]);

// loadMany の場合は、返り値の型が (User | Error)[] になるのでハンドリングが必要
const users123 = users123OrError.reduce((all, userOrError) => {
  if (userOrError instanceof Error) {
    // 実際には適切にハンドリングする
    throw userOrError;
  }
  return all.concat(userOrError);
}, []);

実際にプロダクトで DataLoader を利用する場合は、 context などに DataLoader インスタンスを格納しておいて、利用したい場面で context から取り出して利用するようになるかと思います。

DataLoader の仕組み

次に、 DataLoader の仕組みを簡単にみてみたいと思います。DataLoader には 2 つの特徴があり、それぞれ Batching と Caching になります。

Batching

DataLoader を使うことで、リクエストをまとめて送ることができます。これは DataLoader のテストコードをみるとイメージしやすくなるので、以下でみていきます。

まず、 idLoader に関してですが、これは DataLoader インスタンスと、 loadCalls という配列を返す関数になります。 DataLoader のコンストラクタに渡したコールバックが実行されると、 loadCallskeys を格納するようになっています。この loadCalls がどのように変化していくかが重要になります。

以下の例では、 identityLoader.load12 に対して別々に実行されていますが、 loadCalls[[1, 2]] となっており、リクエストが 1 つにまとめられていることが分かります。このように、一定時間リクエストをためておいて、まとめてリクエストを処理することができます。

function idLoader<K, C = K>(
  options?: Options<K, K, C>
): [DataLoader<K, K, C>, Array<$ReadOnlyArray<K>>] {
  const loadCalls = [];
  const identityLoader = new DataLoader((keys) => {
    loadCalls.push(keys);
    return Promise.resolve(keys);
  }, options);
  return [identityLoader, loadCalls];
}

it("batches multiple requests", async () => {
  const [identityLoader, loadCalls] = idLoader<number>();

  const promise1 = identityLoader.load(1);
  const promise2 = identityLoader.load(2);

  const [value1, value2] = await Promise.all([promise1, promise2]);
  expect(value1).toBe(1);
  expect(value2).toBe(2); // 別々に load を実行したが、まとめて callback が実行されている

  expect(loadCalls).toEqual([[1, 2]]);
});

どうやって一定時間待っているのかというと、 Node.js の実装の場合、 README によると、 Node.js のイベントループの最小単位である 1 フレーム(1 tick)で発生した個々のロードをまとめ上げてから、1 つのリクエストとして実行するようになっています。

DataLoader will coalesce all individual loads which occur within a single frame of execution (a single tick of the event loop) and then call your batch function with all requested keys.

single tick の概念は Node.js に精通していないと難しいと思いますので、また次の機会に深掘りしてみたいと思います。ちなみに、以下のように任意の時間待ってからリクエスト実行するように設定することもできます。

const myLoader = new DataLoader(myBatchFn, {
  // 100ms 待ってからリクエストを実行する
  batchScheduleFn: callback => setTimeout(callback, 100),
});

Caching

Caching の仕組みも DataLoader のテストコードをみるとイメージしやすいです。

まず初めに、 AB に対して、 load 関数を実行します。すると、それぞれをまとめてコールバックが実行されるので、 loadCalls には [["A", "B"]] が格納されます。

const [a, b] = await Promise.all([
  identityLoader.load("A"),
  identityLoader.load("B"),
]);

// ["A", "B"] を引数として、
// DataLoader に渡したコールバックが実行されている
expect(loadCalls).toEqual([["A", "B"]]);

続いて、 AC に対して、 load 関数を実行します。すると、 A はすでにキャッシュされているので、 C だけを引数に渡してコールバックが実行されます。したがって loadCalls には ["C"] が追加されます。

const [a2, c] = await Promise.all([
  identityLoader.load("A"),
  identityLoader.load("C"),
]);

// "A" はすでにキャッシュされているので、
// "C" だけ引数に渡してコールバックが実行されている
expect(loadCalls).toEqual([["A", "B"], ["C"]]);

最後に、 A , B , C に対して load 関数を実行します。すると、それぞれ既にキャッシュされているので、コールバックは実行されず、 loadCalls も前述のときと変化しないようになります。

const [a3, b2, c2] = await Promise.all([
  identityLoader.load("A"),
  identityLoader.load("B"),
  identityLoader.load("C"),
]);

// "A", "B", "C" いずれもキャッシュされているので
// コールバックは実行されていない
expect(loadCalls).toEqual([["A", "B"], ["C"]]);

このように、重複したキーに対して load 関数を実行しても、結果がキャッシュされて無駄なリクエストを防ぐことができます。

まとめ

GraphQL で発生する N+1 問題を、DataLoader を使って解決する方法とその仕組みを簡単にみてきました。 Batching の機能によって、データのリクエストを一定時間ためてから、1 つのリクエストにまとめて送ることができます。これにより GraphQL で発生する N+1 問題を解決することができます。 さらに Caching の機能によって、同じキーに対するリクエストは再度行わないようになっており、効率の良いデータ通信が可能となっています。

DataLoader のコードは非常に短くて読みやすいので、実装の詳細が気になった方はぜひコードを読んでみてください。