Skip to content

Índices y modelado de datos ​

Las dos decisiones que más impactan el rendimiento de una aplicación con MongoDB son cómo modelas los documentos y qué índices creas. Un buen modelo hace que la mayoría de las pantallas se resuelvan con una sola consulta; uno malo te obliga a encadenar $lookup o a cargar documentos gigantes. En esta lección aprenderás los tipos de índice, a leer explain(), la regla ESR, y las reglas para decidir entre embeber y referenciar, con patrones y antipatrones reales. Cerramos con transacciones multi-documento.

Parte 1: índices ​

Concepto ​

Igual que en SQL, un índice es una estructura ordenada (un B-tree) que evita revisar todos los documentos. Sin índice, MongoDB hace un COLLSCAN (recorre la colección completa); con índice, un IXSCAN.

txt
Sin índice (COLLSCAN)                    Con índice en { email: 1 } (IXSCAN)
┌────┬────┬────┬────┬────┬────┐                   [ m ]
│doc │doc │doc │doc │doc │doc │ ← lee todos       /     \
└────┴────┴────┴────┴────┴────┘              [ a-l ]   [ n-z ]
                                                  │
                                         "ana@correo.com" → documento

Toda colección tiene automáticamente un índice único en _id.

Crear y administrar índices ​

js
// Índice simple (1 = ascendente, -1 = descendente)
db.clientes.createIndex({ email: 1 })

// Único: además impide duplicados
db.clientes.createIndex({ email: 1 }, { unique: true, name: "uq_email" })

// Compuesto
db.pedidos.createIndex({ clienteId: 1, fecha: -1 })

// Ver, eliminar
db.pedidos.getIndexes()
db.pedidos.dropIndex("clienteId_1_fecha_-1")
js
db.clientes.insertOne({ nombre: "Otra Ana", email: "ana@correo.com" })
// MongoServerError: E11000 duplicate key error collection: tienda.clientes index: uq_email

Tipos de índice ​

TipoEjemploPara qué
Simple{ precio: 1 }Filtrar/ordenar por un campo
Compuesto{ categoria: 1, precio: -1 }Filtros y orden combinados
Multikey (automático){ etiquetas: 1 } sobre un arrayBuscar valores dentro de arrays
Único{ email: 1 }, { unique: true }Integridad (como UNIQUE)
Parcial{ email: 1 }, { partialFilterExpression: ... }Indexar solo algunos documentos
TTL{ creadoEn: 1 }, { expireAfterSeconds: 3600 }Borrar documentos automáticamente tras un tiempo
Texto{ nombre: "text", descripcion: "text" }Búsqueda por palabras con $text
Hashed{ _id: "hashed" }Sharding con distribución uniforme
Geoespacial{ ubicacion: "2dsphere" }"Tiendas cerca de mí"
Wildcard{ "atributos.$**": 1 }Campos dinámicos (atributos variables)
js
// Multikey: se crea igual; MongoDB detecta que es un array
db.productos.createIndex({ etiquetas: 1 })
db.productos.find({ etiquetas: "gamer" })           // usa el índice

// Parcial: unicidad solo entre documentos que tienen email
db.usuarios.createIndex(
  { email: 1 },
  { unique: true, partialFilterExpression: { email: { $exists: true } } }
)

// Parcial: solo pedidos pendientes (pocos y consultados a menudo)
db.pedidos.createIndex({ fecha: 1 }, { partialFilterExpression: { estado: "pendiente" } })

// TTL: sesiones que se borran solas 2 horas después de creadas
db.sesiones.createIndex({ creadaEn: 1 }, { expireAfterSeconds: 7200 })
db.sesiones.insertOne({ usuarioId: 1, token: "abc", creadaEn: new Date() })

// Wildcard: consultas sobre cualquier atributo del catálogo
db.productos.createIndex({ "atributos.$**": 1 })
db.productos.find({ "atributos.color": "negro" })   // usa el índice wildcard

Búsqueda de texto ​

js
db.productos.createIndex(
  { nombre: "text", "atributos.autor": "text" },
  { default_language: "spanish", weights: { nombre: 10, "atributos.autor": 3 } }
)

db.productos.find(
  { $text: { $search: "libro soledad" } },
  { nombre: 1, puntaje: { $meta: "textScore" } }
).sort({ puntaje: { $meta: "textScore" } })
// Cien años de soledad, Libro Aprende SQL

Solo puede haber un índice de texto por colección. Para buscadores serios (autocompletado, tolerancia a errores, facetas) Atlas Search es mucho más potente.

explain(): ¿usa mi índice? ​

js
db.pedidos.find({ clienteId: 1 }).sort({ fecha: -1 }).explain("executionStats")

Fragmentos clave de la salida:

js
{
  queryPlanner: {
    winningPlan: {
      stage: "FETCH",
      inputStage: { stage: "IXSCAN", indexName: "clienteId_1_fecha_-1", ... }
    }
  },
  executionStats: {
    nReturned: 2,             // documentos devueltos
    totalKeysExamined: 2,     // entradas de índice revisadas
    totalDocsExamined: 2,     // documentos leídos
    executionTimeMillis: 0
  }
}
SeñalBuenoMalo
stageIXSCANCOLLSCAN
totalDocsExamined vs nReturnedParecidosExamina 100,000 para devolver 10
Etapa SORTAusente (el índice ya da el orden)SORT en memoria
PROJECTION_COVEREDConsulta cubierta (solo índice)—

En Compass, la pestaña Explain Plan lo muestra de forma visual.

La regla ESR para índices compuestos ​

El orden de los campos en un índice compuesto importa (igual que el prefijo izquierdo en SQL). MongoDB recomienda la regla ESR:

txt
E — Equality (igualdad)    primero:  campos con valor exacto       { estado: "pagado" }
S — Sort (orden)           después:  campos de .sort()             .sort({ fecha: -1 })
R — Range (rango)          al final: $gt, $lt, $in, $regex...      { total: { $gte: 1000 } }
js
// Consulta: pedidos pagados con total >= 1000, del más reciente al más antiguo
db.pedidos.find({ estado: "pagado", total: { $gte: 1000 } }).sort({ fecha: -1 })

// Índice siguiendo ESR
db.pedidos.createIndex({ estado: 1, fecha: -1, total: 1 })

Un índice { a: 1, b: 1, c: 1 } también sirve para consultas sobre { a } y { a, b } (prefijos), pero no para { b } o { c } solos.

Buenas prácticas de índices

  • Indexa los campos de tus consultas frecuentes, no "todo por si acaso": cada índice ralentiza las escrituras y ocupa RAM.
  • Revisa los índices sin uso: db.pedidos.aggregate([{ $indexStats: {} }]).
  • Crea los índices desde el código de tu app o en scripts de migración (versionados), no solo a mano en producción.
  • En Atlas, el Performance Advisor sugiere índices a partir de tus consultas lentas.

Parte 2: modelado de datos ​

La pregunta central: embeber o referenciar ​

En SQL, normalizas y luego haces JOINs. En MongoDB, la regla de oro es:

Los datos que se leen juntos, se guardan juntos.

Diseña según cómo se consultan los datos (las pantallas y endpoints de tu app), no solo según qué entidades existen.

txt
EMBEBER (subdocumento)                    REFERENCIAR (id hacia otra colección)
{                                         // pedidos
  _id: 1,                                 { _id: 1, clienteId: 1, ... }
  cliente: { nombre: "Ana", ... },        // clientes
  items: [ { ... }, { ... } ]             { _id: 1, nombre: "Ana", ... }
}
✔ Una lectura trae todo                   ✔ Sin duplicación, datos independientes
✔ Escritura atómica del conjunto          ✔ Sin límite de crecimiento
✘ Duplicación, límite de 16 MB            ✘ Requiere 2 consultas o $lookup
Embebe cuando...Referencia cuando...
Los datos se leen siempre juntosSe consultan por separado con frecuencia
Es una relación de "pertenencia" (las líneas de un pedido)La entidad tiene vida propia (un producto, un cliente)
El hijo no tiene sentido sin el padreMuchos padres comparten al mismo hijo
El array está acotado (pocos o cientos)El array crecería sin límite
Los datos embebidos cambian poco (o deben quedar como "foto histórica")Los datos cambian mucho y deben verse actualizados en todos lados

Relación 1:1 ​

Normalmente embebe:

js
// Usuario con su perfil y preferencias
{
  _id: ObjectId("..."),
  email: "ana@correo.com",
  perfil: { nombre: "Ana López", avatarUrl: "https://...", bio: "..." },
  preferencias: { idioma: "es", boletin: true }
}

Separa en otra colección solo si una parte es muy grande y casi nunca se lee (por ejemplo, un historial clínico extenso).

Relación 1:pocos ​

Embebe un array:

js
// Un cliente tiene pocas direcciones (1 a 5)
{
  _id: 1,
  nombre: "Ana López",
  direcciones: [
    { alias: "Casa", calle: "Av. Reforma 100", ciudad: "Ciudad de México", cp: "06600", principal: true },
    { alias: "Oficina", calle: "Insurgentes 450", ciudad: "Ciudad de México", cp: "03100" }
  ]
}

Relación 1:muchos ​

Referencia desde el hijo (como una FK en SQL):

js
// Un producto puede tener miles de reseñas → colección propia
// resenas
{ _id: ObjectId("..."), productoId: 1, clienteId: 2, calificacion: 4, comentario: "Buena batería", fecha: ISODate("...") }

db.resenas.createIndex({ productoId: 1, fecha: -1 })
db.resenas.find({ productoId: 1 }).sort({ fecha: -1 }).limit(10)

Variante 1:muchos acotados (decenas o cientos): un array de ids en el padre:

js
// Una lista de reproducción con hasta 200 canciones
{ _id: "favoritas", nombre: "Mis favoritas", cancionIds: [101, 205, 307, ...] }

Relación N:M ​

Opciones:

js
// 1. Arrays de referencias en uno o ambos lados (si son acotados)
// alumnos
{ _id: 1, nombre: "Luis Torres", cursoIds: [10, 20] }
// cursos
{ _id: 10, nombre: "Bases de Datos" }

// 2. Colección intermedia (si la relación tiene datos propios o es muy grande)
// inscripciones
{ alumnoId: 1, cursoId: 10, calificacion: 9.0, fecha: ISODate("2024-08-12") }

La colección intermedia es la opción natural cuando la relación tiene atributos (calificación, fecha), igual que en SQL.

El caso de la tienda ​

txt
productos  ◄────── referencia (productoId) ──────┐
  (catálogo independiente, cambia con frecuencia) │
                                                  │
clientes   ◄── referencia (clienteId) ── pedidos ─┤
  └ direcciones (embebidas: 1:pocos)       └ items (embebidos: pertenecen al pedido)
                                              + copia de nombre y precio (foto histórica)
resenas ── referencia (productoId, clienteId) — 1:muchos, colección aparte

¿Por qué los items del pedido copian el nombre y el precio del producto? Porque el pedido es un registro histórico: si mañana el producto sube de precio o cambia de nombre, el pedido debe seguir mostrando lo que se compró. Esa "duplicación" no es un error, es un requisito.

js
// La pantalla "Mis pedidos" se resuelve con UNA consulta, sin $lookup
db.pedidos.find({ clienteId: 1 }, { fecha: 1, estado: 1, total: 1, "items.nombre": 1 }).sort({ fecha: -1 })

Patrones de diseño útiles ​

Patrón subconjunto (subset): embebe solo lo que se muestra siempre y deja el resto en otra colección.

js
// El producto guarda sus 3 reseñas más recientes para la página de detalle;
// todas las reseñas viven en la colección resenas
{
  _id: 1,
  nombre: "Audífonos Bluetooth",
  resenasRecientes: [
    { cliente: "Carlos", calificacion: 4, comentario: "Buena batería" },
    { cliente: "Ana", calificacion: 5, comentario: "Excelente sonido" }
  ],
  calificacion: { promedio: 4.5, total: 2 }      // ← patrón calculado
}

Patrón calculado (computed): guarda resultados que se leen mucho y se calculan raramente (totales, promedios, contadores), actualizándolos al escribir:

js
// Al agregar una reseña: insertarla y actualizar el resumen del producto
db.resenas.insertOne({ productoId: 1, clienteId: 3, calificacion: 3, comentario: "Regular" })
db.productos.updateOne(
  { _id: 1 },
  {
    $inc: { "calificacion.total": 1, "calificacion.suma": 3 },
    $push: { resenasRecientes: { $each: [ { cliente: "María", calificacion: 3, comentario: "Regular" } ],
                                 $position: 0, $slice: 3 } }   // conserva solo las 3 más nuevas
  }
)

Patrón cubeta (bucket): agrupa muchos datos pequeños (lecturas de sensores, eventos) en documentos por periodo, en lugar de un documento por lectura:

js
// Un documento por sensor y hora, con hasta 60 lecturas
{
  sensorId: "temp-01",
  hora: ISODate("2024-07-01T10:00:00Z"),
  lecturas: [ { min: 0, valor: 22.5 }, { min: 1, valor: 22.6 } ],
  conteo: 2,
  suma: 45.1
}

(MongoDB 5+ ofrece colecciones de series de tiempo que aplican este patrón automáticamente.)

Patrón de referencia extendida (extended reference): además del id, copia los pocos campos que siempre muestras (como cliente: { id, nombre } en el pedido) para evitar el $lookup.

Antipatrones ​

AntipatrónProblemaSolución
Arrays sin límite (todos los comentarios, logs o pedidos dentro de un documento)Documentos cada vez más pesados, límite de 16 MBColección aparte con referencia
Modelar como en SQL (todo normalizado, $lookup en cada consulta)Lento y complejoEmbeber lo que se lee junto
Documentos gigantes con datos que casi nunca se usanCada lectura trae todoPatrón subconjunto
Demasiadas colecciones (una por cliente, por mes...)Difícil de mantener, muchos índicesUn campo discriminador + índice
Índices de másEscrituras lentas, RAM desperdiciadaRevisar $indexStats
Esquema sin controlTipos mezclados, campos con nombres distintos$jsonSchema o Mongoose
Duplicar datos que cambian muchoActualizaciones masivas e inconsistenciasReferenciar, o copiar solo campos estables

Parte 3: transacciones multi-documento ​

Una operación sobre un documento siempre es atómica (por eso embeber ayuda). Cuando necesitas modificar varios documentos o colecciones de forma "todo o nada" (crear un pedido y descontar stock de varios productos), usa transacciones (MongoDB 4.0+, requieren un replica set: Atlas siempre lo es; en local, inicia mongod con --replSet).

js
// mongosh
const session = db.getMongo().startSession();
const tienda = session.getDatabase("tienda");

session.startTransaction();
try {
  const items = [ { productoId: 2, cantidad: 1 }, { productoId: 7, cantidad: 2 } ];
  const lineas = [];

  for (const { productoId, cantidad } of items) {
    // Descontar solo si hay stock (atómico por documento, protegido por la transacción)
    const producto = tienda.productos.findOneAndUpdate(
      { _id: productoId, stock: { $gte: cantidad } },
      { $inc: { stock: -cantidad } },
      { returnDocument: "after" }
    );
    if (!producto) throw new Error(`Sin stock del producto ${productoId}`);
    lineas.push({ productoId, nombre: producto.nombre, cantidad, precio: producto.precio });
  }

  tienda.pedidos.insertOne({
    _id: 6,
    clienteId: 5,
    fecha: new Date(),
    estado: "pendiente",
    items: lineas,
    total: lineas.reduce((s, l) => s + l.cantidad * l.precio, 0)   // 1250 + 398 = 1648
  });

  session.commitTransaction();
  print("Pedido creado");
} catch (e) {
  session.abortTransaction();       // deshace TODO
  print("Cancelado: " + e.message);
} finally {
  session.endSession();
}

En Node con el driver oficial (o Mongoose) se usa session.withTransaction(async () => { ... }), que además reintenta automáticamente ante errores transitorios. Lo verás en la siguiente lección.

Las transacciones no reemplazan un buen modelo

Las transacciones multi-documento tienen costo (bloqueos, límite de 60 segundos por defecto). Si te descubres usándolas en casi todas las operaciones, quizá tu modelo debería embeber más. Úsalas para los casos realmente críticos: pagos, inventario, transferencias.

Ejercicios ​

  1. Crea los índices adecuados para: a) login por email (único), b) "mis pedidos" (clienteId + orden por fecha descendente), c) catálogo filtrado por categoría y ordenado por precio.
  2. Aplica ESR: ¿qué índice crearías para find({ categoria: "Electrónica", precio: { $lt: 2000 } }).sort({ stock: -1 })?
  3. Diseña el modelo para un blog: autores, artículos, etiquetas y comentarios (un artículo puede tener miles de comentarios). ¿Qué embebes y qué referencias?
  4. Crea una colección codigos_verificacion cuyos documentos se borren solos 10 minutos después de crearse.
  5. Ejecuta explain("executionStats") sobre db.productos.find({ etiquetas: "oficina" }) antes y después de crear el índice en etiquetas. ¿Qué cambia?
Ver solución
js
// 1
db.clientes.createIndex({ email: 1 }, { unique: true })
db.pedidos.createIndex({ clienteId: 1, fecha: -1 })
db.productos.createIndex({ categoria: 1, precio: 1 })

// 2  E = categoria, S = stock, R = precio
db.productos.createIndex({ categoria: 1, stock: -1, precio: 1 })
  1. Una propuesta:
js
// autores (colección): { _id, nombre, bio, avatarUrl }
// articulos (colección):
{
  _id: ObjectId("..."),
  titulo: "Guía de MongoDB",
  slug: "guia-de-mongodb",                       // índice único
  autor: { id: ObjectId("..."), nombre: "Ana López" },  // referencia extendida
  etiquetas: ["mongodb", "nosql"],               // array acotado embebido (índice multikey)
  contenido: "...",
  publicadoEn: ISODate("2024-07-01"),
  comentarios: { total: 1520, recientes: [ /* últimos 3 */ ] }   // subconjunto + calculado
}
// comentarios (colección, crece sin límite): { _id, articuloId, autorNombre, texto, fecha }
// índice: { articuloId: 1, fecha: -1 }
js
// 4
db.codigos_verificacion.createIndex({ creadoEn: 1 }, { expireAfterSeconds: 600 })
db.codigos_verificacion.insertOne({ email: "ana@correo.com", codigo: "482913", creadoEn: new Date() })
// (el proceso TTL corre cada ~60 s, así que puede tardar un poco más de 10 min)

// 5
db.productos.find({ etiquetas: "oficina" }).explain("executionStats")
// Antes: COLLSCAN, totalDocsExamined: 8, nReturned: 3
db.productos.createIndex({ etiquetas: 1 })
db.productos.find({ etiquetas: "oficina" }).explain("executionStats")
// Después: IXSCAN (índice multikey etiquetas_1), totalDocsExamined: 3, nReturned: 3

Resumen ​

  • Los índices evitan el COLLSCAN; crea simples, compuestos, únicos, parciales, TTL, de texto o wildcard según tus consultas.
  • Usa explain("executionStats"): busca IXSCAN y que totalDocsExamined se parezca a nReturned.
  • Ordena los índices compuestos con la regla ESR: igualdad, orden, rango.
  • Modela según cómo se leen los datos: lo que se lee junto, se guarda junto.
  • Embebe relaciones 1:1 y 1:pocos acotadas; referencia 1:muchos sin límite y entidades independientes.
  • Patrones útiles: subconjunto, calculado, cubeta y referencia extendida. Evita arrays sin límite.
  • Para operaciones críticas sobre varios documentos, usa transacciones.

Academia Web — aprende, copia y construye.