← 🗄️ NoSQL
basico🧪 Lab práctico

2.4 · Lab 2: Modelado de documentos con MongoDB

⏱ 55 minMódulo 2: Almacenamiento Distribuido y NoSQL

🎯 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
Embeber optimiza la lectura de un pedido completo; referenciar evita duplicar al cliente.

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ónModelo recomendado
Los datos se leen siempre juntos (pedido y sus líneas)Embebido
La entidad hija no existe sin la madreEmbebido
El array crecería sin límite (comentarios, eventos, logs)Referenciado
La entidad se comparte entre muchos padres y cambia seguidoReferenciado
El documento se acercaría al límite de 16 MB de BSONReferenciado

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'.

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?

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?

🧹 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

RecursoTipoPor qué leerlo
MongoDB — Data ModelingDocsLa 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 PipelineDocsReferencia 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ículoResumen 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).