← Voltar ao blog

Cache e performance do GraphQL no Magento 2: guia prático

Rodrigo Biazzi

Uma loja Magento headless ou híbrida vive ou morre pelo tempo de resposta do GraphQL. Uma página de categoria que dispara cinco queries, cada uma levando 800 ms em um cache miss, não vai parecer rápida por melhor que seja o frontend. A boa notícia é que o GraphQL do Magento pode ser cacheado pelo Varnish ou pelo Fastly quase como as páginas normais, e a maioria das configurações lentas de GraphQL é lenta porque esse cache não está funcionando, não porque o GraphQL seja lento por natureza. Este artigo cobre como o cache de GraphQL funciona no Magento 2.4.8, como tornar seus próprios resolvers cacheáveis e o trabalho no lado do servidor que importa nos cache misses.

Como o Magento faz cache do GraphQL

O módulo Magento_GraphQlCache adiciona suporte a full page cache para o GraphQL. As regras são simples:

  • Só queries enviadas com HTTP GET podem ser cacheadas. Requisições POST sempre vão para o PHP.
  • Mutations nunca são cacheadas e são enviadas com POST.
  • Uma query só é cacheável se todo resolver que ela toca for cacheável. Um único campo não cacheável torna a resposta inteira não cacheável.
  • As respostas carregam cache tags (no header X-Magento-Tags, que o Varnish remove antes de enviar ao cliente) para que salvar um produto ou categoria limpe as entradas certas.

O cache funciona com o full page cache embutido, com o Varnish e com o Fastly no Adobe Commerce Cloud. Em produção use Varnish ou Fastly. O VCL que o Magento gera já contém as regras do GraphQL. Se você roda Varnish, regenere o VCL depois de upgrades, como explicado em full page cache com Varnish no Magento 2.

Envie as queries como GET

Muitos clientes GraphQL usam POST para tudo por padrão. Com Apollo Client ou urql você precisa ativar o GET para queries explicitamente. Se você olhar a aba Network da sua loja e toda query for um POST, nada está sendo cacheado e todo visitante bate no PHP.

curl -G 'https://shop.example.com/graphql' \
  -H 'Store: default' \
  -H 'Content-Currency: EUR' \
  --data-urlencode 'query={products(filter:{sku:{eq:"24-MB01"}}){items{name sku}}}'

Mantenha as queries compactas. Uma query muito longa na URL pode bater nos limites de tamanho de URL do servidor web ou de um proxy. Remova espaços em branco e evite pedir campos que a página não usa.

Variação de cache: store, moeda e contexto do cliente

A mesma query pode devolver dados diferentes conforme o contexto. O Magento varia o cache do GraphQL por headers que descrevem esse contexto:

  • Store seleciona a store view. Sempre envie explicitamente.
  • Content-Currency seleciona a moeda de exibição quando a loja tem mais de uma.
  • Authorization identifica um cliente logado, cujos preços e catalog rules podem variar por grupo de cliente.

Versões recentes do Magento devolvem um header X-Magento-Cache-Id nas respostas do GraphQL. Ele é um hash do contexto que afeta o cache (store, moeda, grupo de cliente e similares). O cliente deve guardar o valor mais recente e enviá-lo de volta como header de requisição nas queries seguintes. O Varnish usa isso para selecionar a variante em cache certa. Requisições que levam um token de cliente mas não têm o cache id são passadas ao backend, então um cliente que ignora esse header fica sem cache para clientes logados.

let cacheId = null;

async function gql(query, variables, token) {
  const url = new URL('https://shop.example.com/graphql');
  url.searchParams.set('query', query);
  if (variables) {
    url.searchParams.set('variables', JSON.stringify(variables));
  }

  const headers = { Store: 'default' };
  if (token) {
    headers.Authorization = 'Bearer ' + token;
  }
  if (cacheId) {
    headers['X-Magento-Cache-Id'] = cacheId;
  }

  const response = await fetch(url, { headers });
  const newId = response.headers.get('X-Magento-Cache-Id');
  if (newId) {
    cacheId = newId;
  }
  return response.json();
}

Se o frontend roda em uma origem diferente, garanta que o CORS permita e exponha o header, senão o navegador o esconde do seu código.

Tornando resolvers customizados cacheáveis

Queries customizadas não são cacheáveis de forma útil por padrão, a menos que você diga ao Magento como marcá-las com tags. Você faz isso no schema.graphqls com a diretiva @cache, apontando para uma classe de identity que devolve as cache tags de um resultado.

type Query {
    storeLocations(country: String!): [StoreLocation]
        @resolver(class: "Acme\\Locator\\Model\\Resolver\\StoreLocations")
        @cache(cacheIdentity: "Acme\\Locator\\Model\\Resolver\\StoreLocations\\Identity")
}
<?php
declare(strict_types=1);

namespace Acme\Locator\Model\Resolver\StoreLocations;

use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;

class Identity implements IdentityInterface
{
    private const CACHE_TAG = 'acme_location';

    public function getIdentities(array $resolvedData): array
    {
        $ids = [];
        foreach ($resolvedData as $location) {
            if (isset($location['entity_id'])) {
                $ids[] = self::CACHE_TAG . '_' . $location['entity_id'];
            }
        }
        if ($ids) {
            $ids[] = self::CACHE_TAG;
        }

        return $ids;
    }
}

Para o purge funcionar, o model que salva uma location precisa limpar as mesmas tags. Se o model implementa Magento\Framework\DataObject\IdentityInterface e devolve acme_location_{id} em getIdentities(), a limpeza de cache do Magento no save cuida tanto do page cache normal quanto das entradas do GraphQL. Se você pular essa etapa, as respostas em cache continuam mostrando dados antigos até o cache ser limpo.

Campos que nunca devem ser cacheados, como qualquer coisa específica do cliente ou do carrinho atual, são marcados com @cache(cacheable: false). Use no campo que realmente varia, não em uma query inteira, para que o resto continue cacheável. Escrever o resolver em si é coberto em resolvers de query GraphQL customizados.

Conferindo se o cache funciona

Em developer mode, as respostas incluem um header X-Magento-Cache-Debug com HIT ou MISS. Em produção, confira o Varnish diretamente:

varnishlog -g request -q 'ReqURL ~ "^/graphql"' -i ReqURL -i VCL_call

Rode a mesma query GET duas vezes. A segunda deve ser um hit. Se for sempre um miss, procure estas causas:

  • A query é um POST.
  • Um dos campos não é cacheável. Remova campos um a um até ela cachear e você acha o culpado.
  • A requisição leva um header Authorization sem X-Magento-Cache-Id.
  • Um cookie ou header enviado pelo frontend faz o Varnish passar a requisição. Compare com as regras do seu VCL.

Performance nos cache misses

O cache esconde problemas até um deploy ou um import de catálogo limpar tudo. Nos cache misses o trabalho acontece no PHP e no MySQL, e o GraphQL tem algumas armadilhas próprias.

O problema do N+1

Um resolver de um campo em um item de lista roda uma vez por item. Se uma query products devolve 48 itens e o resolver do seu campo customizado carrega um model para cada um, são 48 queries extras. O Magento oferece interfaces de batch resolver em Magento\Framework\GraphQl\Query\Resolver, como o BatchResolverInterface, que recebem todas as requisições de um campo de uma vez para você carregar os dados em uma query só. Use-as para qualquer campo customizado adicionado a produtos ou categorias.

Peça menos

Os frontends costumam pedir o mesmo fragment grande para toda lista de produtos, incluindo campos que só a página de produto usa. Cada campo tem um custo. Pedir media gallery, todos os custom attributes e faixas de preço completas para 48 produtos em uma página de categoria soma. Mantenha os fragments de lista pequenos e peça os detalhes na página de produto.

Limite a profundidade e a complexidade das queries

O Magento limita a profundidade e a complexidade das queries para proteger o servidor de queries enormes. Os limites são argumentos de construtor do Magento\Framework\GraphQl\Query\QueryComplexityLimiter e podem ser alterados no di.xml. Quanto menor, mais seguro. Só aumente se uma query real do frontend precisar.

<type name="Magento\Framework\GraphQl\Query\QueryComplexityLimiter">
    <arguments>
        <argument name="queryDepth" xsi:type="number">15</argument>
        <argument name="queryComplexity" xsi:type="number">250</argument>
    </arguments>
</type>

O básico habitual do backend

O GraphQL usa os mesmos models, índices e caches do resto do Magento. Indexers em modo schedule, Redis para cache e sessões, OpenSearch ajustado para o catálogo e PHP-FPM com workers suficientes importam tanto aqui quanto lá. Uma query products lenta com termo de busca muitas vezes é um problema de OpenSearch, não de GraphQL.

Cache no lado do frontend

Um frontend headless normalmente adiciona a própria camada de cache: caches de dados do framework, cache de CDN das páginas renderizadas ou um cache de cliente como o do Apollo. Isso é útil, mas precisa de uma estratégia de invalidação. Se o Magento limpa o Varnish quando um preço muda e o frontend mantém uma página renderizada por uma hora, os clientes ainda veem o preço antigo. Decida qual camada é dona da atualização de cada tipo de dado. Preços e estoque normalmente precisam de lifetimes curtos ou revalidação baseada em eventos. Conteúdo CMS e árvores de categoria podem durar mais. O artigo sobre loja headless com Next.js mostra uma forma de lidar com isso usando tags de revalidação.

Por fim, meça. Registre os nomes das operações GraphQL e os tempos no frontend, acompanhe a taxa de hits do Varnish para /graphql e faça profiling dos resolvers lentos com uma ferramenta como New Relic ou Blackfire. Sem números, otimizar GraphQL vira adivinhação.

FAQ

Por que minhas queries GraphQL do Magento nunca são cacheadas?

Normalmente porque são enviadas como POST, porque um campo da query não é cacheável ou porque uma requisição logada leva um token sem o header X-Magento-Cache-Id.

Mutations do GraphQL podem ser cacheadas?

Não. Mutations alteram dados e sempre vão para o PHP. Só queries enviadas com GET são cacheáveis.

Como torno uma query GraphQL customizada cacheável?

Adicione @cache(cacheIdentity: ...) à query no schema.graphqls e implemente uma classe de identity que devolve cache tags. Garanta que o model limpe as mesmas tags no save.

O que o X-Magento-Cache-Id faz?

Ele identifica o contexto de cache, como store, moeda e grupo de cliente. O cliente envia de volta o último valor recebido para o Varnish poder servir a variante em cache certa.

Atendo como freelancer em Magento e Adobe Commerce, incluindo trabalho de performance. Se a sua API GraphQL está lenta, fale comigo.

Artigos relacionados