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

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

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

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

tbtqntldyuxalrgy5wvz9exu5kc.png


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

{
  album (where: {year: {_eq: 2018}}) {
    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 = 


В общей сложности получится 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).
  • Наш предыдущий опыт работы с языком.


В итоге


Все упомянутые выше оптимизации в результате приводят к довольно серьезным преимуществам в производительности:

oqflgjy29zi_ar0amyy6xfggvbc.png

Фактически, низкое потребление памяти и незначительные задержки по сравнению с прямым обращением к 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)

© Habrahabr.ru