Modelagem de Dados NoSQL a fundo
Quase todo erro caro em NoSQL é de modelagem: array que estoura, partição quente, consulta que vira scatter-gather, migração que trava o banco por horas. Esta apostila trata a modelagem como disciplina — dos padrões de acesso ao single-table design, coluna larga, grafo e séries temporais, até a evolução de schema em produção.
A apostila de NoSQL apresenta as famílias; a de MongoDB aprofunda um banco. Esta é transversal: o método de modelar, que vale para documento, chave-valor, coluna larga e grafo. Onde a teoria distribuída (CAP, quóruns, sharding) aparecer, é resumo — aprofunde nos módulos 3 e 7 da apostila de NoSQL.
Access-pattern-first: a virada mental
Objetivo: entender por que a modelagem relacional (normalizar entidades) e a modelagem NoSQL (moldar dados por consulta) são processos invertidos — e o que isso implica na prática.
1.1 O processo relacional (que você provavelmente já tem no reflexo)
- Identificar entidades e relacionamentos (ERD).
- Normalizar (1FN, 2FN, 3FN) para eliminar redundância.
- Criar tabelas e chaves estrangeiras.
- Escrever as consultas depois — o otimizador e os JOINs se viram.
Isso funciona porque um banco relacional resolve qualquer consulta razoável sobre um schema normalizado, ao custo de JOINs em tempo de execução.
1.2 O processo NoSQL
- Levantar todos os padrões de acesso da aplicação (as perguntas que ela fará), com frequência e volume estimados.
- Projetar as estruturas de armazenamento para que cada padrão seja atendido com uma operação barata (idealmente uma leitura por chave, sem JOIN, sem varredura).
- Aceitar duplicação de dados como preço da leitura rápida.
- Só então nomear coleções/tabelas/chaves.
No relacional, você modela os dados e as consultas se adaptam. No NoSQL, você modela as consultas e os dados se adaptam. Se você desenhou um ERD normalizado e "traduziu" para coleções, provavelmente modelou errado.
1.3 Por que a duplicação deixa de ser pecado
- Armazenamento é barato; latência e I/O não. Guardar o nome do cliente em 10 mil pedidos custa alguns MB; fazer JOIN em 10 mil pedidos a cada listagem custa CPU e tempo em toda requisição.
- Dado copiado ≠ dado que muda junto. Muita duplicação é de dado histórico (o preço no momento da compra) ou de projeção de exibição (o nome para a lista) — nesses casos, a cópia é a modelagem correta, não um efeito colateral.
- O custo real da duplicação aparece quando um dado mutável e compartilhado é copiado em muitos lugares (ex.: o nome do usuário em milhões de comentários). Aí entra a análise de "taxa de mudança × fan-out" (módulo 3).
Entrevistadores de backend/dados abrem com "como você modelaria X em NoSQL?" e avaliam o processo: você pergunta pelos padrões de acesso antes de desenhar? Justifica cada duplicação? Reconhece o risco de dado mutável compartilhado? Quem começa desenhando um ERD normalizado perde pontos nos primeiros 30 segundos.
✏️ Exercício 1 — Identifique o erro de processo
Um time modelou um app de delivery assim: coleções usuarios, restaurantes, pratos, pedidos, itens_pedido, avaliacoes, cada uma com referências às outras, "igual ao banco relacional que a gente já tinha". A tela principal (histórico de pedidos do usuário) faz 5 consultas encadeadas e está lenta. Qual foi o erro e qual a direção da correção?
Gabarito: O erro foi de processo: traduziram um ERD normalizado para coleções em vez de modelar a partir dos padrões de acesso. A tela de histórico precisa, por pedido: itens (com nome e preço do prato no momento da compra), nome do restaurante, status. Correção: o documento pedido embute os itens com extended reference (nome do prato, preço congelado, nome do restaurante), de modo que a tela seja uma consulta por usuarioId com índice { usuarioId: 1, criadoEm: -1 }. pratos e restaurantes continuam como coleções próprias para as telas que os editam; o pedido não depende delas em leitura.
Levantar os padrões de acesso
Objetivo: produzir o artefato central da modelagem NoSQL — a tabela de padrões de acesso — e usá-la como contrato entre produto, backend e dados.
2.1 A tabela de padrões de acesso
Antes de qualquer schema, liste cada operação de leitura e escrita que a aplicação faz. Para cada uma: o que entra (parâmetros), o que sai, com que frequência, em que volume, e qual latência é aceitável.
| # | Padrão de acesso | Chave de entrada | Retorno | Freq. | Obs. |
|---|---|---|---|---|---|
| 1 | Ver perfil do usuário | userId | 1 doc de perfil | alta | p99 < 20 ms |
| 2 | Listar pedidos do usuário (paginado, recentes primeiro) | userId, cursor | 20 pedidos | alta | ordenar por data desc |
| 3 | Ver um pedido | orderId | 1 pedido com itens | média | — |
| 4 | Listar pedidos de um restaurante por dia (painel) | restId, dia | N pedidos | baixa | uso interno |
| 5 | Marcar pedido como entregue | orderId | — | média | escrita idempotente |
2.2 De onde tirar os padrões
- Telas e endpoints: cada tela do app e cada rota da API é um ou mais padrões de acesso. Percorra o Figma / a spec da API.
- Jobs e relatórios: exportações, e-mails agendados, reconciliações — costumam ser os padrões mais pesados e mais esquecidos.
- Eventos e integrações: webhooks, filas, sincronização com outros sistemas.
- Futuro previsível: só o que já está no roadmap. Não modele para requisitos imaginários — mas deixe explícito o que não está coberto.
2.3 Classificar cada padrão
| Eixo | Perguntas |
|---|---|
| Seletividade | Busca 1 item por chave? Uma faixa ordenada? Um agregado? Uma busca textual? |
| Cardinalidade do resultado | Retorna 1, dezenas, milhares, milhões? |
| Frequência × volume | 1000 req/s de 1 item, ou 1 req/dia de 10 M de itens? Molda decisões opostas. |
| Consistência exigida | Precisa ver a própria escrita na hora? Tolera segundos de atraso? |
| Padrão de escrita associado | Quem escreve esse dado, com que frequência, e o que precisa ficar consistente com ele? |
Para cada padrão de acesso de alta frequência, pergunte: "consigo atender isto com uma única operação por uma chave que eu tenho em mãos, sem varrer e sem JOIN?" Se a resposta é não, o schema ainda não está pronto para esse padrão — ou ele é raro o bastante para tolerar o custo.
Em system design, comece sempre escrevendo a lista de padrões de acesso no quadro antes de desenhar qualquer coisa. É o sinal nº 1 de que você sabe modelar NoSQL, e transforma a conversa de "qual banco?" para "quais garantias cada acesso exige?". O livro The DynamoDB Book formaliza isso; entrevistadores esperam ver.
✏️ Exercício 2 — Monte a tabela
Para um app de biblioteca (usuários pegam livros emprestados), liste ao menos 6 padrões de acesso com chave de entrada, retorno e frequência estimada. Inclua ao menos um job/relatório.
Gabarito (exemplo): (1) ver livro por isbn — 1 doc, alta; (2) buscar livros por título/autor — busca textual, média; (3) ver os empréstimos ativos de um usuário por userId — dezenas, alta; (4) ver o histórico de um exemplar por copyId — dezenas, baixa; (5) registrar empréstimo/devolução por copyId — escrita, média, idempotente; (6) job diário: listar todos os empréstimos vencidos (varre por dueDate < hoje e status = ativo) — milhares, 1×/dia; (7) relatório mensal: livros mais emprestados por categoria — agregação, 1×/mês. Observação-chave: (2) pede motor de busca; (6) pede um índice em { status, dueDate } ou uma coleção/índice esparso só de ativos.
Embedding vs referencing decidido
Objetivo: transformar "depende" numa decisão defensável, cruzando cardinalidade, taxa de mudança, fan-out de atualização e limites físicos.
3.1 As quatro variáveis
| Variável | Puxa para embedding | Puxa para referencing |
|---|---|---|
| Padrão de acesso | lidos juntos quase sempre | acessados/atualizados de forma independente |
| Cardinalidade | 1:1, 1:poucos com teto conhecido | 1:muitos que cresce; 1:bilhões |
| Taxa de mudança do lado "um" | muda raramente (ou é imutável/histórico) | muda com frequência e é compartilhado por muitos |
| Limite físico | subdocumento pequeno e limitado | documento se aproximaria do teto de tamanho; array ilimitado |
3.2 O problema do fan-out de atualização
Se você embute o nome do usuário em cada comentário e ele tem 2 milhões de comentários, trocar o nome vira 2 milhões de escritas. Duas saídas de mercado:
- Não duplicar o mutável: guardar só
userIdno comentário e resolver o nome na leitura (via$lookuppontual, cache, ou uma segunda consulta em lote por lista de ids). - Duplicar e aceitar atualização assíncrona: um job/stream propaga a mudança em segundo plano, com a UI tolerando alguns minutos de inconsistência. Válido para nome de exibição; inválido para permissões.
3.3 O mapa por cardinalidade
| Relação | Exemplo | Modelagem |
|---|---|---|
| 1:1 | pedido ↔ dados de entrega | mesmo documento |
| 1:poucos (teto ≤ ~dezenas) | usuário ↔ endereços; produto ↔ variações | embedding em array, cada item com id próprio |
| 1:muitos (cresce, mas por entidade cabe) | post ↔ comentários; conta ↔ transações do mês | referencing; ou subset (N recentes embutidos) + coleção; ou bucket |
| 1:bilhões | sensor ↔ leituras; usuário ↔ eventos de clique | coleção própria, quase sempre coluna larga ou série temporal; nunca embutir |
| N:N | aluno ↔ turmas; produto ↔ tags | array de refs no lado que mais consulta; às vezes nos dois; adjacency list em single-table |
O padrão subset só funciona se houver uma coleção referenciada com o histórico completo e um índice para paginá-lo. Embutir os 10 recentes e não guardar o resto em lugar nenhum é perda de dados disfarçada de otimização.
A pergunta "embedding ou referencing para X?" nunca tem resposta única — o avaliador quer ver você citar as quatro variáveis e o fan-out de atualização. Mencionar "eu duplicaria o nome de exibição e propagaria mudanças por um change stream, mas nunca duplicaria papéis de permissão" é uma resposta de nível sênior.
✏️ Exercício 3 — Decida e justifique
Rede social. (a) Foto do post ↔ curtidas (podem chegar a milhões). (b) Post ↔ contagem de curtidas exibida. (c) Usuário ↔ nome de exibição, mostrado em cada post e comentário. (d) Post ↔ hashtags (até ~30).
Gabarito: (a) Referencing puro — 1:milhões, cresce sem teto; curtidas viram documentos { postId, userId, ts } com índice { postId: 1 } (e único { postId, userId } para evitar dupla). (b) Computed — campo curtidas no post, atualizado com incremento atômico na escrita da curtida; não conte a coleção a cada leitura. (c) Referencing por userId + resolução na leitura (batch por lista de ids, com cache); duplicar o nome em bilhões de itens cria fan-out proibitivo na troca de nome. Se duplicar mesmo assim (para evitar o batch), tem de haver propagação assíncrona. (d) Embedding — array de até 30 strings, lido sempre com o post, teto conhecido.
O catálogo de padrões
Objetivo: conhecer pelo nome os padrões de modelagem que o mercado usa como vocabulário comum — para reconhecê-los, combiná-los e citá-los.
4.1 A tabela de referência
| Padrão | Problema | Solução |
|---|---|---|
| Subset | relação 1:muitos grande para embutir inteira | embute os N mais relevantes + coleção com o resto |
| Bucket | muitos itens pequenos por chave (séries temporais) | 1 documento agrupa uma janela fixa (1 h, 1 dia) num array |
| Computed | recalcular agregado na leitura é caro | pré-calcula na escrita (contadores, somas, médias) |
| Extended Reference | segundo fetch só para exibir um nome | duplica 2–3 campos estáveis do referenciado |
| Polymorphic | "tipos" diferentes consultados juntos | mesma coleção, campo tipo discrimina; campos variam por tipo |
| Attribute | muitos campos parecidos e esparsos, cada um querendo índice (ex.: specs de produto) | converte pares chave/valor em array de { k, v } e indexa k+v uma vez |
| Outlier | 99% dos docs pequenos, 1% gigante | flag hasOverflow + coleção/estrutura à parte para os outliers |
| Tree / Hierarchy | árvores (categorias, org chart, comentários aninhados) | array de ancestrais (materialized path), ou parentRef, ou nested sets — escolha pelo padrão de leitura |
| Approximation | contador exato custa 1 escrita por evento | incrementa em lote / probabilístico (views, likes de baixa criticidade) |
| Schema Versioning | migrar coleção enorme sem downtime | campo schemaVersion + código tolerante + backfill |
| Envelope / document-per-X | granularidade do documento | escolher a "unidade" (1 doc por dia, por sessão, por agregado) que casa com os acessos |
4.2 Padrões de árvore — o mais errado na prática
// parentRef: fácil de escrever, caro para ler a subárvore inteira { _id: "c3", nome: "Tênis", parentId: "c1" } // materialized path: leitura de subárvore e "breadcrumbs" viram 1 consulta por prefixo { _id: "c3", nome: "Tênis", path: ["c0", "c1"], pathStr: "/c0/c1/" } // "toda a subárvore de c1": find({ path: "c1" }) — com índice em path // array of ancestors + parentId: combina os dois; padrão recomendado pela MongoDB { _id: "c3", parentId: "c1", ancestors: ["c0", "c1"] }
Regra: se você lê subárvores inteiras com frequência, materialize o caminho; se só sobe um nível por vez, parentId basta; se a árvore quase nunca muda mas é lida o tempo todo, considere nested sets.
4.3 Combinar padrões é o normal
Um documento de post real pode usar Subset (comentários recentes) + Computed (contadores) + Extended Reference (autor) + Bucket (visualizações por dia) ao mesmo tempo. Os padrões não competem; compõem.
Saber os padrões pelo nome é o vocabulário que MongoDB University, o MongoDB Blog e os livros de DynamoDB usam — e que entrevistadores esperam ouvir. "Aqui eu aplicaria o padrão attribute para não criar 40 índices esparsos" é o tipo de frase que diferencia.
✏️ Exercício 4 — Nomeie e combine
Catálogo de e-commerce com produtos de categorias muito diferentes (eletrônicos têm "voltagem", "resolução"; roupas têm "tamanho", "material"). A página do produto mostra: specs, as 3 avaliações mais recentes, a nota média, e o caminho de categorias (Eletrônicos › TVs › 4K). Que padrões você usa?
Gabarito: Attribute pattern para as specs (specs: [ { k: "voltagem", v: "220V" }, { k: "resolucao", v: "4K" } ] com um índice composto em { "specs.k": 1, "specs.v": 1 } — um índice serve a todas as specs). Subset para as 3 avaliações recentes embutidas + coleção avaliacoes. Computed para notaMedia e totalAvaliacoes. Materialized path (ou array of ancestors) para o caminho de categorias, permitindo os breadcrumbs numa leitura e "todos os produtos abaixo de TVs" com um filtro por prefixo.
Single-table design (DynamoDB)
Objetivo: entender a técnica que assusta e depois convence — várias entidades numa tabela só — e quando ela vale (e quando não).
5.1 O modelo do DynamoDB em 90 segundos
- Uma tabela tem uma Partition Key (PK) e, opcionalmente, uma Sort Key (SK). Juntas formam a chave primária.
- Você só consulta eficientemente por: PK exata + (SK exata | faixa de SK | prefixo de SK). Nada de "WHERE qualquer coisa".
- Global Secondary Index (GSI): uma "cópia" da tabela com outra PK/SK, para atender outros padrões de acesso. Custa armazenamento e escrita.
- Sem JOIN. Sem varredura barata. O
Scanexiste e é o último recurso.
5.2 Por que uma tabela só
Cada Query do DynamoDB lê uma partição de uma tabela. Se as entidades relacionadas (um pedido e seus itens, um usuário e seus endereços) compartilham a mesma PK, uma única Query traz o "agregado" inteiro — o equivalente ao JOIN, pré-computado pela colocação física.
// Item collection: tudo do pedido #123 sob a mesma PK PK SK ...atributos ORDER#123 META#123 status, total, criadoEm ORDER#123 ITEM#001 sku, nome, preco, qtd ORDER#123 ITEM#002 sku, nome, preco, qtd ORDER#123 SHIPMENT#001 transportadora, rastreio // "traga o pedido 123 inteiro": Query(PK = "ORDER#123") → 1 request
5.3 Key overloading e GSIs
Os nomes PK/SK são genéricos de propósito: cada tipo de item os preenche com um prefixo diferente (USER#, ORDER#, PRODUCT#). Para atender "todos os pedidos de um usuário, por data", cria-se um GSI:
// GSI1 — inverte a visão para consultar por usuário GSI1PK GSI1SK USER#u1 ORDER#2026-06-04#123 USER#u1 ORDER#2026-05-21#118 // Query(GSI1PK = "USER#u1", GSI1SK begins_with "ORDER#") ordenado por data
- Sparse index: um GSI só indexa itens que têm os atributos da sua chave. Coloque
GSI2PKsó nos pedidosstatus = "aberto"→ o GSI vira uma "fila" barata de pedidos abertos. - Adjacency list para N:N: cada aresta é um item (
PK = USER#u1,SK = GROUP#g9e o inverso), permitindo "grupos do usuário" e "usuários do grupo" com Query nos dois sentidos.
Ele otimiza custo e latência de padrões de acesso conhecidos e estáveis. Custa caro em: legibilidade (uma tabela com 8 tipos de item exige documentação rigorosa), flexibilidade (um novo padrão de acesso pode exigir um GSI ou um backfill), e ferramental (analytics ad-hoc precisa exportar para outro lugar). Para apps pequenos, times sem experiência em Dynamo, ou requisitos de consulta voláteis, várias tabelas (ou outro banco) podem ser a escolha madura.
Single-table design é assunto de vaga sênior em stacks AWS e de entrevista de system design. Espera-se que você: desenhe a item collection, explique key overloading, use um sparse GSI como fila, e — crucialmente — saiba dizer quando não usar. O The DynamoDB Book (Alex DeBrie) é a referência que entrevistadores assumem.
✏️ Exercício 5 — Desenhe a tabela
App de eventos: um Evento tem metadados e uma lista de Ingressos vendidos; um Usuário tem os ingressos que comprou. Padrões: (1) ver um evento com seus ingressos; (2) ver os ingressos de um usuário; (3) listar eventos futuros por data. Proponha PK/SK e um GSI.
Gabarito: Tabela base — PK = EVENT#{id}, SK = META# (metadados) e SK = TICKET#{ticketId} (ingressos): Query(PK=EVENT#e1) traz o evento inteiro (padrão 1). Cada ingresso também carrega GSI1PK = USER#{userId}, GSI1SK = EVENT#{data}#{eventId} → Query(GSI1PK=USER#u1) traz os ingressos do usuário ordenados por data do evento (padrão 2). Para o padrão 3, um GSI esparso: itens META# de eventos futuros recebem GSI2PK = "EVENT_UPCOMING", GSI2SK = {data}#{eventId}; um job remove o atributo quando o evento passa, mantendo o índice pequeno. Alternativa ao "hot partition" do EVENT_UPCOMING único: particionar por mês (GSI2PK = "UPCOMING#2026-06").
Coluna larga: tabela-por-consulta
Objetivo: modelar para Cassandra/ScyllaDB, onde a semelhança com SQL engana — a chave primária é uma decisão de armazenamento físico, não uma restrição lógica.
6.1 O modelo físico
PRIMARY KEY ( (partition_key) , clustering_col_1, clustering_col_2 )
└─ define o NÓ └─ define a ORDEM dentro da partição
- A partition key decide em qual nó (via consistent hashing) a linha vive. Toda consulta eficiente precisa fornecer a partition key inteira.
- As clustering columns ordenam fisicamente as linhas dentro da partição e permitem faixas e
ORDER BY— só nelas, só na ordem declarada. - Não há JOIN. Não há
WHERElivre (usarALLOW FILTERINGem produção é quase sempre um erro).
6.2 A regra: uma tabela por padrão de consulta
Se a aplicação consulta "mensagens por canal, recentes primeiro" e "mensagens por autor", são duas tabelas, alimentadas pela mesma escrita (desnormalização deliberada):
CREATE TABLE mensagens_por_canal ( canal_id uuid, bucket text, msg_id timeuuid, autor_id uuid, texto text, PRIMARY KEY ( (canal_id, bucket), msg_id ) ) WITH CLUSTERING ORDER BY (msg_id DESC); CREATE TABLE mensagens_por_autor ( autor_id uuid, msg_id timeuuid, canal_id uuid, texto text, PRIMARY KEY ( autor_id, msg_id ) ) WITH CLUSTERING ORDER BY (msg_id DESC);
A aplicação escreve nas duas (idealmente com BATCH logged para as duas linhas relacionadas, ou aceitando reconciliação). Storage duplicado é o preço; leitura de partição única é o prêmio.
6.3 Os inimigos: partições grandes, hot partitions e tombstones
- Partição grande: alvo < 100 MB e < ~100 mil linhas por partição. "Mensagens por canal" sem
bucketcresce para sempre → adicione um componente de tempo à partition key ((canal_id, ano_mes)ou dia). - Hot partition: uma partition key que concentra escrita (o canal viral, o "cliente do dia"). Mitigação: bucketing por sufixo, ou distribuir com um shard sintético (
(canal_id, bucket, shard 0..9)). - Tombstones: deletar em Cassandra escreve um marcador; ler uma partição cheia de tombstones é lento e pode estourar limites. Modele para não deletar — use TTL, ou partições que são descartadas inteiras (
DROPde bucket antigo), em vez deDELETElinha a linha.
CQL parece SQL, então dá vontade de fazer WHERE status = 'x', JOIN, GROUP BY. Cassandra recusa (ou exige ALLOW FILTERING, que varre nós). Se você precisa disso, ou faltou uma tabela desnormalizada, ou o caso não é de coluna larga.
Em vagas de engenharia de dados e system design de escala (chat, feed, telemetria), espera-se: modelar "uma tabela por consulta", escolher partition key + clustering com o alvo de tamanho de partição em mente, e citar o problema dos tombstones. O design do chat do Discord (ScyllaDB, PRIMARY KEY ((channel_id, bucket), message_id)) é o case canônico.
✏️ Exercício 6 — Modele a telemetria
Dispositivos IoT enviam leituras a cada 10 s. Consultas: (1) últimas 24 h de um dispositivo; (2) todas as leituras de um dispositivo num dia específico; (3) valor mais recente de um dispositivo. Retenção: 90 dias. Proponha a(s) tabela(s) e a chave.
Gabarito: CREATE TABLE leituras (device_id uuid, dia date, ts timestamp, valor double, PRIMARY KEY ((device_id, dia), ts)) WITH CLUSTERING ORDER BY (ts DESC) AND default_time_to_live = 7776000; — partition key (device_id, dia) mantém a partição limitada (~8.640 linhas/dia) e distribui a escrita; clustering por ts DESC atende "últimas N" e faixas dentro do dia (padrões 1 e 2 — o padrão 1 lê hoje + ontem). Padrão 3 (valor mais recente): ou LIMIT 1 na partição do dia atual, ou uma tabela ultimo_valor (device_id PRIMARY KEY, ts, valor) sobrescrita a cada leitura. TTL de 90 dias evita DELETE e tombstones; alternativamente, descartar partições de dias antigos.
Modelagem de grafo
Objetivo: decidir o que vira nó, o que vira aresta e o que vira propriedade — e evitar os supernós que matam a performance de travessia.
7.1 Nó, aresta ou propriedade?
- Vira nó o que tem identidade própria e é ponto de partida/chegada de consultas (Pessoa, Produto, Empresa, Cidade).
- Vira aresta o relacionamento que você percorre (
(:Pessoa)-[:COMPROU]->(:Produto),(:Pessoa)-[:AMIGO_DE]->(:Pessoa)). Arestas têm tipo e direção e podem ter propriedades (desde,peso,quantidade). - Vira propriedade o atributo que você lê junto com o nó mas nunca usa como ponto de partida de travessia (nome, e-mail, data de nascimento).
Se você precisa perguntar "todos os X ligados a este Y" ou "o caminho entre A e B", isso é uma aresta. Se um atributo aparece nesse tipo de pergunta como filtro categórico de partida (ex.: "pessoas que moram em São Paulo"), promova-o a nó ((:Pessoa)-[:MORA_EM]->(:Cidade {nome:"São Paulo"})) — travessia por relação é mais rápida que filtro por propriedade.
7.2 Direção e tipo de aresta
Neo4j armazena arestas dirigidas, mas a travessia pode ignorar a direção. Modele a direção que faz sentido semântico (SEGUE, GEROU, PERTENCE_A) e consulte com ou sem direção conforme a pergunta. Prefira tipos específicos (:ENVIOU_MENSAGEM, :CURTIU) a um genérico :REL {tipo:"..."} — o motor indexa por tipo de aresta.
7.3 Supernós o problema nº 1
Um nó com milhões de arestas (o país "Brasil" ligado a todas as pessoas; a hashtag viral) faz qualquer travessia que o toque explodir. Mitigações:
- Nós intermediários: em vez de
(:Pessoa)-[:MORA_EM]->(:Pais), insira(:Pessoa)-[:MORA_EM]->(:Cidade)-[:EM]->(:Estado)-[:EM]->(:Pais). - Particionar a aresta por tempo/categoria:
[:CURTIU_EM_2026_06]em vez de um só[:CURTIU]. - Não modelar como grafo o que é agregação: "quantas curtidas o post tem" é um contador (Computed), não uma travessia.
7.4 Grafo de conhecimento (KG) e GraphRAG tema em alta
Um KG modela entidades e relações extraídas de texto/dados. Decisões de modelagem específicas: ontologia (quais tipos de nó e aresta são permitidos), resolução de entidades (o "Lula" do documento A e do documento B são o mesmo nó?), e proveniência (cada aresta guarda a fonte). O GraphRAG usa o KG para dar ao LLM contexto estruturado — a modelagem do KG é o que determina a qualidade das respostas. Aprofunde na apostila de IA Generativa & RAG e na de Grafos Aplicados.
Modelagem de grafo aparece em vagas de fraude, recomendação, identidade e, cada vez mais, IA (KG + RAG). O que se testa: decidir nó vs aresta vs propriedade num domínio dado, reconhecer e mitigar supernós, e escrever a travessia (Cypher) correspondente. "Eu promoveria cidade a nó e inseriria estados como intermediários para não criar um supernó no país" é resposta de nível pleno/sênior.
✏️ Exercício 7 — Modele um antifraude
Domínio: contas, dispositivos, cartões, endereços de entrega. Sinal de fraude: várias contas "diferentes" compartilhando o mesmo dispositivo e cartão. Modele nós e arestas e descreva a consulta de detecção. Onde está o risco de supernó?
Gabarito: Nós: :Conta, :Dispositivo, :Cartao, :Endereco. Arestas: (:Conta)-[:USOU]->(:Dispositivo), (:Conta)-[:PAGOU_COM]->(:Cartao), (:Conta)-[:ENVIOU_PARA]->(:Endereco), com propriedade ultimoUso/primeiroUso nas arestas. Detecção: encontrar componentes conexos onde ≥ 3 :Conta se ligam ao mesmo :Dispositivo e ao mesmo :Cartao — em Cypher, algo como MATCH (c1:Conta)-[:USOU]->(d:Dispositivo)<-[:USOU]-(c2:Conta), (c1)-[:PAGOU_COM]->(k:Cartao)<-[:PAGOU_COM]-(c2) WHERE c1 <> c2 RETURN d, k, collect(distinct c1)+collect(distinct c2). Risco de supernó: dispositivos de datacenter/VPN e cartões de gift compartilhados legitimamente por milhares — trate com limiar (ignorar nós de grau > N) e/ou tipos de aresta particionados por janela de tempo.
Séries temporais e eventos
Objetivo: modelar dados que só crescem — leituras, logs, eventos de domínio — com bucketing, rollups, retenção e, quando faz sentido, event sourcing.
8.1 Bucketing e a granularidade do documento
Um documento por leitura (milhões/dia) é ineficiente: overhead por documento, índices gigantes, varreduras longas. Um documento por (série, janela) agrupa: 1 doc por sensor por hora, com um array de { t, v } e agregados pré-calculados (min, max, soma, n). Escolha a janela pela consulta dominante: se o dashboard mostra por minuto, bucket de 1 h; se mostra por dia, bucket de 1 dia.
MongoDB (Time Series Collections), TimescaleDB (hypertables) e InfluxDB fazem o bucketing e a compressão por você — você insere e consulta "linha a linha". Use-as por padrão; o bucket manual continua importante para entender o custo e para bancos sem esse recurso (ou para eventos que não são métricas puras).
8.2 Rollups e retenção
- Rollup: materializar agregados por janela maior (hora → dia → mês) num job/stream, e servir os dashboards a partir deles. O dado bruto tem TTL curto; o rollup vive muito mais.
- Retenção em camadas: bruto por 7–30 dias (TTL), rollup horário por 1 ano, rollup diário para sempre. Modele cada camada como sua própria coleção/tabela.
- Descarte por partição, não por linha: em coluna larga, dropar a partição do mês antigo é O(1);
DELETElinha a linha gera tombstones. Em documento, TTL index; em série temporal nativa,expireAfterSeconds.
8.3 Event sourcing e modelos append-only
Em vez de guardar o estado atual, guarde a sequência imutável de eventos (PedidoCriado, ItemAdicionado, PedidoPago); o estado é a soma dos eventos. Implicações de modelagem:
- O stream é a unidade: chave =
{ agregadoId }, ordenação por número de sequência. Toda leitura de um agregado lê seu stream inteiro (ou desde o último snapshot). - Snapshots: a cada N eventos, materialize o estado para não reprocessar milhões de eventos na leitura.
- Projeções (read models): consumidores do stream constroem visões desnormalizadas, uma por tela — é o CQRS. Cada projeção é modelada com os padrões dos módulos 3–4.
- Versionamento de evento: o schema do evento evolui; consumidores precisam lidar com versões antigas para sempre (o passado é imutável).
- Dados que chegam atrasados (late-arriving): em analytics, um evento com timestamp de ontem chegando hoje obriga o rollup a ser reprocessável por janela, não só incremental.
Ganha auditoria perfeita e "viagem no tempo"; paga com complexidade alta, versionamento eterno de eventos, necessidade de snapshots e de projeções. Adote para domínios onde o histórico de como se chegou ao estado tem valor de negócio (financeiro, jurídico, logística) — não como padrão para todo CRUD.
Em engenharia de dados e backend de escala, espera-se saber: bucketing e por que ele existe; retenção em camadas com descarte por partição; e o suficiente de event sourcing/CQRS para conectar "read model desnormalizado" com "os padrões de modelagem NoSQL". "O dado bruto tem TTL de 14 dias, o rollup horário vive 1 ano, e o dashboard lê do rollup" é uma resposta concreta que passa.
✏️ Exercício 8 — Camadas de um produto de métricas
Um SaaS de observabilidade recebe ~50 mil métricas/segundo. Os clientes veem: gráficos ao vivo (último 1 h, resolução de segundos), gráficos históricos (até 13 meses, resolução de horas), e um relatório anual (resolução diária). Descreva o modelo de camadas, chaves e retenção.
Gabarito: Camada bruta: coleção/tabela de série temporal, chave (tenantId, metricId, dia) (coluna larga) ou time series nativa, resolução original, TTL 48–72 h (cobre o "ao vivo" com folga). Rollup horário: job/stream agrega o bruto em { tenantId, metricId, hora, min, max, avg, p95, count }, chave (tenantId, metricId, ano_mes), retenção 13 meses — serve os gráficos históricos. Rollup diário: agrega o horário, chave (tenantId, metricId) clusterizada por dia, retenção indefinida — serve o relatório anual. Descarte por partição (drop de ano_mes antigo) no rollup horário; TTL no bruto. O rollup precisa ser reprocessável por janela para tolerar métricas atrasadas.
Evolução de schema e migração
Objetivo: mudar a forma dos dados em coleções de bilhões de documentos, em produção, sem downtime e sem corromper — e governar o schema num time.
9.1 "Schemaless" é mentira útil
O esquema não desaparece quando o banco não o impõe — ele migra para o código da aplicação, que passa a ser o único guardião da forma dos dados. Sem disciplina, uma coleção acumula 5 formatos diferentes do mesmo documento ao longo de anos, e cada leitura vira um campo minado de if (doc.x) ... else if (doc.y).
9.2 O padrão expand / contract (paralelo ao "parallel change")
- Expand: a aplicação passa a escrever o formato novo e a ler os dois (novo e antigo). Nenhum dado foi tocado ainda; deploy reversível.
- Migrate: backfill converte os documentos antigos em lotes (ver 9.3). Novas escritas já nascem no formato novo.
- Contract: quando 100% está migrado e estável, remova o código que lê o formato antigo e, opcionalmente, ligue a validação de schema.
Cada passo é um deploy independente e reversível. Nunca "big bang".
9.3 Backfill que não derruba o banco
- Pagine por índice (
_idou uma chave indexada), lotes de alguns milhares, comlimit. NuncaupdateMany({}, ...)numa coleção gigante — segura locks e satura o I/O. - Respire entre lotes (sleep de dezenas a centenas de ms) e observe métricas de latência/replication lag — reduza o ritmo se subirem.
- Retomável: persista o último
_idprocessado; o job pode cair e continuar. - Idempotente: filtrar por
{ schemaVersion: { $lt: 2 } }e escreverschemaVersion: 2garante que reprocessar não estraga. - Migração preguiçosa como complemento: ao ler/gravar um documento antigo, converta-o na hora. Cobre os "quentes" de graça; o backfill cobre os frios.
// esqueleto de backfill retomável let ultimoId = carregarCheckpoint(); while (true) { const lote = await col.find({ _id: { $gt: ultimoId }, schemaVersion: { $lt: 2 } }) .sort({ _id: 1 }).limit(2000).toArray(); if (lote.length === 0) break; for (const d of lote) await col.updateOne({ _id: d._id }, { $set: converter(d) }); ultimoId = lote[lote.length - 1]._id; salvarCheckpoint(ultimoId); await sleep(50); }
9.4 Validação e governança
- JSON Schema validation (MongoDB
$jsonSchema, ou na aplicação com Zod/Pydantic/Mongoose): depois do contract, liguevalidationLevel: "moderate"para impedir regressão ao formato antigo sem quebrar documentos legados intocados. - Schema como código: versione a definição do schema junto com a aplicação; toda mudança passa por review.
- Registro de eventos/mensagens: em arquiteturas event-driven, um schema registry (Avro/Protobuf/JSON Schema) versiona os contratos entre produtores e consumidores e valida compatibilidade (backward/forward).
- Documentação viva: uma coleção com 8 tipos de item (single-table) ou polimórfica precisa de um documento de referência mantido — o schema implícito tem de estar explícito em algum lugar.
9.5 Modelagem para IA: metadados que o RAG precisa tema em alta
Guardar embeddings num campo é o começo. O que faz o retrieval funcionar é a modelagem dos metadados: tenantId e ACLs para filtro de segurança antes da busca vetorial; fonte, página, secao para citação; dataDoc e versao para recência; chunkId/parentId para reconstruir o contexto ao redor do trecho. Um chunk é um documento; o design dele determina a qualidade da resposta. Detalhes na apostila de RAG.
"Como você migraria o schema de uma coleção de 2 bilhões de documentos sem downtime?" é pergunta de sênior/staff. A resposta completa: expand/contract + código tolerante a N e N-1 + schemaVersion + backfill paginado, retomável e com throttle + validação ligada só no fim. Quem responde "roda um script de update" não passa.
✏️ Exercício 9 — Planeje a migração
Uma coleção usuarios (800 milhões de docs) tem nome como string única ("Ana Maria Souza"). Produto quer nomeCompleto, primeiroNome e sobrenome separados, para personalização de e-mail. O sistema está no ar 24/7. Descreva o plano completo.
Gabarito: (1) Expand — deploy da app que, ao gravar um usuário, escreve os três campos novos e mantém nome; na leitura, usa primeiroNome ?? nome.split(" ")[0]. Reversível. (2) Backfill — job paginado por _id, lotes de ~2.000, filtro { primeiroNome: { $exists: false } }, derivando os campos de nome, com schemaVersion: 2, checkpoint do último _id, sleep entre lotes e monitor de replication lag. Rodar dias/semanas conforme a folga do cluster. (3) Contract — quando { primeiroNome: { $exists: false } } zerar e estabilizar, remover o fallback de leitura; opcionalmente ligar $jsonSchema exigindo os campos novos com validationLevel: "moderate". Manter nome por mais um ciclo (ou derivá-lo de nomeCompleto) até ter certeza de que nada mais o lê. Risco tratado: nomes com uma palavra só, partículas ("de", "da"), nomes compostos — a função converter deve ter regra e um relatório dos casos ambíguos para revisão.
Mercado de trabalho: roadmap, portfólio e entrevistas
Objetivo: usar a modelagem como diferencial — é a habilidade NoSQL que mais separa níveis e que menos gente pratica de forma deliberada.
10.1 Onde a modelagem aparece nas vagas
| Papel | O que cobram |
|---|---|
| Backend pleno/sênior | "modele X em MongoDB/Dynamo" com raciocínio de padrões de acesso; embedding vs referencing justificado; migração sem downtime |
| Engenharia de Dados | modelagem query-first em coluna larga; bucketing e retenção; read models de CQRS; schema registry |
| System Design (qualquer sênior+) | tabela de padrões de acesso no quadro; escolha de partition/shard key; poliglota justificada |
| IA/ML Engineering | modelagem de chunks e metadados para RAG; KG para GraphRAG; store de estado de agentes |
10.2 Roadmap de estudo (4 semanas)
- Semana 1 — módulos 1–2: pegue um app que você conhece e escreva a tabela de padrões de acesso completa. Sem schema ainda.
- Semana 2 — módulos 3–4: modele esse app em documento (MongoDB), aplicando e nomeando os padrões. Rode local, popule, meça.
- Semana 3 — módulo 5: remodele o mesmo app como single-table no DynamoDB (DynamoDB Local + NoSQL Workbench). Compare.
- Semana 4 — módulos 6–9: modele a parte de "eventos/telemetria" em Cassandra; faça uma migração expand/contract de propósito e documente.
10.3 Banco de perguntas (com a resposta que aprova)
Pleno — "Como você começa a modelar algo em NoSQL?"
"Levantando os padrões de acesso antes de qualquer schema: cada tela, endpoint, job e integração vira uma linha com chave de entrada, retorno, frequência e consistência exigida. Só depois projeto as estruturas para que cada padrão de alta frequência seja uma operação por chave, sem JOIN nem varredura, aceitando duplicação de dado histórico e de exibição." Mencionar que isso é o oposto do processo relacional (dados primeiro) fecha a resposta.
Pleno — "Quando duplicar dados é aceitável e quando é perigoso?"
Aceitável quando o dado é imutável/histórico (preço na hora da compra) ou uma projeção de exibição estável (nome para a lista). Perigoso quando é mutável e compartilhado por muitos: aí a atualização vira fan-out de milhões de escritas. Saídas: não duplicar o mutável (resolver na leitura, com cache/batch) ou duplicar e propagar de forma assíncrona, tolerando inconsistência temporária — nunca para permissões.
Sênior — "Explique single-table design e quando NÃO usar."
Várias entidades numa tabela DynamoDB, com PK/SK genéricas preenchidas por prefixo (key overloading), de modo que entidades relacionadas compartilhem a PK e uma Query traga o agregado inteiro (o JOIN pré-computado pela colocação física). GSIs esparsos atendem outros padrões e servem de filas. Não usar quando: os padrões de acesso são voláteis (cada novo exige GSI/backfill), o time não tem experiência em Dynamo, o app é pequeno, ou há muita necessidade de consulta ad-hoc/analytics — nesses casos, várias tabelas ou outro banco são a escolha madura.
Sênior — "Modele o backend de mensagens de um chat."
Coluna larga: tabela mensagens_por_canal com PRIMARY KEY ((canal_id, bucket_dia), msg_id) e CLUSTERING ORDER BY (msg_id DESC) — leitura de "últimas N do canal" é uma partição; o bucket_dia limita o tamanho da partição e distribui a escrita. Se há "minhas mensagens", uma segunda tabela mensagens_por_autor alimentada pela mesma escrita. Presença/últimos vistos em Redis com TTL. Busca de mensagens em Elasticsearch via CDC. Não deletar linha a linha (tombstones) — TTL ou drop de bucket. É o design do Discord.
Sênior — "Como migrar o schema de uma coleção gigante sem downtime?"
Expand/contract: (1) app escreve o formato novo e lê os dois; (2) backfill paginado por _id, em lotes, retomável, idempotente (schemaVersion), com throttle e monitor de replication lag; migração preguiçosa cobre os quentes; (3) quando 100% migrado, remove a leitura do formato antigo e liga a validação. Cada passo é deploy reversível. Nunca updateMany({}, ...) nem big bang.
10.4 Projetos de portfólio
- "O mesmo app, três modelagens": um domínio (ex.: reservas de restaurante) modelado em MongoDB (documento + padrões), DynamoDB (single-table) e Cassandra (tabela-por-consulta), com um README comparando padrões de acesso atendidos, custo de escrita e trade-offs. Raro e muito convincente.
- Migração documentada: um repo que parte de um schema "ingênuo", tem um branch com o backfill expand/contract completo (job retomável, testes, métricas antes/depois) e um post-mortem.
- Modelagem de KG para RAG: extrair entidades/relações de um corpus, definir ontologia e resolução de entidades, e comparar RAG puro vs GraphRAG em algumas perguntas.
10.5 Fontes
- Livros: The DynamoDB Book (Alex DeBrie) — a bíblia de single-table e access patterns; Designing Data-Intensive Applications (Kleppmann) para a base; o MongoDB Blog (série "Building with Patterns") para o catálogo do módulo 4.
- Ferramentas de prática: DynamoDB Local + NoSQL Workbench; MongoDB local + Compass; um cluster Cassandra/Scylla em Docker.
- Apostilas irmãs: NoSQL (famílias e teoria distribuída), MongoDB a fundo, Engenharia de Dados (CDC, lakehouse), Grafos Aplicados.
Uma frase carrega a apostila: o schema é uma função dos padrões de acesso. Levante as consultas primeiro; projete estruturas onde cada consulta frequente é uma operação por chave; duplique dado histórico e de exibição sem culpa; trate dado mutável compartilhado com cuidado; e evolua o schema por expand/contract, nunca por big bang. Documento, chave-valor, coluna larga e grafo mudam a mecânica — não o método.