- PVSM.RU - https://www.pvsm.ru -
Привет! Представляю вашему вниманию перевод статьи «Architecture of a high performance GraphQL to SQL engine» [1].
Это перевод статьи про то, как устроен изнутри и какие оптимизации и архитектурные решения несет в себе Hasura — высокопроизводительный легковесный GraphQL сервер, выступающий прослойкой между вашим веб-приложением и базой данных PostgreSQL.
Он позволяет генерировать GraphQL схему на основе существующей базы данных или создать новую. Поддерживает GraphQL Subscriptions из коробки на основе Postgres-триггеров, динамический контроль прав доступа, автоматическую генерацию join’ов, решает проблему N+1 запросов (batching) и многое другое.

Вы можете использовать foreign keys constraints в PostgreSQL для того, чтобы получить иерархические данные в одном запросе. К примеру вы можете выполнить этот запрос для того чтобы получить альбомы и соответствующие им треки (если в таблице «track» создан foreign key, указывающий на таблицу «album»)
{
album (where: {year: {_eq: 2018}}) {
title
tracks {
id
title
}
}
}
Как вы, возможно, догадались, запрашивать данные можно любой глубины. Этот API в сочетании с контролем прав доступа позволяет веб-приложениям запрашивать данные из PostgreSQL без написания собственного backend’a. Он разработан с целью максимально быстро выполнять запросы, иметь высокую пропускную способность, при этом экономить процессорное время и потребление памяти на сервере. Мы расскажем об архитектурных решениях, которые позволили нам достичь этого.
Запрос, отправленный в Hasura, проходит через следующие стадии:
Требования примерно следующие:
Существует несколько подходов к получению данных, необходимых для GraphQL запроса:
Выполнение GraphQL запросов обычно включает в себя вызов resolver’a для каждого поля.
В примере запроса мы получаем альбомы, выпущенные в 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 запросов для получения всех необходимых данных.
Инструменты вроде dataloader [2] призваны решить проблему N+1 запросов с помощью batching’a. Количество 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 запрос используя JOINs для получения всех данных. Получается, данные, необходимые для любого 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. В 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. Prepared statements лучше всего подходит для таких случаев, когда есть повторяющийся сложный SQL запрос, в котором меняются только параметры.
Допустим, такой запрос выполняется впервые:
{
album (where: {year: {_eq: 2018}}) {
title
tracks {
id
title
}
}
}
Мы создаем prepared statement для 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 года, мы просто вызываем тот же prepared statement с другим аргументом:
EXECUTE prep_1('2017');
Это даёт примерно 10-20% прироста скорости в зависимости от сложности GraphQL запроса.
Haskell хорошо подходит по нескольким причинам:
Все упомянутые выше оптимизации в результате приводят к довольно серьезным преимуществам в производительности:

Фактически, низкое потребление памяти и незначительные задержки по сравнению с прямым обращением к PostgreSQL, позволяют в большинстве случаев заменить ORM в вашем backend’е вызовами GraphQL API.
Бенчмарки:
Тестовый стенд:
Запрос 1: tracks_media_some
query tracks_media_some {
tracks (where: {composer: {_eq: "Kurt Cobain"}}){
id
name
album {
id
title
}
media_type {
name
}
}}
Запрос 2: tracks_media_all
query tracks_media_all {
tracks {
id
name
media_type {
name
}
}}
Запрос 3: album_tracks_genre_some
query albums_tracks_genre_some {
albums (where: {artist_id: {_eq: 127}}) {
id
title
tracks {
id
name
genre {
name
}
}
}}
Запрос 4: album_tracks_genre_all
query albums_tracks_genre_all {
albums {
id
title
tracks {
id
name
genre {
name
}
}
}
Автор: Maxpain154
Источник [8]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/297393
Ссылки в тексте:
[1] «Architecture of a high performance GraphQL to SQL engine»: https://blog.hasura.io/architecture-of-a-high-performance-graphql-to-sql-server-58d9944b8a87
[2] dataloader: https://github.com/facebook/dataloader
[3] подробнее тут: https://stackoverflow.com/questions/35027952/why-is-haskell-ghc-so-darn-fast
[4] warp: https://www.stackage.org/package/warp
[5] warp’s architecture: http://www.aosabook.org/en/posa/warp.html
[6] wrk: https://github.com/wg/wrk
[7] chinook: https://github.com/lerocha/chinook-database
[8] Источник: https://habr.com/post/428133/?utm_campaign=428133
Нажмите здесь для печати.