Desempenho em aplicações web que utilizam GraphQL (mas não somente)
GDG Lauro de Freitas
08 de novembro de 2024
- Ciência da Computação / UFBA
- Ruby On Rails desde 2008 / JavaScript desde 2006
- https://ca.ios.ba
- @caiosba
- São Francisco - Califórnia
- Engenheiro de Software desde 2011
- https://meedan.com
- @meedan
Check
github.com/meedan/check-api
github.com/meedan/check
O foco desta palestra é...
GraphQL
Ruby On Rails
... mas não precisa ser!
Muitos conceitos e arquiteturas são aplicados a outros frameworks e stacks.
Mais disclaimers!
- Não existe bala de prata
- Otimização Prematura
- Os problemas e soluções apresentados aqui funcionaram para mim, mas dependem de fatores diferentes (por exemplo, banco de dados) e necessidades - e lembre-se, toda decisão tem um trade-off (manutenibilidade, legibilidade, dependências, etc.)
GraphQL
"GraphQL é uma linguagem de consulta para sua API, e uma ferramenta do lado do servidor para executar consultas usando um sistema de tipos que você define para seus dados."
REST
Media
Comment
Tag
1
*
User
*
*
*
1
GET /api/v1/medias/1
GET /api/v1/medias/1/comments
GET /api/v1/medias/1/tags
GET /api/v1/medias/1/comments/1
GET /api/v1/users/1?fields=avatar,name
GET /api/v1/users/2?fields=avatar,name
GET /api/v1/users/3?fields=avatar,name
...
Endpoints reutilizáveis
GET /api/v1/medias/1?include=comments&count=5
GET /api/v1/medias/1?include=comments,tags
&comments_count=5&tags_count=5
GET /api/v1/medias/1?fields=comments(text,date)
&tags(tag)
...
GET /api/v1/media_and_comments/1
GET /api/v1/media_comments_and_tags/1
GET /api/v1/media_comments_tags_and_users/1
GET /api/v1/medias/1?include=comments&count=5
GET /api/v1/medias/1?include=comments,tags
&comments_count=5&tags_count=5
GET /api/v1/medias/1?fields=comments(text,date)
&tags(tag)
...
GET /api/v1/media_and_comments/1
GET /api/v1/media_comments_and_tags/1
GET /api/v1/media_comments_tags_and_users/1
Muitas requisições!
GraphQL
Um endpoint para todos governar
POST /graphql
POST /api/graphql?query=
{
media(id: 1) {
title
embed
tags(first: 3) {
tag
}
comments(first: 5) {
created_at
text
user {
name,
avatar
}
}
}
}
POST /api/graphql?query=
{
media(id: 1) {
title
embed
tags(first: 3) {
tag
}
comments(first: 5) {
created_at
text
user {
name,
avatar
}
}
}
}
Media
Comment
Tag
1
*
User
*
*
*
1
~
POST /api/graphql?query=
{
media(id: 1) {
title
embed
tags(first: 3) {
tag
}
comments(first: 5) {
created_at
text
user {
name,
avatar
}
}
}
}
{
"media": {
"title": "Avangers Hulk Smash",
"embed": "<iframe src=\"...\"></iframe>",
"tags": [
{ "tag": "avengers" },
{ "tag": "operation" }
],
"comments": [
{
"text": "This is true",
"created_at": "2016-09-18 15:04:39",
"user": {
"name": "Ironman",
"avatar": "http://[...].png"
}
},
...
]
}
}
GraphQL
Ruby On Rails
Tipos
- Tipos personalizados
- Argumentos
- Campos
- Conexões
Mutations
mutation {
createMedia(
input: {
url: "http://youtu.be/7a_insd29fk"
clientMutationId: "1"
}
)
{
media {
id
}
}
}
As mutações fazem alterações no seu lado do servidor.
CRUD:
Queries: Read
Mutations:
- Create
- Update
- Delete
# mutation {
createMedia(
# input: {
url: "http://youtu.be/7a_insd29fk"
# clientMutationId: "1"
# }
# )
{
media {
id
}
}
# }
Nome da mutação
Parâmetros de entrada
Saída desejada
Muito flexível!
😊
Flexível demais!
😔
query {
teams(first: 1000) {
name
profile_image
users(first: 1000) {
name
email
posts(first: 1000) {
title
body
tags(first: 1000) {
tag_name
}
}
}
}
}
Consultas aninhadas podem se tornar um verdadeiro problema.
A complexidade real de uma consulta e o custo de alguns campos podem ficar ocultos pela expressividade da linguagem.
Vamos ver algumas estratégias para lidar com isso.
Vamos por partes... Você não pode realmente consertar o que não pode testar
# Some controller test
gql_query = 'query { posts(first: 10) { title, user { name } } }'
assert_queries 5 do
post :create, params: { query: gql_query }
end
Monitore se alguma refatoração ou alteração de código introduz regressões em como algumas consultas GraphQL são executadas.
# Some test helper
def assert_queries(max, &block)
query_cache_enabled = ApplicationRecord.connection.query_cache_enabled
ApplicationRecord.connection.enable_query_cache!
queries = []
callback = lambda { |_name, _start, _finish, _id, payload|
if payload[:sql] =~ /^SELECT|UPDATE|INSERT/ && !payload[:cached]
queries << payload[:sql]
end
}
ActiveSupport::Notifications.subscribed(callback, "sql.active_record", &block)
queries
ensure
ApplicationRecord.connection.disable_query_cache! unless query_cache_enabled
message = "#{queries.size} expected to be less or equal to #{max}."
assert queries.size <= max, message
end
Debaixo dos panos, uma maneira é:
Ok, os testes estão passando! Hora de enviar, implantar e... monitorar
O monitoramento padrão de requisições HTTP não é suficiente...
- As coisas podem melhorar com um log mais estruturado
- Útil para integrar com outras ferramentas de alerta (Uptime, etc.)
Sabemos quanto tempo as requisições HTTP GraphQL estão levando, mas quanto tempo a consulta GraphQL realmente leva?
OpenTelemetry / Honeycomb
Ótimo! Agora podemos ver quanto tempo cada etapa da GraphQL realmente leva... mas quais campos?
Apollo GraphQL Studio
Apollo GraphQL Studio
Apollo GraphQL Studio
Outras ferramentas
- Grafana
- Stellate
- Hasura
- ...
Agora que podemos rastrear e monitorar requisições GraphQL, como realmente melhorá-las ?
Evitar Consultas N+1
query {
posts(first: 5) {
id
author {
name
}
}
}
Post Load (0.9ms) SELECT "posts".* FROM "posts"
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 4], ["LIMIT", 1]]
User Load (0.3ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 5], ["LIMIT", 1]]
class User < ApplicationRecord
has_many :posts
end
class Post < ApplicationRecord
belongs_to :user
has_many :tags
end
Em um endpoint REST, você normalmente preveria retornar tanto posts quanto seus autores, sugerindo eager-loading na sua consulta. No entanto, como não podemos antecipar o que o cliente solicitará aqui, não podemos sempre pré-carregar os dados.
graphql-batch
# app/graphql/types/post_type.rb
field :author, Types::UserType do
resolve -> (post, _args, _context) {
RecordLoader.for(User).load(post.user_id)
}
end
Post Load (0.5ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."id" DESC LIMIT $1 [["LIMIT", 5]]
User Load (0.4ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5)
BatchLoader pode ser usado também.
Isso funciona bem para relacionamentos 1-para-1, mas e quanto a 1-para-muitos?
graphql-preload
# app/graphql/types/post_type.rb
field :tags, !types[Types::TagType] do
preload :tags
resolve -> (post, _args, _ctx) { post.tags }
end
Mas ainda assim, pode haver problemas de performance ao lidar com consultas mais complexas... E se pudéssemos prever dados consultados com precisão e criar um hash dinâmico para pré-carregar associações?
graphql: lookahead
field :users, [Types::UserType], null: false, extras: [:lookahead]
def users(lookahead:)
# Do something with lookahead
end
query
└── users
├── id
├── name
└── posts
├── id
└── title
O objeto lookahead é como uma estrutura de árvore que representa as informações que você precisa para otimizar sua consulta. Na prática, é muito mais complicado do que isso.
Evite consultas complexas
query {
teams(first: 1000) {
users(first: 1000) {
name
posts(first: 1000) {
tags(first: 1000) {
tag_name
}
author {
posts(first: 1000) {
title
}
}
}
}
}
}
- Consultas podem facilmente se tornar muito aninhadas, muito profundas e até circulares
graphql: max_depth
# app/graphql/your_schema.rb
YourSchema = GraphQL::Schema.define do
max_depth 4 # adjust as required
use GraphQL::Batch
enable_preloading
mutation(Types::MutationType)
query(Types::QueryType)
end
Aplicando timeouts
# Added to the bottom of app/graphql/your_schema.rb
YourSchema.middleware <<
GraphQL::Schema::TimeoutMiddleware.new(max_seconds: 5) do |e, q|
Rails.logger.info("GraphQL Timeout: #{q.query_string}")
end
Cache de fragmentos
class PostType < BaseObject
field :id, ID, null: false
field :title, String, null: false, cache_fragment: true
end
class QueryType < BaseObject
field :post, PostType, null: true do
argument :id, ID, required: true
end
def post(id:)
last_updated_at = Post.select(:updated_at).find_by(id: id)&.updated_at
cache_fragment(last_updated_at, expires_in: 5.minutes) { Post.find(id) }
end
end
Nossa própria abordagem para cache de campos para campos de alta demanda (baseados em eventos, meta-programação)
# app/models/media_rb
cached_field :last_seen,
start_as: proc { |media| media.created_at },
update_es: true,
expires_in: 1.hour,
update_on: [
{
model: TiplineRequest,
if: proc { |request| request.associated_type == 'Media' },
affected_ids: proc { |request| request.associated.medias },
events: {
create: proc { |request| request.created_at },
}
},
{
model: Relationship,
if: proc { |relationship| relationship.is_confirmed? },
affected_ids: proc { |relationship| relationship.parent.medias },
events: {
save: proc { |relationship| relationship.created_at },
destroy: :recalculate
}
}
]
Até aqui, vimos consultas únicas...
Mas o que acontece se muitas consultas forem enviadas ao mesmo tempo?
Por exemplo, o agrupamento de consultas do cliente Apollo ou até mesmo Relay do React:
Então, o backend é capaz de processá-las de forma concorrente
# Prepare the context for each query:
context = {
current_user: current_user,
}
# Prepare the query options:
queries = [
{
query: "query postsList { posts { title } }",
variables: {},
operation_name: 'postsList',
context: context,
},
{
query: "query latestPosts ($num: Int) { posts(last: $num) }",
variables: { num: 3 },
operation_name: 'latestsPosts',
context: context,
}
]
# Execute concurrently
results = YourSchema.multiplex(queries)
Multiplex:
- Economiza tempo com autorização e sobrecarga HTTP
- Quando habilitado, até mesmo uma única consulta é executada dessa forma (então, "multiplex de um")
- Execuções de multiplex têm seu próprio contexto, analisadores e instrumentação
- Cada consulta é validada e analisada independentemente. O array de resultados pode incluir uma mistura de resultados bem-sucedidos e resultados falhos.
- Novamente, o monitoramento é importante para ter certeza de que uma consulta específica não está atrasando as outras
Isso é bastante para consultas GraphQL, e quanto às mutações?
Algumas dicas rápidas para isso:
- Trabalhos em segundo plano (Sidekiq, SQS, etc.)
- Operações em massa (INSERT de múltiplos registros, etc.)
Conclusão:
"Com grandes sistemas vem grande responsabilidade"
Não, sério, conclusão:
-
Priorize a otimização de desempenho para melhorar a experiência do usuário e a escalabilidade da aplicação.
-
Identifique e Resolva Gargalos: Monitore sua aplicação regularmente para identificar gargalos de desempenho, focando em consultas de banco de dados, execução de código e otimização de consultas GraphQL/web.
-
A otimização é um processo contínuo: A otimização de desempenho não é uma tarefa única; é um processo contínuo que requer monitoramento, análise e melhoria constantes.
Obrigado! :)
https://ca.ios.ba
@caiosba
Desempenho em aplicações web que utilizam GraphQL (mas não somente)
By Caio Sacramento
Desempenho em aplicações web que utilizam GraphQL (mas não somente)
- 20