🔄
概念 #データ設計 #N+1問題 #DataLoader #GraphQL #ORM #パフォーマンス 📚 データ志向アプリケーション設計(DDIA)

N+1問題とDataLoaderパターン

ORMやGraphQLで頻出するN+1問題の根本原因と、バッチローディング・DataLoaderパターンによる解決策。Eager LoadingとLazy Loadingのトレードオフを理解する

定義

N+1問題:1件のリストを取得するクエリ(1)と、リストの各要素に対して発行されるクエリ(N)で、合計N+1回のDBアクセスが発生するパフォーマンス問題。

問題の発生パターン

REST + ORMの場合

// ブログ投稿一覧を返すAPI
app.get('/posts', async (req, res) => {
  const posts = await Post.findAll();  // クエリ1回: SELECT * FROM posts
  
  const result = await Promise.all(
    posts.map(async (post) => ({
      ...post,
      author: await User.findById(post.authorId),  // N回のクエリ発生!
    }))
  );
  
  res.json(result);
});

// 実際に発行されるSQL:
// SELECT * FROM posts;                    ← 1回
// SELECT * FROM users WHERE id = 1;      ← posts[0]の著者
// SELECT * FROM users WHERE id = 2;      ← posts[1]の著者
// SELECT * FROM users WHERE id = 3;      ← posts[2]の著者
// ... N回続く
// 合計: N + 1 クエリ

GraphQLの場合

query {
  posts {          # 1クエリ
    title
    author {       # 各投稿ごとにクエリ → N回
      name
    }
  }
}

GraphQLのリゾルバは各フィールドを独立して解決するため、N+1が構造的に発生しやすい。

解決策1:Eager Loading(JOIN)

関連データを最初から一緒に取得する。

// Prismaの例
const posts = await prisma.post.findMany({
  include: {
    author: true,  // JOINで一緒に取得
  },
});

// 発行されるSQL:
// SELECT posts.*, users.*
// FROM posts
// LEFT JOIN users ON users.id = posts.author_id;
// → 1クエリで解決

メリット:最もシンプル。1クエリで完結。
デメリット:不要なデータまで取得する(GraphQLで一部フィールドしか要求されていない場合でもJOIN)。ネストが深いと巨大なJOINになる。

解決策2:DataLoaderパターン

バッチ処理で複数IDをまとめてDBに問い合わせる。

通常のN+1:
  User.findById(1)  → SELECT WHERE id = 1
  User.findById(2)  → SELECT WHERE id = 2
  User.findById(3)  → SELECT WHERE id = 3

DataLoader:
  イベントループの1tick内のリクエストを収集
  → SELECT WHERE id IN (1, 2, 3)  ← 1クエリにまとめる

DataLoaderの実装(Node.js)

import DataLoader from 'dataloader';

// バッチ関数: IDsの配列を受け取り、同じ順序で結果を返す
const userLoader = new DataLoader<string, User>(async (userIds) => {
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  );
  
  // IDの順序に合わせて結果を並び替える(重要!)
  const userMap = new Map(users.map(u => [u.id, u]));
  return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
});

// GraphQL リゾルバ
const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId),
    // 複数のpostが同じtickでauthorを要求 → バッチにまとめられる
  }
};

// 発行されるSQL:
// SELECT * FROM users WHERE id IN (1, 2, 3, 4, 5)  ← 1クエリ

DataLoaderのキャッシュ

// DataLoaderはリクエストスコープでキャッシュする
const user1 = await userLoader.load('1');  // DBアクセス
const user1Again = await userLoader.load('1');  // キャッシュヒット

// リクエストをまたいでキャッシュしてはいけない
// → 別ユーザーのデータが混ざるセキュリティリスク
// → リクエストごとに新しいDataLoaderインスタンスを作る

// Expressの例
app.use((req, res, next) => {
  req.loaders = {
    user: new DataLoader(batchLoadUsers),
    post: new DataLoader(batchLoadPosts),
  };
  next();
});

解決策3:事前計算・非正規化

// コメント数を毎回カウントするのではなく
// posts テーブルに comment_count カラムを持つ

// コメント追加時:
await db.transaction(async (tx) => {
  await tx.query('INSERT INTO comments ...', [...]);
  await tx.query(
    'UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1',
    [postId]
  );
});

// 読み取り時: JOINもサブクエリも不要
SELECT title, comment_count FROM posts;

トレードオフ:書き込みが複雑になり、整合性管理が必要。読み取りが非常に速くなる。

Lazy Loading の危険性

多くのORMはデフォルトでLazy Loading(アクセス時に自動ロード)を提供する。

# Django ORM(Lazy Loading)
posts = Post.objects.all()  # クエリ1回

for post in posts:
    print(post.author.name)  # アクセスのたびにクエリ!
    
# → N+1が暗黙に発生する
# → コードを見ただけではクエリ数が分からない

# 解決: select_related(JOIN)または prefetch_related(IN句)
posts = Post.objects.select_related('author').all()

クエリログで確認する

// Prismaのクエリログ有効化
const prisma = new PrismaClient({
  log: ['query'],
});

// 開発時に同じリクエストで何クエリ発行されているか確認
// N+1が起きていると大量のログが出る

使い分けのまとめ

状況解決策
常にauthorが必要Eager Loading(JOIN)
GraphQLで動的にフィールドが変わるDataLoader
読み取り頻度が高い集計値非正規化(カウンターカラム)
深くネストしたデータDataLoader + 部分的なEager Loading
原則:
  1. まず問題を計測(推測で最適化しない)
  2. Eager LoadingでJOINできるなら最もシンプル
  3. GraphQLのように動的な場合はDataLoader
  4. 集計が重ければ非正規化を検討

関連概念

出典・参考文献

  • Facebook, “DataLoader” — github.com/graphql/dataloader
  • Prisma Documentation, “Relation queries” — prisma.io/docs
  • GraphQL Best Practices — graphql.org/learn/best-practices
  1. 1. 🗄️データ志向アプリケーション設計:概要
  2. 2. 🧩データモデルとクエリ言語
  3. 3. 💾ストレージエンジンとインデックス
  4. 4. 🔁レプリケーション
  5. 5. 🍕パーティショニング(シャーディング)
  6. 6. 🔒トランザクションとACID
  7. 7. 分散システムの本質的な問題
  8. 8. 🤝一貫性と分散合意
  9. 9. 📦バッチ処理
  10. 10. 🌊ストリーム処理
  11. 11. 📋エンコーディングとスキーマ進化
  12. 12. 🔗Sagaパターンと分散トランザクション
  13. 13. 🏗️データシステムの統合設計
  14. 14. 📸MVCC(多版型同時実行制御)
  15. 15. 📊列指向ストレージとOLAP設計
  16. 16. 🕰️ベクタークロックと因果順序
  17. 17. 🔀CRDT(競合なし複製データ型)
  18. 18. 🔍クエリオプティマイザーと実行計画
  19. 19. キャッシュ戦略とRedis設計
  20. 20. 🔎全文検索と転置インデックス
  21. 21. 🌐NewSQL(分散ACIDデータベース)
  22. 22. 📝WALと論理レプリケーション
  23. 23. 🔌コネクションプーリング
  24. 24. 🚧ゼロダウンタイムマイグレーション
  25. 25. 🆔分散ID生成
  26. 26. 🔄N+1問題とDataLoaderパターン
  27. 27. 📈タイムシリーズDB
  28. 28. 🛡️Row Level Security(行レベルセキュリティ)
  29. 29. 📤Outboxパターン(トランザクショナルアウトボックス)
  30. 30. 💾DBバックアップとPITR
  31. 31. ⚠️データベース設計アンチパターン
  32. 32. 🕸️グラフDB深掘り
  33. 33. 🔋バックプレッシャーとサーキットブレーカー

出典: GraphQL DataLoader / Prisma Documentation