Hasura. Arquitetura GraphQL para SQL Server de alto desempenho

Olá Habr! Apresento a você a tradução do artigo “Arquitetura de um mecanismo GraphQL to SQL de alto desempenho” .

Esta é uma tradução de um artigo sobre como é estruturada internamente e quais otimizações e soluções arquiteturais o Hasura carrega - um servidor GraphQL leve e de alto desempenho, que atua como uma camada entre o aplicativo da Web e o banco de dados PostgreSQL.

Permite gerar um esquema GraphQL com base em um banco de dados existente ou criar um novo. Ele suporta Subscrições GraphQL da caixa com base nos gatilhos do Postgres, controle de acesso dinâmico, geração automática de junções, resolve o problema de solicitações N + 1 (lote) e muito mais.


Você pode usar restrições de chaves estrangeiras no PostgreSQL para obter dados hierárquicos em uma única consulta. Por exemplo, você pode executar esta consulta para obter os álbuns e suas faixas correspondentes (se uma chave estrangeira for criada na tabela "faixa" que aponta para a tabela "álbum")

{ album (where: {year: {_eq: 2018}}) { title tracks { id title } } } 

Como você deve ter adivinhado, pode solicitar dados de qualquer profundidade. Essa API, combinada ao controle de acesso, permite que aplicativos da Web consultem dados do PostgreSQL sem escrever seu próprio back-end. Ele foi projetado para atender a consultas o mais rápido possível, com alta largura de banda, economizando tempo do processador e consumo de memória no servidor. Falaremos sobre soluções arquitetônicas que nos permitiram alcançar isso.

Solicitar ciclo de vida


Uma solicitação enviada ao Hasura passa pelas seguintes etapas:

  1. Sessões de recebimento : a solicitação cai no gateway, que verifica a chave (se houver) e adiciona vários cabeçalhos, por exemplo, o identificador e a função do usuário.
  2. Análise de consulta : Hasura recebe uma consulta, analisando cabeçalhos para obter informações do usuário, cria o GraphQL AST com base no corpo da solicitação.
  3. Validação de solicitações : é feita uma verificação para ver se a solicitação está semanticamente correta, e os direitos de acesso correspondentes à função do usuário são aplicados.
  4. Execução de consulta : a consulta é convertida em SQL e enviada para o Postgres.
  5. Geração de resposta : o resultado da consulta SQL é processado e enviado ao cliente (o gateway pode usar gzip, se necessário ).

Objetivos


Os requisitos são aproximadamente os seguintes:

  1. A pilha HTTP deve adicionar sobrecarga mínima e ser capaz de lidar com muitas solicitações simultâneas de alta taxa de transferência.
  2. Geração rápida de SQL a partir da consulta GraphQL.
  3. A consulta SQL gerada deve ser eficiente para o Postgres.
  4. O resultado da consulta SQL deve ser efetivamente repassado do Postgres.

Processamento de consultas GraphQL


Existem várias abordagens para obter os dados necessários para uma consulta GraphQL:

Resolvedores convencionais


A execução de consultas GraphQL normalmente envolve a chamada de resolvedor para cada campo.
No exemplo de solicitação, lançamos os álbuns em 2018 e, em seguida, para cada um deles, solicitamos as faixas correspondentes - um problema clássico de solicitações de N + 1. O número de consultas cresce exponencialmente com o aumento da profundidade da consulta.

Os pedidos feitos pelo Postgres serão:

 SELECT id,title FROM album WHERE year = 2018; 

Esta solicitação retornará todos os álbuns para nós. Suponha que o número de álbuns retornados pela solicitação seja igual a N. Em seguida, para cada álbum, executaríamos a seguinte solicitação:

 SELECT id,title FROM tracks WHERE album_id = <album-id> 

No total, você recebe consultas N + 1 para obter todos os dados necessários.

Solicitações em lote


Ferramentas como o carregador de dados são projetadas para resolver o problema de solicitações N + 1 usando lotes. O número de consultas SQL para os dados incorporados não depende mais do tamanho da amostra inicial, porque Agora isso afeta o número de nós na consulta GraphQL. Nesse caso, são necessárias 2 solicitações ao Postgres para obter os dados necessários:

Temos álbuns:

 SELECT id,title FROM album WHERE year = 2018 

Obtemos as faixas dos álbuns que recebemos na solicitação anterior:

 SELECT id, title FROM tracks WHERE album_id IN {the list of album ids} 

No total, são recebidas 2 consultas. Evitamos executar consultas SQL nas faixas para cada álbum individual; em vez disso, usamos o operador WHERE para obter todas as faixas necessárias em uma consulta ao mesmo tempo.

Junções


O Dataloader foi projetado para funcionar com diferentes fontes de dados e não permite explorar os recursos de uma determinada. No nosso caso, o Postgres é a única fonte de dados e, como todos os bancos de dados relacionais, fornece a capacidade de coletar dados de várias tabelas com uma única consulta usando o operador JOIN. Podemos determinar todas as tabelas necessárias para uma consulta GraphQL e gerar uma única consulta SQL usando JOINs para obter todos os dados. Acontece que os dados necessários para qualquer consulta do GraphQL podem ser obtidos usando uma única consulta SQL. Esses dados são convertidos antes de serem enviados ao cliente.

Tal pedido:

 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 

Devolverá esses dados:

 album_id, album_title, track_id, track_title 1, Album1, 1, track1 1, Album1, 2, track2 2, Album2, NULL, NULL 

Em seguida, ele será convertido em JSON e enviado ao cliente:

 [ { "title" : "Album1", "tracks": [ {"id" : 1, "title": "track1"}, {"id" : 2, "title": "track2"} ] }, { "title" : "Album2", "tracks" : [] } ] 

Otimização da geração de respostas


Descobrimos que a maior parte do tempo no processamento de consultas é gasta na função de converter o resultado de uma consulta SQL em JSON.

Após várias tentativas de otimizar essa função de várias maneiras, decidimos transferi-la para o Postgres. O Postgres 9.4 ( lançado na época do primeiro lançamento de Hasura ) adicionou um recurso à agregação JSON que nos ajudou a fazer nosso trabalho. Após essa otimização, as consultas SQL começaram a ficar assim:

 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 

O resultado dessa consulta terá uma coluna e uma linha, e esse valor será enviado ao cliente sem mais conversões. De acordo com nossos testes, essa abordagem é cerca de 3 a 6 vezes mais rápida que a função de conversão Haskell.

Declarações preparadas


As consultas SQL geradas podem ser bastante grandes e complexas, dependendo do nível de aninhamento da consulta e das condições de uso. Normalmente, os aplicativos da web têm um conjunto de consultas que são executadas repetidamente com parâmetros diferentes. Por exemplo, a consulta anterior precisa ser executada para 2017, em vez de 2018. As instruções preparadas são mais adequadas para os casos em que há uma consulta SQL complexa repetida na qual apenas os parâmetros são alterados.

Digamos que esta consulta seja executada pela primeira vez:

 { album (where: {year: {_eq: 2018}}) { title tracks { id title } } } 

Criamos uma instrução preparada para a consulta SQL em vez de executá-la:

 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. 

Depois disso, nós o executamos imediatamente:

 EXECUTE prep_1('2018'); 

Quando você precisa executar a consulta GraphQL para 2017, simplesmente chamamos a mesma instrução preparada com um argumento diferente:

 EXECUTE prep_1('2017'); 

Isso aumenta em cerca de 10 a 20% a velocidade, dependendo da complexidade da consulta do GraphQL.

Haskell


Haskell funciona bem por vários motivos:


No final


Todas as otimizações mencionadas acima resultam em vantagens de desempenho bastante sérias:



De fato, baixo consumo de memória e atrasos insignificantes em comparação com chamadas diretas ao PostgreSQL permitem na maioria dos casos substituir ORMs no seu back-end por chamadas à API do GraphQL.

Benchmarks:

Suporte de teste:

  1. Laptop com 8 GB de RAM e i7
  2. Postgres rodando no mesmo computador
  3. wrk , foi usado como uma ferramenta de comparação e, para vários tipos de solicitações, tentamos "maximizar" os rps
  4. Uma instância do Hasura GraphQL Engine
  5. Tamanho do pool de conexão: 50
  6. Conjunto de dados : chinook


Pedido 1: tracks_media_some

 query tracks_media_some { tracks (where: {composer: {_eq: "Kurt Cobain"}}){ id name album { id title } media_type { name } }} 

  • Pedidos por segundo: 1375 req / s
  • Atraso: 17.5ms
  • CPU: ~ 30%
  • RAM: ~ 30 MB (Hasura) + 90 MB (Postgres)

Pedido 2: tracks_media_all

 query tracks_media_all { tracks { id name media_type { name } }} 

  • Pedidos por segundo: 410 req / s
  • Atraso: 59ms
  • CPU: ~ 100%
  • RAM: ~ 30 MB (Hasura) + 130 MB (Postgres)

Solicitação 3: album_tracks_genre_some

 query albums_tracks_genre_some { albums (where: {artist_id: {_eq: 127}}) { id title tracks { id name genre { name } } }} 

  • Pedidos por segundo: 1029 req / s
  • Atraso: 24ms
  • CPU: ~ 30%
  • RAM: ~ 30 MB (Hasura) + 90 MB (Postgres)

Solicitação 4: album_tracks_genre_all

 query albums_tracks_genre_all { albums { id title tracks { id name genre { name } } } 

  • Pedidos por segundo: 328 req / s
  • Atraso: 73ms
  • CPU: 100%
  • RAM: ~ 30 MB (Hasura) + 130 MB (Postgres)

Source: https://habr.com/ru/post/pt428133/


All Articles