GraphQLのN+1問題を解決する:DataLoaderによる高パフォーマンスAPIの構築

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

GraphQLにおける「静かなるデータベースキラー」

GraphQLを使用すると、クライアントは必要なデータだけをピンポイントで取得できます。これはフロントエンド開発者にとっては素晴らしいことですが、バックエンドでは本番環境に移行するまで気づかないようなパフォーマンスの悪夢を引き起こすことがよくあります。かつて私がソーシャルメディアのダッシュボードをデプロイした際、ステージング環境の50レコードでは完璧に動作していました。しかし、1万人以上のアクティブユーザーがいる本番環境に移行した途端、CPU使用率は100%に達し、レスポンス時間は軽快な150ミリ秒から見るに堪えない5秒へと跳ね上がりました。

その原因は、古典的な「N+1クエリ問題」でした。負荷に耐えうるバックエンドシステムを構築するには、この問題をマスターすることが不可欠です。対策を講じなければ、GraphQLサーバーは実質的に、自社のデータベースに対して自らDoS攻撃(サービス拒否攻撃)を仕掛けているような状態になってしまいます。

N+1問題とは何か?

根本的な原因を理解するために、GraphQLがどのようにデータを解決(リゾルブ)するかを見てみましょう。GraphQLはリゾルバーをネストされた再帰的な方法で実行します。例えば、投稿リストとその投稿者を取得するクエリを考えてみましょう。

query {
  posts {
    id
    title
    author {
      name
    }
  }
}

もしpostsリゾルバーが100件のアイテムを返すと、GraphQLエンジンはauthorリゾルバーを個別に100回実行します。データベースのログは以下のようになります。

SELECT * FROM posts; -- (リスト取得のための1つのクエリ)
SELECT * FROM users WHERE id = 1; -- (+1つ目のクエリ)
SELECT * FROM users WHERE id = 2; -- (+2つ目のクエリ)
... 
SELECT * FROM users WHERE id = 100; -- (+100個目のクエリ)

これが「N+1」の罠です。親レコードを取得するための1つのクエリと、子レコードを取得するためのN個の追加クエリが発生します。1,000件の投稿を取得すると、1回のリクエストで1,001回のデータベース呼び出しが発生することになります。この動作はすぐにデータベースのコネクションプールを枯渇させ、膨大なレイテンシを引き起こします。

戦略の比較:手動Join vs DataLoader

解決策を実装する前に、なぜ従来のSQLの習慣がGraphQLにおいて必ずしも上手くいかないのかを理解しておきましょう。

1. Joinによるアプローチ(SQLスタイル)

最上位のpostsリゾルバーで複雑なSQLのJOINを書こうとするかもしれません。フラットな構造であれば機能しますが、これではGraphQLのモジュール性が損なわれてしまいます。postsリゾルバーは、クライアントが投稿者の名前を求めているのか、あるいは最新の5つのコメントを求めているのかを関知すべきではありません。これを行うと「オーバーフェッチ(過剰なデータ取得)」を招き、コードが密結合で壊れやすくなります。

2. DataLoaderによるアプローチ

DataLoaderは、主にバッチ処理(Batching)キャッシュ(Caching)という2つの技術を使用するユーティリティです。クエリを即座に実行する代わりに、DataLoaderはNode.jsのイベントループの「ティック(tick)」を1回分待ちます。その間にリクエストされたすべてのIDを収集し、それらを一度に取得するための単一のバッチクエリを発行します。

メリットとデメリット

  • 手動Join: データベースへの往復(ラウンドトリップ)を最小限にするには最適ですが、スキーマが大きくなるにつれてメンテナンスが困難になります。
  • DataLoader: リゾルバーをクリーンで独立した状態に保てます。データベースの負荷を大幅に軽減し、リクエスト期間中のキャッシュ機能も組み込まれています。

推奨されるセットアップ

最良のアプローチは、HTTPリクエストごとに新しいDataLoaderインスタンスを作成することです。これにより、キャッシュを単一のユーザーに限定(隔離)できます。データの漏洩を防ぎつつ、その特定クエリの実行パスを最適化できます。

通常、私はDataLoaderをGraphQLのコンテキスト(context)にアタッチします。これにより、ツリー内のどのリゾルバーからでもアクセスできるようになります。

// ApolloまたはYoga用のコンテキスト構造
const context = async ({ req }) => {
  return {
    db,
    loaders: {
      userLoader: createUserLoader(db),
    }
  };
};

実装ガイド:ステップ・バイ・ステップ

標準的なdataloaderライブラリを使用して、実践的な実装を行ってみましょう。

ステップ1:依存関係のインストール

npm install dataloader

ステップ2:バッチ関数の定義

バッチ関数はDataLoaderの心臓部です。キー(IDなど)の配列を受け取り、値の配列に解決されるPromiseを返す必要があります。結果の配列は、入力されたキーの長さおよび順序と完全に一致していなければなりません。

const DataLoader = require('dataloader');

const batchUsers = async (userIds) => {
  // すべてのユーザーを一度に取得
  const users = await db.table('users').whereIn('id', userIds);

  // 素早い検索のためにユーザーをオブジェクトにマッピング
  const userMap = {};
  users.forEach(user => {
    userMap[user.id] = user;
  });

  // userIdsの元の順序を維持する
  return userIds.map(id => userMap[id] || null);
};

const userLoader = new DataLoader(batchUsers);

ステップ3:リゾルバーへの統合

ネストされたリゾルバーでデータベースを直接呼び出すのをやめましょう。代わりに、DataLoaderの.load()メソッドを使用します。

const resolvers = {
  Post: {
    author: (parent, args, context) => {
      // DataLoaderがこれらの呼び出しを自動的にバッチ化します!
      return context.loaders.userLoader.load(parent.authorId);
    }
  }
};

内部の仕組み

  1. postsリゾルバーが100個のアイテムを返します。
  2. authorリゾルバーが100回呼び出されますが、実際にはuserLoader.load(id)をキューに追加するだけです。
  3. DataLoaderは、現在の実行スタックが完了するのを待ちます。
  4. 100個すべてのIDをまとめて、batchUsersを一度だけ実行します。
  5. データベースはSELECT * FROM users WHERE id IN (1, 2, ..., 100)を実行します。
  6. DataLoaderは、それぞれの結果を特定のリゾルバーに返します。

リクエストレベル・キャッシュの威力

DataLoaderは自動的なキャッシュ機能も提供します。もし5つの異なる投稿が同じ投稿者によって書かれていた場合、同じIDでuserLoader.load(authorId)が5回呼び出されます。DataLoaderは重複したIDを認識し、既存のPromiseを返します。重複分をバッチに追加することさえしないため、関連性の高いデータのパフォーマンスが劇的に向上します。

このキャッシュは一時的なものであることに注意してください。1つのHTTPリクエストの生存期間中のみ有効です。これは意図的な設計です。グローバルなキャッシュは、データの不整合や、デバッグが困難なメモリリークを引き起こすことが多いためです。

本番環境におけるベストプラクティス

  • 結果のマッピング: データベースが常に要求した順序でレコードを返すとは限りません。データの不整合を防ぐため、必ず結果を入力IDの順序にマッピングし直してください。
  • 存在しないレコードの処理: レコードが存在しない場合は、そのインデックスに対してnullを返してください。インデックスをスキップしてはいけません。さもないと、データがずれて別の親に割り当てられてしまいます。
  • バッチサイズの制限: 膨大なデータセットを扱う場合は、maxBatchSizeオプション(例:1000)を使用してください。これにより、データベースが解析できないほど巨大なSQLクエリが生成されるのを防げます。

DataLoaderを使いこなせるようになったことで、私のGraphQLバックエンドの設計方法は一変しました。コードの疎結合を保ちながら、手動で調整したSQLのような効率性を実現できます。単なるプロトタイプ以上のものを構築するのであれば、DataLoaderは単なる最適化手段ではなく、必須の要件と言えるでしょう。

Share: