Хабрахабр

[Из песочницы] Hasura. Архитектура высокопроизводительного GraphQL to SQL сервера

Привет, Хабр! Представляю вашему вниманию перевод статьи «Architecture of a high performance GraphQL to SQL engine».

Это перевод статьи про то, как устроен изнутри и какие оптимизации и архитектурные решения несет в себе Hasura — высокопроизводительный легковесный GraphQL сервер, выступающий прослойкой между вашим веб-приложением и базой данных PostgreSQL.

Поддерживает GraphQL Subscriptions из коробки на основе Postgres-триггеров, динамический контроль прав доступа, автоматическую генерацию join’ов, решает проблему N+1 запросов (batching) и многое другое. Он позволяет генерировать GraphQL схему на основе существующей базы данных или создать новую.

Вы можете использовать foreign keys constraints в PostgreSQL для того, чтобы получить иерархические данные в одном запросе. К примеру вы можете выполнить этот запрос для того чтобы получить альбомы и соответствующие им треки (если в таблице «track» создан foreign key, указывающий на таблицу «album»)

}) { title tracks { id title } }
}

Как вы, возможно, догадались, запрашивать данные можно любой глубины. Этот API в сочетании с контролем прав доступа позволяет веб-приложениям запрашивать данные из PostgreSQL без написания собственного backend’a. Он разработан с целью максимально быстро выполнять запросы, иметь высокую пропускную способность, при этом экономить процессорное время и потребление памяти на сервере. Мы расскажем об архитектурных решениях, которые позволили нам достичь этого.

Жизненный цикл запросов

Запрос, отправленный в Hasura, проходит через следующие стадии:

  1. Получение сессий: Запрос попадает в шлюз, который проверяет ключ (если есть) и добавляет различные заголовки, например идентификатор и роль пользователя.
  2. Парсинг запросов: Hasura получает запрос, парсит заголовки для получения информации о пользователе, создает GraphQL AST на основе тела запроса.
  3. Валидация запросов: Выполняется проверка, является ли запрос семантически правильным, затем применяются права доступа, соответствующие роли пользователя.
  4. Выполнение запросов: Запрос конвертируется в SQL и отправляется в Postgres.
  5. Генерация ответа: Результат SQL запроса обрабатывается и отправляется клиенту (шлюз может использовать gzip, если это нужно).

Цели

Требования примерно следующие:

  1. HTTP стек должен добавлять минимальный overhead и позволять обрабатывать множество одновременных запросов для высокой пропускной способности.
  2. Быстрая генерация SQL из GraphQL запроса.
  3. Сгенерированный SQL запрос должен быть эффективным для Postgres.
  4. Результат 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 9. После нескольких попыток оптимизировать эту функцию различными способами, мы приняли решение перенести её в Postgres. После этой оптимизации SQL запросы стали выглядеть так: 4 (выпущенный примерно во время первого релиза Hasura) добавили функцию для агрегации JSON, которая помогла нам сделать задуманное.

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.

Бенчмарки:

Тестовый стенд:

  1. Ноутбук с 8GB RAM и i7
  2. Postgres, работающий на этом же компьютере
  3. wrk, использовался в качестве инструмента сравнения и для различных типов запросов мы пытались «максимизировать» rps
  4. Один экземпляр Hasura GraphQL Engine
  5. Размер пула подключений: 50
  6. Набор данных: 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)
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть