← Voltar ao blog

N+1 queries: o gargalo silencioso em loops PHP

Uma consulta dentro do foreach pode transformar 10 registros em 1.000 idas ao banco. Aprenda a detectar e resolver o N+1 no PHP com Firebird.


Uma tela que lista dez registros e demora dois minutos tem, quase sempre, o mesmo culpado: uma consulta dentro de um laço. O padrão se chama N+1 — uma consulta para buscar a lista e mais N para completar cada linha.

O nome vem da conta: 1 consulta principal + N consultas adicionais, uma para cada registro. Com 10 linhas, são 11 idas ao banco. Com 5.000, são 5.001.

O problema é traiçoeiro porque o ambiente de teste tem 20 linhas e responde rápido. No cliente, com anos de histórico, a mesma tela trava.


O problema na prática

$apontamentos = db_getAll(
    "SELECT ID, IDPROFISSIONAL, DATA FROM TIMECARDS WHERE DATA >= ?",
    [$inicio]
);

foreach ($apontamentos as $a) {
    $prof = db_getRow(
        "SELECT NOME FROM PROFISSIONAIS WHERE ID = ?",
        [$a['IDPROFISSIONAL']]
    );
    $a['NOME_PROFISSIONAL'] = $prof['NOME'] ?? '';
}

Cada iteração abre uma ida ao banco: rede, parse da query, plano, execução, retorno. Em Firebird, o custo por consulta triviais pode parecer baixo, mas multiplicado por milhares vira minutos. E o servidor ainda precisa atender todos os outros usuários ao mesmo tempo.

Sintomas típicos:

  • a listagem demora muito mesmo com poucas linhas exibidas;
  • o tempo cresce junto com o volume de dados do cliente;
  • o monitor de statements mostra a mesma query com milhares de execuções;
  • a tela fica pior depois de um filtro mais amplo (mais linhas, mais N).

Como medir antes de mexer

Não refatore no escuro. Primeiro conte as consultas da requisição:

function db_getAllCounted(string $sql, array $params = []): array {
    static $count = 0;
    $count++;
    error_log("query #{$count}: {$sql}");
    return db_getAll($sql, $params);
}

Se o número de consultas cresce junto com o tamanho da lista, é N+1. No monitor de statements do Firebird, queries triviais com execuções altíssimas também são pista: muita gente procura índice quando o problema é repetição.

Solução 1: uma consulta para o lote, com mapa em memória

Busque todos os registros de uma vez e monte um mapa:

$ids = array_values(array_unique(array_column($apontamentos, 'IDPROFISSIONAL')));
$ids = array_map('intval', $ids);

$profissionais = [];
if ($ids) {
    $in = implode(',', $ids);
    $rows = db_getAll("SELECT ID, NOME FROM PROFISSIONAIS WHERE ID IN ($in)");
    foreach ($rows as $r) {
        $profissionais[$r['ID']] = $r['NOME'];
    }
}

foreach ($apontamentos as &$a) {
    $a['NOME_PROFISSIONAL'] = $profissionais[$a['IDPROFISSIONAL']] ?? '';
}
unset($a);

Agora são duas consultas, independentemente do tamanho da lista. O array_map('intval') garante que o IN receba apenas números, evitando injeção.

Em listas muito grandes, divida em lotes (por exemplo, 500 IDs por consulta) para não gerar um SQL gigante e estourar limites do parser.

Solução 2: JOIN, quando a relação é obrigatória

Se a consulta principal já pode trazer o dado, elimine a segunda ida:

SELECT t.ID, t.DATA, p.NOME
FROM TIMECARDS t
INNER JOIN PROFISSIONAIS p ON p.ID = t.IDPROFISSIONAL
WHERE t.DATA >= ?

JOIN é a melhor opção quando:

  • a relação é obrigatória (INNER JOIN) e toda linha da lista tem correspondente;
  • o filtro da tabela principal já limita bem o conjunto;
  • você não precisa do mesmo conjunto em outros pontos da tela.

O mapa em PHP é melhor quando a consulta principal é cara e não deve ser duplicada, ou quando o mesmo conjunto alimenta vários blocos da página.

Solução 3: cache para tabelas de configuração

Tabelas como status, perfis e divisões mudam pouco e aparecem em quase toda tela. Carregar uma vez por requisição elimina dezenas de consultas repetidas:

function statusMap(): array {
    static $map = null;
    if ($map === null) {
        $map = [];
        foreach (db_getAll("SELECT ID, NOME FROM HD_STATUS") as $r) {
            $map[$r['ID']] = $r['NOME'];
        }
    }
    return $map;
}

O static mantém o cache durante a requisição. Não use esse padrão para dados que mudam a cada minuto, e nunca guarde dados por usuário em cache global.

Solução 4: escrita em lote

O N+1 também aparece na gravação: um UPDATE por linha dentro do laço. Agrupe em uma única instrução:

UPDATE TIMECARDS t
SET STATUS = CASE ID
    WHEN 1 THEN 'A'
    WHEN 2 THEN 'R'
    WHEN 3 THEN 'A'
  END
WHERE ID IN (1, 2, 3);

Quando a lógica é mais complexa, use EXECUTE BLOCK para processar o conjunto no servidor:

EXECUTE BLOCK AS
DECLARE VARIABLE id INTEGER;
BEGIN
  FOR SELECT ID FROM TIMECARDS WHERE DATA >= :inicio INTO :id DO
  BEGIN
    UPDATE TIMECARDS SET STATUS = 'A' WHERE ID = :id;
  END
END;

O bloco ainda faz um UPDATE por linha, mas tudo dentro do servidor: uma ida ao banco em vez de milhares.

Qual solução usar?

Situação Solução recomendada
Relação obrigatória e filtro já limita as linhas JOIN
Mesmo conjunto alimenta vários blocos da tela Mapa em memória com IN
Tabela de configuração que muda pouco Cache static por requisição
Muitas linhas para gravar CASE em massa ou EXECUTE BLOCK

O N+1 que não parece N+1

O padrão aparece em lugares onde você não procura:

  • Helpers que consultam por campo. getNomeCliente($id) chamado dentro de um foreach é N+1 disfarçado.
  • Verificação de permissão por item. Checar acesso linha a linha pode gerar uma consulta por registro.
  • Lazy loading de ORM. O acesso à propriedade dispara a consulta; parece mágica até o cliente reclamar.
  • Chamadas HTTP dentro de laço. Não é SQL, mas o custo é o mesmo: N round-trips.
  • Contadores e totais por linha. SELECT COUNT(*) ... WHERE id = ? dentro do laço vira uma consulta por item.

Passo a passo para refatorar

  1. Meça o tempo e o número de consultas da tela com um volume realista.
  2. Identifique todos os db_get* dentro de foreach, while e for.
  3. Agrupe por tabela: tudo que busca a mesma tabela pode virar uma consulta única.
  4. Monte o mapa em memória e substitua as chamadas.
  5. Teste os casos de borda: lista vazia, IDs nulos, duplicados e referências inexistentes.
  6. Compare o tempo antes e depois com o mesmo conjunto.
  7. Monitore para garantir que a consulta em lote usa índice adequado.

Checklist

  • Conte as consultas de uma requisição.
  • Troque laços por IN (...) com mapa em memória.
  • Use JOIN quando a relação é obrigatória.
  • Cacheie configuração durante a requisição.
  • Agrupe escritas em CASE ou EXECUTE BLOCK.
  • Valide listas vazias e IDs nulos.
  • Meça com o volume de dados do cliente, não com 20 linhas.

Conclusão

N+1 não aparece no EXPLAIN, não melhora com índice e não reproduz no ambiente de teste. Ele nasce da forma como o código percorre os dados. A correção é quase sempre a mesma: buscar uma vez, montar um mapa e usar em memória.

Quer escrever a consulta em lote sem errar a sintaxe do Firebird? O Sqlify transforma a descrição em português na query pronta, com parâmetros e explicação. Descreva "buscar profissionais dos apontamentos do mês" e receba o SQL para colar no seu código.

Leia também: