Uma consulta relacional em banco de documentos é um join feito com referências e lookups em vez de chaves estrangeiras — ou evitado ao embutir os dados. Esse “ou” é o assunto inteiro: bancos de documentos dão três formas de relacionar dados, e a decisão de design é quando o join acontece — na escrita (embutir), na consulta dentro do servidor (lookup), ou na consulta dentro da aplicação (referências mais uma busca de acompanhamento).
Principais pontos
| Pergunta | Resposta |
|---|---|
| Bancos de documentos fazem join? | Sim — lookups fazem left outer joins, retornando arrays, não linhas |
| A decisão real | Embutir vs. referenciar — quando o join acontece? |
| Regra padrão | Embuta o que é lido junto e limitado; referencie o que vive só ou cresce |
| A verdade de performance | Lookups no servidor são joins de loop aninhado — bons por requisição, errados para analytics |
| O bug clássico | Queries N+1 — resolvidas com batch, include ou embutindo |
As três formas de relacionar documentos
// 1 · Embutir — o "join" aconteceu na hora da escrita
{ _id: 1, title: "Dune", author: { name: "Frank Herbert", born: 1920 } }
// 2 · Referência + $lookup — o join acontece na consulta, no servidor
db.books.aggregate([
{ $lookup: { from: "authors", localField: "authorId",
foreignField: "_id", as: "author" } }, // left outer join → array
{ $unwind: "$author" } // achata → inner join
])
// 3 · Referência + join no lado da aplicação — o caminho de ODM/SDK (abaixo)
O terceiro caminho é onde vive a maior parte do código de aplicação — referências tipadas percorridas de forma eager em uma requisição, para os documentos relacionados chegarem juntos sem pipeline:
// JavaScript / Node.js — Back4app JS SDK
// A join in a document database: Pointer + include, one request
const query = new Parse.Query('Comment');
query.equalTo('post', postPointer); // Comment.post is a Pointer<Post>
query.include('author'); // "join" the author document in
const comments = await query.find();
const name = comments[0].get('author').get('username'); // already loaded — no N+1 // Flutter / Dart — Back4app Flutter SDK
// A join in a document database: Pointer + include, one request
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', postPointer)
..includeObject(['author']); // "join" the author document in
final response = await query.query();
final author = (response.results!.first as ParseObject)
.get<ParseObject>('author'); // already loaded — no N+1 // iOS / Swift — Back4app Swift SDK
// A join in a document database: Pointer + include, one request
let query = Comment.query("post" == postPointer)
.include("author") // "join" the author document in
query.find { result in
if case .success(let comments) = result {
print(comments.first?.author?.username ?? "") // already loaded — no N+1
}
} // Android / Kotlin — Back4app Android SDK
// A join in a document database: Pointer + include, one request
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", postPointer)
query.include("author") // "join" the author document in
query.findInBackground { comments, e ->
if (e == null) {
val name = comments[0].getParseObject("author")?.getString("username")
Log.d("Comments", "by $name") // already loaded — no N+1
}
} Embutir vs. referenciar: a decisão
| Dimensão | Embutir | Referenciar |
|---|---|---|
| Padrão de leitura | Sempre buscado com o pai | Buscado sozinho ou sob demanda |
| Padrão de escrita | Atualizado com o pai, atomicamente | Atualizado de forma independente |
| Cardinalidade | Um-para-poucos | Um-para-muitos em diante |
| Crescimento | Limitado (os endereços de uma pessoa) | Sem limite (os comentários de um post) |
| Compartilhamento | Pertence a um pai só | Compartilhado entre pais |
| O custo do join | Zero — pago na escrita | Pago por consulta — lookup, include ou batch |
Os degraus de cardinalidade dão a mesma tabela em forma de regra de bolso: um-para-poucos embute o array, um-para-muitos referencia por ID, um-para-enormes inverte a direção — o filho guarda a referência ao pai, porque nenhum documento pai pode segurar um array que só cresce. O que aponta para os dois antipadrões por trás da maioria dos incidentes de modelagem de documentos: arrays sem limite e o teto de 16MB por documento que eles acabam ameaçando. As correções padrão — referenciar, embutir um subconjunto limitado (os N mais recentes com uma coleção de excedente), ou agrupar filhos em documentos-balde — são todas versões de “pare o documento de crescer”.
O lookup, honestamente
O $lookup é um join de verdade com dois asteriscos honestos. Formato: ele retorna os casamentos como um array embutido por documento de entrada — $unwind o achata em linhas e, sem a semântica de preservar vazios, converte o left join em comportamento de inner join. Performance: motores de documentos só executam joins de loop aninhado, e os benchmarks públicos são diretos — juntar um milhão de documentos levou dezenas de segundos com índices, contra meio segundo do equivalente embutido. As regras operacionais que seguem daí: sempre indexe o campo estrangeiro (sem ele, cada documento de entrada dispara uma varredura da coleção), use lookups para joins por requisição sobre um punhado de documentos, e nunca construa fan-outs analíticos sobre eles — essa carga pertence a um warehouse ou a um motor relacional.
O problema N+1 e suas quatro saídas
O bug clássico: uma query para N pais, depois um loop disparando uma query por pai para os filhos — N+1 idas ao banco que escalam com o tamanho da página. As saídas, da melhor para as demais: batch — colete os IDs dos pais e busque todos os filhos numa query de contido-em (o populate dos bons ODMs faz isso por você); include — eager loading no nível do SDK, que retorna os documentos referenciados na mesma requisição, como nas abas acima; lookup — um pipeline no servidor; embutir — o join deixa de existir. O que transforma N+1 de bug em arquitetura é não notá-lo: ele entrega rápido com dez registros de teste e derrete com mil — a patologia completa tem verbete próprio no registro deste glossário.
Casos de uso comuns
- Conteúdo com autoria. Posts, comentários, autores — referências com includes para as listas, embeds para os campos de snapshot só de exibição.
- Catálogos e pedidos. A referência estendida canônica: um pedido embute o nome e o preço do produto como vendidos (snapshot imutável) mais uma referência ao produto vivo.
- Fluxos de atividade e eventos. Um-para-enormes — documentos filhos guardando referências ao pai, nunca arrays no pai.
- Perfis de usuário. A vitrine do embutir: endereços, preferências, configurações — lidos juntos, limitados, de um dono só.
- Grafos sociais. Arrays de referências muitos-para-muitos — e o sinal honesto de que, passado um ponto, esse formato quer um motor relacional ou de grafos.
Embutir, referenciar ou trocar de motor? Matriz de decisão
| Embuta quando… | Referencie quando… | Use um banco relacional quando… |
|---|---|---|
| Lido e atualizado junto | Acessado por conta própria | Joins são a carga de trabalho, não a exceção |
| Pequeno e limitado | Sem limite ou de alta cardinalidade | Analytics ad hoc cruzando entidades |
| De um pai só | Compartilhado entre pais | Integridade referencial estrita exigida |
| Atualização atômica com o pai importa | Ciclos de atualização independentes | Transações complexas de múltiplas linhas |
| O caso clássico: campos de perfil | O caso clássico: comentários | O caso clássico: domínios pesados em muitos-para-muitos |
A terceira coluna é a seção que páginas de fornecedor não escrevem: se toda tela precisa de três lookups e a integridade referencial tira o seu sono, os dados estão pedindo tabelas — modelos de documentos ganham quando os padrões de acesso são conhecidos e hierárquicos, não como substituto universal.
Limitações e trade-offs
- Denormalização é um instrumento de dívida. Campos duplicados matam joins mas precisam ser quitados no update — escritas em fan-out, janelas de dados velhos e código de consistência (transações ou triggers de change stream) são os juros.
- Sem chaves estrangeiras, sem rede de proteção. Referências não garantem existência; deletar um autor deixa referências de livros órfãs em silêncio, a menos que a plataforma ou o seu código limpe.
- Lookups não otimizam. Sem reordenação de joins, sem estratégias de hash — a ordem do pipeline é o seu plano de query.
- Migrações entre formatos são projetos de verdade. De embutido para referenciado (o resgate do array que cresceu) significa fazer backfill de coleções e reescrever queries — modele para a cardinalidade de amanhã, não a de hoje.
- O teto de 16MB é um penhasco, não um aviso. Padrões de crescimento que se aproximam dele degradam a performance muito antes do impacto.
Consultas relacionais no Back4app
O Back4app é uma plataforma open-source de Backend as a Service (BaaS) que combina banco de dados gerenciado, APIs REST e GraphQL geradas automaticamente, autenticação, armazenamento de arquivos e funções serverless com Cloud Code. Seu banco de documentos fala um vocabulário relacional por design: Pointers declaram arestas um-para-muitos, Relations resolvem muitos-para-muitos, e include() percorre as arestas numa única requisição — a saída do N+1 embutida no SDK, como as abas de código acima mostram. A API GraphQL gerada automaticamente aninha objetos relacionados em uma query de graça, e deletes podem cascatear por triggers de Cloud Code — a rede de proteção de integridade referencial que motores de documentos deixam de fora.
Perguntas frequentes
MongoDB suporta joins?
Não são joins no estilo SQL — mas são joins relacionalmente úteis. O estágio de agregação $lookup faz um left outer join, anexando os documentos correspondentes de outra coleção como um array embutido em vez de linhas planas. Adicionar $unwind converte para semântica de inner join. O que difere do SQL é o formato do resultado, o perfil de performance, e o fato de o join ser exceção, não padrão.
Devo embutir ou referenciar dados relacionados?
A regra de consenso: embuta o que você lê, atualiza e arquiva junto — dados pequenos, limitados e fortemente acoplados. Referencie o que vive por conta própria: compartilhado entre pais, atualizado de forma independente, de alta cardinalidade ou sem limite de crescimento. A orientação oficial manda favorecer embutir na ausência de um motivo forte — e os motivos fortes são exatamente esses quatro.
Como a cardinalidade muda a decisão de modelagem?
Os degraus clássicos: um-para-poucos (os endereços de uma pessoa) — embuta o array. Um-para-muitos (centenas a milhares) — um array de referências. Um-para-enormes (os eventos de log de uma máquina) — inverta a direção e guarde a referência ao pai em cada filho, porque o pai não pode segurar um array que só cresce. Muitos-para-muitos — arrays de referências, às vezes dos dois lados.
O $lookup é lento?
Comparado a joins relacionais, consistentemente sim: motores de documentos executam joins de loop aninhado, sem as estratégias de merge e hash que otimizadores relacionais têm. Benchmarks públicos juntando um milhão de documentos mediram dezenas de segundos mesmo com índice, contra meio segundo do equivalente embutido. As regras operacionais: sempre indexe o campo estrangeiro, mantenha o $lookup em caminhos por requisição juntando um punhado de documentos, e nunca construa fan-outs analíticos sobre ele.
O que é o limite de 16MB por documento e por que importa aqui?
Um teto rígido para o tamanho de um único documento — e o motivo pelo qual "embuta tudo" falha. Arrays embutidos sem limite (comentários, logs, eventos) crescem em direção ao teto e degradam cache e eficiência de índice muito antes de atingi-lo. As correções padrão: trocar para referências, embutir só um subconjunto limitado (os N mais recentes) com uma coleção de excedente, ou agrupar filhos em documentos-balde.
O que é o problema N+1 em bancos de documentos?
Buscar N pais com uma query e depois disparar mais uma query por pai para os dados relacionados — N+1 idas ao banco que crescem com o tamanho do resultado. As correções, na ordem de preferência: agrupar o segundo passo numa única query sobre os IDs coletados, usar um lookup no servidor num pipeline só, buscar os documentos relacionados numa requisição via o mecanismo de include do SDK, ou embutir para que o "join" tenha acontecido na escrita.
Como ODMs e SDKs de backend expressam relações?
Como referências tipadas com um operador de eager loading. ODMs de documentos declaram campos de referência e os populam com queries de acompanhamento em batch; SDKs de backend usam Pointers — uma referência tipada a outro objeto — e um operador include que busca os documentos referenciados na mesma requisição, mais tipos Relation para grandes conjuntos muitos-para-muitos. A mesma ideia em toda parte: declare a aresta, depois escolha quando percorrê-la.
Quando um banco relacional é simplesmente a escolha melhor?
Quando joins são a carga de trabalho, e não a exceção: domínios pesados em muitos-para-muitos, analytics ad hoc cruzando entidades, integridade referencial estrita e transações complexas de múltiplas linhas. Modelos de documentos ganham quando os padrões de acesso são conhecidos e hierárquicos — um documento por tela de dados. Se toda query precisa de três lookups, os dados estão dizendo que querem tabelas.