Производительность: 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 — локальная установка не требуется.
Все уроки этого курса
- GraphQL и REST
- Создание схемы с graphql-php
- Резолверы, мутации и подписки
- Производительность: N+1 и DataLoader