← 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 problema

$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'] ?? '';
}

Com 10 linhas, são 11 consultas. Com 5.000 linhas, são 5.001. Cada ida ao banco tem custo de rede, parse e execução. O resultado é uma tela lenta que piora quanto mais dados o cliente tem, e que ninguém reproduz no ambiente de teste com 20 linhas.

Como detectar

  • Conte as consultas por requisição. Se o número cresce junto com o tamanho da lista, é N+1.
  • Procure db_get*, ibase_query e mysqli_query dentro de foreach, while e for.
  • Em logs, observe a mesma consulta repetida com parâmetros diferentes em sequência.
  • No monitor de statements do Firebird, queries triviais com execuções altíssimas costumam ser N+1, não indexação.

A solução: uma consulta para o lote

Busque todos os registros de uma vez e monte um mapa em memória:

$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. Em listas muito grandes, divida em lotes (por exemplo, 500 IDs por consulta) para não gerar um SQL gigante.

Quando o JOIN é melhor

Se a consulta principal já pode trazer o dado, evite 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 e o filtro da tabela principal já limita bem as linhas. O mapa em PHP é melhor quando você precisa do mesmo conjunto em vários lugares ou quando a consulta principal é cara e não deve ser duplicada.

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.

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 com CASE ou use EXECUTE BLOCK no Firebird para processar o conjunto no servidor. Menos round-trips, mesma transação.

Checklist

  1. Liste as consultas de uma requisição e compare com o número de linhas.
  2. Troque laços por IN (...) com mapa em memória.
  3. Use JOIN quando a relação é obrigatória.
  4. Cacheie configuração durante a requisição.
  5. Meça antes e depois com o mesmo volume de dados do cliente.

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.