Привет! Представляю вашему вниманию перевод статьи «Architecture of a high performance GraphQL to SQL engine».
Это перевод статьи про то, как устроен изнутри и какие оптимизации и архитектурные решения несет в себе 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, проходит через следующие стадии:
- Получение сессий: Запрос попадает в шлюз, который проверяет ключ (если есть) и добавляет различные заголовки, например идентификатор и роль пользователя.
- Парсинг запросов: Hasura получает запрос, парсит заголовки для получения информации о пользователе, создает GraphQL AST на основе тела запроса.
- Валидация запросов: Выполняется проверка, является ли запрос семантически правильным, затем применяются права доступа, соответствующие роли пользователя.
- Выполнение запросов: Запрос конвертируется в SQL и отправляется в Postgres.
- Генерация ответа: Результат SQL запроса обрабатывается и отправляется клиенту (шлюз может использовать gzip, если это нужно).
Цели
Требования примерно следующие:
- HTTP стек должен добавлять минимальный overhead и позволять обрабатывать множество одновременных запросов для высокой пропускной способности.
- Быстрая генерация SQL из GraphQL запроса.
- Сгенерированный SQL запрос должен быть эффективным для Postgres.
- Результат SQL запроса должен эффективно передаваться обратно от Postgres.
Обработка GraphQL запроса
Существует несколько подходов к получению данных, необходимых для GraphQL запроса:
Обычные resolvers
Выполнение 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 запросов для получения всех необходимых данных.
Batching запросов
Инструменты вроде dataloader призваны решить проблему 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, чтобы получить все необходимые треки сразу в одном запросе.
Joins
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.
Prepared statements
Сгенерированные 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
Haskell хорошо подходит по нескольким причинам:
- Компилируемый язык с отличной производительностью (подробнее тут).
- Очень эффективный HTTP стек (warp, warp’s architecture).
- Наш предыдущий опыт работы с языком.
В итоге
Все упомянутые выше оптимизации в результате приводят к довольно серьезным преимуществам в производительности:
Фактически, низкое потребление памяти и незначительные задержки по сравнению с прямым обращением к PostgreSQL, позволяют в большинстве случаев заменить ORM в вашем backend’е вызовами GraphQL API.
Бенчмарки:
Тестовый стенд:
- Ноутбук с 8GB RAM и i7
- Postgres, работающий на этом же компьютере
- wrk, использовался в качестве инструмента сравнения и для различных типов запросов мы пытались «максимизировать» rps
- Один экземпляр Hasura GraphQL Engine
- Размер пула подключений: 50
- Набор данных: chinook
Запрос 1: tracks_media_some
query tracks_media_some {
tracks (where: {composer: {_eq: "Kurt Cobain"}}){
id
name
album {
id
title
}
media_type {
name
}
}}
- Запросов в секунду: 1375 req/s
- Задержка: 17.5ms
- CPU: ~30%
- RAM: ~30MB (Hasura) + 90MB (Postgres)
Запрос 2: tracks_media_all
query tracks_media_all {
tracks {
id
name
media_type {
name
}
}}
- Запросов в секунду: 410 req/s
- Задержка: 59ms
- CPU: ~100%
- RAM: ~30MB (Hasura) + 130MB (Postgres)
Запрос 3: album_tracks_genre_some
query albums_tracks_genre_some {
albums (where: {artist_id: {_eq: 127}}) {
id
title
tracks {
id
name
genre {
name
}
}
}}
- Запросов в секунду: 1029 req/s
- Задержка: 24ms
- CPU: ~30%
- RAM: ~30MB (Hasura) + 90MB (Postgres)
Запрос 4: album_tracks_genre_all
query albums_tracks_genre_all {
albums {
id
title
tracks {
id
name
genre {
name
}
}
}
- Запросов в секунду: 328 req/s
- Задержка: 73ms
- CPU: 100%
- RAM: ~30MB (Hasura) + 130MB (Postgres)
Автор: Maxpain154