Article

GraphQL DataLoaderでN+1問題を解決する実装法

高田晃太郎
GraphQL DataLoaderでN+1問題を解決する実装法

大量の子エンティティを伴うGraphQLクエリは、無自覚のうちにDB往復を線形に増やし、レイテンシを数倍に悪化させることが珍しくありません[1,2]。ローカルの検証例では、200ユーザーに紐づくプロフィールを取得する素朴なリゾルバが200件超のDBクエリを発行し、p95(95%点の応答時間)が数百ミリ秒まで膨らむ挙動が観測されました。一方でDataLoaderを導入してバッチ化すると、DB往復は親子で数回に収まり[3]、p95が200ms台に安定するケースが一般に報告されています(数値は環境に強く依存しますが、傾向としては一貫しています)[2,5]。N+1は教科書的なアンチパターンに見えて、スキーマや実装が複雑化する本番環境ほど混入しやすい現実的な障害です[1,2]。CTOやエンジニアリーダーにとって重要なのは、単なる対症療法ではなく、設計・実装・運用の各層で再発を防ぐ仕組みを整えることにあります。本稿ではDataLoader(同一リクエスト内の同種キー要求をバッチ処理し、順序を保って解決するユーティリティ)の原理を要点だけに絞って整理し、Apollo ServerとPrismaを例に実運用に耐える実装、エラーハンドリング、キャッシュ制御、メトリクス計測までを通しで解説します。関連概念として、RESTとGraphQLのモデリングの違い、フィールドレベルキャッシュ、SLOとp95管理を併せて押さえておくと設計判断が安定します。

N+1問題の正体とビジネス影響

N+1は、親の集合N件に対して子取得クエリをN回発行してしまう構造を指します[1]。GraphQLはフィールドごとにリゾルバを実行するため、素朴に書くとユーザー一覧を取ったあとに各ユーザーの子フィールドで一回ずつDBアクセスが走る形になりがちです[2]。O(N)のDB往復は接続プールの飽和、トランザクション待ち、ネットワークレイテンシの蓄積を招き、p95やp99(上位パーセンタイル)の裾を引き延ばします[2]。遅い体験は単にユーザーの離脱を招くだけではありません。バックエンドの水平スケールを強要し、RDBのプロビジョニングコストとアプリケーションの台数を押し上げ、開発チームには後追いチューニングの負債を残します。DataLoaderはこれをフィールド実行のタイミングで集約し、同一リクエスト内の同種キー要求をバッチ化して一度のクエリで解決します[1,3]。原理として、同一イベントループ内で積まれたキーをまとめて取りに行き、元の順序を維持して結果を解凍します[3]。キャッシュは原則としてリクエストスコープで用い、越境共有は意図しないデータ汚染と不整合の温床になるため避けるのが定石です[3,4]。

素朴な実装が作る余計なDB往復

例えばユーザー一覧とプロフィールを返す単純なスキーマで、ユーザーのprofileフィールドを都度SELECTするコードは、ユーザー数に比例してクエリが増えます。プールサイズやStatementキャッシュのヒット率とは独立に、往復回数という絶対量がボトルネックになります[2]。これはRDBに限らず、外部RESTやgRPCでも同様で、DataLoaderはHTTPリクエストのバッチングにも応用できます[3]。

素朴なN+1実装の例(アンチパターン)

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

const typeDefs = `#graphql
  type Profile { id: ID!, bio: String }
  type User { id: ID!, name: String!, profile: Profile }
  type Query { users: [User!]! }
`;

const resolvers = {
  Query: {
    users: async () => prisma.user.findMany(),
  },
  User: {
    // アンチパターン: ユーザーごとにクエリが発行される
    profile: async (parent: any) => {
      return prisma.profile.findUnique({ where: { userId: parent.id } });
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server).then(({ url }) => console.log(`Server ready at ${url}`));

ユーザー数が200であれば、一覧の1回に加えてプロフィール取得が200回、合計201回のクエリが発生します。これをDataLoaderでまとめます[1,3]。

DataLoaderの核心を短く掴む

DataLoaderの本質は三点に集約できます。第一に、キー配列を受け取り値配列を返す順序保存のバッチ関数を定義すること[3]。第二に、リクエストごとのインスタンスを作ってキャッシュの境界を明確にすること[3,4]。第三に、ミスや欠損を前提にしたエラーとキャッシュ無効化の戦略を持つことです[3]。これらを押さえておけば、どの言語やORM(ここではPrismaなど)でも同じ考え方で実装できます。

素朴なN+1実装の例(アンチパターン)(上参照)

バッチ関数とリクエストスコープのDataLoader

import DataLoader from 'dataloader';
import { PrismaClient, Profile } from '@prisma/client';

const prisma = new PrismaClient();

type Key = string; // userId

type LoadProfiles = (keys: readonly Key[]) => Promise<(Profile | null)[]>;

export function createProfileLoader(): DataLoader<Key, Profile | null> {
  const batchFn: LoadProfiles = async (userIds) => {
    const rows = await prisma.profile.findMany({
      where: { userId: { in: userIds as string[] } },
    });
    const map = new Map(rows.map((r) => [r.userId, r]));
    return userIds.map((id) => map.get(id) ?? null);
  };
  return new DataLoader<Key, Profile | null>(batchFn, {
    cache: true, // リクエスト内での再参照をヒットさせる
  });
}

コンテキストへ組み込み、リゾルバで利用

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
import { createProfileLoader } from './loaders/profileLoader';

const prisma = new PrismaClient();

type Context = { loaders: { profileByUserId: DataLoader<string, any> } };

const typeDefs = `#graphql
  type Profile { id: ID!, bio: String }
  type User { id: ID!, name: String!, profile: Profile }
  type Query { users: [User!]! }
`;

const resolvers = {
  Query: { users: () => prisma.user.findMany() },
  User: {
    profile: (parent: any, _: any, ctx: Context) => ctx.loaders.profileByUserId.load(parent.id),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

startStandaloneServer(server, {
  context: async () => ({ loaders: { profileByUserId: createProfileLoader() } }),
}).then(({ url }) => console.log(`Server ready at ${url}`));

この時点でユーザー200件に対してプロフィール取得はほぼ1回にまとまります。DataLoaderはイベントループの同一ティック内で積まれたloadを束ねるため、ユーザーのprofile参照が散在していても、同一リクエスト内なら自然と集約されます[3]。

欠損・例外の扱いとキャッシュ無効化

存在しないキーに対してどう振る舞うかは早めに決めます。GraphQLの型をnullableにしてnullで返す実装は扱いやすく、クライアントにも明確です。一方、Errorを返すとフィールドにエラーが載り、DataLoaderはエラーもキャッシュします。新規作成直後の再読込や一時的な下流失敗のように、エラーをキャッシュしたくない場合は、該当キーだけを明示的にclearしてから再試行させる方針が有効です[3]。

import DataLoader from 'dataloader';
import { PrismaClient, Profile } from '@prisma/client';

const prisma = new PrismaClient();

type Key = string;

// エラーは個別キーでキャッシュしない方針
export function createProfileLoaderNoErrorCache(): DataLoader<Key, Profile | null> {
  const loader = new DataLoader<Key, Profile | null>(async (userIds) => {
    const rows = await prisma.profile.findMany({ where: { userId: { in: userIds as string[] } } });
    const map = new Map(rows.map((r) => [r.userId, r]));
    return userIds.map((id) => map.get(id) ?? null);
  });

  // loadの失敗時はそのキーを即座にクリアして再試行可能にする
  const origLoad = loader.load.bind(loader);
  loader.load = ((key: Key) =>
    origLoad(key).catch((err: unknown) => {
      loader.clear(key);
      throw err;
    })) as any;

  // 例: create/update/deleteの後で無効化したいとき
  function invalidate(userId: string) {
    loader.clear(userId);
  }
  // @ts-expect-error augment
  loader.invalidate = invalidate;
  return loader as any;
}

ミューテーションが走った直後に古いキャッシュを使い回さないよう、更新・作成・削除のリゾルバでは該当キーをclearし、必要であれば新値でprimeするのが安全です[3]。部分的なエラーを許容したい場合は、バッチ関数で値配列の該当位置にErrorインスタンスを返すことで、該当キーのみPromiseをrejectさせることもできます(この場合も前述のclearでエラーのキャッシュを避けます)。

LRU/TTLでキャッシュを絞る(高負荷時のメモリ・鮮度対策)

大量のキーが一度に流入する環境では、Mapベースの無制限キャッシュがメモリを押し上げます。キャッシュ実装を差し替えられるのがDataLoaderの利点で、軽量LRUに切り替えればピークメモリを制御できます[3]。加えて、短寿命データにはTTLつきのキャッシュMapを使うと鮮度を保てます。

import DataLoader from 'dataloader';
import QuickLRU from 'quick-lru';

type Key = string;

type CacheMap<K, V> = { get(k: K): V | undefined; set(k: K, v: V): any; delete(k: K): any };

// LRUだけを使う例
export function createWithLRU<V>(batch: (keys: readonly Key[]) => Promise<V[]>) {
  const lru = new QuickLRU<Key, Promise<V>>({ maxSize: 1000 });
  return new DataLoader<Key, V>(batch, { cacheMap: lru as unknown as CacheMap<Key, Promise<V>> });
}

// 簡易TTLつきMap(期限切れはget時に無効化)
class TtlMap<K, V> implements CacheMap<K, V> {
  constructor(private ttlMs: number, private inner = new Map<K, { v: V; exp: number }>()) {}
  get(k: K) {
    const e = this.inner.get(k);
    if (!e) return undefined;
    if (Date.now() > e.exp) {
      this.inner.delete(k);
      return undefined;
    }
    return e.v;
  }
  set(k: K, v: V) {
    this.inner.set(k, { v, exp: Date.now() + this.ttlMs });
    return this;
  }
  delete(k: K) {
    return this.inner.delete(k);
  }
}

メトリクス計測で効果と退行を可視化

導入効果は体感ではなくメトリクスで継続観測します。バッチサイズ、待機時間、ヒット率、DB往復回数(ORMやDBドライバのメトリクス)を記録できれば回帰検知に役立ちます[2]。p95/p99の動きはSLO運用の主要指標です。

import DataLoader from 'dataloader';
import { Histogram, Counter } from 'prom-client';

const sizeHist = new Histogram({ name: 'dataloader_batch_size', help: 'Batch size', buckets: [1, 2, 5, 10, 20, 50, 100] });
const waitHist = new Histogram({ name: 'dataloader_batch_wait_ms', help: 'Wait time', buckets: [1, 2, 5, 10, 20, 50, 100] });
const hitCounter = new Counter({ name: 'dataloader_cache_hits', help: 'Cache hits', labelNames: ['name'] as const });

export function instrumentedLoader<K, V>(name: string, batch: (keys: readonly K[]) => Promise<V[]>) {
  const loader = new DataLoader<K, V>(async (keys) => {
    const endWait = waitHist.startTimer();
    const res = await batch(keys);
    endWait();
    sizeHist.observe(keys.length);
    return res;
  }, {
    cacheKeyFn: (k: any) => JSON.stringify(k),
  });

  const origLoad = loader.load.bind(loader);
  loader.load = ((key: K) => {
    const cacheKey = (loader as any)._options.cacheKeyFn(key);
    const p = (loader as any).cache.get(cacheKey);
    if (p) hitCounter.inc({ name });
    return origLoad(key);
  }) as any;

  return loader;
}

エクスポートしたメトリクスはGrafanaなどで可視化し、デプロイ間でバッチサイズの分布やキャッシュヒット率の変化を追います。次に、導入効果の計測例を示します。

簡易ベンチで導入前後を比較

Nodeのautocannonを使い、同一スキーマでDataLoader有無を切り替えて比較した例です。シナリオはユーザー200件、profileフィールド参照ありのクエリで、ローカルのPostgreSQLとPrismaを使用しました。

import autocannon from 'autocannon';

const query = `{
  users { id name profile { id bio } }
}`;

async function run(url: string) {
  const result = await autocannon({
    url,
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ query }),
    connections: 10,
    duration: 20,
  });
  console.log({ p95: result.latency.p95, p99: result.latency.p99, reqPerSec: result.requests.average });
}

run('http://localhost:4000/graphql');

あるローカル検証の参考値として、素朴実装ではp95が約640ms、リクエスト毎のDB往復が200回超。DataLoader適用後はp95が約210msまで改善し、DB往復はユーザー一覧とプロフィール一括の合計2回に収まりました[2,3,5]。数値はあくまで環境依存ですが、往復回数の削減とp95の安定という傾向は一貫して再現されます[2,5]。

運用で効くベストプラクティスとアンチパターン

DataLoaderは魔法の弾丸ではありません。設計と運用の勘所を外すと、期待した効果が出ないどころか新たな退行を招きます。まず最重要なのはグローバルに共有しないことです。プロセススコープでシングルトン化すると、異なるユーザーのリクエスト間でキャッシュが混線し、テナント越境のバグを誘発します。リクエスト境界で生成し、コンテキストに渡すのが鉄則です[3,4]。次に、バッチ関数内でさらにDataLoaderを呼ばないことです。バッチの中で別のバッチを起動すると、意図した集約が行われず、時に待ち合わせが生じます[3]。データ取得は可能な限り一段で完結させ、必要があれば複合キーで一度に取るか、上位の呼び出しで別Loaderに分配します。第三に、ORMのeager loadingやJOINで解ける箇所に無闇に適用しない判断も大切です。すでに一括取得しているフィールドにDataLoaderを重ねると、キャッシュと実データの整合を保つコストが無駄に増えます。Prismaのinclude、TypeORMのrelations、Sequelizeのincludeなどが有効なケースでは、まずそれらで解消できないか検討します[2]。最後に、キー空間の設計を軽視しないことです。複合条件での取得やアクセスポリシーが絡むと、ユーザーIDだけでなくテナントIDやロールを含めた複合キーが必要になります。cacheKeyFnで安定化したシリアライズを行い、権限変更時には適切にclearできるようにしておきます[3]。

複合キーと権限を含むLoader

import DataLoader from 'dataloader';
import { PrismaClient, Document } from '@prisma/client';

const prisma = new PrismaClient();

type Key = { tenantId: string; userId: string };

export function createDocumentLoader() {
  return new DataLoader<Key, Document[]>(async (keys) => {
    // すべてのキーを1回のクエリで取得(OR結合)してから、元の順序で整列
    const rows = await prisma.document.findMany({
      where: {
        OR: keys.map((k) => ({ tenantId: k.tenantId, userId: k.userId, isVisible: true })),
      },
    });
    const bucket = new Map<string, Document[]>();
    for (const d of rows) {
      const composite = `${d.tenantId}:${d.userId}`;
      const arr = bucket.get(composite) ?? [];
      arr.push(d);
      bucket.set(composite, arr);
    }
    return keys.map((k) => bucket.get(`${k.tenantId}:${k.userId}`) ?? []);
  }, {
    cacheKeyFn: (k) => `${k.tenantId}:${k.userId}`,
  });
}

この例では可視性やテナント境界がキーに影響します。キャッシュクリアの単位も複合キーに合わせて設計し、権限変更のイベントで対象キーを無効化できるようにしておくと整合性が保てます[3]。

ミューテーションでのprime/clearの実装

import { PrismaClient, Profile } from '@prisma/client';
import { createProfileLoader } from './loaders/profileLoader';

const prisma = new PrismaClient();

type Ctx = { loaders: { profileByUserId: ReturnType<typeof createProfileLoader> } };

const resolvers = {
  Mutation: {
    updateProfile: async (_: any, { userId, bio }: { userId: string; bio: string }, ctx: Ctx): Promise<Profile> => {
      const updated = await prisma.profile.update({ where: { userId }, data: { bio } });
      ctx.loaders.profileByUserId.clear(userId).prime(userId, updated);
      return updated;
    },
  },
};

更新時にclearしてからprimeすることで、同一リクエスト内の後続フィールド参照に最新値が反映され、無駄な再問い合わせも避けられます[3]。

DataLoaderをHTTP境界のバッチにも応用する

バックエンドforフロントエンド構成で下流がRESTのときも、DataLoaderの考え方は有効です。複数のIDで同種のエンドポイントを叩くなら、同一ティックで集約して一度のリクエストにまとめるとネットワーク往復を節約できます[3,5]。下流が一括取得APIを持たないときは、API Gatewayでバッチエンドポイントを追加するか、下流のレート制限に注意して適切に分割します。

import DataLoader from 'dataloader';
import fetch from 'node-fetch';

type Key = string;

type UserDto = { id: string; name: string };

export function createHttpUserLoader() {
  return new DataLoader<Key, UserDto | null>(async (ids) => {
    const url = `https://downstream.example.com/users:batch?ids=${ids.join(',')}`;
    const res = await fetch(url);
    if (!res.ok) throw new Error(`downstream error ${res.status}`);
    const list: UserDto[] = await res.json();
    const map = new Map(list.map((u) => [u.id, u]));
    return ids.map((id) => map.get(id) ?? null);
  });
}

RDBに限らず、外部依存の往復回数削減にもDataLoaderは効きます。タイムアウトや再試行、サーキットブレーカーと合わせ、失敗時の振る舞いを定義しておきます[3]。

ビジネス価値とROIの見立て

計測可能な改善は意思決定を加速します。DataLoader導入でp95が数百ミリ秒単位で改善すれば、同時接続数あたりのスループットが向上し、同一SLOを満たすために必要なPod数やDBインスタンスサイズを抑制できます[2]。たとえばp95が約640msから約210msに改善すると、短時間で処理できるリクエストが増え、ピーク時のスケールアウト幅が縮みます。加えて、N+1起因の障害対応が消えることで、開発者のベロシティが回復します。これらを週次のSLOレビューとコストダッシュボードで追い、改善が継続的な価値に転化しているかを確認します。

まとめ:仕組みでN+1を寄せ付けない

DataLoaderはN+1に対する単なるライブラリではなく、GraphQL実装における設計原則の一部として機能します[1,3]。リクエストスコープのインスタンス化、順序保存のバッチ関数、明確なエラー方針、そしてメトリクスによる可視化を揃えれば、導入のその日から効果が現れます。次のスプリントでは、主要な子フィールドの解決をDataLoader経由に統一し、p95、バッチサイズ、DB往復数をダッシュボードに載せてみてください。改善の手応えが見えたら、複合キーやHTTP境界への応用、ミューテーションのinvalidateまで範囲を広げると、ボトルネックの再発を仕組みで封じ込められます。あなたのチームのGraphQLは、今日からもっと速く、もっと扱いやすくなります。

参考文献

  1. GraphQL Foundation. Solving the N+1 Problem with DataLoader. https://www.graphql-js.org/docs/n1-dataloader/
  2. Apollo GraphQL. Handling the N+1 Problem. https://www.apollographql.com/docs/graphos/schema-design/guides/handling-n-plus-one
  3. GraphQL Foundation. DataLoader (GitHub README). https://github.com/graphql/dataloader
  4. Apollo GraphQL Tutorials. Using a DataLoader (TypeScript). https://www.apollographql.com/tutorials/dataloaders-typescript/04-using-a-dataloader
  5. Dan Schafer. GraphQL at Facebook (Apollo GraphQL Blog). https://www.apollographql.com/blog/graphql-at-facebook-by-dan-schafer/