0Pricing
PHP Academy · レッスン

パフォーマンス:N+1とDataLoader

フィールドの解決をまとめて処理し、キャッシュして高速性を保ちます。

「パフォーマンス:N+1とDataLoader」はCoddyKit上の無料PHP Academyレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはPHP Academy学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 PHP Academyコースには全4レッスンが含まれています。

静かなる脅威:N+1

GraphQLにおける最大のパフォーマンス上の落とし穴は、N+1クエリ問題です。各リゾルバーを個別に見ると問題がないように見えるため、気付きにくい問題です。しかし、リストと要素ごとのフィールドをネストすると、リスト用に1回、各要素用に1回ずつクエリが実行されます。100件なら、往復は101回です。このレッスンでは、DataLoaderを使ってこれらを数回のバッチクエリにまとめる方法を説明します。

N+1を具体的に見る

{ posts { author { name } } }について考えてみます。postsリゾルバーが1回クエリを実行します。続いて各投稿について、authorリゾルバーがそれぞれ独自のクエリを実行します。以下の素朴なコードを見ると、そのコストが明確になります。

<?php
// 1 query for posts...
$posts = [['id'=>1,'author_id'=>7],['id'=>2,'author_id'=>7],['id'=>3,'author_id'=>9]];

$queries = 1;
foreach ($posts as $post) {
    // ...then 1 query PER post to fetch its author
    $queries++;
    // SELECT * FROM users WHERE id = $post['author_id']
}
echo "Total DB queries: {$queries}\n"; // Total DB queries: 4

重要な洞察:レベルごとにバッチ化する

GraphQLはレベルごとに解決します。投稿リストに含まれるすべてのauthorリゾルバーは、同じ実行ティック内で実行されます。各著者の検索を遅延させ、要求されたidを集めてから、単一のWHERE id IN (...)を実行できれば、N回のクエリを1回にできます。この遅延を実現するのが、まさにGraphQL\Deferredです。

<?php
$ids = [7, 7, 9];
$unique = array_values(array_unique($ids));
// One query instead of three:
echo 'SELECT * FROM users WHERE id IN (' . implode(',', $unique) . ")\n";
// SELECT * FROM users WHERE id IN (7,9)

最小構成のバッファー/ローダー

DataLoaderの本質は次のとおりです。idを蓄積するバッファー、バッチ関数で一度に読み込む仕組み、そして結果をキャッシュする仕組みです。同じキーが2回要求されても1回しか読み込まれないため、重複排除も自動的に行われます。

<?php
class UserLoader {
    private array $queue = [];
    private array $cache = [];
    public function __construct(private \Closure $batchFn) {}

    public function add(int $id): void { $this->queue[$id] = true; }

    public function loadOnce(): void {
        $missing = array_diff(array_keys($this->queue), array_keys($this->cache));
        if ($missing) {
            foreach (($this->batchFn)(array_values($missing)) as $id => $row) {
                $this->cache[$id] = $row;
            }
        }
        $this->queue = [];
    }

    public function get(int $id): mixed { return $this->cache[$id] ?? null; }
}

リゾルバーに組み込む

authorリゾルバーはidをキューに追加し、Deferredを返します。graphql-phpは現在のレベルが完了した後にすべてのDeferredを実行します。そのためクロージャが実行される時点では、リスト全体のすべての著者idがキューに追加されています。1回のloadOnce()で、単一のバッチクエリが実行されます。

<?php
use GraphQL\Deferred;

$authorField = [
    'type' => $userType,
    'resolve' => function ($post, $args, $context) {
        /** @var UserLoader $loader */
        $loader = $context['userLoader'];
        $loader->add($post['author_id']);
        return new Deferred(function () use ($loader, $post) {
            $loader->loadOnce();              // batches across all posts
            return $loader->get($post['author_id']);
        });
    },
];

overblog/dataloaderライブラリを使う

この仕組みを自分で実装することは、ほとんどありません。overblog/dataloader-phpは、FacebookのDataLoaderを移植した定評のあるライブラリです。キーの配列を受け取り、同じ順序の値を返すPromiseを返すバッチ関数を渡します。キャッシュ、重複排除、Promiseの解決はライブラリが処理します。

composer require overblog/dataloader-php

DataLoaderを構築する

バッチ関数の契約は厳密です。[k1, k2, k3]を受け取った場合、位置を対応させて[v1, v2, v3]に解決しなければなりません。DBの行をキーでインデックス化し、入力の順序に合わせて結果を再構成することで、存在しないキーはnullにします。

<?php
use Overblog\DataLoader\DataLoader;
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
use Overblog\PromiseAdapter\Adapter\WebonyxGraphQLSyncPromiseAdapter;

$adapter = new WebonyxGraphQLSyncPromiseAdapter(new SyncPromiseAdapter());

$userLoader = new DataLoader(function (array $ids) use ($adapter, $db) {
    $rows = $db->usersByIds($ids);          // SELECT ... WHERE id IN (...)
    $byId = [];
    foreach ($rows as $r) { $byId[$r['id']] = $r; }
    // MUST return values in the SAME ORDER as $ids
    $ordered = array_map(fn($id) => $byId[$id] ?? null, $ids);
    return $adapter->createFulfilled($ordered);
}, $adapter);

ローダー経由で解決する

リゾルバーでは、$loader->load($id)を呼び出すだけで、Promiseが返されます。graphql-phpはアダプター経由でこれらを集め、ティックの最後に自動的にバッチを実行します。手動でバッファーを管理する必要はありません。

<?php
$authorField = [
    'type' => $userType,
    'resolve' => fn($post, $args, $context) =>
        $context['userLoader']->load($post['author_id']),
];

リクエスト単位のライフタイムが重要

DataLoaderはキーごとにキャッシュするため、リクエストごとに新しく作成しなければなりません。リクエスト間で共有したローダーは、古いデータを返したり、メモリリークを引き起こしたりします。リクエストの$contextを組み立てるときにローダーを作成し、リクエストの終了時に破棄してください。

<?php
// Per request: brand new loaders, attached to context
function buildContext($db, $currentUser): array {
    return [
        'db' => $db,
        'user' => $currentUser,
        'userLoader' => makeUserLoader($db),  // fresh, not a singleton
        'postLoader' => makePostLoader($db),
    ];
}

その他のパフォーマンス対策

DataLoaderはN+1の読み取りを解消しますが、悪意のあるクエリや不用意なクエリによって負荷が高まる可能性は残ります。次の対策も重ねて適用してください。

  • クエリの深さ制限 — QueryDepthルールで、異常に深くネストされたクエリを拒否します。
  • クエリの複雑度 — QueryComplexityでフィールドごとにコストの予算を設定します。
  • 永続化クエリ — 既知の許可リストにある操作だけを許可します。
  • ページネーション — 上限のないリストを決して解決せず、カーソル接続を使用します。
<?php
use GraphQL\Validator\Rules\QueryDepth;
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;

DocumentValidator::addRule(new QueryDepth(10));
DocumentValidator::addRule(new QueryComplexity(200));

効果を測定する

改善効果は必ず数値で確認してください。テストでDB層をラップしてクエリ数を数え、リストのサイズにかかわらず、バッチ版が一定回数以内のクエリしか発行しないことをアサートします。これにより、誰かが素朴なネストされたリゾルバーを追加して、気付かないうちにN+1を再発させるような退行を防げます。

<?php
class CountingDb {
    public int $queries = 0;
    public function usersByIds(array $ids): array {
        $this->queries++;            // one batched call
        return array_map(fn($id) => ['id' => $id], $ids);
    }
}

$db = new CountingDb();
$db->usersByIds([7, 9, 11, 13]);     // 4 authors
echo "Queries for 4 authors: {$db->queries}\n"; // Queries for 4 authors: 1

確認問題

DataLoaderをリクエストごとに作成しなければならないのはなぜですか。

まとめ

GraphQLにおける最大のパフォーマンス上の落とし穴を解消しました。

  • ネストされたリストフィールドはN+1を引き起こし、要素ごとに1回のクエリが発生します。
  • graphql-phpはレベルごとに解決するため、遅延させることで、すべてのキーを1回のIN (...)クエリにまとめられます。
  • GraphQL\Deferredが基本機能であり、overblog/dataloader-phpがバッチ化、キーごとのキャッシュ、重複排除をまとめて提供します。
  • バッチ関数は、入力キーと同じ順序で値を返さなければなりません。
  • ローダーはリクエスト単位で作成し、さらに深さ制限、複雑度制限、ページネーションを対策として追加します。

よくある質問

「パフォーマンス:N+1とDataLoader」レッスンは無料ですか?

はい。「パフォーマンス:N+1とDataLoader」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、PHP Academyコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 PHP Academyコースには全4レッスンが含まれています。

「パフォーマンス:N+1とDataLoader」で何を学びますか?

フィールドの解決をまとめて処理し、キャッシュして高速性を保ちます。 ブラウザで直接実行するハンズオンコードでPHP Academyを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

PHP Academyを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのPHP Academyは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。

「パフォーマンス:N+1とDataLoader」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このPHP Academyレッスンでコードを書いて実行できますか?

はい。すべてのPHP Academyレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. GraphQLとRESTの比較
  2. graphql-phpでスキーマを構築する
  3. リゾルバー、ミューテーション、サブスクリプション
  4. パフォーマンス:N+1とDataLoader
← PHP Academyに戻る