Armazéns de dados e NoSQL
Grão, esquemas multidimensionais, ETL, OLAP, famílias NoSQL e consultas MongoDB.
Reservar dois teclados exige atualizar o stock e guardar uma venda sem quebrar referências. Comparar vendas de vários anos exige percorrer factos históricos e agregá-los por produto, cliente ou tempo.
Essas tarefas têm padrões de acesso diferentes. Vamos seguir as vendas da loja desde as linhas operacionais até ao desenho analítico, antes de comparar outros modelos de dados.
OLTP e armazém de dados
OLTP, processamento transacional, suporta operações correntes como reservar stock, criar uma encomenda e atualizar uma morada. Usa transações curtas, integridade e dados atuais.
Um armazém de dados, ou data warehouse, integra dados para análise. É orientado a assuntos, como vendas, em vez de a cada aplicação de origem. Reúne formatos e códigos de várias fontes, conserva a evolução temporal e privilegia carregamento e consulta em vez de alterações operacionais contínuas.
“Não volátil” significa que os factos analíticos são relativamente estáveis e não sofrem o mesmo ciclo de atualizações operacionais. Não significa que nunca se corrigem dados ou que o sistema dispensa integridade e transações.
O armazém pode receber dados periodicamente, ficando atrasado em relação ao sistema operacional. Essa latência pode ser aceitável num relatório mensal e inaceitável para decidir se ainda há uma unidade em stock.
Grão e dimensões
O grão diz o que representa uma linha da tabela de factos. Para a loja, escolhemos um produto numa encomenda. A linha da encomenda 100/produto 10 guarda quantidade 2 e valor 9000 cêntimos.
A tabela de factos guarda medidas e referências a dimensões. As dimensões dão os eixos de análise, como tempo, produto e cliente.
Não mistures numa mesma medida linhas de encomenda com totais de encomenda. A junção e a soma poderiam contar a mesma venda duas vezes.
As dimensões têm chaves próprias. A chave operacional de um produto pode ser preservada, mas armazéns também usam chaves substitutas para distinguir versões históricas da dimensão. Se um cliente mudar de cidade e quisermos analisar a cidade na altura da compra, substituir a cidade em todos os registos antigos perde essa história.
Medidas e agregação
Quantidade vendida e valor da venda são aditivos no grão escolhido, desde que as unidades sejam compatíveis. Podemos somá-los por produto, cliente ou tempo.
Um saldo de conta num certo dia é semiaditivo: pode somar-se entre contas na mesma data, mas somar saldos diários não dá o saldo do mês. Uma percentagem é normalmente não aditiva. Para a margem média, pode ser necessário somar receitas e custos e depois calcular a razão, em vez de fazer a média de percentagens com pesos diferentes.
As quatro linhas da loja dão medidas com o mesmo grão:
| Encomenda/produto | Quantidade | Preço por unidade, cêntimos | Valor, cêntimos |
|---|---|---|---|
| 100/10 | 2 | 4500 | 9000 |
| 100/11 | 1 | 2500 | 2500 |
| 101/12 | 1 | 18000 | 18000 |
| 102/11 | 3 | 2500 | 7500 |
| Total | 7 | não se soma | 37000 |
O preço médio por unidade é SUM(valor) / SUM(qtd): 37000/7 cêntimos, cerca de 52,86 euros. A média simples dos quatro preços daria 68,75 euros, atribuindo o mesmo peso à venda de uma unidade e à de três. O grão determina o que cada soma e média significa.
Estrela, floco de neve e constelação
Um esquema em estrela tem uma tabela de factos ligada diretamente a dimensões, frequentemente desnormalizadas para facilitar consulta. Uma dimensão Produto pode conter nome e categoria numa só tabela.
Um floco de neve normaliza parte das dimensões, separando, por exemplo, Categoria de Produto. Reduz certas repetições, mas introduz mais junções.
Uma constelação de factos tem várias tabelas de factos que partilham dimensões, como Vendas e Devolucoes. Define o grão de cada uma. Juntar diretamente várias vendas a várias devoluções do mesmo produto pode multiplicar medidas. Uma comparação correta pode exigir agregar cada facto no mesmo nível antes de juntar.
ETL e data marts
ETL significa extrair, transformar e carregar. A extração recolhe dados completos ou mudanças desde o último carregamento. A transformação uniformiza formatos, limpa erros, reconcilia ids, converte unidades e resolve regras de histórico. O carregamento preenche dimensões e factos na ordem que respeita as referências.
Se duas fontes usam o código 10 para produtos diferentes, não podemos juntar só por esse número. Se uma fonte dá euros e outra cêntimos, também não podemos somar os valores antes de converter. O desenho inclui a proveniência e as regras de correspondência, mesmo quando essas colunas não aparecem num relatório final.
Um data mart é um conjunto analítico focado num grupo ou assunto. É dependente quando deriva de um armazém central; independente quando integra diretamente fontes próprias. A diferença refere-se à origem e integração, não apenas ao tamanho.
Operações OLAP
OLAP, processamento analítico, explora medidas em diferentes níveis e subconjuntos:
- Roll-up agrega, por exemplo dias em meses e meses em anos.
- Drill-down desce ao detalhe, por exemplo ano para mês, se esse detalhe existir.
- Slice fixa uma dimensão, como o ano de 2026.
- Dice escolhe um subconjunto em várias dimensões, como janeiro a março e duas categorias.
- Pivot muda a disposição dos eixos da apresentação, sem alterar o significado dos factos.
- Drill-across compara factos ligados através de dimensões compatíveis.
Um cubo de dados pode ter mais de três dimensões. O nome cubo é uma representação, não uma limitação a três atributos.
Agregar em SQL
O exemplo abaixo extrai factos das linhas de compra e agrega por mês. É uma consulta SQLite sobre o esquema operacional pequeno, para observar o cálculo analítico.
WITH Facto AS (
SELECT e.data, i.qtd, i.qtd * i.precoUnitario AS valor
FROM Item AS i JOIN Encomenda AS e ON e.id = i.idEncomenda
)
SELECT substr(data, 1, 7) AS mes, SUM(qtd) AS unidades,
SUM(valor) / 100.0 AS euros,
SUM(valor) / (100.0 * SUM(qtd)) AS eurosPorUnidade
FROM Facto GROUP BY substr(data, 1, 7) ORDER BY mes;PRAGMA foreign_keys = ON;
CREATE TABLE Cliente (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE Produto (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
precoCentimos INTEGER NOT NULL CHECK (precoCentimos >= 0),
stock INTEGER NOT NULL CHECK (stock >= 0)
);
CREATE TABLE Encomenda (
id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
idCliente INTEGER NOT NULL REFERENCES Cliente(id)
);
CREATE TABLE Item (
idEncomenda INTEGER NOT NULL REFERENCES Encomenda(id) ON DELETE CASCADE,
idProduto INTEGER NOT NULL REFERENCES Produto(id),
qtd INTEGER NOT NULL CHECK (qtd > 0),
precoUnitario INTEGER NOT NULL CHECK (precoUnitario >= 0),
PRIMARY KEY (idEncomenda, idProduto)
);
INSERT INTO Cliente VALUES
(1, 'Ana', 'ana@example.test'),
(2, 'Rui', NULL),
(3, 'Mia', NULL);
INSERT INTO Produto VALUES
(10, 'Teclado', 4500, 20),
(11, 'Rato', 2500, 50),
(12, 'Monitor', 18000, 5);
INSERT INTO Encomenda VALUES
(100, '2026-01-05', 1),
(101, '2026-01-06', 1),
(102, '2026-01-06', 2);
INSERT INTO Item VALUES
(100, 10, 2, 4500),
(100, 11, 1, 2500),
(101, 12, 1, 18000),
(102, 11, 3, 2500);Executa a consulta para ver as tabelas.
Janeiro tem 7 unidades e 370 euros, ou seja, o preço médio por unidade é , cerca de 52,86 euros. Como a loja só tem vendas em janeiro, o total do mês coincide com o total geral; com mais meses, o roll-up acrescentaria uma linha de total que soma todos os meses.
Em motores com estas extensões, GROUP BY ROLLUP(ano, categoria) produz agrupamentos por (ano,categoria), por ano e o total geral. CUBE(ano,categoria) inclui também o agrupamento apenas por categoria, ou seja, todos os subconjuntos de dimensões. SQLite não implementa estes comandos; podemos construir os níveis com consultas e UNION ALL.
Para comparar ROLLUP e CUBE, imagina que agregámos as vendas por ano e categoria. Os dois operadores escolhem níveis diferentes:
| Nível de agrupamento | ROLLUP(ano, categoria) | CUBE(ano, categoria) |
|---|---|---|
| Ano e categoria | sim | sim |
| Só ano | sim | sim |
| Só categoria, juntando anos | não | sim |
| Total geral | sim | sim |
ROLLUP retira atributos da direita para a esquerda, conservando prefixos. CUBE inclui todos os subconjuntos. Nenhum inventa detalhe que os factos não guardam.
Nos resultados de OLAP, um NULL que marca um subtotal não deve ser confundido com um valor desconhecido da dimensão. Motores que suportam GROUPING permitem distinguir essas situações.
Famílias NoSQL
NoSQL reúne modelos que não se limitam ao modelo relacional tradicional. Não define uma garantia única de transações, esquema ou linguagem.
| Família | Modelo e acesso habitual |
|---|---|
| Chave-valor | Uma chave dá acesso a um valor, útil para sessões e caches. |
| Documentos | Objetos com campos, arrays e estruturas aninhadas, consultados por conteúdo. |
| Famílias de colunas | Linhas por chave com conjuntos de colunas flexíveis, desenhadas para acesso distribuído. |
| Grafos | Nós e arestas com propriedades, úteis para percorrer relações e caminhos. |
Famílias de colunas, como no modelo Bigtable, não são simplesmente o mesmo que armazenamento colunar analítico. Este último guarda colunas em conjunto para ler e agregar grandes conjuntos, podendo servir um sistema relacional.
Um modelo de documentos permite estruturas diferentes, mas continua a precisar de regras de validação e de significado dos campos. Não significa “sem esquema” no sentido de “sem qualquer contrato”. Sistemas NoSQL podem oferecer transações ACID; MongoDB, por exemplo, oferece transações com vários documentos em configurações suportadas. Verifica as garantias concretas da operação e do sistema.
Replicação, partição e CAP
Replicação conserva cópias dos dados em vários nós. Sharding divide os dados entre nós por uma chave de partição. São técnicas diferentes e podem coexistir. Acrescentar réplicas não aumenta automaticamente a capacidade de todas as escritas; escolher uma chave de partição desigual pode concentrar a carga num nó.
Durante uma partição de rede, nós que não conseguem comunicar têm de escolher como tratar operações que exigem coordenação. O teorema CAP estabelece a incompatibilidade entre garantir simultaneamente consistência linearizável e disponibilidade para todas as operações durante essa partição, sob o modelo do teorema. Não é uma regra para “escolher quaisquer dois” sem considerar uma falha de comunicação.
A consistência de CAP refere-se à aparência de uma única cópia atualizada, não à letra C de ACID, que trata invariantes do domínio. Consistência eventual admite leituras desatualizadas e procura convergência quando deixam de ocorrer alterações e a comunicação permite propagar os dados. Não significa que qualquer conflito se resolva sozinho corretamente.
BASE descreve uma orientação para disponibilidade e convergência eventual. Não é uma especificação única de produto nem o contrário obrigatório de todas as propriedades ACID.
Consultar documentos em MongoDB
Estes excertos são para mongosh, numa coleção de exemplo, e não para o motor SQLite. Considera documentos com _id, nome, preco, categorias e detalhes, como:
{
"_id": 10,
"nome": "Teclado",
"preco": 45,
"categorias": ["periferico", "entrada"],
"detalhes": { "layout": "PT" }
}
find recebe um filtro e uma projeção. O excerto seguinte procura preço acima de 30 e devolve nome e preço, omitindo _id, por ordem decrescente de preço:
db.produtos
.find({ preco: { $gt: 30 } }, { _id: 0, nome: 1, preco: 1 })
.sort({ preco: -1, nome: 1 });
A projeção de inclusão não se mistura livremente com exclusão, exceto o tratamento especial de _id. Para um campo aninhado, usa "detalhes.layout"; para procurar um elemento num array simples, o filtro { categorias: "entrada" } procura essa presença.
insertOne insere um documento; updateOne altera no máximo um documento correspondente; updateMany altera todos os correspondentes. Operadores como $set modificam campos e $inc incrementa valores. deleteMany elimina todos os documentos do filtro.
db.produtos.updateOne({ _id: 10 }, { $set: { 'detalhes.layout': 'US' } });
A operação altera apenas o campo layout. Substituir o documento inteiro é uma operação diferente, que pode apagar campos omitidos. Um filtro por null em MongoDB pode também corresponder a documentos sem o campo; distingue ausência de campo e valor explícito quando a pergunta o exigir.
Para valores distintos de um campo, db.produtos.distinct('categorias') devolve cada categoria uma vez. Para contar documentos que satisfazem um filtro, db.produtos.countDocuments({ preco: { $gt: 30 } }) conta os documentos correspondentes; não é uma estimativa do tamanho da coleção.
Nos exemplos seguintes, o catálogo tem apenas estes dois documentos:
| _id | nome | preco, euros | categorias |
|---|---|---|---|
| 10 | Teclado | 45 | entrada, periferico |
| 11 | Rato | 25 | periferico |
Os valores de categorias são arrays de strings. O teclado pertence a dois grupos; o rato, a um. Por isso, contar elementos após $unwind pode dar um total diferente de contar documentos.
Pipelines de agregação
Um pipeline passa documentos por etapas. $match filtra, $project escolhe ou calcula campos, $unwind transforma um array em linhas, $group agrega, $sort ordena e $limit limita a resposta.
db.produtos.aggregate([
{ $unwind: '$categorias' },
{ $group: { _id: '$categorias', produtos: { $sum: 1 } } },
{ $sort: { produtos: -1, _id: 1 } },
]);
Cada produto contribui uma vez por elemento do array. Um teclado com duas categorias contribui para dois grupos. Arrays com elementos repetidos exigem decidir se queremos contar ocorrências ou produtos distintos; $unwind não elimina repetições.
Depois de $unwind, o teclado do exemplo dá dois documentos: um com categorias: "periferico" e outro com categorias: "entrada". Os restantes campos mantêm-se em ambos. $group recebe estes documentos separados e acrescenta 1 à contagem de cada categoria; não altera o produto guardado na coleção.
Etapa a etapa, com o teclado e o rato:
após $unwind, três documentos:
{"nome": "Teclado", "categorias": "periferico"}
{"nome": "Teclado", "categorias": "entrada"}
{"nome": "Rato", "categorias": "periferico"}
após $group por categoria, dois documentos:
{"_id": "periferico", "produtos": 2}
{"_id": "entrada", "produtos": 1}
$lookup acrescenta correspondências de outra coleção, sempre num array, mesmo quando há no máximo uma. Para juntar cada encomenda ao seu cliente:
db.encomendas.aggregate([
{ $match: { _id: 100 } },
{
$lookup: {
from: 'clientes',
localField: 'idCliente',
foreignField: '_id',
as: 'cliente',
},
},
]);
A encomenda 100 passa a ter este campo:
{ "_id": 100, "idCliente": 1, "cliente": [{ "_id": 1, "nome": "Ana" }] }
Se nenhum cliente corresponder, o array fica vazio; a encomenda não desaparece do resultado.
$facet corre vários subpipelines sobre a mesma entrada e devolve um único documento, com um array por saída. Para caracterizar o catálogo em duas dimensões de uma vez:
db.produtos.aggregate([
{
$facet: {
porCategoria: [
{ $unwind: '$categorias' },
{ $sortByCount: '$categorias' },
],
precoMedio: [{ $group: { _id: null, media: { $avg: '$preco' } } }],
},
},
]);
A resposta tem um documento com um array por saída. Com o teclado e o rato:
{
"porCategoria": [
{ "_id": "periferico", "count": 2 },
{ "_id": "entrada", "count": 1 }
],
"precoMedio": [{ "_id": null, "media": 35 }]
}
Os dois subpipelines recebem os mesmos documentos de entrada e são independentes: a saída de um não serve de entrada ao outro. A média é (45+25)/2. Cada etapa de um $facet está limitada a 100 megabytes e o documento final ao limite BSON de 16 megabytes, por isso esta técnica serve resumos, não despejos integrais.
Escolha do modelo de dados
Para vendas com referências e regras transacionais entre entidades, o relacional é uma escolha natural. Para análise histórica, um desenho multidimensional permite agregações bem definidas. Para catálogos com atributos aninhados e acessos por documento, um modelo de documentos pode simplificar certas leituras.
A decisão exige saber o grão, as consultas, a frequência de escrita, as relações entre dados e as garantias durante falhas. Escala e flexibilidade não dispensam estas perguntas, e um sistema pode combinar vários modelos para tarefas diferentes.
Exercícios
Preço médio com pesos
Uma linha vende uma unidade a 10 euros; outra vende três unidades a 30 euros cada. Qual é o preço médio por unidade, em euros?
Pista
Soma valor vendido e divide pelo número total de unidades.
Solução
O valor vendido é 100 euros e existem quatro unidades, por isso a média é 25 euros por unidade. A média simples dos preços das duas linhas daria 20, mas atribuiria o mesmo peso a uma unidade e a três. O grão da linha e o significado da medida determinam a agregação.
Duas agregações sobre a mesma entrada
Três produtos têm categorias [entrada,periferico], [periferico] e
[visualizacao], com preços 45, 25 e 180. Um $facet calcula porCategoria
com unwind e sortByCount, e precoMedio com a média dos preços. Descreve
o documento de resposta, com a média arredondada a duas casas decimais. A soma das contagens é igual ao número de produtos? E se uma categoria aparecer duas vezes no mesmo array?
Pista
Cada subpipeline recebe os três documentos originais.
Mais uma pista
A média usa preços de documentos; a contagem usa associações após unwind.
Solução
A resposta é um documento com duas listas:
{
"porCategoria": [
{ "_id": "periferico", "count": 2 },
{ "_id": "entrada", "count": 1 },
{ "_id": "visualizacao", "count": 1 }
],
"precoMedio": [{ "_id": null, "media": 83.33 }]
}A média exata é (45+25+180)/3 = 83,333…; 83,33 é o arredondamento pedido, não um arredondamento automático de $avg. A ordem entre as categorias com contagem 1 não está garantida. Os subpipelines são independentes: a saída de um não alimenta o outro. A soma das contagens é 4, porque conta associações documento/categoria, não os três documentos. Uma categoria repetida num array voltaria a contar; para contar produtos distintos por categoria, elimina repetições do par id/categoria antes de agregar.
Confere a tua resposta:
- Previ um único documento com dois arrays.
- Expliquei que os subpipelines recebem a mesma entrada.
- Distingui contagem de associações de contagem de documentos.
Erros frequentes
Não esperes um documento por categoria à saída de $facet. Não confundas a
média de preços com uma média de contagens.
A junção devolve sempre um array
Clientes contém apenas { _id: 1, nome: "Ana" }. Encomendas contém { _id: 100, idCliente: 1 } e { _id: 103, idCliente: 99 }. Um $lookup usa localField idCliente, foreignField _id e as cliente. Descreve o campo
cliente da encomenda 100 e de uma encomenda com cliente 99 inexistente.
Pista
Mesmo com no máximo uma correspondência, o resultado é um array.
Solução
A 100 fica com um elemento, o documento de Ana. A encomenda sem
correspondência fica com um array vazio, mas continua no resultado. Para
obter um campo escalar seria preciso outro passo que extraia o elemento;
$lookup por si só acrescenta o array.
Confere a tua resposta:
- Previ
clientecom um documento para a encomenda 100. - Previ array vazio sem eliminar a encomenda sem correspondência.
- Distingui esse array de uma coluna escalar de junção.
Erros frequentes
Não esperes NULL nem a eliminação da linha sem correspondência. Não
confundas o array de saída com as linhas que um JOIN multiplicaria.