Uma consulta por tela: SELECT no MySQL com só as colunas que você mostra
Escreva a consulta a partir da tela: colunas nomeadas, LIMIT, filtro por parâmetro, ORDER BY por lista branca e um índice que combine. Menos dados, menos vazamento.
A home do blog da escola de música leva 2,3 segundos para abrir. Ela mostra dez cards com título, data e nome do autor. A consulta por trás é SELECT * FROM artigos JOIN autores ..., que traz o corpo inteiro de cada artigo (40 KB em média) e, de brinde, o hash de senha do autor. São uns 400 KB saindo do banco para desenhar menos de 2 KB de HTML. A resposta curta: comece pela tela, não pela tabela; liste no SELECT só as colunas que aparecem, filtre e limite no banco, passe os valores como parâmetro e ordene apenas por colunas de uma lista que o código conhece.
Este guia mostra como escrever a consulta de cada tela e como confirmar com EXPLAIN que ela está barata. Os riscos de segurança do asterisco são o tema de SELECT * traz coluna que você não queria mostrar, e o problema da consulta sem limite está em consulta que funciona com dez linhas trava com dez mil.
Por que o asterisco pesa mais do que parece
Cada coluna trazida é lida do disco, transferida pela rede até o PHP e guardada na memória do processo, mesmo que o template nunca a use. Colunas de texto longo, como LONGTEXT com o corpo do artigo, são as mais caras. Em tabela pequena ninguém percebe. Com 50 mil artigos e a home sendo aberta mil vezes por hora, o banco passa o dia lendo texto que vai direto para o lixo.
Há também um efeito silencioso com JOIN. As tabelas artigos e autores têm, ambas, uma coluna id. Com SELECT * e PDO::FETCH_ASSOC, o array só pode ter uma chave id, e a última coluna com esse nome vence. O link "ler mais" passa a apontar para o id do autor, não do artigo. É o tipo de bug que só aparece quando os números deixam de coincidir.
Como escrever a consulta a partir da tela
- Abra o template e liste cada dado que ele imprime.
- Escreva o
SELECTcom essas colunas, com prefixo da tabela e apelido quando o nome se repete. - Coloque o filtro e o
LIMITna consulta, nunca no PHP depois de buscar. - Passe todo valor variável como parâmetro.
- Se o usuário escolhe a ordenação, traduza a escolha por um array de opções permitidas.
- Rode
EXPLAINe crie o índice que a consulta pede.
A lista da home
O card mostra título, data, nome do autor e link. Isso dá cinco colunas:
SELECT a.id, a.slug, a.titulo, a.publicado_em, u.nome AS autor
FROM artigos a
JOIN autores u ON u.id = a.autor_id
WHERE a.status = 'publicado'
ORDER BY a.publicado_em DESC
LIMIT 10 OFFSET 0;
Nenhum corpo de artigo, nenhum dado de login do autor. O apelido autor evita a colisão de nomes e deixa o template claro.
A página do artigo
O detalhe precisa do corpo, mas de um artigo só, buscado pelo slug:
SELECT a.id, a.titulo, a.corpo, a.publicado_em, u.nome AS autor
FROM artigos a
JOIN autores u ON u.id = a.autor_id
WHERE a.slug = ? AND a.status = 'publicado'
LIMIT 1;
Duas telas, duas consultas. Parece mais trabalho do que uma função genérica buscarArtigos() que serve para tudo, mas é justamente a função genérica que acaba trazendo tudo para todo mundo.
Parâmetros, inclusive no LIMIT
No PHP com PDO e MySQL, filtro e paginação entram como parâmetros. Há um detalhe com o LIMIT: com a emulação de prepared statements ligada (o padrão do driver MySQL no PDO), um valor passado como texto vira '10' entre aspas, e o MySQL recusa a consulta com erro de sintaxe. Use bindValue com PDO::PARAM_INT:
<?php
$porPagina = 10;
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$ordens = [
'recentes' => 'a.publicado_em DESC',
'antigos' => 'a.publicado_em ASC',
'titulo' => 'a.titulo ASC',
];
$ordem = $ordens[$_GET['ordem'] ?? ''] ?? $ordens['recentes'];
$sql = "SELECT a.id, a.slug, a.titulo, a.publicado_em, u.nome AS autor
FROM artigos a
JOIN autores u ON u.id = a.autor_id
WHERE a.status = 'publicado'
ORDER BY $ordem
LIMIT :limite OFFSET :inicio";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':inicio', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
$artigos = $stmt->fetchAll(PDO::FETCH_ASSOC);
A ordenação é o único pedaço interpolado, e só porque nome de coluna não pode ser parâmetro. Por isso ela nunca vem direto de $_GET: a pessoa escolhe uma chave, e o código entrega um trecho de SQL que ele mesmo escreveu. Qualquer outro valor cai no padrão. Para páginas muito profundas, o OFFSET fica lento e pode pular itens quando há inserções; a alternativa por cursor está em paginação que não pula.
Como confirmar com EXPLAIN
Coloque EXPLAIN antes da consulta da lista e olhe três colunas do resultado para a tabela artigos: type, key e rows. Se type for ALL e key vier vazio, o MySQL está lendo a tabela inteira. Se a coluna Extra mostrar Using filesort, ele está ordenando na mão depois de ler. Um índice composto que combine filtro e ordenação resolve os dois:
CREATE INDEX idx_artigos_status_publicado ON artigos (status, publicado_em);
Rode o EXPLAIN de novo. O esperado é ver o índice novo em key e um número em rows muito menor que o total da tabela. No caso da escola de música, a home caiu de 2,3 segundos para algo abaixo de 100 milissegundos com essas duas mudanças: colunas nomeadas e índice. Esses números são do exemplo; meça os seus antes e depois.
O que nunca sai na consulta de vitrine
Hash de senha, token de redefinição, CPF completo, e-mail de quem não é público. Se a tela não mostra, a consulta não busca. A regra protege contra o dia em que alguém faz json_encode($artigos) para uma API nova e publica tudo o que veio do banco.
Erros comuns
SELECT *na lista. Texto longo e dados sensíveis viajam em toda requisição sem nunca aparecer.- Asterisco com
JOIN. Colunas com o mesmo nome se sobrescrevem no array e o link aponta para o registro errado. - Buscar tudo e paginar no PHP. O banco entrega dez mil linhas para a página mostrar dez.
- Interpolar o
ORDER BYvindo da URL. Abre injeção de SQL num lugar onde parâmetro não funciona. - Passar o
LIMITcomo texto no PDO. A consulta falha com erro de sintaxe por causa das aspas. - Uma função genérica para todas as telas. Ela acaba trazendo a união de todas as colunas que alguma tela já precisou.
Checklist
- Cada tela tem uma consulta com colunas nomeadas, correspondendo ao que o template imprime.
- Colunas com nome repetido em
JOINtêm apelido. - Filtro e
LIMITestão no SQL, com valores como parâmetro. LIMITeOFFSETsão ligados comPDO::PARAM_INT.- A ordenação vem de uma lista branca no código.
- O
EXPLAINda consulta principal usa índice. - Nenhuma consulta de vitrine traz senha, token ou documento.
Abra a consulta da página mais acessada do seu sistema, ao lado do template dela, e risque cada coluna que o HTML não imprime. Quantas sobraram, e alguma das riscadas era um dado que você não gostaria de ver numa resposta de API?
Referência: http://pt-br.html.net/tutorials/php/lesson20.php