Dados e bancos de dados

Modelagem de banco de dados e normalização: das entidades à FNBC

Iniciante9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Normalização não é cerimônia acadêmica: é uma forma mecânica de eliminar a redundância que causa anomalias de atualização. Este guia modela um esquema pequeno, deriva suas dependências funcionais e o leva de uma planilha não normalizada até a FNBC; depois trata de chaves substitutas versus chaves naturais, integridade referencial, desnormalização deliberada e dos sinais de que um modelo está com problemas.

O que você vai aprender

  • Identificar entidades, relacionamentos, chaves candidatas e cardinalidades a partir da descrição de um domínio de negócio
  • Escrever dependências funcionais e usá-las para detectar redundância
  • Levar uma tabela da forma não normalizada à 1FN, à 2FN, à 3FN e à FNBC
  • Escolher entre chaves substitutas e naturais e garantir a integridade com constraints
  • Decidir quando a desnormalização se justifica e como mantê-la segura

É na modelagem do banco de dados que se decidem os problemas futuros de um projeto. Uma aplicação pode ser reescrita em um fim de semana; um esquema que está em produção há três anos, com milhões de linhas e uma dúzia de consumidores, não. A boa notícia é que quase toda essa disciplina pode ser ensinada e é bastante mecânica: descreva o domínio com honestidade, anote quais valores determinam quais outros e deixe essas dependências mostrarem onde as tabelas devem ser divididas.

Este guia desenvolve um único exemplo pequeno do começo ao fim. O domínio é uma conferência: participantes se inscrevem em sessões, as sessões acontecem em salas e palestrantes apresentam temas. Ele é propositalmente banal, porque a parte interessante não é o domínio, e sim a redundância que aparece quando você o registra sem cuidado.

Entidades, relacionamentos e chaves

Comece nomeando as coisas de que o negócio fala. Cada substantivo que tem existência independente e atributos próprios é uma entidade candidata: participante, sessão, sala, palestrante. Cada verbo que os conecta é um relacionamento: um participante se inscreve em uma sessão; uma sessão acontece em uma sala.

A cardinalidade do relacionamento decide como ele é armazenado:

  • Um-para-muitos — uma sala recebe muitas sessões, e uma sessão fica em uma única sala. Coloque a chave estrangeira no lado "muitos": session.room_id.
  • Muitos-para-muitos — um participante se inscreve em muitas sessões, e uma sessão tem muitos participantes. Isso exige uma tabela própria, geralmente chamada de tabela de junção ou associativa, cuja chave primária é o par de chaves estrangeiras. Os atributos do próprio relacionamento (o horário da inscrição, se o participante compareceu) também ficam nela.
  • Um-para-um — realmente raro. Em geral indica duas entidades que poderiam ser uma só tabela, ou uma extensão opcional que você quer manter separada por questões de tamanho ou de controle de acesso.

Uma chave candidata é um conjunto mínimo de colunas cujos valores são únicos em todas as linhas e nunca nulos. Uma tabela pode ter várias; a que você escolhe como chave primária é uma questão de conveniência, não de verdade. attendee.email e attendee.badge_number podem ser, ambas, chaves candidatas.

Vale definir dois termos logo de início, porque a normalização é enunciada a partir deles. Uma coluna é um atributo primo se pertence a alguma chave candidata, e um atributo não primo caso contrário. Cada forma normal a seguir é uma regra sobre como os atributos não primos podem depender dos primos.

Dependências funcionais

Uma dependência funcional, escrita A → B, significa que duas linhas quaisquer que coincidem em A precisam coincidir em B. É uma afirmação sobre o negócio, não sobre os dados que você tem naquele momento. "Duas sessões na mesma sala têm a mesma capacidade de sala" é uma dependência; "no momento, não há dois participantes com o mesmo sobrenome" é uma coincidência.

Suponha que alguém lhe entregue esta planilha, exportada como uma única tabela:

attendee_email | attendee_name | session_code | session_title | room | room_capacity | starts_at           | topics
[email protected]| Ana Ruiz      | S12          | Query plans   | R2   | 120           | 2026-05-04 09:00    | sql, performance
[email protected]| Ana Ruiz      | S19          | Sharding      | R1   | 300           | 2026-05-04 11:00    | nosql, scaling
[email protected]| Bob Neri      | S12          | Query plans   | R2   | 120           | 2026-05-04 09:00    | sql, performance

As dependências que o negócio confirma são:

attendee_email                -> attendee_name
session_code                  -> session_title, room, starts_at
room                          -> room_capacity
(attendee_email, session_code)-> everything (the composite key)

A redundância agora aparece como regra, e não como palpite: como session_code → room, cada inscrição extra em S12 repete "R2" e "120". Essa repetição é o que causa as três anomalias clássicas. A anomalia de atualização: renomear uma sessão exige editar muitas linhas, e esquecer uma deixa a tabela se contradizendo. A anomalia de inserção: você não consegue registrar a capacidade de uma sala até que alguém se inscreva em uma sessão realizada nela. A anomalia de exclusão: cancelar a última inscrição em S12 apaga até o fato de que S12 existe.

Aplicando as formas normais

Cada forma normal elimina uma classe de dependência. Atingir uma forma significa também satisfazer todas as anteriores.

Primeira forma normal

A 1FN exige que cada coluna contenha um único valor atômico de um só tipo e proíbe grupos repetidos. A coluna topics não passa: ela empacota uma lista em uma string, então você não consegue indexá-la, fazer join por ela, aplicar constraints nem contar os temas sem fazer parsing de texto. Colunas chamadas phone1, phone2, phone3 falham no mesmo teste, só que na direção oposta.

A solução é uma tabela:

CREATE TABLE session_topic (
  session_code  varchar(8) NOT NULL REFERENCES session(code) ON DELETE CASCADE,
  topic         varchar(40) NOT NULL,
  PRIMARY KEY (session_code, topic)
);

Uma dúvida comum: uma coluna jsonb ou de array quebra a 1FN? Formalmente, sim; na prática, depende. Guardar em uma coluna de documento conteúdos realmente opacos e variáveis é uma escolha de engenharia razoável; guardar ali dados estruturados que você filtra e usa em joins com frequência é fugir da 1FN com passos extras.

Segunda forma normal

A 2FN só se aplica quando a chave primária é composta. Ela proíbe a dependência parcial: um atributo não primo que depende de parte da chave, e não dela inteira. Aqui a chave é (attendee_email, session_code), mas session_title depende apenas de session_code, e attendee_name apenas de attendee_email.

Dividir a tabela segundo essas dependências gera três tabelas — attendee, session e a tabela de junção das inscrições —, e esta última mantém só o que depende da chave inteira, como signed_up_at.

Terceira forma normal

A 3FN proíbe a dependência transitiva: um atributo não primo determinado por outro atributo não primo. Na tabela session, temos session_code → room e room → room_capacity, então a capacidade depende transitivamente da chave. Mova-a:

CREATE TABLE room (
  code      varchar(8) PRIMARY KEY,
  capacity  integer NOT NULL CHECK (capacity > 0)
);

CREATE TABLE session (
  code      varchar(8) PRIMARY KEY,
  title     varchar(120) NOT NULL,
  room_code varchar(8) NOT NULL REFERENCES room(code),
  starts_at timestamptz NOT NULL
);

Agora a capacidade é armazenada uma única vez. Corrigi-la é um update de uma só linha, que não tem como deixar o banco inconsistente.

Forma normal de Boyce-Codd

A FNBC aperta a 3FN: todo determinante precisa ser uma chave candidata, inclusive os determinantes de atributos primos. A diferença só aparece quando há chaves candidatas sobrepostas.

Considere uma tabela que registra qual palestrante cobre qual tema em uma sessão, com a regra de que um mesmo palestrante sempre cobre um único tema:

speaker_assignment(session_code, topic, speaker)
  (session_code, topic)   -> speaker      -- candidate key
  (session_code, speaker) -> topic        -- candidate key
  speaker                 -> topic        -- determinant that is NOT a key

A tabela está na 3FN — topic é primo, então não há dependência transitiva sobre um atributo não primo —, mas o fato palestrante-tema se repete em cada sessão em que aquele palestrante aparece. Decomponha-a em speaker(name, topic) e session_speaker(session_code, speaker). Seja honesto sobre o trade-off: essa decomposição é sem perdas, mas não preserva a chave composta original como constraint de uma única tabela, o que é um dos motivos pelos quais a 3FN às vezes é o ponto de parada pragmático.

A quarta forma normal vai um passo além e separa fatos multivalorados independentes. Se uma sessão tivesse ao mesmo tempo uma lista de temas e uma lista de patrocinadores sem relação entre si, enfiar as duas na mesma tabela produziria uma linha para cada combinação. Separá-las em duas tabelas é a 4FN, e depois que você vê isso uma vez, parece quase senso comum.

Chaves e integridade referencial

Uma chave natural é um dado com significado — um ISBN, um código de país, um endereço de e-mail. Uma chave substituta (surrogate key) é um valor gerado, sem significado: uma coluna identity, uma sequence, um UUID.

Chaves naturais ficam legíveis nas consultas e dispensam um join quando a tabela filha só precisa do código (ORDER BY country_code não exige nenhuma busca). Elas falham quando o identificador "imutável" se revela mutável, quando é longo o bastante para inchar todo índice que o referencia ou quando um órgão externo o redefine.

Chaves substitutas são estáveis e compactas, e é por isso que são o padrão mais comum. Use uma coluna identity gerada pelo banco (GENERATED ALWAYS AS IDENTITY no PostgreSQL e no Oracle 12c ou posterior), a menos que você precise criar os identificadores antes da inserção ou mesclar dados de vários sistemas; nesse caso, um UUID cai bem — um gerador de UUID é útil para popular linhas de teste. A regra crítica ao usar uma chave substituta: declare mesmo assim uma constraint de unicidade na chave natural, senão a tabela vai armazenar tranquilamente a mesma entidade do mundo real duas vezes.

Seja qual for sua escolha, deixe o banco fazer valer as regras. Chaves estrangeiras garantem que uma inscrição não possa referenciar uma sessão inexistente; constraints NOT NULL e CHECK codificam fatos que a validação da aplicação deixa escapar sob concorrência; UNIQUE barra duplicatas que nenhum código de aplicação consegue evitar de forma confiável assim que ele roda em dois servidores. Escolha o comportamento de exclusão de forma deliberada: ON DELETE CASCADE para linhas que não podem existir sem o pai, RESTRICT para tudo o que uma pessoa deveria confirmar, SET NULL só onde o null realmente significa "não está mais vinculado". Indexe toda coluna de chave estrangeira, ou excluir um pai vai varrer a tabela filha inteira — o guia de índices e planos de execução explica por quê.

Desnormalizando de propósito

Desnormalizar significa armazenar conscientemente um fato duas vezes para baratear uma leitura. É uma técnica legítima com um requisito de entrada: um mecanismo que mantenha as cópias consistentes.

Os casos razoáveis incluem armazenar um order_total calculado, mantido por um trigger ou pela mesma transação que grava os itens; copiar o preço histórico para um item do pedido, o que na verdade nem é redundância, já que o preço atual do produto é outro fato; manter uma materialized view ou uma tabela de resumo para um dashboard; e os esquemas estrela de analytics, em que as tabelas de dimensão são desnormalizadas de propósito porque os dados são carregados, não editados — veja business intelligence.

Os casos não razoáveis têm uma característica em comum: nada mantém a cópia verdadeira. A cidade de um cliente copiada em cada pedido "para relatórios", que ninguém atualiza, silenciosamente vira ficção.

Sinais de alerta em um esquema

  • Entidade-atributo-valor. Uma tabela (entity_id, attribute_name, value) promete flexibilidade infinita e entrega dados sem tipo, regras impossíveis de restringir e self-joins em toda consulta. Use-a apenas para campos realmente definidos pelo usuário e, de preferência, opte por uma coluna jsonb com uma check constraint de validação.
  • A tabela larga. Oitenta colunas, a maioria nula na maioria das linhas, costuma significar várias entidades dividindo uma tabela, ou uma hierarquia de herança achatada sem muito critério.
  • Tudo nullable. Se quase todas as colunas aceitam null, o esquema parou de documentar o domínio. Cada coluna nullable deveria ter um significado declarado para o seu null.
  • Booleanos que deveriam ser estados. is_active, is_cancelled e is_archived em uma mesma linha podem codificar combinações impossíveis. Uma única coluna status com uma check constraint não pode.
  • Grupos de colunas repetidos. address_line_1 até address_line_5, ou q1_total até q4_total, são linhas se passando por colunas.
  • Sem chaves estrangeiras "por desempenho". O custo de uma chave estrangeira é uma consulta ao índice por escrita; o custo de dados órfãos é um imposto permanente sobre cada consulta e cada desenvolvedor, e ainda priva o otimizador de informação, como explica o guia de boas práticas de SQL.

Quando há um ORM por cima, o esquema continua vindo primeiro: os mapeamentos de entidades devem descrever um bom modelo relacional, e não ditá-lo, um ponto abordado em mapeamento de entidades JPA.

Principais pontos

  • Modele primeiro as entidades e os relacionamentos, depois escreva as dependências funcionais; a redundância é consequência das dependências, então são elas que mostram onde dividir.
  • A 1FN elimina grupos repetidos, a 2FN elimina dependências parciais de uma chave composta, a 3FN elimina dependências transitivas, e a FNBC exige que todo determinante seja uma chave candidata.
  • A redundância se manifesta como anomalias de atualização, inserção e exclusão — falhas concretas, não reclamações de estilo.
  • Chaves substitutas são o padrão estável, mas sempre mantenha uma constraint de unicidade na chave natural para que duplicatas não entrem.
  • Deixe as constraints garantirem o modelo: chaves estrangeiras com regras de exclusão bem pensadas, NOT NULL, CHECK e UNIQUE.
  • Desnormalize só para uma consulta identificada, depois de medir, e apenas com um mecanismo que mantenha correto o valor duplicado.

Fontes e leituras complementares

Perguntas frequentes

A terceira forma normal é suficiente para uma aplicação real?
Para a imensa maioria dos esquemas transacionais, sim. A 3FN elimina a redundância responsável por quase todas as anomalias de atualização que você vai encontrar na prática, e a forma normal de Boyce-Codd só difere dela em tabelas com chaves candidatas compostas que se sobrepõem, algo raro fora dos livros didáticos. Mire na 3FN em todo lugar, verifique a FNBC onde houver chaves compostas sobrepostas e pare por aí, a menos que uma anomalia específica prove o contrário.
A normalização deixa as consultas mais lentas?
Ela troca linhas mais largas por mais joins. Os otimizadores modernos fazem join de tabelas indexadas com muita eficiência, e tabelas mais estreitas significam mais linhas por página e menos I/O; por isso, esquemas normalizados muitas vezes são lidos mais rápido, e não mais devagar. As escritas são quase sempre mais rápidas, porque cada fato é armazenado uma única vez. Meça antes de presumir: um plano de execução vai mostrar se o custo real está em um join ou em uma varredura.
Toda tabela deve ter uma coluna id gerada automaticamente?
Não automaticamente. Uma chave substituta é o padrão certo para entidades cujo identificador natural é instável, longo ou controlado por terceiros. Tabelas que apenas associam duas entidades normalmente não precisam de coluna extra: o par de chaves estrangeiras já é uma chave primária perfeita, e acrescentar um id deixa passar pares duplicados, a menos que você também declare uma constraint de unicidade sobre eles.
Quando a desnormalização é a resposta certa?
Quando você consegue apontar a consulta que ela acelera, mediu que a forma normalizada é lenta demais e tem um mecanismo que mantém a cópia correta. Armazenar o total calculado de um pedido, guardar em cache um nome de exibição ou montar uma tabela de relatórios são escolhas razoáveis. Copiar um atributo mutável para uma segunda tabela sem nada que mantenha as duas sincronizadas não é.