Устранение «проблемы n+1»
Давайте разберёмся, как Gato GraphQL полностью избегает «проблемы n+1» благодаря архитектурному проектированию.
Что такое «проблема n+1»
«Проблема n+1» означает, что количество запросов, выполняемых к базе данных, может быть таким же большим, как количество узлов в графе.
Что это означает? Рассмотрим на примере: допустим, мы хотим получить список режиссёров и для каждого из них список его фильмов, используя следующий запрос:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Для эффективной работы мы ожидаем выполнить лишь 2 запроса к базе данных: 1 для получения данных о режиссёрах и 1 для получения данных о всех фильмах всех режиссёров.
Однако для выполнения этого запроса GraphQL потребуется выполнить «n+1» запросов к базе данных: сначала 1 для получения списка N режиссёров (в данном случае 10), а затем для каждого из N режиссёров — 1 запрос для получения списка его фильмов. В нашем случае необходимо выполнить 1+10=11 запросов.
Эта проблема возникает потому, что резолверы GraphQL обрабатывают только 1 объект за раз, а не все объекты одного типа одновременно. В нашем примере резолвер, обрабатывающий объекты типа Query (который является корневым типом), будет вызван один раз для получения списка всех объектов Director, а затем резолвер типа Director будет вызван по одному разу для каждого объекта Director, чтобы получить список его фильмов.
Иными словами: резолверы GraphQL видят дерево, но не лес.
Эта проблема на самом деле серьёзнее, чем кажется на первый взгляд, поскольку количество узлов в графе растёт экспоненциально с увеличением числа уровней. Поэтому название «n+1» справедливо лишь для графа глубиной 2 уровня. Для графа глубиной 3 уровня проблему следовало бы назвать «N2+n+1»! И так далее...
Например, продолжая наш пример, добавим к запросу список актёров/актрис для каждого фильма:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
actors(first: 10) {
name
}
}
}
}
}Тогда запросы к базе данных выглядят следующим образом: сначала 1 для получения списка 10 режиссёров, затем 1 запрос для получения списка фильмов каждого из 10 режиссёров, и наконец 1 запрос для получения списка актёров/актрис для каждого из 10 фильмов каждого из 10 режиссёров. Итого: 1+10+100=111 запросов.
Осознав это поведение, «проблему n+1» можно по праву считать главным узким местом производительности GraphQL: если её не устранить, выполнение запросов к графам даже небольшой глубины может стать настолько медленным, что GraphQL окажется практически бесполезным.
Общее решение «проблемы n+1»
Стандартное решение «проблемы n+1» впервые предложила утилита DataLoader. Её стратегия очень проста: откладывать разрешение фрагментов запроса до более позднего этапа, на котором все объекты одного типа могут быть разрешены разом, в одном запросе. Эта стратегия называется «батчинг» (batching) и эффективно решает проблему «n+1».
Кроме того, DataLoader кэширует объекты после их получения, так что если последующий запрос должен загрузить уже загруженный объект, можно пропустить выполнение и взять объект из кэша. Эта стратегия, называемая «кэшированием» (caching), по сути является оптимизацией поверх «батчинга».
Проблемы с решением «батчинг/отложенная обработка»
Технически с самой стратегией «батчинга» или «отложенной обработки» нет никаких проблем — она просто работает.
(Далее будем называть эту стратегию только «отложенной».)
Проблема, однако, в том, что эта стратегия является запоздалой мерой: разработчик может сначала реализовать сервер и лишь потом, заметив, насколько медленно выполняются запросы, решит внедрить механизм отложенной обработки. Из-за этого реализация резолверов может потребовать лишних шагов, что создаёт дополнительные трудности в процессе разработки. Кроме того, поскольку разработчик должен понять, как работает «отложенный» механизм, его реализация оказывается сложнее, чем могла бы быть в ином случае.
Проблема не в самой стратегии, а в том, что сервер GraphQL предлагает эту функциональность как дополнение — хотя без неё выполнение запросов может быть настолько медленным, что GraphQL становится практически бесполезным.
Решение этой проблемы, таким образом, очевидно: «отложенная» стратегия не должна быть дополнением — она должна быть встроена непосредственно в сервер GraphQL. Вместо двух стратегий выполнения запросов — «обычной» и «отложенной» — должна существовать только одна: «отложенная». И сервер GraphQL должен применять «отложенный» механизм, даже если разработчик реализует резолвер «обычным» способом (иными словами, дополнительная сложность ложится на плечи сервера GraphQL, а не разработчика).
Именно это и делает Gato GraphQL.
«Отложенная» обработка как единственная стратегия сервера GraphQL
Проблема большинства серверов GraphQL состоит в том, что ответственность за разрешение типов объектов (object, union и interface) лежит на самих резолверах при обработке родительского узла (например: films => directors), вместо того чтобы делегировать эту задачу движку загрузки данных.
Gato GraphQL передаёт эту ответственность от резолвера к движку загрузки данных сервера следующим образом:
- Резолверы возвращают идентификаторы (ID), а не объекты, при разрешении связи между родительским и дочерним узлами
- Получив список ID определённого типа, сущность
DataLoaderвозвращает соответствующие объекты этого типа - Движок загрузки данных сервера служит связующим звеном между этими 2 частями: сначала он получает ID объектов от резолверов, а непосредственно перед выполнением вложенного запроса для связи (к этому моменту он накопит все ID для конкретного типа) — извлекает объекты по этим ID через
DataLoader(который может эффективно включить все ID в один запрос).
Этот подход можно резюмировать так: «Работайте с ID, а не с объектами».
Используем тот же пример, чтобы наглядно представить новый подход. Приведённый ниже запрос возвращает список режиссёров и их фильмов:
{
query {
directors(first: 10) {
name
films(first: 10) {
title
}
}
}
}Обратите внимание на 2 поля, которые нужно получить для каждого режиссёра — name и films — и на то, насколько они отличаются друг от друга:
Поле name имеет скалярный тип. Оно разрешается немедленно, поскольку можно ожидать, что объект типа Director содержит свойство типа string с именем name, хранящее имя режиссёра. Таким образом, получив объект Director, нет необходимости выполнять дополнительный запрос для разрешения этого свойства.
Поле films, однако, представляет собой список объектного типа. Обычно оно не разрешается немедленно, поскольку ссылается на список объектов типа Film, которые ещё нужно получить из базы данных с помощью одного или нескольких дополнительных запросов. Именно для него разработчику потребовалось бы реализовать «отложенный» механизм.
Теперь рассмотрим другое поведение и сделаем так, чтобы поле films разрешалось как список ID (а не список объектов). Поскольку можно ожидать, что объект Director содержит свойство filmIDs с ID всех его фильмов типа array of string (при условии, что ID представлен в виде строки), это поле также может быть разрешено немедленно, без необходимости реализовывать «отложенный» механизм.
Наконец, помимо ID, резолвер должен предоставить дополнительную информацию: тип ожидаемого объекта (в нашем примере это могло бы выглядеть как [(Film, 2), (Film, 5), (Film, 9)]). Однако эта информация является внутренней — она передаётся движку и не включается в ответ на запрос.
Реализация адаптированного подхода в коде
Рассмотрим, как Gato GraphQL реализует этот подход в PHP-коде. В приведённом ниже коде показаны различные резолверы (для наглядности весь код был отредактирован).
FieldResolvers
FieldResolvers получают объект определённого типа и разрешают его поля. Для связей они также должны указывать тип объекта, к которому выполняется разрешение. Вот их контракт:
interface FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = []);
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string;
}Реализация выглядит следующим образом:
class PostFieldResolver implements FieldResolverInterface
{
public function resolveValue($object, string $field, array $args = [])
{
$post = $object;
switch ($field) {
case 'title':
return $post->title;
case 'author':
return $post->authorID; // This is an ID, not an object!
}
return null;
}
public function resolveFieldTypeResolverClass(string $field, array $args = []): ?string
{
switch ($field) {
case 'author':
return UserTypeResolver::class;
}
return null;
}
}Обратите внимание, что после удаления логики, связанной с промисами/отложенными объектами, код разрешения поля author стал очень простым и лаконичным.
TypeResolvers
TypeResolvers — это объекты, работающие с определённым типом: они знают имя типа и какой TypeDataLoader загружает объекты этого типа, и не только.
Когда движок загрузки данных разрешает поля, он получает ID от определённого класса TypeResolver. Затем, при получении объектов по этим ID, движок запросит у TypeResolver, какой объект TypeDataLoader следует использовать для загрузки этих объектов.
Их контракт определяется следующим образом:
interface TypeResolverInterface
{
public function getTypeName(): string;
public function getTypeDataLoaderClass(): string;
}В нашем примере класс UserTypeResolver определяет, что данные типа User должны загружаться через класс UserTypeDataLoader:
class UserTypeResolver implements TypeResolverInterface
{
public function getTypeName(): string
{
return 'User';
}
public function getTypeDataLoaderClass(): string
{
return UserTypeDataLoader::class;
}
}TypeDataLoaders
TypeDataLoaders получают список ID определённого типа и возвращают соответствующие объекты этого типа. Вот их контракт:
interface TypeDataLoaderInterface
{
public function getObjects(array $ids): array;
}Получение пользователей выглядит так:
class UserTypeDataLoader implements TypeDataLoaderInterface
{
public function getObjects(array $ids): array
{
$userAPI = UserAPIFacade::getInstance();
return $userAPI->getUsers($ids);
}
}Выполнение (по-настоящему) большого запроса
Давайте проверим, что эта стратегия работает. Откройте клиент GraphiQL в Gato GraphQL и выполните приведённый ниже запрос, который охватывает граф глубиной 10 уровней (posts => author => posts => tags => posts => comments => author => posts => comments => author) и не мог бы быть выполнен за разумное время, если бы «проблема n+1» имела место.
query {
posts(pagination:{ limit:10 }) {
excerpt
title
url
author {
name
url
posts(pagination:{ limit:10 }) {
title
tags(pagination:{ limit:10 }) {
slug
url
posts(pagination:{ limit:10 }) {
title
comments(pagination:{ limit:10 }) {
content
date
author {
name
posts(pagination:{ limit:10 }) {
title
url
comments(pagination:{ limit:10 }) {
content
date
author {
name
username
url
}
}
}
}
}
}
}
}
}
}
}Прокрутив результаты, вы увидите, насколько большим является ответ, сколько сущностей он охватывает и сколько уровней было извлечено — и при этом всё было выполнено быстро, без каких-либо затруднений.