0Pricing
PHP Academy · Урок

Производительность: N+1 и DataLoader

Объединяйте пакетную обработку и кэширование разрешения полей, чтобы сохранять высокую скорость

«Производительность: N+1 и DataLoader» — бесплатный урок PHP Academy на CoddyKit. Это урок 4 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения PHP Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс PHP Academy содержит 4 уроков всего.

Тихий убийца: N+1

Самая опасная ловушка производительности GraphQL — проблема запросов N+1. Она незаметна, потому что каждый резолвер по отдельности выглядит безобидно, но если вложить список и поле для каждого элемента, выполняется один запрос для списка и ещё по одному запросу на каждый элемент. Для 100 элементов это 101 обмен с сервером. В этом уроке показано, как с помощью DataLoader свести их к нескольким пакетным запросам.

Наглядный пример N+1

Рассмотрим { posts { author { name } } }. Резолвер posts выполняет один запрос. Затем для каждого сообщения резолвер 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 для списка сообщений запускаются в рамках одного такта выполнения. Если отложить каждый поиск автора, собрать запрошенные идентификаторы, а затем выполнить один запрос WHERE id IN (...), можно превратить N запросов в один. Именно такую отсрочку обеспечивает 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: буфер накапливает идентификаторы, один раз загружает их с помощью функции пакетной загрузки и возвращает результаты из кэша. Один и тот же ключ, запрошенный дважды, загружается один раз — дубликаты устраняются автоматически.

<?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 ставит идентификатор в очередь и возвращает Deferred. graphql-php запускает все отложенные операции после текущего уровня, поэтому к моменту выполнения замыкания в очередь уже добавлены идентификаторы всех авторов из списка. Один вызов 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 — популярный порт DataLoader от Facebook. Ему передают функцию пакетной загрузки, которая получает массив ключей и должна вернуть промис со значениями в том же порядке. Библиотека сама обрабатывает кэширование, удаление дубликатов и разрешение промисов.

composer require overblog/dataloader-php

Создание DataLoader

Контракт функции пакетной загрузки строгий: получив [k1, k2, k3], она должна разрешиться в [v1, v2, v3] в том же порядке. Проиндексируйте строки из БД по ключу и восстановите порядок входных данных, чтобы отсутствующие ключи превращались в 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), который возвращает промис. graphql-php через адаптер собирает эти промисы и автоматически запускает пакетную загрузку в конце такта. Вручную буферизовать данные не нужно.

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

Время жизни на один запрос критично

DataLoaders кэшируют данные по ключу, поэтому их нужно создавать заново для каждого запроса. Загрузчик, общий для нескольких запросов, будет возвращать устаревшие данные и приведёт к утечке памяти. Создавайте загрузчики при сборке $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));

Измерение результата

Всегда измеряйте улучшение количественно. Оберните слой БД, чтобы считать запросы в тесте, а затем проверьте, что пакетная версия выполняет ограниченное число запросов независимо от размера списка. Это защищает от регрессий, когда кто-то добавляет наивный вложенный резолвер и незаметно возвращает проблему 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

Быстрая проверка

Почему DataLoaders нужно создавать для каждого запроса?

Итоги

Вы устранили худшую ловушку производительности GraphQL:

  • Вложенные поля списка приводят к проблеме N+1: по одному запросу на каждый элемент.
  • graphql-php обрабатывает данные уровень за уровнем, поэтому отсрочка позволяет объединить все ключи в один запрос IN (...).
  • GraphQL\Deferred — базовый механизм, а overblog/dataloader-php объединяет пакетную загрузку, кэширование каждого ключа и удаление дубликатов.
  • Функция пакетной загрузки должна возвращать значения в том же порядке, что и входные ключи.
  • Загрузчики создаются для каждого запроса; дополнительно используйте ограничения глубины и сложности, а также постраничную выдачу.

Часто задаваемые вопросы

Урок «Производительность: N+1 и DataLoader» бесплатный?

Да — полный текст урока «Производительность: N+1 и DataLoader» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс PHP Academy, подпишись на CoddyKit PRO. Курс PHP Academy содержит 4 уроков всего.

Чему я научусь в уроке «Производительность: N+1 и DataLoader»?

Объединяйте пакетную обработку и кэширование разрешения полей, чтобы сохранять высокую скорость Ты практикуешь PHP Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать PHP Academy?

Предыдущий опыт не требуется. PHP Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 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