Привет, Хабр! Представляю вашему вниманию перевод статьи «Архитектура высокопроизводительного движка GraphQL to SQL» .
Это перевод статьи о том, как работает изнутри и какие оптимизации и архитектурные решения включены в Hasura — высокопроизводительный легкий GraphQL-сервер, выполняющий роль прослойки между вашим веб-приложением и базой данных PostgreSQL. Он позволяет сгенерировать схему GraphQL на основе существующей базы данных или создать новую.
Поддерживает подписки GraphQL «из коробки» на основе триггеров Postgres, динамическое управление правами доступа, автоматическое создание объединений, решает проблему запросов N+1 (пакетная обработка) и многое другое.
Вы можете использовать ограничения внешних ключей в PostgreSQL для получения иерархических данных в одном запросе.
Например, вы можете запустить этот запрос, чтобы получить альбомы и соответствующие им треки (если в таблице «дорожки» создан внешний ключ, указывающий на таблицу «альбом»).
Как вы уже догадались, вы можете запрашивать данные любой глубины.{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } }
Этот API в сочетании с контролем доступа позволяет веб-приложениям запрашивать данные из PostgreSQL без написания собственного бэкэнда.
Он предназначен для максимально быстрого выполнения запросов, имеет высокую пропускную способность, при этом экономя процессорное время и потребление памяти на сервере.
Мы поговорим об архитектурных решениях, которые позволили нам этого добиться.
Жизненный цикл запроса
Запрос, отправленный в Хасуру, проходит следующие этапы:- Прием сеансов : запрос поступает на шлюз, который проверяет ключ (если есть) и добавляет различные заголовки, такие как идентификатор пользователя и роль.
- Парсинг запросов : Hasura получает запрос, анализирует заголовки для получения информации о пользователе, создает GraphQL AST на основе тела запроса.
- Проверка запроса : проверяет, является ли запрос семантически правильным, затем применяет разрешения, соответствующие роли пользователя.
- Выполнение запросов : запрос преобразуется в SQL и отправляется в Postgres.
- Создание ответа : результат SQL-запроса обрабатывается и отправляется клиенту ( при необходимости шлюз может использовать gzip ).
Цели
Требования примерно следующие:- Стек HTTP должен добавлять минимальные накладные расходы и позволять обрабатывать множество одновременных запросов с высокой пропускной способностью.
- Быстрая генерация SQL из запроса GraphQL.
- Сгенерированный SQL-запрос должен быть эффективен для Postgres.
- Результат запроса SQL должен быть эффективно передан обратно из Postgres.
Обработка запроса GraphQL
Существует несколько подходов к получению данных, необходимых для запроса GraphQL:Обычные резольверы
Выполнение запросов GraphQL обычно включает вызов преобразователя для каждого поля.В примере запроса мы получаем альбомы, выпущенные в 2018 году, а затем для каждого из них запрашиваем соответствующие треки — классическая задача запроса N+1. Количество запросов растет экспоненциально по мере увеличения глубины запроса.
Запросы, выполняемые в Postgres, будут следующими: SELECT id,title FROM album WHERE year = 2018;
Этот запрос вернет нам все альбомы.
Допустим, количество альбомов, возвращаемых запросом, равно N. Тогда для каждого альбома мы выполним следующий запрос: SELECT id,title FROM tracks WHERE album_id = <album-id>
Всего будет N+1 запросов на получение всех необходимых данных.
Пакетирование запросов
Такие инструменты, как загрузчик данных предназначен для решения проблемы запросов N+1 с использованием пакетной обработки.
Количество SQL-запросов к вложенным данным больше не зависит от размера исходной выборки, поскольку теперь на это влияет количество узлов в запросе GraphQL. В этом случае для получения необходимых данных потребуется 2 запроса к Postgres:
Мы получаем альбомы: SELECT id,title FROM album WHERE year = 2018
Получаем треки для альбомов, которые мы получили в предыдущем запросе: SELECT id, title FROM tracks WHERE album_id IN {the list of album ids}
Всего есть 2 запроса.
Мы избегали выполнения SQL-запросов к трекам для каждого отдельного альбома, а вместо этого использовали предложение WHERE, чтобы получить все необходимые треки сразу в одном запросе.
Присоединяется
Dataloader предназначен для работы с разными источниками данных и не позволяет использовать возможности какого-то конкретного.В нашем случае единственным источником данных является Postgres и он, как и все реляционные базы данных, предоставляет возможность собирать данные из нескольких таблиц в одном запросе с помощью оператора JOIN. Мы можем определить все таблицы, необходимые для запроса GraphQL, и сгенерировать один SQL-запрос, используя JOIN, для получения всех данных.
Оказывается, данные, необходимые для любого запроса GraphQL, можно получить с помощью одного SQL-запроса.
Эти данные преобразуются перед отправкой клиенту.
Этот запрос: SELECT
album.id as album_id,
album.title as album_title,
track.id as track_id,
track.title as track_title
FROM
album
LEFT OUTER JOIN
track
ON
(album.id = track.album_id)
WHERE
album.year = 2018
Вернет нам следующие данные: album_id, album_title, track_id, track_title
1, Album1, 1, track1
1, Album1, 2, track2
2, Album2, NULL, NULL
После чего он будет конвертирован в JSON и отправлен клиенту: [
{
"title" : "Album1",
"tracks": [
{"id" : 1, "title": "track1"},
{"id" : 2, "title": "track2"}
]
},
{
"title" : "Album2",
"tracks" : []
}
]
Оптимизация генерации ответов
Мы обнаружили, что большая часть времени при обработке запроса тратится на функцию преобразования результата SQL-запроса в JSON. После нескольких попыток различными способами оптимизировать эту функцию мы решили перенести ее в Postgres. В Постгресе 9.4 ( выпущен примерно во время первого выпуска Hasura ) добавил функцию агрегации JSON, которая помогла нам достичь нашего плана.
После такой оптимизации SQL-запросы стали выглядеть так: SELECT json_agg(r.*) FROM (
SELECT
album.title as title,
json_agg(track.*) as tracks
FROM
album
LEFT OUTER JOIN
track
ON
(album.id = track.album_id)
WHERE
album.year = 2018
GROUP BY
album.id
) r
Результат этого запроса будет иметь один столбец и одну строку, и это значение будет отправлено клиенту без дальнейшего преобразования.
В наших тестах этот подход примерно в 3-6 раз быстрее, чем функция преобразования Haskell.
Подготовленные заявления
Сгенерированные SQL-запросы могут быть довольно большими и сложными в зависимости от уровня вложенности запроса и условий использования.Обычно веб-приложения имеют набор запросов, которые неоднократно выполняются с разными параметрами.
Например, предыдущий запрос необходимо выполнить за 2017 год, а не за 2018 год. Подготовленные операторы лучше всего подходят для случаев, когда имеется повторяющийся сложный SQL-запрос, в котором меняются только параметры.
Допустим, этот запрос выполняется впервые: {
album (where: {year: {_eq: 2018}}) {
title
tracks {
id
title
}
}
}
Мы создаем подготовленный оператор для SQL-запроса вместо его выполнения: PREPARE prep_1 AS SELECT json_agg(r.*) FROM (
SELECT
album.title as title,
json_agg(track.*) as tracks
FROM
album
LEFT OUTER JOIN
track
ON
(album.id = track.album_id)
WHERE
album.year = $1
GROUP BY
album.
Затем мы немедленно выполняем его: EXECUTE prep_1('2018');
Когда нам нужно запустить запрос GraphQL за 2017 год, мы просто вызываем тот же подготовленный оператор с другим аргументом: EXECUTE prep_1('2017');
Это дает примерно 10-20% прироста скорости в зависимости от сложности запроса GraphQL.
Хаскелл
Haskell хорошо подходит по нескольким причинам:- Скомпилированный язык с отличной производительностью ( подробнее здесь ).
- Очень эффективный HTTP-стек ( деформировать , архитектура варпа ).
- Наш предыдущий опыт работы с языком.
В конце концов
Все упомянутые выше оптимизации приводят к довольно серьезному увеличению производительности:Фактически, низкое потребление памяти и незначительная задержка по сравнению с прямым вызовом PostgreSQL позволяют в большинстве случаев заменить ORM в вашем бэкэнде вызовами GraphQL API. Тесты: Испытательный стенд:
- Ноутбук с 8 ГБ ОЗУ и i7
- Postgres работает на той же машине
- работать , использовался как инструмент сравнения и для разных типов запросов мы пытались «максимизировать» число запросов в секунду.
- Один экземпляр Hasura GraphQL Engine.
- Размер пула подключений: 50
- Набор данных: чавыча
query tracks_media_some {
tracks (where: {composer: {_eq: "Kurt Cobain"}}){
id
name
album {
id
title
}
media_type {
name
}
}}
- Запросов в секунду: 1375 запросов/с.
- Задержка: 17,5 мс
- ЦП: ~30%
- ОЗУ: ~30 МБ (Хасура) + 90 МБ (Postgres)
query tracks_media_all {
tracks {
id
name
media_type {
name
}
}}
- Запросов в секунду: 410 запросов/с.
- Задержка: 59 мс
- ЦП: ~100%
- ОЗУ: ~30 МБ (Хасура) + 130 МБ (Postgres)
query albums_tracks_genre_some {
albums (where: {artist_id: {_eq: 127}}) {
id
title
tracks {
id
name
genre {
name
}
}
}}
- Запросов в секунду: 1029 запросов/с.
- Задержка: 24 мс
- ЦП: ~30%
- ОЗУ: ~30 МБ (Хасура) + 90 МБ (Postgres)
query albums_tracks_genre_all {
albums {
id
title
tracks {
id
name
genre {
name
}
}
}
- Запросов в секунду: 328 запросов/с.
- Задержка: 73 мс
- Процессор: 100%
- ОЗУ: ~30 МБ (Хасура) + 130 МБ (Postgres)
-
Древнее Устройство – Весы
19 Oct, 24 -
В Игре Виртономика Появится Настоящий Душ
19 Oct, 24 -
Цена Apple Iphone Снизится
19 Oct, 24 -
Что? Где? Сколько? В Региональном Рунете
19 Oct, 24 -
Stm32 И Usb-Hid – Это Просто
19 Oct, 24