🎯 Objetivo
Levantar MongoDB en Docker y modelar el mismo dominio de pedidos dos veces —embebido y referenciado— para sentir en la práctica el trade-off entre ambos.
✅ Antes de empezar
Necesitas tres cosas. Verifícalas ahora, no a mitad del lab.
# 1) Docker instalado y funcionando
docker --version
# Esperado: Docker version 24.x.x (o superior)
# 2) Python 3.10 o superior
python --version
# Esperado: Python 3.10.x (o superior)
# 3) El cliente de MongoDB para Python
pip install pymongo
Puerto 27017 libre. Es el puerto por defecto de MongoDB. Si ya tienes un MongoDB local corriendo, o el contenedor no arranca, cambia el mapeo a -p 27018:27017 y ajusta la cadena de conexión en todos los scripts.
¿Sin Docker? Puedes usar el free tier de MongoDB Atlas (https://www.mongodb.com/cloud/atlas/register) y reemplazar la URI local por la que te da Atlas; el resto del lab es idéntico.
Paso 1: Levantar el contenedor
Docker nos ahorra instalar MongoDB en el sistema. Bajamos la imagen oficial, la corremos en segundo plano y exponemos su puerto al host. Cuando terminemos, borrar el contenedor borra todo rastro.
docker run -d --name mongo-lab -p 27017:27017 mongo:7
docker ps
Paso 2: Conectar con pymongo
Usamos pymongo como cliente principal porque es portable y nos deja escribir el lab entero en Python. Una conexión de MongoClient es perezosa: no falla al crearse, sino al primer comando real. Por eso probamos con server_info().
# archivo: paso2_conexion.py
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017/", serverSelectionTimeoutMS=3000)
info = client.server_info()
print("Versión del servidor:", info["version"])
print("Bases de datos:", client.list_database_names())
Si prefieres la consola interactiva, la alternativa es docker exec -it mongo-lab mongosh.
Paso 3: Generar e insertar 500 pedidos embebidos
Los datos los genera el propio script con random, así el lab no depende de ninguna descarga. Clave del diseño: cada pedido lleva dentro los datos del cliente y la lista de líneas de producto. Un pedido completo es un solo documento.
# archivo: paso3_carga.py
import random
from datetime import datetime, timedelta
from pymongo import MongoClient
random.seed(42)
client = MongoClient("mongodb://localhost:27017/")
db = client["tienda"]
db.pedidos.drop() # idempotente: puedes re-ejecutar el script
CLIENTES = [
{"cliente_id": 1, "nombre": "Ana Quispe", "ciudad": "Lima", "plan": "premium"},
{"cliente_id": 2, "nombre": "Luis Mamani", "ciudad": "Arequipa", "plan": "basico"},
{"cliente_id": 3, "nombre": "Rosa Flores", "ciudad": "Cusco", "plan": "basico"},
{"cliente_id": 4, "nombre": "Diego Ramos", "ciudad": "Lima", "plan": "premium"},
{"cliente_id": 5, "nombre": "Elena Vargas", "ciudad": "Trujillo", "plan": "basico"},
]
PRODUCTOS = [
{"sku": "TEC-01", "nombre": "Teclado mecanico", "precio": 180.0},
{"sku": "MON-02", "nombre": "Monitor 27 pulgadas", "precio": 950.0},
{"sku": "AUR-03", "nombre": "Auriculares", "precio": 220.0},
{"sku": "MOU-04", "nombre": "Mouse inalambrico", "precio": 95.0},
{"sku": "SSD-05", "nombre": "SSD 1TB", "precio": 340.0},
{"sku": "HUB-06", "nombre": "Hub USB-C", "precio": 130.0},
]
inicio = datetime(2026, 1, 1)
pedidos = []
for i in range(500):
cliente = random.choice(CLIENTES)
lineas = []
for prod in random.sample(PRODUCTOS, random.randint(1, 4)):
cantidad = random.randint(1, 3)
lineas.append({
"sku": prod["sku"],
"nombre": prod["nombre"],
"precio": prod["precio"],
"cantidad": cantidad,
"subtotal": round(prod["precio"] * cantidad, 2),
})
pedidos.append({
"_id": i + 1,
"fecha": inicio + timedelta(days=random.randint(0, 180)),
"cliente": cliente, # <-- cliente EMBEBIDO
"lineas": lineas, # <-- lineas EMBEBIDAS
"total": round(sum(l["subtotal"] for l in lineas), 2),
"estado": random.choice(["pagado", "enviado", "entregado"]),
})
resultado = db.pedidos.insert_many(pedidos)
print("Insertados:", len(resultado.inserted_ids))
print("Total en la coleccion:", db.pedidos.count_documents({}))
print("Ejemplo:", db.pedidos.find_one({"_id": 1}))
Paso 4: Consultar — filtros, proyección y agregación
Tres niveles de consulta. Primero filtros simples (incluida la notación de punto para navegar dentro del subdocumento). Después proyección, para traer solo los campos que necesitas. Y por último el pipeline de agregación: $unwind “aplana” el array de líneas, generando un documento por línea. Eso es exactamente lo que en SQL haría un JOIN contra la tabla de detalle.
# archivo: paso4_consultas.py
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017/")["tienda"]
# 1) Filtro con notacion de punto sobre el subdocumento embebido
print("Pedidos de Lima:", db.pedidos.count_documents({"cliente.ciudad": "Lima"}))
# 2) Filtro compuesto + proyeccion (1 = incluir, 0 = excluir)
caros = db.pedidos.find(
{"total": {"$gt": 1500}, "estado": "entregado"},
{"_id": 1, "cliente.nombre": 1, "total": 1},
).sort("total", -1).limit(5)
for doc in caros:
print(doc)
# 3) Agregacion: facturacion por producto
pipeline = [
{"$unwind": "$lineas"},
{"$group": {
"_id": "$lineas.nombre",
"unidades": {"$sum": "$lineas.cantidad"},
"facturacion": {"$sum": "$lineas.subtotal"},
}},
{"$sort": {"facturacion": -1}},
]
print("\nFacturacion por producto:")
for fila in db.pedidos.aggregate(pipeline):
print(f"{fila['_id']:<22} {fila['unidades']:>5} u. S/ {fila['facturacion']:>10,.2f}")
En SQL equivalente escribirías SELECT p.nombre, SUM(l.subtotal) FROM pedidos JOIN lineas .... Aquí no hay JOIN porque las líneas ya viven dentro del pedido: $unwind solo las despliega.
Paso 5: Índices — de COLLSCAN a IXSCAN
Sin índice, MongoDB lee los 500 documentos uno por uno: eso es un COLLSCAN. Con un índice sobre el campo filtrado, salta directo a los que coinciden: un IXSCAN. explain() te muestra qué plan eligió el motor. Con 500 documentos el ahorro es pequeño; con 500 millones es la diferencia entre milisegundos y minutos.
# archivo: paso5_indices.py
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017/")["tienda"]
consulta = {"cliente.ciudad": "Arequipa"}
def etapas(plan):
"""Recorre el plan y devuelve los nombres de etapa que encuentre."""
encontradas = []
def recorrer(nodo):
if isinstance(nodo, dict):
if "stage" in nodo:
encontradas.append(nodo["stage"])
for valor in nodo.values():
recorrer(valor)
elif isinstance(nodo, list):
for valor in nodo:
recorrer(valor)
recorrer(plan)
return encontradas
def medir(etiqueta):
plan = db.pedidos.find(consulta).explain()
stats = plan["executionStats"]
print(etiqueta)
print(" etapas:", etapas(plan["queryPlanner"]["winningPlan"]))
print(" documentos examinados:", stats["totalDocsExamined"])
print(" documentos devueltos:", stats["nReturned"])
db.pedidos.drop_indexes()
medir("ANTES del indice")
db.pedidos.create_index("cliente.ciudad", name="idx_ciudad")
medir("DESPUES del indice")
print("\nIndices actuales:", db.pedidos.index_information().keys())
graph TD subgraph Embebido["Documento embebido"] E1["pedido<br/>cliente: datos<br/>lineas: lista"] end subgraph Referenciado["Colecciones separadas"] R1["clientes"] --> R2["pedidos<br/>cliente_id"] end
Paso 6: El contraste — modelo referenciado y $lookup
Ahora modelamos el mismo dominio como lo haría un esquema relacional normalizado: una colección clientes y una colección pedidos_ref que solo guarda el cliente_id. Para reconstruir el pedido completo necesitas $lookup, el equivalente al JOIN de SQL. Compara el esfuerzo de las dos versiones.
# archivo: paso6_referenciado.py
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017/")["tienda"]
db.clientes.drop()
db.pedidos_ref.drop()
# 1) Extraemos los clientes a su propia coleccion (sin duplicados)
vistos = {}
for pedido in db.pedidos.find({}, {"cliente": 1}):
c = pedido["cliente"]
vistos[c["cliente_id"]] = c
db.clientes.insert_many(
[{"_id": cid, **datos} for cid, datos in sorted(vistos.items())]
)
print("Clientes unicos:", db.clientes.count_documents({}))
# 2) Los pedidos guardan solo la referencia
referenciados = []
for pedido in db.pedidos.find():
referenciados.append({
"_id": pedido["_id"],
"fecha": pedido["fecha"],
"cliente_id": pedido["cliente"]["cliente_id"], # <-- solo el ID
"lineas": pedido["lineas"],
"total": pedido["total"],
"estado": pedido["estado"],
})
db.pedidos_ref.insert_many(referenciados)
print("Pedidos referenciados:", db.pedidos_ref.count_documents({}))
# 3) Reconstruir el pedido completo exige un JOIN: eso es lookup
pipeline = [
{"$match": {"_id": 1}},
{"$lookup": {
"from": "clientes",
"localField": "cliente_id",
"foreignField": "_id",
"as": "cliente",
}},
{"$unwind": "$cliente"},
{"$project": {"total": 1, "estado": 1, "cliente.nombre": 1, "cliente.ciudad": 1}},
]
print("Pedido 1 reconstruido:", list(db.pedidos_ref.aggregate(pipeline)))
# 4) El precio del embebido: cambiar la ciudad de un cliente
# - referenciado: 1 documento tocado
# - embebido: hay que actualizar TODOS sus pedidos
print("Referenciado:", db.clientes.update_one({"_id": 2}, {"$set": {"ciudad": "Tacna"}}).modified_count)
print("Embebido:", db.pedidos.update_many({"cliente.cliente_id": 2}, {"$set": {"cliente.ciudad": "Tacna"}}).modified_count)
Cuándo conviene cada uno
| Situación | Modelo recomendado |
|---|---|
| Los datos se leen siempre juntos (pedido y sus líneas) | Embebido |
| La entidad hija no existe sin la madre | Embebido |
| El array crecería sin límite (comentarios, eventos, logs) | Referenciado |
| La entidad se comparte entre muchos padres y cambia seguido | Referenciado |
| El documento se acercaría al límite de 16 MB de BSON | Referenciado |
La regla práctica: embebe lo que lees junto, referencia lo que actualizas aparte.
🧪 Reto final
🧪 Ejercicio
Top 3 de ciudades por ticket promedio
Sobre la colección pedidos (la embebida), escribe un pipeline de agregación que devuelva, para cada ciudad: el número de pedidos, la facturación total y el ticket promedio. Ordena por ticket promedio de mayor a menor y quédate solo con las 3 primeras ciudades. Considera únicamente los pedidos en estado 'entregado'.
🔍 Necesitas cuatro etapas: filtrar por estado, agrupar por el campo anidado 'cliente.ciudad', ordenar y limitar. Para el promedio usa el acumulador avg sobre el campo total; para contar, sum de 1.
from pymongo import MongoClient
db = MongoClient("mongodb://localhost:27017/")["tienda"]
pipeline = [
{"$match": {"estado": "entregado"}},
{"$group": {
"_id": "$cliente.ciudad",
"pedidos": {"$sum": 1},
"facturacion": {"$sum": "$total"},
"ticket_promedio": {"$avg": "$total"},
}},
{"$sort": {"ticket_promedio": -1}},
{"$limit": 3},
]
for fila in db.pedidos.aggregate(pipeline):
print(
fila["_id"],
"|", fila["pedidos"], "pedidos",
"|", round(fila["facturacion"], 2), "total",
"|", round(fila["ticket_promedio"], 2), "promedio",
)
# Variante equivalente con proyeccion final mas limpia:
pipeline_v2 = pipeline + [
{"$project": {
"_id": 0,
"ciudad": "$_id",
"pedidos": 1,
"ticket_promedio": {"$round": ["$ticket_promedio", 2]},
}}
]
print(list(db.pedidos.aggregate(pipeline_v2)))Comprueba lo aprendido
Comprueba que lo pillaste
Una app de blog guarda artículos y sus comentarios. Cada artículo puede acumular miles de comentarios que se cargan paginados y aparte del texto. ¿Qué modelo conviene?
El array de comentarios crece sin límite y no se lee junto con el artículo: es el caso clásico de referenciar. Embeber lo llevaría al límite de 16 MB por documento y obligaría a traer todo el artículo para leer una página de comentarios.
Comprueba que lo pillaste
En una réplica set de MongoDB con la configuración por defecto, ¿dónde cae el sistema en el teorema CAP ante una partición de red?
MongoDB es CP por defecto: solo el primario acepta escrituras, y si un lado de la partición pierde el primario deja de aceptarlas hasta elegir uno nuevo. Prefiere consistencia sobre disponibilidad, al revés que Cassandra o DynamoDB, que son AP.
🧹 Limpieza
Detén y borra el contenedor. Los datos viven dentro de él, así que esto deja el sistema exactamente como estaba.
docker stop mongo-lab
docker rm mongo-lab
📚 Lecturas y fuentes
| Recurso | Tipo | Por qué leerlo |
|---|---|---|
| MongoDB — Data Modeling | Docs | La comparación oficial entre “Embedded Data Models” y “Normalized Data Models”. Es el sustento teórico de los pasos 3 y 6 (en inglés). |
| MongoDB — Aggregation Pipeline | Docs | Referencia de las etapas que usaste: $unwind, $group, $lookup, $match. Revisa la tabla de acumuladores para el reto final (en inglés). |
| Building with Patterns: A Summary (MongoDB Blog) | Artículo | Resumen de los 12 patrones de esquema (Subset, Extended Reference, Bucket, Computed…). Empieza por “Extended Reference”: es el punto medio entre embeber y referenciar (en inglés). |