|

Queries N+1 e Gem: GraphQL Batch Loader

Escrito por Jonathan Campos, Backend Engineer na Pipefy.

Usar GraphQL para a construção de APIs traz uma liberdade de customização que APIs REST não contempla.

Endpoint em GraphQL

query {

  rentals {

    id

    owner {

      name

    }

  }

}

Endpoint em REST

REST API => GET api/requests -> [ { id, owner } ]

Comparando esse mesmo endpoint em REST seria necessário criar uma requisição GET que retornaria essa mesma listagem. Caso fosse necessário apenas buscar a lista de ids dos aluguéis, teriamos 2 opções, ou criar um novo endpoint api/requests/ids por exemplo ou trazer toda uma carga de dados que não seriam utilizados caso usássemos o endpoint api/requests. As 2 opções possuem problemas que são resolvidos de forma simples em GraphQL apenas modificando a query desta forma:

query {

  rentals {

    id

  }

}

Porém, “Com grandes poderes vem grandes responsabilidades” como já diria o Tio Ben. Endpoints GraphQL precisam estar preparados para todas as variações possíveis de retorno. Tanto em GraphQL como em REST os SQLs gerados seriam:

Ou seja, 1 para os aluguéis e N para os usuários, o que chamamos de N+1. Este problema é muito mais fácil de ser identificado e resolvido no casos dos endpoints em REST pois eles têm responsabilidades específicas, diferentemente do GraphQL como mencionado que deve estar preparado para todas as variações possíveis.

Mas por que isso deve ser considerado um problema?

É bastante comum na construção dos campos GraphQL que os fields sejam reaproveitados em lugares diferentes. Imagine no nosso exemplo que exista uma estrutura chamada Apartamentos, e para cada apartamento exista uma lista de aluguéis:

query {

  apartments {

    id

    rentals {

      id

      owner {

        id

      }

    }

  }

}

Por conta de que a maioria dos frameworks usam Lazy load no que se refere a busca pelos dados, será executado 1 SQL para buscar a lista de apartamentos, N SQLs para os aluguéis e para cada aluguel N SQLs para os locatários. Pensando numa base de dados com milhões de entradas para cada estrutura e que cada SQL leva um tempo médio de 0.2 ms como mostrado na imagem o tempo de execução dessa query ficará impraticável. Imagine essa query sendo executada várias vezes ao dia, todos os dias. Não há orçamento de infra que aguente.

Ok, como eu soluciono esse problema?

Uma das recomendações recorrentes, principalmente em endpoints REST é a correção manual usando eager load, ou seja o pré-carregamento de todas as estruturas necessárias para sua requisição. No nosso exemplo executaríamos esse comando:

Apartment.includes(rentals: :owner)

Com isso, sempre que a lista de apartamentos fosse carregada, outros dois SQLs buscando os aluguéis e seus respectivos locatários seriam executados.

Para a abordagem manual funcionar é preciso ter uma ferramenta que identifica as queries problemáticas de forma eficiente (falaremos mais sobre elas nesse artigo). Porém, alguns cenários podem ficar enviesados, dependendo de como seus dados no ambiente local estiverem dispostos e criaremos uma alta dependência dessas ferramentas de identificação de N+1.

Outro grande problema é o pré-carregamento desnecessário das estruturas. Nos cenários que fosse requisitado apenas as informações de apartamento (seguindo nosso exemplo) seria buscado a lista de aluguéis juntamente com o usuário em toda requisição desnecessariamente.

Para os endpoints GraphQL, em que precisamos estar preparados para todas as variações na requisição, a solução é juntar todos e fazer um carregamento só, que chamamos de carregamento em batch. No nosso primeiro exemplo ao invés de executar os N SQLs, juntaríamos todos os ids dos usuários e faríamos 1 busca para essa lista de ids. 

Para isso recomendo o uso da gem GraphQL Batch Loader. Essa gem é mantida pela Shopify, empresa que tem uma grande participação nas iniciativas relacionadas com Ruby on Rails. Ela faz uso da gem promise.rb permitindo que as queries sejam feitas em lote (batch).

Gem GraphQL Batch Loader

Para usar a gem basta adicionar no seu gemfile e rodar um comando `bundle install`:

Em todos os seus schemas GraphQL é necessário adicionar o `use GraphQL::Batch:

Para cada field que antes gerava N+1 é preciso criar um loader:

O método perform irá agrupar todos os ids para posteriormente fazer o carregamento em lote.

Para mais detalhes de implementação segue o link da documentação: https://github.com/Shopify/graphql-batch.

Identificando N+1

Como citado anteriormente é possível que em determinadas situações os testes locais, podem estar enviesados, por motivos como dados insuficientes por exemplo. Desta forma não é recomendado contar apenas com a “experiência e observabilidade” do programador para identificar as queries N+1. Existem algumas gems que fazem isso de forma consistente inclusive com 0 falsos positivos e 0 falsos negativos, exemplo a prosopite https://github.com/charkost/prosopite. Segue um exemplo de log identificando uma query com N+1:

Qual o tamanho do impacto das queries N+1?

Rodando um experimento local que atinge 10000 entradas é possível perceber o impacto que esse problema pode gerar na nossa infraestrutura:

AntesDepois
Tempo da requisição51897ms6190ms
Tempo do ActiveRecord18396.6ms263.6ms

O tempo da requisição é 8x maior com o N+1 e o tempo gasto com o ActiveRecord é 70x maior. Imagine essa query sendo executada várias vezes no dia, 30 dias no mês, o impacto seria muito grande na infraestrutura.

Considerações finais

Podemos observar que um relativamente simples descuido na construção de um endpoint pode acarretar em sérias dores de cabeça para a infraestrutura dos nossos sistemas. As queries N+1 podem se espalhar por todo nosso projeto e fazer com que gastemos vários dólares desnecessários. Desta forma uma solução relativamente simples como a proposta pela gem GraphQL Batch Loader se faz muito útil para solucionar esse problema.

Referências

Similar Posts

Leave a Reply