Hasura Arquitectura de alto rendimiento GraphQL a SQL Server

Hola Habr! Les presento la traducción del artículo "Arquitectura de un motor GraphQL a SQL de alto rendimiento" .

Esta es una traducción de un artículo sobre cómo está estructurado internamente y qué optimizaciones y soluciones arquitectónicas tiene Hasura: un servidor GraphQL liviano de alto rendimiento, que actúa como una capa entre su aplicación web y la base de datos PostgreSQL.

Le permite generar un esquema GraphQL basado en una base de datos existente o crear uno nuevo. Admite suscripciones GraphQL desde el cuadro basado en activadores de Postgres, control de acceso dinámico, generación automática de combinaciones, resuelve el problema de las solicitudes N + 1 (procesamiento por lotes) y mucho más.


Puede usar restricciones de claves externas en PostgreSQL para obtener datos jerárquicos en una sola consulta. Por ejemplo, puede ejecutar esta consulta para obtener álbumes y sus pistas correspondientes (si se crea una clave externa en la tabla "pista" que apunta a la tabla "álbum")

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

Como habrás adivinado, puedes solicitar datos de cualquier profundidad. Esta API, combinada con el control de acceso, permite a las aplicaciones web consultar datos de PostgreSQL sin escribir su propio backend. Está diseñado para responder consultas lo más rápido posible, tener un gran ancho de banda, al tiempo que ahorra tiempo de procesador y consumo de memoria en el servidor. Hablaremos de soluciones arquitectónicas que nos permitieron lograr esto.

Solicitar ciclo de vida


Una solicitud enviada a Hasura pasa por las siguientes etapas:

  1. Recepción de sesiones : la solicitud cae en la puerta de enlace, que comprueba la clave (si la hay) y agrega varios encabezados, por ejemplo, el identificador y la función del usuario.
  2. Análisis de solicitudes : Hasura recibe la solicitud, analiza los encabezados para obtener información sobre el usuario, crea GraphQL AST en función del cuerpo de la solicitud.
  3. Validación de solicitudes : se realiza una comprobación para ver si la solicitud es semánticamente correcta, luego se aplican los derechos de acceso correspondientes a la función del usuario.
  4. Ejecución de la consulta : la consulta se convierte a SQL y se envía a Postgres.
  5. Generación de respuesta : el resultado de la consulta SQL se procesa y se envía al cliente (la puerta de enlace puede usar gzip si es necesario ).

Objetivos


Los requisitos son aproximadamente los siguientes:

  1. La pila HTTP debería agregar una sobrecarga mínima y ser capaz de manejar muchas solicitudes concurrentes para un alto rendimiento.
  2. Generación rápida de SQL a partir de consultas GraphQL.
  3. La consulta SQL generada debería ser eficiente para Postgres.
  4. El resultado de la consulta SQL se debe pasar efectivamente de Postgres.

Procesamiento de consultas GraphQL


Existen varios enfoques para obtener los datos necesarios para una consulta GraphQL:

Resolvedores convencionales


La ejecución de consultas GraphQL generalmente implica llamar al solucionador para cada campo.
En la solicitud de ejemplo, obtenemos los álbumes lanzados en 2018, y luego para cada uno de ellos solicitamos las pistas correspondientes, un problema clásico de las solicitudes N + 1. El número de consultas crece exponencialmente al aumentar la profundidad de la consulta.

Las solicitudes hechas por Postgres serán:

 SELECT id,title FROM album WHERE year = 2018; 

Esta solicitud nos devolverá todos los álbumes. Suponga que el número de álbumes devueltos por la solicitud es igual a N. Luego, para cada álbum, ejecutaremos la siguiente solicitud:

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

En total, obtienes consultas N + 1 para obtener todos los datos necesarios.

Solicitudes por lotes


Las herramientas como el cargador de datos están diseñadas para resolver el problema de las solicitudes N + 1 mediante el procesamiento por lotes. El número de consultas SQL para los datos incrustados ya no depende del tamaño de la muestra inicial, porque Ahora afecta el número de nodos en la consulta GraphQL. En este caso, se requieren 2 solicitudes a Postgres para obtener los datos requeridos:

Tenemos álbumes:

 SELECT id,title FROM album WHERE year = 2018 

Obtenemos las pistas de los álbumes que recibimos en la solicitud anterior:

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

En total, se reciben 2 consultas. Evitamos ejecutar consultas SQL en las pistas para cada álbum individual; en su lugar, utilizamos el operador WHERE para obtener todas las pistas necesarias en una consulta a la vez.

Se une


Dataloader está diseñado para trabajar con diferentes fuentes de datos y no permite explotar las capacidades de una en particular. En nuestro caso, Postgres es la única fuente de datos y, como todas las bases de datos relacionales, proporciona la capacidad de recopilar datos de varias tablas con una sola consulta utilizando el operador JOIN. Podemos determinar todas las tablas necesarias para una consulta GraphQL y generar una sola consulta SQL utilizando JOIN para obtener todos los datos. Resulta que los datos necesarios para cualquier consulta GraphQL se pueden obtener utilizando una sola consulta SQL. Estos datos se convierten antes de ser enviados al cliente.

Tal solicitud:

 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 

Nos devolverá dichos datos:

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

Luego se convertirá a JSON y se enviará al cliente:

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

Optimización de la generación de respuesta.


Descubrimos que la mayor parte del tiempo en el procesamiento de consultas se dedica a la función de convertir el resultado de una consulta SQL a JSON.

Después de varios intentos de optimizar esta función de varias maneras, decidimos transferirla a Postgres. Postgres 9.4 ( lanzado en el momento del primer lanzamiento de Hasura ) agregó una función para la agregación JSON que nos ayudó a hacer nuestro trabajo. Después de esta optimización, las consultas SQL comenzaron a verse 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 = 2018 GROUP BY album.id ) r 

El resultado de esta consulta tendrá una columna y una fila, y este valor se enviará al cliente sin más conversiones. Según nuestras pruebas, este enfoque es de 3 a 6 veces más rápido que la función de conversión de Haskell.

Declaraciones preparadas


Las consultas SQL generadas pueden ser bastante grandes y complejas dependiendo del nivel de anidamiento de la consulta y las condiciones de uso. Por lo general, las aplicaciones web tienen un conjunto de consultas que se ejecutan repetidamente con diferentes parámetros. Por ejemplo, la consulta anterior debe ejecutarse para 2017, en lugar de 2018. Las declaraciones preparadas son más adecuadas para los casos en los que hay una consulta compleja de SQL repetitiva en la que solo se cambian los parámetros.

Digamos que esta consulta se ejecuta por primera vez:

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

Creamos una declaración preparada para la consulta SQL en lugar de ejecutarla:

 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. 

Después de lo cual lo ejecutamos de inmediato:

 EXECUTE prep_1('2018'); 

Cuando necesita ejecutar la consulta GraphQL para 2017, simplemente llamamos a la misma declaración preparada con un argumento diferente:

 EXECUTE prep_1('2017'); 

Esto proporciona un aumento del 10-20% en la velocidad dependiendo de la complejidad de la consulta GraphQL.

Haskell


Haskell funciona bien por varias razones:


Al final


Todas las optimizaciones mencionadas anteriormente dan como resultado ventajas de rendimiento bastante serias:



De hecho, el bajo consumo de memoria y los retrasos insignificantes en comparación con las llamadas directas a PostgreSQL permiten en la mayoría de los casos reemplazar el ORM en su back-end con llamadas a la API GraphQL.

Puntos de referencia:

Banco de pruebas:

  1. Laptop con 8GB de RAM y i7
  2. Postgres corriendo en la misma computadora
  3. wrk , se usó como herramienta de comparación y para varios tipos de solicitudes tratamos de "maximizar" rps
  4. Una instancia de Hasura GraphQL Engine
  5. Tamaño de la piscina de conexión: 50
  6. Conjunto de datos : chinook


Solicitud 1: tracks_media_some

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

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

Solicitud 2: tracks_media_all

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

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

Solicitud 3: album_tracks_genre_some

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

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

Solicitud 4: album_tracks_genre_all

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

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

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


All Articles