Desnormalização Intencional
Cópias otimizadas para leitura com uma estratégia de atualização documentada trocam o custo de junção por velocidade de consulta - apenas depois que o design normalizado se provar insuficiente.
Receita
Cartão de receita de referência rápida - pronto para copiar e colar.
-- Fonte de verdade normalizada
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers (customer_id),
status text NOT NULL
);
CREATE TABLE customers (
customer_id bigint PRIMARY KEY,
email text NOT NULL
);
-- Cópia de leitura desnormalizada em orders (documente o invariante)
ALTER TABLE orders ADD COLUMN customer_email text;
CREATE OR REPLACE FUNCTION sync_order_customer_email()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
SELECT email INTO NEW.customer_email FROM customers WHERE customer_id = NEW.customer_id;
RETURN NEW;
END;
$$;
CREATE TRIGGER orders_customer_email_trg
BEFORE INSERT OR UPDATE OF customer_id ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_customer_email();Quando usar isso:
pg_stat_statementsmostra que uma junção crítica está bloqueando SLOs após a existência de índices adequados.- O caminho de leitura precisa de colunas de snapshot estáveis (preço no momento da compra).
- A réplica de relatórios ainda está muito lenta para uma consulta de dashboard específica.
Exemplo de Trabalho
BEGIN;
CREATE TABLE products (
product_id bigint PRIMARY KEY,
sku text NOT NULL,
name text NOT NULL,
price numeric(12, 2) NOT NULL
);
CREATE TABLE orders (
order_id bigint PRIMARY KEY,
placed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id),
product_id bigint NOT NULL REFERENCES products (product_id),
quantity integer NOT NULL CHECK (quantity > 0),
-- Snapshot desnorm: o preço do catálogo pode mudar depois
unit_price numeric(12, 2) NOT NULL,
product_name text NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- Preenche colunas de desnorm em inserts via trigger
CREATE OR REPLACE FUNCTION order_items_snapshot_product()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
p products%ROWTYPE;
BEGIN
SELECT * INTO p FROM products WHERE product_id = NEW.product_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'product % not found', NEW.product_id;
END IF;
NEW.unit_price := COALESCE(NEW.unit_price, p.price);
NEW.product_name := COALESCE(NEW.product_name, p.name);
RETURN NEW;
END;
$$;
CREATE TRIGGER order_items_snapshot_trg
BEFORE INSERT ON order_items
FOR EACH ROW EXECUTE FUNCTION order_items_snapshot_product();
INSERT INTO products (product_id, sku, name, price)
VALUES (1, 'A1', 'Alpha', 19.99);
INSERT INTO orders (order_id) VALUES (100);
INSERT INTO order_items (order_id, product_id, quantity, unit_price, product_name)
VALUES (100, 1, 2, 19.99, 'Alpha');
-- Renomear catálogo não reescreve itens históricos
UPDATE products SET name = 'Alpha Pro' WHERE product_id = 1;
SELECT product_name, unit_price FROM order_items WHERE order_id = 100;
COMMIT;O que isso demonstra:
unit_priceeproduct_namesão snapshots intencionais na linha de fato.- O trigger preenche os snapshots na inserção para que os aplicativos não esqueçam os campos desnormalizados.
- Relatórios históricos permanecem corretos quando o catálogo muda.
Mergulho Profundo
Como Funciona
- A desnormalização duplica dados que poderiam ser obtidos por junção de uma tabela de dimensão.
- Invariantes devem ser escritos: "
order_items.product_nameé o nome no momento da inserção do pedido, não o catálogo atual." - Estratégias de atualização: trigger na escrita, job em lote ou atualização de view materializada.
- Consultas de detecção de desvio comparam colunas desnormalizadas com tabelas de origem.
Estratégias de Desnormalização
| Estratégia | Frescor | Complexidade |
|---|---|---|
| Trigger na escrita | Imediato | Médio |
| Escrita dupla na aplicação | Imediato | Alto (caminhos de código perdidos) |
| UPDATE em lote agendado | Atraso aceitável | Baixo |
| View materializada | Intervalo de atualização | Médio |
Notas SQL
-- Detecção de desvio: o e-mail desnormalizado deve corresponder ao cliente, a menos que as regras de snapshot digam o contrário
SELECT o.order_id, o.customer_email, c.email
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE o.customer_email IS DISTINCT FROM c.email;Armadilhas
- Desnormalizar antes de indexar junções - colunas redundantes sem medição. Correção: indexar FKs,
EXPLAIN, então desnormalizar. - Colunas de espelho em tempo real - espera-se que
customer_emailemordersacompanhe as mudanças de e-mail sem atualização. Correção: documentar snapshot vs espelho; adicionar job de atualização. - Desvio do trigger -
UPDATEmanual ignora os triggers da aplicação no SQL administrativo. Correção: job de reconciliação periódico. - Linhas largas por conveniência - 40 colunas desnormalizadas anuláveis. Correção: view materializada ou snapshot JSONB com versão de esquema.
- Sem histórico de rollback - não é possível recalcular a desnormalização a partir da origem. Correção: manter a origem normalizada como autoridade; a desnormalização é derivada.
Alternativas
| Alternativa | Usar Quando | Não Usar Quando |
|---|---|---|
| Junções normalizadas indexadas | Os dados cabem no cache do buffer | Junção comprovada de bilhões de linhas |
| View materializada | Muitos leitores, mesma projeção | Necessidade de frescor em nível de linha no caminho OLTP |
| Réplica de leitura + índice de cobertura | Divisão de leitura/escrita suficiente | Atraso da réplica inaceitável |
| Cache da aplicação (Redis) | Dados de exibição efêmeros | Snapshots financeiros que exigem trilha de auditoria |
FAQs
Quando a desnormalização é justificada?
Quando você tem evidências: pg_stat_statements, EXPLAIN (ANALYZE, BUFFERS), e tentativas falhas com índices de cobertura no esquema normalizado.
Os preços históricos devem ser desnormalizados?
Sim - unit_price em order_items é modelagem de comércio padrão, não um hack de performance.
Como documentar invariantes?
COMMENT ON COLUMN order_items.product_name IS
'SNAPSHOT: nome do produto no momento da inserção da linha; não rastreia renomeações de catálogo';Triggers vs. escrita dupla na aplicação?
Triggers capturam todos os caminhos de inserção, incluindo SQL ad-hoc. Aplicações são mais fáceis de testar, mas mais fáceis de contornar de forma inconsistente.
Colunas geradas podem desnormalizar?
Sim, quando a derivação é SQL puro:
ALTER TABLE order_items ADD COLUMN line_total numeric(12,2)
GENERATED ALWAYS AS (quantity * unit_price) STORED;Com que frequência as verificações de desvio devem ser executadas?
Noturnamente para colunas de espelho; nunca para snapshots intencionais, a menos que as regras de negócios mudem retroativamente.
A desnormalização quebra a 3FN?
Sim, por definição. Aceite isso conscientemente para ganhos de leitura medidos ou snapshots necessários.
E a desnormalização JSONB?
Armazene um blob de snapshot versionado na inserção para auditoria (product_snapshot jsonb). Indexe apenas os campos nos quais você filtra.
Como remover a desnormalização depois?
Comprove o desempenho da junção com índices, migre os leitores, remova a coluna na fase de migração de contrato.
O cache é o mesmo que desnormalização?
Conceitualmente semelhante - duplica dados para velocidade. A desnormalização no banco de dados sobrevive à expiração do cache e suporta relatórios SQL.
Relacionado
- Bases da Normalização - por que duplicatas prejudicam
- Views Materializadas - projeções desnormalizadas em lote
- Melhores Práticas de Desnormalização - checklist de invariantes
- Design de Índices - tente índices primeiro
Versões da Stack: Esta página foi escrita para PostgreSQL 18.4 (estável 18, manutenção 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, e PostGIS 3.5+.