Denormalización y datos derivados en MongoDB | Nicolás Garzón
Texto
Copiar source of truth
→ copia o cálculo derivado
→ lectura optimizada
→ reconciliaciónToda copia necesita responder tres preguntas: cuál es la fuente oficial, cuánto atraso se acepta y cómo se repara una divergencia.
No todas las duplicaciones tienen la misma semántica.
Conserva el valor en el momento de un evento:
JavaScript
Copiar {
orderId,
customer : {
id : customerId,
nameAtPurchase : 'Laura Gómez'
} ,
items : [
{
productId,
nameAtPurchase : 'Café Premium' ,
unitPrice : Decimal128 ( '18.50' )
}
]
} Si el cliente o producto cambia después, la orden no debe actualizarse. El dato duplicado representa historia.
Busca reflejar el estado actual:
JavaScript
Copiar {
productId,
category : {
id : categoryId,
currentName : 'Bebidas'
}
} Cuando cambia la categoría, las copias deben propagarse. Esto introduce consistencia eventual o coordinación.
Confundir ambas semánticas causa errores: un snapshot no debe “corregirse” automáticamente y una copia actual no debe quedar obsoleta indefinidamente.
JavaScript
Copiar {
productId,
reviewCount : 1240 ,
ratingSum : 5681 ,
averageRating : 4.58
} Guardar ratingSum y reviewCount permite actualizar el promedio de forma incremental:
JavaScript
Copiar db. products. updateOne (
{ _id : productId } ,
{
$inc : {
ratingSum : rating,
reviewCount : 1
} ,
$set : { updatedAt : new Date ( ) }
}
) ; El promedio puede calcularse al leer o mantenerse derivado. Si se almacena, debe poder recomputarse desde reviews.
Los contadores materializados evitan ejecutar $count sobre millones de documentos, pero son sensibles a:
retries duplicados;
deletes;
cambios de estado;
importaciones;
fallos entre operaciones;
eventos fuera de orden.
Una operación idempotente necesita identidad:
JavaScript
Copiar {
eventId,
aggregateId,
type : 'ReviewCreated'
} Un índice unique sobre eventId evita aplicar dos veces el mismo evento.
Cuando fuente y derivado viven juntos, usa operadores atómicos. Es la opción más simple.
Cuando la regla cruza pocos documentos y necesita all-or-nothing:
Texto
Copiar insert review
+ update product counters
→ commitAñade coste y no debe usarse para fan-out masivo.
La transacción guarda el cambio y un evento. Un worker actualiza proyecciones de forma idempotente.
Texto
Copiar transaction
→ source of truth + outbox
→ worker
→ derived collectionEs apropiado cuando se tolera atraso breve y se necesita recuperación.
Pueden observar cambios y actualizar proyecciones. Necesitan resume tokens, oplog window e idempotencia. Un cambio técnico no siempre expresa la intención de negocio correcta.
Recalcula cada cierto tiempo. Es simple para métricas no críticas, pero introduce una ventana clara de staleness.
Una colección derivada puede servir dashboards:
JavaScript
Copiar {
businessId,
day : ISODate ( '2026-07-24T00:00:00Z' ) ,
orderCount : 312 ,
grossSales : Decimal128 ( '8450000' ) ,
updatedAt
}
lectura pequeña;
índices especializados;
aislamiento de reporting;
evita pipelines pesados en cada request.
pipeline de actualización;
backfill;
reconciliación;
versionado del cálculo;
manejo de eventos tardíos.
No uses “eventual” como descripción vaga. Define:
Texto
Copiar perfil público: hasta 30 s
inventario disponible: menos de 1 s o lectura directa
reporte diario: hasta 15 min
facturación: exactitud reconciliada antes de cierreEl presupuesto determina arquitectura y alertas.
Cada campo duplicado debe tener una fuente explícita:
Dato Fuente Copias Precio actual `products.price` Search, cache Precio vendido `orders.items.unitPrice` snapshot inmutable Total diario `orders` confirmadas `dailySales`
Sin esta tabla, los equipos pueden editar una copia como si fuera original.
Todo sistema derivado necesita poder comprobar y reparar:
JavaScript
Copiar const expected = await reviews. aggregate ( [ ] ) ;
const actual = await products. findOne ( { _id : productId } ) ;
comparación por lotes;
checksums;
conteos por partición;
rebuild completo;
alertas por diferencia;
reparación idempotente.
La reconciliación no es un parche: es parte del diseño distribuido.
Cambiar el nombre de una categoría podría afectar millones de productos. Actualizar todos inmediatamente puede ser costoso.
guardar snapshot y no propagar;
resolver nombre al leer;
propagar por batches;
mantener versión;
utilizar Search projection regenerable;
guardar solo ID en hot data.
No dupliques campos muy cambiantes en fan-out grande sin medir.
Dos eventos pueden actualizar un contador simultáneamente. Usa $inc, no read-modify-replace.
Eventos fuera de orden deben incluir versión:
JavaScript
Copiar db. projections. updateOne (
{
_id : productId,
sourceVersion : { $lt : event. version }
} ,
{
$set : {
currentPrice : event. price,
sourceVersion : event. version
}
}
) ; Cuando la fuente se elimina, define si la copia:
se conserva como histórico;
se anonimiza;
se marca deleted;
se elimina;
queda inaccesible.
Una orden puede conservar nombre del producto, mientras un índice de búsqueda debe retirarlo.
Duplicar PII multiplica superficies:
backups;
logs;
proyecciones;
Search indexes;
data warehouses;
caches.
Minimiza campos, aplica retención y asegúrate de que procesos de borrado cubran todas las copias necesarias.
evento aplicado dos veces;
worker cae después de escribir pero antes de checkpoint;
backfill y eventos en vivo compiten;
cambio de fórmula;
source document eliminado;
tenant incorrecto;
contador negativo;
proyección reconstruida con schema viejo;
dato tardío altera un periodo cerrado.
duplicar sin source of truth;
no distinguir snapshot de copia actual;
actualizar contadores con read-modify-write;
asumir exactly-once;
no definir staleness;
depender de Change Streams sin rebuild;
guardar PII en todas las proyecciones;
cambiar fórmula sin versionar.
Documenta fuentes y copias.
Simula retries.
Detén el worker.
Reproduce eventos fuera de orden.
Ejecuta backfill con tráfico activo.
Compara contra recomputación.
Prueba deletes y privacidad.
Mide lag.
Cambia versión del cálculo.
Ensaya rebuild completo.
La denormalización es válida cuando acelera una lectura concreta y conserva una estrategia clara de consistencia. Snapshots históricos no se sincronizan; copias actuales sí. Los datos derivados necesitan source of truth, idempotencia, presupuesto de staleness, observabilidad y reconciliación.
Comprueba lo aprendido
¿Qué diferencia existe entre snapshot y copia sincronizada?
¿Cómo evitarías duplicar un contador por retry?
¿Qué es un staleness budget?
¿Cuándo usarías una materialized view?
¿Por qué necesitas reconciliación?
¿Qué riesgo introduce duplicar PII?