GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド
- GraphQL の N+1 問題は、一覧 resolver が 1 回・各要素のフィールド resolver が N 回 DB に行くことでクエリが爆発する現象。
- DataLoader は同一イベントループ内のキーをまとめて 1 回の IN クエリにし、リクエストスコープのキャッシュで重複取得を防ぐ。
- Apollo Server では context ごとに DataLoader を new する。
- あわせて depth limit と query complexity で悪意ある深いクエリを拒否し、一覧は カーソル型ページネーション で件数を cap する。
- 修正前後を EXPLAIN ANALYZE と SQL ログで比較し、101 回 → 2 回に減ったことを確認してから本番へ。
GraphQL N+1 問題とは
GraphQL では、クライアントが必要なフィールドだけを指定して取得できる。これは over-fetch を減らす強みだが、サーバー側の フィールド resolver の実行モデル と組み合わさると、データベースへの問い合わせが 1 + N 回 に膨らむ典型パターンが生まれる。これを N+1 問題(N+1 query problem)と呼ぶ。
具体例を考える。ブログの posts 一覧(20件)と、各投稿の author 名前を返す API がある。
query PostsWithAuthors {
posts(limit: 20) {
id
title
author {
id
name
}
}
}
理想的には DB へは次の 2 回で済むはずだ。
- 投稿一覧
SELECT id, title, author_id FROM posts ORDER BY id DESC LIMIT 20 - 著者一覧
SELECT id, name FROM users WHERE id IN (1, 5, 8, 12, 15)
ところが、各フィールドに独立した resolver を書くと、次のような実装になりがちだ。
// 親: 投稿一覧 — 1 回のクエリ
async function posts(parent, args, ctx) {
return ctx.db.query('SELECT id, title, author_id FROM posts LIMIT $1', [args.limit]);
}
// 子: 各投稿の author — 投稿ごとに 1 回ずつ実行される
async function postAuthor(post, args, ctx) {
return ctx.db.query('SELECT id, name FROM users WHERE id = $1', [post.author_id]);
}
GraphQL エンジンは posts の結果 20 件それぞれについて author resolver を呼ぶ。結果、SQL は 1 + 20 = 21 回。件数が 100 なら 101 回、1000 なら 1001 回 になる。REST で JOIN 済み JSON を返す設計なら 1 クエリで済む場面で、GraphQL が 意図せず DB を圧迫 する。
REST との違い — なぜ GraphQL で目立つのか
REST の一覧エンドポイントは、サーバー側で JOIN した DTO を返す設計が多い。GraphQL は スキーマ上の型ごとに resolver が分離 され、クライアントが author { organization { name } } のように深くネストした要求をその場で組み立てられる。resolver 作者がバッチ処理を書かない限り、木構造の各辺ごとに DB 往復 が発生する。
| 観点 | REST(JOIN 済み DTO) | GraphQL(素朴な resolver) |
|---|---|---|
| クエリ回数 | 通常 1 回 | 1 + N(+ ネスト分) |
| フィールド選択 | サーバー固定 | クライアント可変 |
| 性能劣化の発見 | 比較的早い | ステージングで見逃しやすい |
| 対策 | JOIN 設計 | DataLoader / JOIN / SQL バッチ |
N+1 は GraphQL 固有のバグではなく、リレーション解決をループ内クエリで書いたときに起きる古典的問題 だ。GraphQL の実行モデルがそれを露呈しやすいだけである。
問題の見つけ方
本番前に次の方法で検出できる。
- SQL ログ — 1 リクエストあたりの SELECT 回数。21 以上なら疑う
- APM / トレース — span が投稿件数と比例しているか
- DataLoader 未使用の resolver レビュー —
parent.idで都度 SELECT していないか - 負荷試験 — 件数を 10 → 100 → 1000 と増やし、レイテンシが線形増加するか
開発 DB が数百行しかないと N+1 でも体感 50ms 程度で済み、問題が見えない。本番相当の件数(数千〜数万 parent)で EXPLAIN ANALYZE と SQL 回数を必ず計測する。
再現用スキーマと Before 実装
以降の例では PostgreSQL、Node.js、Apollo Server 4、pg プールを使う。ドメインは「投稿(Post)」と「著者(User)」の多対一。
GraphQL スキーマ
type Query {
posts(limit: Int = 20): [Post!]!
post(id: ID!): Post
}
type Post {
id: ID!
title: String!
body: String
authorId: ID!
author: User!
commentCount: Int!
}
type User {
id: ID!
name: String!
email: String!
}
テーブル定義(抜粋)
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT,
author_id BIGINT NOT NULL REFERENCES users(id)
);
CREATE INDEX idx_posts_author_id ON posts(author_id);
Before: N+1 が発生する resolver
// resolvers/before.js — 問題のある実装(そのまま使わないこと)
const resolvers = {
Query: {
async posts(_, { limit }, { db }) {
const { rows } = await db.query(
`SELECT id, title, body, author_id AS "authorId"
FROM posts
ORDER BY id DESC
LIMIT $1`,
[limit]
);
return rows;
},
},
Post: {
// 各 Post ごとに 1 回 SELECT → N+1 の本体
async author(post, _, { db }) {
const { rows } = await db.query(
'SELECT id, name, email FROM users WHERE id = $1',
[post.authorId]
);
return rows[0];
},
// コメント数も件数分クエリ → さらに N 回
async commentCount(post, _, { db }) {
const { rows } = await db.query(
'SELECT COUNT(*)::int AS count FROM comments WHERE post_id = $1',
[post.id]
);
return rows[0].count;
},
},
};
posts(limit: 20) に author と commentCount を付けると、概算 1 + 20 + 20 = 41 回 の SQL になる。limit を 100 にすれば 201 回 だ。
Before 実行時の SQL ログ(イメージ)
[query] SELECT id, title, body, author_id FROM posts ORDER BY id DESC LIMIT 20
[query] SELECT id, name, email FROM users WHERE id = 3
[query] SELECT id, name, email FROM users WHERE id = 3 -- 同じ author が重複
[query] SELECT id, name, email FROM users WHERE id = 7
[query] SELECT id, name, email FROM users WHERE id = 12
[query] SELECT id, name, email FROM users WHERE id = 3 -- 再び重複
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 101
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 100
[query] SELECT COUNT(*)::int FROM comments WHERE post_id = 99
-- 以降、author 用に残り16回 + commentCount 用に残り17回 → 合計41回
同じ authorId が複数投稿に現れても、都度 SELECT される点も無駄である。
EXPLAIN ANALYZE で Before を計測する
PostgreSQL では EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) で、プランナが選んだ実行計画と 実測時間 を確認できる。N+1 の 1 クエリあたりは軽くても、回数 × 往復レイテンシ で全体が遅くなる。
単体クエリの EXPLAIN
著者 1 件取得:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, email FROM users WHERE id = 42;
典型的な出力(インデックスあり):
Index Scan using users_pkey on users (cost=0.29..8.30 rows=1 width=...) (actual time=0.025..0.026 rows=1 loops=1)
Index Cond: (id = 42)
Planning Time: 0.12 ms
Execution Time: 0.05 ms
1 回 0.05ms でも、100 回で 5ms + ネットワーク往復 になる。アプリから DB まで RTT が 1ms なら、クエリ本体 5ms + 往復 100ms ≒ 105ms が author 解決だけのコストだ。コメント数も同様なら 200ms 超 になりやすい。
バッチ相当の EXPLAIN(After の目標)
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, email FROM users WHERE id IN (3, 7, 12, 15, 42);
Bitmap Heap Scan on users (cost=... rows=5 width=...) (actual time=0.05..0.08 rows=5 loops=1)
Recheck Cond: (id = ANY ('{3,7,12,15,42}'::bigint[]))
-> Bitmap Index Scan on users_pkey (cost=0.00..12.50 rows=5 width=0) (actual time=0.03..0.03 rows=5 loops=1)
Planning Time: 0.15 ms
Execution Time: 0.12 ms
5 件まとめて 0.12ms — ループ 5 回(合計 ~0.25ms + 5 RTT)より圧倒的に効率的。
コメント数のバッチ集計
EXPLAIN (ANALYZE, BUFFERS)
SELECT post_id, COUNT(*)::int AS count
FROM comments
WHERE post_id IN (101, 100, 99, 98, 97)
GROUP BY post_id;
HashAggregate または Index Scan + 集約が 1 回で済む。Before の COUNT(*) × N ループと比べ、Seq Scan が post_id インデックスで Index Only Scan になる ことも EXPLAIN で確認できる。
計測チェックリスト
| 項目 | Before(N+1) | After(DataLoader) |
|---|---|---|
| SQL 回数(posts 20 + author) | 21 | 2 |
| author 解決の Execution Time 合計 | ~1ms + 20 RTT | ~0.1ms + 1 RTT |
| 同一 authorId の重複 SELECT | あり | キャッシュで 0 |
| プランナの起動コスト | × N 回 | × 1 回 |
アプリ層では pg の log_statement = 'all' や Apollo Server プラグイン でクエリ数を数えると Before/After の差が一目瞭然だ。
DataLoader の仕組み
DataLoader は Facebook が GraphQL 向けに公開した小さなライブラリで、次の 2 機能を提供する。
- Batching — 短い時間窓(同一イベントループ tick)に
load()されたキーを配列にまとめ、batch 関数を 1 回だけ 呼ぶ - Caching — 同一 DataLoader インスタンス内で同じキーの
load()は メモリ上の Promise を再利用
import DataLoader from 'dataloader';
const userLoader = new DataLoader(async (userIds) => {
// userIds = [3, 7, 3, 12] のように重複含む場合あり
const uniqueIds = [...new Set(userIds)];
const rows = await db.query(
'SELECT id, name, email FROM users WHERE id = ANY($1::bigint[])',
[uniqueIds]
);
const map = new Map(rows.map((r) => [String(r.id), r]));
// 重要: 返却配列は userIds と同じ長さ・同じ順序
return userIds.map((id) => map.get(String(id)) ?? new Error(`User ${id} not found`));
});
load(3) を複数 resolver から呼んでも、1 イベントループ内では batch 関数が 1 回 だけ実行される。2 回目以降の load(3) はキャッシュから返る。
batch 関数の契約
| ルール | 理由 |
|---|---|
返却配列の長さ = keys.length | DataLoader が各 load() に結果を対応付ける |
順序 = keys の順序 | インデックスでマッピング |
見つからないキーは Error または null | 仕様に合わせる(GraphQL なら null 許容フィールドか Error) |
| batch 内でループ SELECT 禁止 | それでは N+1 が batch 内に移るだけ |
スケジューリング — いつバッチが flush されるか
DataLoader はデフォルトで Promise の microtask キュー に batch 実行を載せる。同一 resolver ツリー内の sibling フィールドが同じ tick で load() すれば 1 バッチにまとまる。await で途中にイベントループを挟むと、複数バッチに分割 されることがある。通常の GraphQL フィールド解決では問題になりにくいが、意図的に setTimeout между load するとバッチが割れる点は知っておく。
DataLoader 完全実装
プロジェクト構成例:
src/
db/
pool.js
loaders/
createLoaders.js
userLoader.js
commentCountLoader.js
resolvers/
index.js
index.js # Apollo Server 起動
db/pool.js
import pg from 'pg';
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // インスタンス数 × max が PostgreSQL 上限を超えないよう設計([接続プール枯渇の対処](/blog/postgres-connection-pool-exhaustion-対処/))
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
export async function query(text, params) {
const start = Date.now();
const result = await pool.query(text, params);
if (process.env.LOG_SQL === '1') {
console.log('[sql]', { ms: Date.now() - start, text, rows: result.rowCount });
}
return result;
}
loaders/userLoader.js
import DataLoader from 'dataloader';
import { query } from '../db/pool.js';
export function createUserLoader() {
return new DataLoader(async (userIds) => {
const ids = [...new Set(userIds.map(Number))];
if (ids.length === 0) return userIds.map(() => null);
const { rows } = await query(
`SELECT id, name, email FROM users WHERE id = ANY($1::bigint[])`,
[ids]
);
const byId = new Map(rows.map((row) => [String(row.id), row]));
return userIds.map((id) => {
const row = byId.get(String(id));
return row ?? null;
});
});
}
loaders/commentCountLoader.js
import DataLoader from 'dataloader';
import { query } from '../db/pool.js';
export function createCommentCountLoader() {
return new DataLoader(async (postIds) => {
const ids = [...new Set(postIds.map(Number))];
if (ids.length === 0) return postIds.map(() => 0);
const { rows } = await query(
`SELECT post_id, COUNT(*)::int AS count
FROM comments
WHERE post_id = ANY($1::bigint[])
GROUP BY post_id`,
[ids]
);
const countByPostId = new Map(rows.map((r) => [String(r.post_id), r.count]));
return postIds.map((id) => countByPostId.get(String(id)) ?? 0);
});
}
loaders/createLoaders.js — ファクトリ
import { createUserLoader } from './userLoader.js';
import { createCommentCountLoader } from './commentCountLoader.js';
/** リクエストごとに 1 回呼ぶ */
export function createLoaders() {
return {
userLoader: createUserLoader(),
commentCountLoader: createCommentCountLoader(),
};
}
リクエストスコープ が最重要。グローバル singleton の DataLoader は、キャッシュがリクエストをまたいで汚染される。
ネストした User → Organization 用 loader(深い N+1 対策)
// loaders/organizationLoader.js
import DataLoader from 'dataloader';
import { query } from '../db/pool.js';
export function createOrganizationLoader() {
return new DataLoader(async (orgIds) => {
const ids = [...new Set(orgIds.map(Number))];
const { rows } = await query(
`SELECT id, name FROM organizations WHERE id = ANY($1::bigint[])`,
[ids]
);
const byId = new Map(rows.map((r) => [String(r.id), r]));
return orgIds.map((id) => byId.get(String(id)) ?? null);
});
}
// User.organization resolver
User: {
async organization(user, _, { loaders }) {
if (!user.organization_id) return null;
return loaders.organizationLoader.load(user.organization_id);
},
},
木の深さが 3 でも、各エッジに DataLoader があれば各レベルでバッチが効く。
After: DataLoader を使った resolver
// resolvers/after.js
const resolvers = {
Query: {
async posts(_, { limit }, { db }) {
const { rows } = await db.query(
`SELECT id, title, body, author_id AS "authorId"
FROM posts ORDER BY id DESC LIMIT $1`,
[limit]
);
return rows;
},
},
Post: {
async author(post, _, { loaders }) {
return loaders.userLoader.load(post.authorId);
},
async commentCount(post, _, { loaders }) {
return loaders.commentCountLoader.load(post.id);
},
},
};
author resolver は 1 行も SQL を書かない。load() はバッチキューに載るだけ。20 投稿なら userLoader の batch が 1 回、commentCountLoader の batch が 1 回。posts の Query と合わせて 合計 3 回(以前は 41 回)。
Before / After 比較
// ❌ Before — フィールドごとに DB
async author(post, _, { db }) {
const { rows } = await db.query(
'SELECT id, name, email FROM users WHERE id = $1',
[post.authorId]
);
return rows[0];
}
// ✅ After — DataLoader に委譲
async author(post, _, { loaders }) {
return loaders.userLoader.load(post.authorId);
}
| posts limit | Before SQL 回数 | After SQL 回数 |
|---|---|---|
| 20(author のみ) | 21 | 2 |
| 20(author + commentCount) | 41 | 3 |
| 100(author + commentCount) | 201 | 3 |
件数 N に比例していたクエリ数が、フィールド種類 + 1 程度の定数 に抑えられる。
Apollo Server への組み込み
Apollo Server 4 の最小構成例。context で createLoaders() を呼び、全 resolver が同じ loader インスタンスを共有する。
index.js — サーバー起動
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { readFileSync } from 'node:fs';
import { resolvers } from './resolvers/index.js';
import { pool, query } from './db/pool.js';
import { createLoaders } from './loaders/createLoaders.js';
const typeDefs = readFileSync('./schema.graphql', 'utf8');
const server = new ApolloServer({
typeDefs,
resolvers,
plugins: [
{
async requestDidStart() {
let sqlCount = 0;
return {
async executionDidStart() {
return {
willResolveField({ info }) {
// デバッグ用: フィールド解決のトレース
if (process.env.LOG_FIELDS === '1') {
console.log('[field]', info.parentType.name, info.fieldName);
}
},
};
},
async willSendResponse(ctx) {
if (process.env.LOG_SQL === '1') {
console.log('[request] sqlCount approx from logs');
}
},
};
},
},
],
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => {
// 認証ユーザー等もここで
const userId = req.headers['x-user-id'] ?? null;
return {
db: { query },
loaders: createLoaders(), // ★ リクエストごとに new
userId,
};
},
});
console.log(`GraphQL ready at ${url}`);
Express ミドルウェアと併用する場合
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import cors from 'cors';
import { createLoaders } from './loaders/createLoaders.js';
const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use(
'/graphql',
cors(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
db: { query },
loaders: createLoaders(),
userId: req.user?.id ?? null,
}),
})
);
認可と DataLoader
loader の batch 関数内で テナント ID や ACL を WHERE に含める。
export function createUserLoader(tenantId) {
return new DataLoader(async (userIds) => {
const ids = [...new Set(userIds.map(Number))];
const { rows } = await query(
`SELECT id, name, email FROM users
WHERE tenant_id = $1 AND id = ANY($2::bigint[])`,
[tenantId, ids]
);
const byId = new Map(rows.map((row) => [String(row.id), row]));
return userIds.map((id) => byId.get(String(id)) ?? null);
});
}
// context
loaders: createLoaders(tenantIdFromAuth(req)),
バッチしても 他テナントの行は返さない 設計にする。
サンプルクエリとレスポンス
リクエスト
query PostsWithAuthors {
posts(limit: 3) {
id
title
author {
id
name
}
commentCount
}
}
レスポンス
{
"data": {
"posts": [
{
"id": "103",
"title": "DataLoader 入門",
"author": { "id": "7", "name": "Alice" },
"commentCount": 12
},
{
"id": "102",
"title": "GraphQL N+1",
"author": { "id": "3", "name": "Bob" },
"commentCount": 0
},
{
"id": "101",
"title": "EXPLAIN ANALYZE",
"author": { "id": "7", "name": "Alice" },
"commentCount": 5
}
]
}
}
authorId 7 は 2 回登場するが、After では userLoader のキャッシュ により DB には 1 行分としてバッチに含まれ、結果も 1 回の SELECT で取得される。
発行 SQL(After)
SELECT id, title, body, author_id FROM posts ORDER BY id DESC LIMIT 3
SELECT id, name, email FROM users WHERE id = ANY('{7,3}')
SELECT post_id, COUNT(*)::int AS count FROM comments WHERE post_id = ANY('{103,102,101}') GROUP BY post_id
DataLoader 以外の選択肢
DataLoader が万能ではない。ユースケースに応じて次も検討する。
1. SQL JOIN / DataLoader 併用
一覧が 常に author 付き なら、Query.posts 内で JOIN して author を埋め込む方が速い場合がある。
async posts(_, { limit }, { db }) {
const { rows } = await db.query(
`SELECT p.id, p.title, p.body, p.author_id AS "authorId",
u.id AS "author_id", u.name AS "author_name", u.email AS "author_email"
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.id DESC
LIMIT $1`,
[limit]
);
return rows.map((r) => ({
id: r.id,
title: r.title,
body: r.body,
authorId: r.authorId,
author: { id: r.author_id, name: r.author_name, email: r.author_email },
}));
},
Post: {
author(post) {
return post.author; // すでに JOIN 済み
},
},
欠点: クライアントが author を要求しない場合も JOIN コストがかかる。GraphQL の柔軟性とトレードオフ。
2. Prisma / Drizzle の include
ORM の include: { author: true } は JOIN または IN クエリに変換される。ただし GraphQL フィールドと ORM の include が 1:1 でない と over-fetch や N+1 が再発する。Prisma では [Relation] loader パターンや @prisma/extension-optimize、dataloader プラグインの利用を検討。
3. PostgreSQL の LATERAL / サブクエリ
レポート用途で固定形状なら、1 本の SQL に集約する。
SELECT p.*, u.name AS author_name,
(SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) AS comment_count
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.id DESC
LIMIT 20;
EXPLAIN ANALYZE で SubPlan が N 回実行されていないか(InitPlan / 集約サブクエリ)を必ず確認する。
4. 永続キャッシュ(Redis)
読み取りが多く更新が少ない User マスタなどは、loader 内で Redis を見る。
return new DataLoader(async (ids) => {
const cached = await redis.mget(ids.map((id) => `user:${id}`));
const missing = [];
// cache miss だけ DB IN クエリ
});
リクエストスコープの DataLoader キャッシュ と Redis はレイヤーが違う。Redis はプロセス横断、DataLoader は同一リクエスト内。
| A | B |
|---|---|
| 素朴な resolver | 実装最速 / N+1 リスク最大 |
| DataLoader | GraphQL 向き / フィールド単位バッチ |
| JOIN 一覧 | 固定形状一覧が最速 / 柔軟性低 |
| ORM include | 小規模向き / 深いネストで要注意 |
よくある落とし穴
batch 内ループ SELECT
// ❌ バッチのふりをした N+1
async (ids) => {
const results = [];
for (const id of ids) {
const { rows } = await query(
'SELECT id, name, email FROM users WHERE id = $1',
[id]
);
results.push(rows[0] ?? null);
}
return results;
}
返却順序の不一致
WHERE id IN (...) の結果順は保証されない。必ず Map で keys 順に並べ替える。
グローバル singleton DataLoader
マルチテナント SaaS で致命傷。必ず createLoaders() を context 内で呼ぶ。
Error と null の混同
GraphQL で non-null フィールド User! に null を返すと親ごと null になる。存在しない ID はビジネスルールに合わせて Error または nullable に。
DataLoader クリアタイミング
同一リクエスト内で「古いデータを再取得したい」 rare case では loader.clear(id) や loader.clearAll() を使う。mutation 直後に同リクエストで再 query する場合など。
テスト戦略
SQL 回数アサーション
import { describe, it, expect } from 'vitest';
import { createLoaders } from '../loaders/createLoaders.js';
describe('userLoader', () => {
it('batches duplicate ids into one query', async () => {
let queryCount = 0;
const mockQuery = async () => {
queryCount++;
return { rows: [{ id: 1, name: 'A', email: '[email protected]' }] };
};
const loader = createUserLoader({ query: mockQuery });
await Promise.all([loader.load(1), loader.load(1), loader.load(1)]);
expect(queryCount).toBe(1);
});
});
結合テスト — GraphQL リクエスト
const res = await server.executeOperation({
query: `query { posts(limit: 10) { author { name } } }`,
});
expect(res.body.singleResult.errors).toBeUndefined();
// ログまたは mock で SQL が 2 回であることを確認
EXPLAIN ANALYZE を CI に入れるか
本番 DB 負荷を避け、マイグレーション後の代表クエリ だけステージングで EXPLAIN を取り、Seq Scan 化していないかレビューするのが現実的。完全自動化は postgres の統計情報依存のため、閾値(Execution Time < 50ms)でアラートする程度がよい。
Query Complexity 制限 — DataLoader だけでは足りない防御線
DataLoader は 同一リクエスト内の DB 往復 を減らすが、GraphQL には別の DoS ベクトルがある。クライアントが次のようなクエリを送ると、DB は効率化されても resolver 呼び出し回数・CPU・メモリ は爆発する。
query EvilNested {
posts(limit: 100) {
author {
organization {
parent {
parent {
parent {
name
}
}
}
}
}
}
}
query EvilAliases {
a1: posts(limit: 50) { id title }
a2: posts(limit: 50) { id title }
a3: posts(limit: 50) { id title }
a4: posts(limit: 50) { id title }
a5: posts(limit: 50) { id title }
a6: posts(limit: 50) { id title }
a7: posts(limit: 50) { id title }
a8: posts(limit: 50) { id title }
}
DataLoader があっても posts resolver はエイリアス数 × 1 回、author は 50 × エイリアス数回 load() される。DB クエリはバッチ化 されても、GraphQL エンジンのフィールド解決コストは線形に増える。対策は depth limit(深さ) と query complexity(複雑度スコア) の二段構えだ。
depth limit — ネストの深さを cap する
graphql-depth-limit は validation 段階で AST の深さを数え、閾値超過なら 実行前に 400 を返す。
import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
});
深さ 7 なら posts → author → organization → parent × 4 程度まで許容、それ以上は拒否。スキーマの最大ネストに合わせて調整する。本番では 6〜8 がよく使われる。
query complexity — フィールドごとにコストを付与
graphql-query-complexity は、各フィールドに重みを付け、クエリ全体の合計スコアが上限を超えたら拒否する。
import {
createComplexityLimitRule,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { ApolloServer } from '@apollo/server';
const MAX_COMPLEXITY = 300;
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityLimitRule(MAX_COMPLEXITY, {
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity) => {
if (process.env.LOG_COMPLEXITY === '1') {
console.log('[complexity]', complexity);
}
},
}),
],
});
スキーマ側で重いフィールドに @complexity を付ける例:
type Query {
posts(limit: Int = 20): [Post!]!
@complexity(value: 10, multipliers: ["limit"])
}
type Post {
id: ID!
title: String!
author: User! @complexity(value: 2)
commentCount: Int! @complexity(value: 3)
}
posts(limit: 100) なら 10 × 100 = 1000 点になり、上限 300 なら 実行前に拒否 される。limit 引数を multipliers に含めるのが GraphQL 一覧 API では重要だ。
Apollo Server 4 — depth + complexity をまとめて適用
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import depthLimit from 'graphql-depth-limit';
import {
createComplexityLimitRule,
simpleEstimator,
fieldExtensionsEstimator,
} from 'graphql-query-complexity';
import { readFileSync } from 'node:fs';
import { resolvers } from './resolvers/index.js';
import { query } from './db/pool.js';
import { createLoaders } from './loaders/createLoaders.js';
const typeDefs = readFileSync('./schema.graphql', 'utf8');
const complexityRule = createComplexityLimitRule(300, {
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
});
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7), complexityRule],
formatError: (formattedError) => {
if (
formattedError.message.includes('exceeds maximum operation depth') ||
formattedError.message.includes('exceeds maximum query complexity')
) {
return {
message: 'Query too expensive. Reduce depth, limit, or field selection.',
extensions: { code: 'QUERY_REJECTED' },
};
}
return formattedError;
},
});
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async () => ({
db: { query },
loaders: createLoaders(),
}),
});
console.log(`GraphQL ready at ${url}`);
拒否されたクエリは DB に到達しないため、接続プールを守る第一防衛 になる。プール枯渇の調査手順は PostgreSQL 接続プール枯渇の対処 を参照。
limit 引数のサーバー側バリデーション
complexity だけに頼らず、resolver 入口でも limit を clamp する。
const MAX_PAGE_SIZE = 50;
function clampLimit(limit) {
const n = Number(limit ?? 20);
if (!Number.isFinite(n) || n < 1) return 20;
return Math.min(n, MAX_PAGE_SIZE);
}
const resolvers = {
Query: {
async posts(_, { limit, cursor }, { db }) {
const safeLimit = clampLimit(limit);
const { rows } = await db.query(
`SELECT id, title, body, author_id AS "authorId"
FROM posts
WHERE ($2::bigint IS NULL OR id < $2)
ORDER BY id DESC
LIMIT $1`,
[safeLimit, cursor ? Number(cursor) : null]
);
return rows;
},
},
};
深いオフセットではなく カーソル型 で次ページを取る設計は、件数上限と相性が良い。方式の選定は API ページネーション比較(オフセット vs カーソル) を参照。
DataLoader は 正しいクエリの DB 効率 を上げる。Query complexity / depth limit は 許可するクエリの形状 を制限する。両方入れて初めて、性能と可用性のバランスが取れる。
| A | B |
|---|---|
| depth limit | ネスト深さの上限 / 実装が軽い / エイリアスは別問題 |
| query complexity | フィールド重み × 引数で総コスト制御 / 一覧 limit に必須 |
| maxBatchSize | 1 バッチの IN 句サイズ上限 / DB プランナ保護 |
| clampLimit | resolver 入口の件数 cap / 最後の砦 |
拒否クエリの監視
// plugins/metricsPlugin.js
export const metricsPlugin = {
async requestDidStart() {
return {
async didEncounterErrors(ctx) {
for (const err of ctx.errors) {
if (err.extensions?.code === 'QUERY_REJECTED') {
console.warn('[graphql-rejected]', {
query: ctx.request.query?.slice(0, 200),
operation: ctx.operationName,
});
}
}
},
};
},
};
拒否率が急増したら、正当なクライアントの complexity 設定ミスか、攻撃かを切り分ける。正当利用なら @complexity の重みを見直す。
パフォーマンスチューニングの追加施策
DataLoader 導入後も、次を検討する。
| 施策 | 効果 |
|---|---|
author_id にインデックス | IN クエリの Bitmap Index Scan |
comments(post_id) インデックス | GROUP BY 集約の高速化 |
maxBatchSize オプション | 超大 IN を分割(例: 500 ID ずつ) |
| Query complexity 制限 | 悪意ある深いクエリを拒否 |
| Persisted Queries | 本番は許可リストのみ |
new DataLoader(batchFn, { maxBatchSize: 500 });
IN 句が数千 ID になるとプランナも負荷が上がる。parent 件数自体を抑える のが根本解で、GraphQL 一覧は カーソル型ページネーション と limit の二重 cap(resolver + complexity)が定番だ。同時リクエストが増えて pg.Pool が待ち行列に入る場合は PostgreSQL 接続プール枯渇の対処 も合わせて確認する。
GraphQL N+1 と API 設計の原則
- リレーションは loader 経由 — フィールド resolver で
db.query直書きしない - リクエストスコープ — loader は context で生成
- 計測してから最適化 — EXPLAIN ANALYZE と SQL 回数
- 一覧専用の高速 path — 必要なら JOIN 一覧 resolver を別途用意
- 深いネストも同じパターン — エッジごとに DataLoader
- depth + complexity — 悪意あるクエリは DB 到達前に拒否
スキーマと N+1 になる Before resolver を書く
EXPLAIN ANALYZE / SQL ログで 1+N を確認
batch 関数(IN クエリ + Map 並べ替え)を実装
createLoaders を context に載せ After resolver へ差し替え
Apollo Server で E2E テスト、SQL 回数が定数化されたことを確認
depth limit と query complexity を validationRules に追加
ネスト・mutation 後のキャッシュ・認可をレビュー
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| Web APIのエラーハンドリング設計 | Web APIのエラーハンドリング設計|RFC 9457・error_code・400/422/409の使い分け |
| Web APIのべき等性(Idempotency)設計 | Web APIのべき等性(Idempotency)設計|二重決済を防ぐ実装ガイド |
| APIのレート制限(Rate Limiting)実装ガイド | APIのレート制限(Rate Limiting)実装ガイド|アルゴリズム選定と429エラーの適切なハンドリング |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| OpenAPI 3.1仕様のCIバリデーション完全ガイド | OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
まとめ
GraphQL N+1 問題は、親一覧 1 回に対し子フィールドが N 回の SELECT を発行する性能障害である。原因は GraphQL 自体ではなく、リレーション解決を per-item クエリで書く実装 にある。
DataLoader は、同一リクエスト内の load(key) を 1 回のバッチクエリ にまとめ、同じ key の 重複取得をキャッシュ する。Apollo Server では context: () => ({ loaders: createLoaders() }) が定番。
Before では posts(limit: 100) + author + commentCount で 201 SQL、After では 3 SQL まで減らせる。PostgreSQL の EXPLAIN ANALYZE で、ループ Index Scan × N から Bitmap Index Scan × 1 への改善を確認しよう。
JOIN 一括取得や ORM include も選択肢だが、GraphQL の フィールド選択の柔軟性 を保ちたいなら DataLoader がバランス良い。depth limit と query complexity でクエリ形状を制限し、一覧件数は カーソル型ページネーション で抑える。DB 同時接続が逼迫する場合は 接続プール枯渇の対処 もセットで見る。本番投入前に、代表クエリの SQL 回数と Execution Time を必ず記録しておくことが、長期的な SLA 維持につながる。