GraphQL N+1をDataLoaderで潰す——キャッシュ汚染という見落としがちな罠

  • #Node.js
  • #データベース
  • #パフォーマンス

N+1は設計で防ぐ、DataLoaderパターンの実装と落とし穴

GraphQLのリゾルバを書いていると、思ったより簡単にN+1を踏む。ORMの場合はEager LoadingやJOINで大体なんとかなるが、GraphQLはリゾルバが独立して動く構造上、同じ問題をより気づきにくい形で引き起こす。

この記事ではN+1が「なぜ起きるのか」を構造レベルで整理して、DataLoaderによる解決と、本番でやりがちなキャッシュスコープのミスに絞って書く。

なぜリゾルバはN+1を引き起こすのか

たとえばこういうGraphQLスキーマがある。

type Query {
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

posts クエリで10件取得し、各 Postauthor を解決しようとすると、素直に書けばこうなる。

const resolvers = {
  Query: {
    posts: () => db.query("SELECT * FROM posts LIMIT 10"),
  },
  Post: {
    author: (post) => db.query("SELECT * FROM users WHERE id = ?", [post.authorId]),
  },
};

これは posts で1回、各 post.author で10回、合計11クエリ走る。リゾルバが「1件分の処理」として独立して設計されているので、呼び出し側がまとめるタイミングがない。

ORMでも同様の構造は起きる。Sequelizeなどで include を忘れてネストしたリレーションを取ろうとすれば同じことになる。ただORMの場合はクエリログを見て気づきやすい。GraphQLはリゾルバが散在するため、どこで何が走っているか把握しにくい。

DataLoaderでバッチ処理する

DataLoader はFacebookが作ったライブラリで、「イベントループの1ティック内に来たロードリクエストをまとめてバッチ処理する」という仕組みで動く。

import DataLoader from "dataloader";

const userLoader = new DataLoader(async (userIds: readonly string[]) => {
  const users = await db.query("SELECT * FROM users WHERE id = ANY(?)", [userIds]);
  // DataLoaderはキーの順序通りに結果を返す必要がある
  return userIds.map((id) => users.find((u) => u.id === id) ?? null);
});

バッチ関数の戻り値は、受け取ったキー配列と同じ順序・同じ長さでなければならない。これを守らないとデータがずれる。自分が最初にハマったのもここで、JOINで取ってきた結果をそのまま返したら順番がバラバラになり、ユーザーに別人の情報が返った。

リゾルバ側はこうなる。

const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId),
  },
};

これで posts が10件あっても、ユーザーのクエリは1回で済む。DataLoaderが同一ティック内の load 呼び出しをまとめてバッチ関数に渡してくれる。

キャッシュスコープを誤るとデータが汚染される

DataLoaderにはデフォルトでインメモリキャッシュが付いている。同じキーに対して load を複数回呼んでも、2回目以降はキャッシュから返す。

問題はここだ。userLoader をモジュールのトップレベルで1つ作ってしまうと、リクエストをまたいでキャッシュが共有される

// やってはいけない
export const userLoader = new DataLoader(async (ids) => {
  // ...
});

// リゾルバがこれをimportして使っている

ユーザーAへのリクエストでキャッシュされたデータが、ユーザーBのリクエストで返ってくる。権限チェックやマルチテナント構成なら、これは普通にセキュリティインシデントになる。

正しい対処は、リクエストごとにDataLoaderインスタンスを生成すること。

// context生成時にインスタンスを作る
const createContext = ({ req }) => {
  return {
    userId: req.user.id,
    loaders: {
      user: new DataLoader(async (ids: readonly string[]) => {
        const users = await db.query(
          "SELECT * FROM users WHERE id = ANY(?)",
          [ids]
        );
        return ids.map((id) => users.find((u) => u.id === id) ?? null);
      }),
    },
  };
};

// リゾルバはcontextから取る
const resolvers = {
  Post: {
    author: (post, _args, context) => context.loaders.user.load(post.authorId),
  },
};

Apollo ServerでもHonoでも、contextファクトリはリクエストごとに実行されるので、ここでインスタンスを作れば安全にスコープが切れる。

キャッシュが不要な場合、たとえばミューテーション後に確実に最新データを取りたい場合は { cache: false } オプションを渡すか、loader.clear(id)loader.clearAll() でキャッシュを明示的に破棄する。

// ミューテーション後に該当ユーザーのキャッシュを消す
await db.query("UPDATE users SET name = ? WHERE id = ?", [name, userId]);
context.loaders.user.clear(userId);

バッチ関数の中でもN+1は起きる

DataLoaderで1クエリにまとめたつもりでも、バッチ関数の中でループしてクエリを投げると元も子もない。

// これはN+1のまま
const userLoader = new DataLoader(async (ids) => {
  return Promise.all(ids.map((id) => db.query("SELECT * FROM users WHERE id = ?", [id])));
});

バッチ関数の責務は「複数のIDをまとめて1回のDBアクセスで取得する」こと。ANYIN を使ったクエリにしないと、バッチをまとめた意味がない。

DataLoaderは魔法ではなく、バッチ関数の中でちゃんとバルクフェッチするように実装して初めて機能する。当たり前といえば当たり前だが、後から追加されたリゾルバが誰かにコピペされて、バッチ関数の中身が知らぬ間にN+1になっていたケースを何度か見ている。コードレビューでバッチ関数の中身は必ず確認したほうがいい。