Desde sempre eu gostei muito de banco de dados. E, sempre que aparecia alguma oportunidade de encostar num problema de performance, modelagem ou query esquisita, eu tava lá: “deixa eu ver isso aí”.
Eu até brinco que, se não fosse dev, eu seria DBA. Mas não aquele DBA que só executa query (o que a gente vê bastante por aí e parece uma vida meio triste, kkk), e sim o DBA curioso, que gosta de entender o porquê das coisas e caçar gargalo.
A questão é que hoje em dia a linguagem raramente é o vilão. Se você pegar sistemas rodando em Python, Node ou PHP, dá pra ver claramente: a maior parte do tempo tá indo embora em I/O. E uma fatia enorme desse I/O é… banco de dados. Na prática, o gargalo quase nunca é “a linguagem lenta”. Normalmente é consulta ruim, índice faltando, rede, lock, ou qualquer combinação dessas coisas.
Então sim: saber trabalhar bem com banco, e saber otimizar quando precisa, é uma skill bem valiosa.
Nesses anos como dev, eu tive a oportunidade de mexer bastante com banco e fazer várias melhorias de performance. E depois de apanhar o suficiente, eu fiquei bem convencido de uma coisa: existe um beabá que todo dev deveria ter na ponta da língua.
[!NOTE] Este artigo saiu de uma talk minha chamada Autópsia de um banco de dados de alta performance. No meio dessa talk, eu falo sobre essas boas práticas que trago resumidas aqui.
E se você curtiu esse assunto e quer levar essa conversa para dentro da sua empresa ou do seu time, pode me pingar.
Obviamente, é difícil o dev saber tudo isso que eu vou comentar aqui de cabo a rabo. Mas, no final das contas, dominar esse básico bem feito aumenta (e muito) a chance do seu sistema ser de alta performance.
O que você vai ler
- O Básico que Não É Tão Básico Assim
- Chaves Únicas: UUIDv4 vs UUIDv7
- Além das Queries
- Planeje Antes de Precisar Mudar
O Básico que Não É Tão Básico Assim
Quando a gente fala de “básico” ou “boas práticas”, muita gente pensa logo em nomenclatura. E sim, nomenclatura é importante. Quem nunca pegou um banco onde todas as tabelas começavam com tb_ ou tabela_?
Mas esse “beabá” que quero trazer vai muito além de nomes bonitinhos. Estamos falando de estrutura e performance. Existem guias ótimos sobre nomenclatura por aí, mas aqui vamos focar no que realmente faz diferença no dia a dia.
Evite SELECT *
A primeira coisa que todo DBA vai apontar quando você chegar com problema de query é: “Não faz SELECT *”. Mesmo que esse não seja o problema, ele vai apontar isso. E com razão.
Selecionar todas as colunas tem vários efeitos colaterais. Você está trafegando muito mais dados na rede do que deveria. Um payload que poderia ser menor, porque às vezes você vai usar só o ID e o status, acaba carregando todos os 10, 20 campos que existem na tabela.
Em um dos sistemas que trabalhei, por exemplo, não buscamos no banco o created_at nem o updated_at. Por quê? Porque não usamos essas colunas na aplicação. Por mais que isso “estrague” um pouco a modelagem (o created_at no banco é NOT NULL, mas na entidade é nullable), é um tradeoff que vale a pena.
Quem aqui não tem uma tabela com mais de 10, 20 colunas? E todo ano que passa sempre tem uma coluna a mais. “Essa é a última coluna, daqui não sai”. E do nada surge um campo novo. Já peguei tabelas com 100 colunas. Imagina fazer SELECT * nisso.
A regra é simples: busque apenas o que você vai usar.
Índices: Nem Demais, Nem de Menos
Usar índices nas colunas onde você faz filtros é essencial. Se você faz muito WHERE em status, em user_id, em created_at, esses campos precisam de índices. Sem índice, o banco vai varrer a tabela inteira pra cada consulta.
Mas aqui tem um tradeoff que muita gente ignora: índice acelera leitura e encarece escrita. Cada INSERT ou UPDATE num registro com índice precisa atualizar o índice também. E quando você tem vários índices na mesma tabela, uma operação simples de escrita vira várias escritas em disco, o famoso write amplification. A conta chega em forma de latência, CPU alta e banco suando em horário de pico.
Já peguei banco com mais de 10 índices numa tabela que era write-heavy. O time ficava reclamando que o insert tava lento, e o problema não era a query nem o hardware. Era o carnaval de índices que precisavam ser atualizados a cada escrita.
Então, antes de sair indexando tudo, eu gosto de pensar em duas perguntas: quais queries realmente importam? (as que rodam o tempo todo e doem quando ficam lentas) e onde estão os filtros, joins e order by dessas queries? (é aí que índice costuma fazer diferença). Se a coluna quase nunca aparece num WHERE ou JOIN, o índice dela provavelmente só tá cobrando aluguel.
O famoso ORM
O ORM é um ponto polêmico. Tem gente que ama, tem gente que odeia. Ele é muito bom para coisas simples no código, facilita bastante a construção de queries. Mas ele dificulta bastante a otimização ou esconde problemas reais quando faz unions e joins.
Cada ORM vai fazer um union ou join de uma maneira diferente. Ele vai fazer uma subquery, ou vai fazer um join diretamente na consulta, ou vai criar uma view temporária para guardar esses dados e depois cruzar. E isso às vezes é ruim do ponto de vista do desenvolvedor, porque a gente não sabe exatamente como ele está fazendo aquilo por baixo.
Se você vai fazer um insert ou update, acho razoável fazer com ORM. É mais rápido, mais prático. Mas se você vai fazer um join cruzado com outra tabela, e o resultado dessa você vai cruzar com uma terceira tabela ainda, o ideal talvez seja fazer a query na mão mesmo. Assim você tem certeza do que está sendo escrito no banco de dados e consegue testar performance.
ORM tem seus casos de uso. Vai ter hora que vai ser mais rápido e tá certo, mas vai ter hora que não convém. Faça na mão e teste a performance.
CTEs vs Joins Monstruosos
Esse é um ponto que muita gente não conhece ou subestima. Quando a query começa a crescer, misturando filtros, agregações e tabelas grandes, um joinzão direto pode virar uma bomba. E muitas vezes uma CTE (WITH) bem feita é mais rápida e mais fácil de manter do que tentar resolver tudo num join só.
A ideia é simples: você “fatia” o problema em etapas, reduz o conjunto de dados cedo e deixa o plano de execução mais previsível. Não é que join seja “ruim”. É que, quando o volume cresce, quebrar em pedaços menores costuma ajudar o otimizador do banco a tomar decisões melhores.
Um exemplo: imagine que você quer somar os pedidos dos últimos 30 dias de cada usuário ativo. Num join direto ficaria algo assim:
SELECT u.id, u.name, SUM(o.total) AS total_30d
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.status = 'active'
AND o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY u.id, u.name;
Funciona? Funciona. Mas quando a tabela de orders tem milhões de linhas, o banco tá cruzando tudo de uma vez. Agora, a mesma ideia fatiando primeiro:
WITH recent_orders AS (
SELECT user_id, total
FROM orders
WHERE created_at >= NOW() - INTERVAL '30 days'
),
active_users AS (
SELECT id, name
FROM users
WHERE status = 'active'
)
SELECT au.id, au.name, SUM(ro.total) AS total_30d
FROM active_users au
JOIN recent_orders ro ON ro.user_id = au.id
GROUP BY au.id, au.name;
Cada CTE reduz o dataset antes do cruzamento. O banco trabalha com conjuntos menores e o plano tende a ficar mais estável. Fora que fica muito mais legível, dá pra testar cada pedaço isolado.
Claro, não é regra absoluta. Em alguns bancos/versões, CTE pode ser materializada e piorar. Mas no dia a dia, quando a consulta começa a crescer, CTE costuma salvar.
O Inimigo Oculto: ORDER BY
O ORDER BY é um grande vilão. Se você faz um select por status e depois faz um ORDER BY em ordem decrescente, ele vai pegar todos os dados do banco, ordenar primeiro para depois fazer o filtro.
Isso muitas vezes vai degradar muito a performance. E às vezes esse ORDER BY vai ser feito só na aplicação para ordenar o primeiro e último pagamento ou o último ou primeiro usuário de uma tabela.
Então às vezes é muito mais vantajoso fazer o ORDER BY na aplicação, em tempo de execução dentro da collection, do que mandar esse ORDER BY ser feito pelo banco. Porque o banco pode degradar muita performance fazendo sorting, principalmente se esse campo de ORDER BY não tiver índice.
Já pegamos caso em que o problema era só o ORDER BY. Tiramos da consulta e ela melhorou 10x, porque o banco estava ordenando gigas e gigas de dados, e na aplicação a gente só queria filtrar um registro.
Enums como Texto
Em algumas versões mais modernas de banco tem uma coluna chamada ENUM. O cara olha e fala: “Caramba, tem uma coluna Enum, é genial, vou fazer isso”.
Só que isso tem um problema. A cada vez que você insere um registro novo naquela tabela, o banco de dados vai validar se aquele Enum é válido ou não para aquele campo. Até aí, tudo bem, quando você tem campos predefinidos e que nunca vão mudar.
Mas quando você tem campos no formato status, tipos, microsserviços ou meio de pagamento que costumam crescer conforme a aplicação cresce, cada vez que você tem que fazer um ALTER TABLE nesse campo é muito custoso.
Pensa numa tabela com alguns terabytes de dados. Fazer um ALTER TABLE numa coluna dessa para adicionar um método de pagamento novo é inviável, porque esse ALTER TABLE vai ter que percorrer esses terabytes para conseguir mudar esse tipo da coluna.
É muito melhor a coluna ser um VARCHAR e a aplicação é que valida se aquele enum é válido ou não. Você pode sofrer muito para fazer ALTER TABLE quando tiver que incluir um enum novo, porque a tabela pode estar muito grande e esse ALTER TABLE vai ser custoso.
Use o Tipo Certo pra Cada Dado
Parece papo de guia básico, eu sei. Mas essa é daquelas coisas que todo mundo concorda na teoria e esquece na prática. Já perdi as contas de quantas vezes peguei coluna de data armazenada como VARCHAR, boolean como VARCHAR('true'), e número como texto.
O problema não é só “feio”. É que o tipo errado custa caro. Um VARCHAR ocupa mais espaço que um INT ou BOOLEAN. Mais espaço por linha significa mais páginas no disco, índices maiores e menos dados cabendo no cache. O banco acaba fazendo mais I/O pra cada operação, e isso vai se acumulando.
Tem outro ponto que é mais sutil: quando o tipo tá errado, o banco precisa fazer conversão implícita pra comparar valores. E isso pode matar o uso de índice sem você perceber. Já vi query que deveria ser instantânea levando segundos porque o banco tava convertendo VARCHAR pra INT em cada linha do WHERE.
A regra é direta: data é DATE/TIMESTAMP, boolean é BOOLEAN, número é INT/BIGINT/NUMERIC. E VARCHAR fica pro que realmente é texto.
Cuidado com Conversões no WHERE
Esse aqui é primo do ponto anterior, e é onde muita query começa a ficar lenta sem ninguém perceber. A gente quer “só ajustar um detalhe” e mete uma função em cima da coluna no WHERE. Parece inocente, mas pode impedir o banco de usar o índice.
O exemplo clássico é buscar por dia usando TRUNC/DATE:
-- Parece inocente… mas pode impedir o uso do índice em created_at
SELECT id
FROM orders
WHERE TRUNC(created_at) = DATE '2026-02-02';
O banco olha isso e pensa: “preciso aplicar TRUNC em cada linha antes de comparar”. Adeus índice. Numa tabela com milhões de registros, isso é um full scan disfarçado.
A saída é filtrar por intervalo, e aí o índice em created_at trabalha de verdade:
SELECT id
FROM orders
WHERE created_at >= TIMESTAMP '2026-02-02 00:00:00'
AND created_at < TIMESTAMP '2026-02-03 00:00:00';
Outro clássico é normalizar texto no WHERE:
-- Pode matar índice em email
SELECT id
FROM users
WHERE LOWER(email) = 'eu@douglasmedeiros.dev';
Aqui depende do banco e do contexto, mas geralmente você tem algumas saídas: normalizar na escrita (armazenar sempre em minúsculo e validar na aplicação), usar collation/citext no Postgres quando faz sentido, ou criar um índice funcional se você realmente precisa do LOWER(...) no filtro.
No final das contas: se você tá aplicando função em cima de coluna dentro de um WHERE em tabela grande, tem uma boa chance de isso virar gargalo.
Chaves Únicas: UUIDv4 vs UUIDv7
Esse é um assunto que gera bastante discussão e que eu já vi causar problema real em produção. A maioria dos sistemas que trabalhei usava UUIDv4 como chave primária, e por um bom tempo eu nem questionava. É simples, é aleatório, praticamente elimina colisão e esconde IDs sequenciais. Parecia perfeito.
Até que em um dos sistemas, a gente começou a perceber que a escrita ficava mais lenta conforme a tabela crescia. Não era nada gritante no começo, era coisa de milissegundos a mais aqui e ali. Mas quando a tabela bateu centenas de milhões de registros, a coisa mudou.
O que acontece é o seguinte: UUIDv4 é totalmente aleatório. Quando você usa ele como chave primária (e o banco indexa isso num B-Tree, que é o padrão), cada novo insert cai num lugar diferente da árvore do índice. O banco fica quebrando páginas pra encaixar o novo valor (page split), o índice vai ficando fragmentado, o cache perde eficiência e o I/O sobe. Não é “só inserir”, é inserir + reorganizar a estrutura interna + escrever mais coisa em disco. E num cenário de alto volume de escrita, esse custo escala rápido.
Outro ponto que a gente sentiu: UUIDv4 não tem nenhuma ordem natural. Então se você precisar de consultas ordenadas por tempo (que é super comum, “me dá os últimos 100 pagamentos”), o ID não ajuda em nada. Você depende 100% de outra coluna com índice.
Aí entra o UUIDv7. Ele carrega um timestamp nos primeiros bits e o resto continua aleatório. Na prática, os inserts passam a cair perto do final do índice em vez de espalhados no meio. Menos page split, menos fragmentação, melhor aproveitamento de cache. E de bônus: você ganha uma “ordem natural” no ID, que ajuda em consultas do dia a dia sem precisar de uma coluna extra, tipo listar eventos, pagamentos ou logs por ordem de criação.
Em um sistema onde a gente fez a troca de v4 pra v7, a diferença no tempo de insert foi perceptível. Não foi mágica, mas num cenário de milhões de escritas por dia, cada milissegundo importa.
Pra sistema novo, é uma troca tranquila: geralmente é só mudar a versão na biblioteca que você já usa. Pra sistema existente, aí vale avaliar. Mas se você tá num cenário de alto volume de escrita e o ID é a PK, eu hoje iria de UUIDv7 sem pensar duas vezes.
Além das Queries
Até aqui eu falei bastante de query, índice, tipo. Mas banco de dados vai muito além de SELECT e INSERT. Tem um monte de hábito e decisão que separa o “banco funciona” do “banco aguenta o tranco”, e que a gente só sente falta quando dá ruim: dado inconsistente em produção, migração travando, lock bizarro, deploy que virou madrugada.
O ponto não é virar DBA. É entender que banco não é um JSON gigante onde a gente joga coisa lá dentro e reza. Banco tem regra, tem custo e tem comportamento. E esses hábitos que eu vou listar aqui são coisas que, depois de anos apanhando, eu considero parte do beabá tanto quanto um SELECT bem feito.
Constraints são sua primeira linha de defesa. Use NOT NULL, UNIQUE e FOREIGN KEY pra garantir integridade. Isso evita que a aplicação precise ser juiz de tudo o tempo todo. Já vi sistema que não tinha UNIQUE onde deveria, e o time só descobriu quando apareceu duplicidade e ninguém sabia mais qual registro era o certo. Constraint no banco é barato. Dado inconsistente em produção é caro.
Filtre sempre seus UPDATE e DELETE. Parece óbvio, mas todo mundo já viu (ou quase fez) a tragédia do “faltou o WHERE”. Se você vai rodar um UPDATE ou DELETE perigoso, roda primeiro um SELECT com o mesmo filtro pra confirmar o que vai ser afetado:
-- primeiro confirma o que vai pegar:
SELECT id
FROM users
WHERE status = 'inactive';
-- só depois:
-- DELETE FROM users WHERE status = 'inactive';
Stored procedures têm lugar, mas com cuidado. Elas reduzem tráfego e podem melhorar performance quando você precisa executar vários passos perto dos dados. Mas também podem virar um “software orientado a fofoca”: ninguém sabe o que tem lá dentro, não tem versionamento direito e a regra de negócio vira um Frankenstein. Meu meio-termo pessoal: procedure pra coisa bem data-centric (lote, manutenção, agregação). Regra de negócio fica na aplicação.
Persista agregados que são consultados o tempo todo. Se você tem um valor que é recalculado a cada request (total de pedidos do usuário, saldo, contadores), avalie manter um campo agregado. Não é pra sair duplicando dado sem critério. É pra evitar que o banco recalcule a mesma coisa mil vezes por minuto. Em alto volume, essa diferença é brutal. Já peguei relatório que somava milhões de linhas a cada requisição, na primeira semana era lindo, depois virou o maior consumidor de CPU do banco.
Monitore bloqueios e deadlocks. São inimigos silenciosos. Não dá pra otimizar o que você não enxerga. Algumas coisas práticas que ajudam: transação curta (não abre transação e depois fica chamando serviço externo, por favor), evitar lock desnecessário (SELECT ... FOR UPDATE sem motivo real), e manter consistência na ordem de acesso às tabelas pra reduzir a chance de deadlock.
Backup precisa ser testado. Backup todo mundo faz ou deveria. O problema é que quase ninguém testa o restore. Saiba quanto tempo leva pra restaurar, qual o RPO/RTO que você aguenta, e onde isso quebra primeiro. Descobrir isso no dia que precisar é tarde demais.
Documente o schema e as regras do banco. Seu eu do futuro (e seus colegas) vão agradecer. Não precisa ser nada elaborado: um README, um diagrama básico, comentários em migration, ou até uma nota explicando “por que esse índice existe?” já ajuda. Banco sem documentação vira aquele sistema que só o fulano sabe como funciona, e quando o fulano sai, ninguém sabe mais nada.
Expurgo e particionamento: quando o volume passa de terabytes, só eles salvam. Chega um ponto em que não adianta mais otimizar query, criar índice ou tunear configuração. Quando a tabela tem terabytes de dados e continua crescendo, o problema deixa de ser a consulta e passa a ser o volume em si. O banco fica pesado, backup demora, vacuum sofre, e o custo de infra só sobe. A saída aqui é atacar o dado em si: expurgo (arquivar ou remover dados antigos que ninguém mais consulta) e particionamento (dividir a tabela em pedaços menores, normalmente por data). Com particionamento, o banco consegue trabalhar só com a partição relevante em vez de varrer a tabela inteira. E o expurgo fica muito mais simples: ao invés de rodar um DELETE pesadão que trava tudo, você simplesmente dropa a partição antiga. É rápido, barato e não gera lock. Já peguei caso em que o time ficou meses tentando otimizar queries numa tabela de 4TB, e a solução de verdade foi particionar por mês e expurgar dados com mais de 1 ano. A performance melhorou absurdamente, e a conta de cloud caiu junto.
Tabelas insert-only: quando faz sentido não atualizar. Tem cenários em que faz muito mais sentido nunca fazer UPDATE numa tabela. Ao invés de alterar o registro, você insere um novo com o estado atualizado e usa sempre o mais recente. Parece estranho? É o padrão de event sourcing, e mesmo fora dele faz sentido em tabelas de log, auditoria, histórico de status e transações financeiras. A vantagem é que tabela insert-only praticamente elimina problemas de lock em escrita (ninguém tá disputando a mesma linha), facilita replicação e torna o expurgo mais previsível. A desvantagem é que a tabela cresce rápido, mas aí entra o particionamento que a gente acabou de falar. Essa combinação de insert-only + partição + expurgo é uma das arquiteturas mais sólidas que já usei pra tabelas de alto volume.
COPY ao invés de INSERT: o hack que pouca gente conhece. Se você precisa inserir muitos registros de uma vez (carga de dados, migração, batch grande), fazer milhares de INSERT um por um é a pior coisa que você pode fazer. Cada insert é uma transação, cada transação é um roundtrip, e o banco fica ocupado gerenciando overhead ao invés de realmente gravando dados. A alternativa é usar o comando COPY (no Postgres) ou o equivalente do seu banco (LOAD DATA INFILE no MySQL, por exemplo). O COPY manda os dados em bulk, direto pro banco, sem o overhead de transação por linha. A diferença de performance é absurda. É o tipo de coisa que parece “truque de DBA”, mas que todo dev que trabalha com volume deveria conhecer.
Planeje Antes de Precisar Mudar
Depois de mais de uma década na área, eu fico cada vez mais convencido de que a maioria dos problemas graves de banco de dados não vem de falta de skill técnica. Vem de falta de planejamento. A galera começa a construir, vai tocando, e quando percebe já tá com a modelagem engessada, os índices errados e uma dívida técnica que custa caro pra pagar.
Não adianta só tocar as coisas e no meio do caminho tentar mudar. É muito mais custoso, porque o sistema já vai estar amarrado em decisões que tomamos lá atrás, e voltar atrás dói. Ter tempo pra planejar com calma a modelagem e as decisões de banco é uma das coisas mais valiosas que um time pode fazer.
Banco de dados é um mundo à parte. Falei aqui 1%, poderia ter ido bem mais fundo, dado mais exemplos, explorado mais cenários. Mas se você gosta desse assunto tanto quanto eu, vale a pena se aprofundar. É o tipo de conhecimento que muda a forma como você constrói sistema.
[!TIP] E você, tem alguma história de guerra com banco de dados? Alguma boa prática que descobriu apanhando em produção e que não tá aqui? Compartilha nos comentários, esse tipo de troca é o que faz a gente evoluir.