ストーリー
高橋アーキテクトがデータベースのクエリログを見せた。 画面に表示された SQL は100行以上に及んでいた。
N+1問題とは
問題の発生メカニズム
# このクエリを実行すると...
query {
tasks(projectId: "proj_123", limit: 10) {
id
title
assignee { # ← タスクごとにユーザーを取得
name
}
labels { # ← タスクごとにラベルを取得
name
}
}
}
// リゾルバーの実行順序
// 1回目: タスク一覧を取得
SELECT * FROM tasks WHERE project_id = 'proj_123' LIMIT 10;
// → 10件のタスクが返る
// 2〜11回目: 各タスクの担当者を取得(10回)
SELECT * FROM users WHERE id = 'usr_1';
SELECT * FROM users WHERE id = 'usr_2';
SELECT * FROM users WHERE id = 'usr_3';
// ... 以下、タスクの数だけ繰り返し(N回)
// 12〜21回目: 各タスクのラベルを取得(10回)
SELECT * FROM labels JOIN task_labels ON ... WHERE task_id = 'tsk_1';
SELECT * FROM labels JOIN task_labels ON ... WHERE task_id = 'tsk_2';
// ... 以下、タスクの数だけ繰り返し(N回)
// 合計: 1 + N + N = 1 + 10 + 10 = 21クエリ
// タスクが100件なら: 1 + 100 + 100 = 201クエリ
なぜ起きるのか
// フィールドリゾルバーは「各アイテムごとに独立して」実行される
const resolvers = {
Task: {
// このリゾルバーはタスクの数だけ呼ばれる
assignee: async (task: Task) => {
// タスクごとに1回ずつDBクエリが発行される
return await db.users.findById(task.assigneeId);
},
},
};
DataLoaderによる解決
DataLoaderの仕組み
// DataLoader: 複数のリクエストをバッチ処理する
// 同一のイベントループ内で発生した複数のロード要求を
// 1回のバッチリクエストにまとめる
import DataLoader from 'dataloader';
// ユーザーを一括取得するDataLoader
const userLoader = new DataLoader<string, User>(async (userIds) => {
// 個別のクエリではなく、IN句で一括取得
// SELECT * FROM users WHERE id IN ('usr_1', 'usr_2', 'usr_3', ...)
const users = await db.users.findByIds([...userIds]);
// DataLoaderの要件: 入力の順序と同じ順序で結果を返す
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) || new Error(`User ${id} not found`));
});
// リゾルバーでの使用
const resolvers = {
Task: {
assignee: async (task: Task, _args: unknown, context: Context) => {
// load() は即座にDBクエリを発行しない
// 同一ティック内のload()をまとめてバッチ処理する
return context.loaders.user.load(task.assigneeId);
},
},
};
// 結果:
// 10件のタスクに対して assignee が呼ばれても
// DBクエリは1回だけ:
// SELECT * FROM users WHERE id IN ('usr_1', 'usr_2', ..., 'usr_10')
DataLoaderの作成パターン
// DataLoaderファクトリ
function createLoaders() {
return {
user: new DataLoader<string, User>(async (ids) => {
const users = await userRepository.findByIds([...ids]);
const map = new Map(users.map(u => [u.id, u]));
return ids.map(id => map.get(id) || new Error(`User not found: ${id}`));
}),
tasksByProject: new DataLoader<string, Task[]>(async (projectIds) => {
const tasks = await taskRepository.findByProjectIds([...projectIds]);
const grouped = groupBy(tasks, t => t.projectId);
return projectIds.map(id => grouped.get(id) || []);
}),
labelsByTask: new DataLoader<string, Label[]>(async (taskIds) => {
const relations = await db.taskLabels.findByTaskIds([...taskIds]);
const labelIds = [...new Set(relations.map(r => r.labelId))];
const labels = await labelRepository.findByIds(labelIds);
const labelMap = new Map(labels.map(l => [l.id, l]));
const grouped = new Map<string, Label[]>();
for (const rel of relations) {
const taskLabels = grouped.get(rel.taskId) || [];
const label = labelMap.get(rel.labelId);
if (label) taskLabels.push(label);
grouped.set(rel.taskId, taskLabels);
}
return taskIds.map(id => grouped.get(id) || []);
}),
};
}
// Apollo Serverのcontext設定
const server = new ApolloServer({
typeDefs,
resolvers,
});
// リクエストごとに新しいDataLoaderを作成(キャッシュの分離)
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
currentUser: await authenticate(req),
loaders: createLoaders(), // リクエストごとに新規作成
}),
});
DataLoaderの効果
Before(N+1問題あり)
タスク10件のクエリ数:
タスク一覧: 1
担当者取得: 10(各タスクごと)
ラベル取得: 10(各タスクごと)
合計: 21クエリ
タスク100件の場合: 201クエリ
After(DataLoader使用)
タスク10件のクエリ数:
タスク一覧: 1
担当者取得: 1(IN句でバッチ取得)
ラベル取得: 1(IN句でバッチ取得)
合計: 3クエリ
タスク100件の場合: 3クエリ(同じ!)
クエリの複雑さ制限
// 悪意のあるクエリや深すぎるネストを防ぐ
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(5), // ネストの深さを5階層まで
createComplexityLimitRule(1000), // クエリの複雑さを制限
],
});
// これにより以下のような攻撃的クエリをブロック
// query {
// tasks {
// assignee {
// tasks {
// assignee {
// tasks { ... } // 無限ネスト攻撃
// }
// }
// }
// }
// }
まとめ
| ポイント | 内容 |
|---|---|
| N+1問題 | フィールドリゾルバーがアイテムごとに独立してDBクエリを発行する問題 |
| DataLoader | 複数のload()をバッチ処理し、1回のDBクエリにまとめる |
| キャッシュ | DataLoaderはリクエスト内でキャッシュし、同じIDの重複取得を防ぐ |
| リクエスト分離 | DataLoaderはリクエストごとに新規作成する |
| 複雑さ制限 | depthLimit, complexityLimit で攻撃的クエリを防ぐ |
チェックリスト
- N+1問題が発生するメカニズムを説明できる
- DataLoaderの仕組み(バッチ処理とキャッシュ)を理解した
- DataLoaderの実装パターンを把握した
- クエリの複雑さ制限の必要性を理解した
次のステップへ
N+1問題とDataLoaderを学びました。
次のセクションでは、RESTとGraphQLの使い分けについて学びます。 どちらが優れているかではなく、どの場面でどちらを選ぶべきかを理解しましょう。
推定読了時間: 40分