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_queryemysqli_querydentro deforeach,whileefor. - 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
- Liste as consultas de uma requisição e compare com o número de linhas.
- 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.
- 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.