O modelo que eu criava em 2013 é o mesmo que funciona em 2035. Boas práticas de banco de dados não mudam; o que muda é que ainda tem gente fazendo errado.
"CRUD é tudo. Todo sistema, no fundo, é criar, ler, atualizar e deletar. Quem domina isso, domina o alicerce. Em TI, tudo é nivelado pelo mais lento, e o mais lento sempre foi o banco."
Cesar Zanis
01/Inegociável desde sempre.
PRIMARY KEY: INT/BIGINT com IDENTITY ou SERIAL
O campo de chave primária é o eixo de rotação de toda tabela. Use INT (até ~2 bilhões de linhas) ou BIGINT (além disso). Sempre com geração automática: IDENTITY no SQL Server, SERIAL/GENERATED ALWAYS AS IDENTITY no PostgreSQL.
Nunca exponha esse ID em APIs públicas ou URLs. Ele é interno, sequencial e previsível: ótimo para performance de B-Tree, péssimo para segurança.
SQLSQL padrão
-- PostgreSQL
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
-- ...
CONSTRAINT uq_orders_public_id UNIQUE (public_id)
);
-- SQL Server
CREATE TABLE orders (
id BIGINT IDENTITY(1,1) PRIMARY KEY,
public_id UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
-- ...
CONSTRAINT uq_orders_public_id UNIQUE (public_id)
);
O índice UNIQUE na coluna public_id garante integridade sem custo de fragmentação no clustered index. É o melhor dos dois mundos: performance interna, segurança externa.
02/UUID como PK é erro clássico que custa caro depois.
UUID: campo de exposição, nunca PRIMARY KEY
UUID v4 é aleatório. Quando você usa UUID como PRIMARY KEY em uma tabela grande, cada INSERT fragmenta o índice clustered porque o novo valor não vai para o final; vai para um lugar aleatório no meio. Em volume, isso custa entre 30-50% de performance de escrita e aumenta o espaço em disco de forma significativa.
A solução correta: INT/BIGINT como PK (ordenado, sem fragmentação) + UUID em coluna separada com índice UNIQUE (para exposição pública e integração entre sistemas).
Se precisar de UUID ordenado por tempo, use UUID v7: ele resolve o problema de fragmentação mantendo a universalidade.
SQLSQL padrão
-- ❌ O erro clássico
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
-- Cada insert fragmenta o B-Tree. Em 500k linhas você vai sentir.
);
-- ✅ O padrão correto
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_customers_public_id UNIQUE (public_id)
);
Nunca faça JOIN por UUID em tabelas grandes. JOIN por BIGINT é 3-5x mais rápido por ser inteiro de 8 bytes com ordenação natural.
03/Dado deletado que importa para negócio nunca some de verdade.
Soft Delete: deleted_at ou is_deleted
Hard delete (DELETE físico) é irreversível e quebra integridade referencial histórica. Para qualquer tabela transacional (como pedidos, clientes e produtos), use soft delete.
A coluna deleted_at (com timestamp) é superior ao is_deleted (boolean) porque você sabe quando foi deletado, não só se foi. Isso vale para auditoria, LGPD e relatórios de BI.
A contrapartida é que todo SELECT precisa filtrar WHERE deleted_at IS NULL. Crie uma view ou use filtros globais no seu ORM/repositório. Se você esquecer esse filtro uma vez, vai exibir dados deletados em produção.
SQLSQL padrão
-- Estrutura recomendada
CREATE TABLE products (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id UUID NOT NULL DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL, -- NULL = ativo, NOT NULL = deletado
CONSTRAINT uq_products_public_id UNIQUE (public_id)
);
-- Índice filtrado para buscar só ativos (PostgreSQL)
-- Ignora registros deletados no índice: menor e mais rápido
CREATE INDEX ix_products_name_active
ON products (name)
WHERE deleted_at IS NULL;
-- Query padrão: sempre com o filtro
SELECT * FROM products WHERE deleted_at IS NULL AND name ILIKE '%notebook%';
Índices filtrados (WHERE deleted_at IS NULL) são ordens de magnitude menores que índices completos em tabelas com muitos registros deletados. Use-os.
04/Índice errado é pior que nenhum índice.
Índices: o que, quando e o custo
Índices aceleram leitura e custam escrita. Cada índice adicional em uma tabela aumenta o custo de INSERT, UPDATE e DELETE, porque o banco precisa manter o índice atualizado.
O custo vale quando a query é frequente e a seletividade é alta (poucos resultados para muitos candidatos). Não vale para colunas com poucos valores distintos (boolean, status com 3 opções).
Covering index (com INCLUDE) elimina Key Lookup: quando o índice já contém todas as colunas que a query precisa, o banco não volta à tabela. É a otimização mais impactante para queries de leitura frequente.
SQLSQL padrão
-- ❌ Índice simples: força Key Lookup se a query pede outras colunas
CREATE INDEX ix_orders_customer_id ON orders (customer_id);
-- ✅ Covering index: a query encontra tudo no índice
CREATE INDEX ix_orders_customer_id_cover
ON orders (customer_id)
INCLUDE (status, total_amount, created_at);
-- A query abaixo não toca na tabela principal:
SELECT status, total_amount, created_at
FROM orders
WHERE customer_id = 42 AND deleted_at IS NULL;
-- ✅ Índice composto: ordem importa
-- Coluna mais seletiva primeiro
CREATE INDEX ix_orders_status_created
ON orders (status, created_at DESC)
WHERE deleted_at IS NULL;
Não indexe colunas booleanas ou colunas com baixa cardinalidade. O banco pode ignorar o índice e fazer table scan mesmo assim.
05/Banco sem manutenção é dívida técnica silenciosa.
Rotinas de Manutenção (PostgreSQL)
O PostgreSQL tem MVCC: cada UPDATE cria uma nova versão da linha, a versão antiga fica como "dead tuple" até o VACUUM rodar. Em tabelas com alto volume de UPDATE (logs, filas, sessões), a tabela pode inchar 3-5x o tamanho real se o VACUUM não rodar corretamente.
O autovacuum cuida da maioria dos casos, mas tabelas críticas precisam de atenção manual. ANALYZE mantém as estatísticas do query planner atualizadas: sem ele, o banco pode escolher planos de execução ruins mesmo com índices corretos.
SQLSQL padrão
-- Manutenção manual em tabelas críticas
-- Roda fora do horário de pico
VACUUM (VERBOSE, ANALYZE) orders;
-- Ver dead tuples por tabela (monitorar proativamente)
SELECT
schemaname,
relname AS table_name,
n_dead_tup AS dead_tuples,
n_live_tup AS live_tuples,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
last_vacuum,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_pct DESC;
-- Ver índices não utilizados (candidatos à remoção)
SELECT
indexrelname AS index_name,
relname AS table_name,
idx_scan AS times_used
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE '%pkey%'
ORDER BY relname;
Configure log_min_duration_statement = 500 no PostgreSQL para capturar queries lentas automaticamente. É o primeiro passo para qualquer otimização baseada em evidência.
06/Fragmentação de índice mata performance em silêncio.
Rotinas de Manutenção (SQL Server)
No SQL Server, índices se fragmentam com o tempo, especialmente em tabelas com muitos INSERT e DELETE. Fragmentação acima de 30% degrada performance de leitura significativamente. Abaixo de 5%, ignore. Entre 5-30%, reorganize (online). Acima de 30%, reconstrua.
As estatísticas desatualizadas fazem o query optimizer tomar decisões ruins, como escolher um índice ineficiente por não saber quantos registros existem de verdade. Update Statistics resolve isso.
SQLSQL padrão
-- Verificar fragmentação por índice
SELECT
OBJECT_NAME(ips.object_id) AS table_name,
i.name AS index_name,
ips.avg_fragmentation_in_percent AS fragmentation_pct,
ips.page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
JOIN sys.indexes i ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 5
AND ips.page_count > 100
ORDER BY ips.avg_fragmentation_in_percent DESC;
-- Reorganizar (online, baixo impacto): fragmentação 5-30%
ALTER INDEX ix_orders_customer_id ON orders REORGANIZE;
-- Reconstruir (mais agressivo): fragmentação > 30%
ALTER INDEX ix_orders_customer_id ON orders REBUILD WITH (ONLINE = ON);
-- Atualizar estatísticas de uma tabela
UPDATE STATISTICS orders WITH FULLSCAN;
-- Ver queries mais lentas (Top 10 por CPU)
SELECT TOP 10
qs.total_elapsed_time / qs.execution_count / 1000 AS avg_ms,
qs.execution_count,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY avg_ms DESC;
Automatize o check de fragmentação como job semanal. Em produção com alto volume, rode diariamente fora do horário de pico.