Pular para o conteúdo

N+1 e o lote

Cem produtos, cem consultas ao banco — e como isso vira duas.

O N+1 é o defeito que toda camada de consulta tipada produz por construção: uma busca traz 100 produtos, o campo categoria de cada um chama o resolvedor, e são 101 idas ao banco. O código parece certo, e a página leva três segundos.

dataforge
adopt Arcane.Lavra as Lavra

record Produto:
    id: Integer
    nome: String
    categoria_id: Integer

record Categoria:
    id: Integer
    nome: String

CATEGORIAS := {1: Categoria(1, "bebidas"), 2: Categoria(2, "acessórios")}
PRODUTOS := [Produto(1, "café", 1), Produto(2, "chá", 1), Produto(3, "filtro", 2)]
IDAS := {"n": 0}

action buscar_categorias(chaves):
    // UMA ida, com todas as chaves de uma vez.
    IDAS["n"] := IDAS["n"] + 1
    yield {k: CATEGORIAS[k] cycle k in chaves}

action todos(raiz, args, ctx):
    yield PRODUTOS

action categoria_de(produto, args, ctx):
    // 'pedir' NÃO vai ao banco: ele registra a chave e devolve uma
    // promessa. O Lavra junta as chaves do nível inteiro e chama
    // 'buscar_categorias' UMA vez.
    Lavra.lote(ctx, "categorias", buscar_categorias)
    yield Lavra.pedir(ctx, "categorias", produto.categoria_id)

esq := Lavra.esquema("loja")
Lavra.tipo(esq, Produto)
Lavra.tipo(esq, Categoria)
Lavra.campo(esq, "Produto", "categoria", "Categoria", resolve := categoria_de)
Lavra.busca(esq, "produtos", "[Produto!]!", resolve := todos)
Lavra.conferir(esq)

r := Lavra.executar(esq, """
busca:
    produtos:
        nome
        categoria:
            nome
""")
assert len(r["dados"]["produtos"]) is 3
assert r["dados"]["produtos"][0]["categoria"]["nome"] is "bebidas"

// três produtos, duas categorias distintas — e UMA ida
assert IDAS["n"] is 1
out $"3 produtos, {IDAS['n']} consulta de categoria"

Por que isto não é cache#

LoteCache
viveuma requisiçãoentre requisições
resolveN chamadas viram umaa segunda chamada não acontece
pode devolver dado velhonãosim, e é o ponto dele
precisa de invalidaçãonãosim — e é a parte difícil

Os dois se somam: o lote tira o N+1 de dentro da requisição; o cache tira a requisição inteira. Um cache sem lote continua com N+1 na primeira visita, que é a que o usuário novo vê.

O campo que custa caro declara o custo#

dataforge
adopt Arcane.Lavra as Lavra

record Usuario:
    id: Integer

esq := Lavra.esquema("rede")
Lavra.tipo(esq, Usuario)
// 'custo' diz quanto este campo pesa; o limite de complexidade soma
// o custo da consulta ANTES de executar qualquer resolvedor.
Lavra.campo(esq, "Usuario", "amigos", "[Usuario!]!",
    resolve := lambda r, a, c => [], custo := 10)
Lavra.busca(esq, "usuario", "Usuario", resolve := lambda r, a, c => Usuario(1))
Lavra.limites(esq, profundidade := 5, complexidade := 50)
Lavra.conferir(esq)

// Uma consulta funda demais é recusada sem tocar no banco.
r := Lavra.executar(esq, """
busca:
    usuario:
        amigos:
            amigos:
                amigos:
                    amigos:
                        id
""")
assert len(r["erros"]) is 1
out r["erros"][0]["mensagem"]

Recusar antes de executar é o ponto: um limite conferido no meio já pagou metade do custo, e é exatamente essa metade que derruba o banco.