GL
Bases de Datos 22 de julio de 2025 16 min de lectura Por Gustavo Leyva

Optimización de consultas en MongoDB

Técnicas empresariales para optimizar MongoDB: índices, sharding, agregaciones, y estrategias de escalado horizontal usadas por empresas de alto tráfico.

#MongoDB #Bases de Datos #Performance #NoSQL #Escalabilidad
O

Introducción

MongoDB es una base de datos NoSQL popular utilizada por empresas como eBay, Adobe, Cisco y The New York Times. Sin una optimización adecuada, las consultas pueden volverse lentas a medida que los datos crecen. En este artículo, exploraremos técnicas empresariales para optimizar MongoDB y escalar a millones de documentos.

1. Índices efectivos

Los índices son fundamentales para el rendimiento de las consultas. Un índice bien diseñado puede reducir el tiempo de consulta de segundos a milisegundos.

// Crear un índice simple
db.usuarios.createIndex({ email: 1 });

// Índice compuesto (orden importa)
db.pedidos.createIndex({ usuario_id: 1, fecha: -1 });

// Índice de texto para búsquedas full-text
db.productos.createIndex({ 
  nombre: "text", 
  descripcion: "text" 
}, {
  weights: {
    nombre: 10,
    descripcion: 5
  }
});

// Índice único para prevenir duplicados
db.usuarios.createIndex({ email: 1 }, { unique: true });

// Índice parcial (solo documentos que cumplen condición)
db.pedidos.createIndex(
  { fecha: 1 },
  { partialFilterExpression: { estado: "completado" } }
);

// Índice TTL para expiración automática
db.sesiones.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 3600 } // 1 hora
);

Tipos de índices

  • Índice simple: En un solo campo
  • Índice compuesto: En múltiples campos (orden importa)
  • Índice de texto: Para búsquedas de texto completo
  • Índice geoespacial: Para consultas de ubicación
  • Índice hash: Para sharding
  • Índice parcial: Solo indexa documentos que cumplen condición
  • Índice TTL: Elimina documentos automáticamente

Estrategia de índices compuestos

// Consulta común
db.pedidos.find({ usuario_id: 123, estado: "pendiente" }).sort({ fecha: -1 });

// Índice óptimo: ESR (Equality, Sort, Range)
// 1. Equality: campos de igualdad primero
// 2. Sort: campos de ordenamiento
// 3. Range: campos de rango al final
db.pedidos.createIndex({ 
  usuario_id: 1,  // Equality
  fecha: -1,      // Sort
  estado: 1       // Range (si fuera rango)
});

2. Proyecciones

Limita los campos devueltos para reducir el uso de ancho de banda y memoria.

// ❌ Malo: devuelve todos los campos
db.usuarios.find({ edad: { $gte: 18 } });

// ✅ Bueno: solo campos necesarios
db.usuarios.find(
  { edad: { $gte: 18 } },
  { nombre: 1, email: 1, _id: 0 }
);

// Proyección con arrays
db.posts.find(
  { autor_id: 123 },
  { 
    titulo: 1,
    comentarios: { $slice: 10 } // Solo primeros 10 comentarios
  }
);

3. Pipeline de agregación eficiente

Ordena las etapas del pipeline para filtrar datos lo antes posible y usar índices.

// ✅ Pipeline optimizado
db.ventas.aggregate([
  // 1. $match primero para usar índices
  { $match: { 
    fecha: { $gte: new Date("2025-01-01") },
    estado: "completado"
  }},
  
  // 2. $project temprano para reducir datos
  { $project: {
    producto_id: 1,
    cantidad: 1,
    precio: 1,
    total: { $multiply: ["$cantidad", "$precio"] }
  }},
  
  // 3. $group después de filtrar
  { $group: {
    _id: "$producto_id",
    totalVentas: { $sum: "$total" },
    cantidadVendida: { $sum: "$cantidad" }
  }},
  
  // 4. $sort al final
  { $sort: { totalVentas: -1 } },
  
  // 5. $limit para reducir resultados
  { $limit: 10 }
]);

// Usar $lookup con pipeline para optimizar joins
db.pedidos.aggregate([
  { $match: { fecha: { $gte: new Date("2025-01-01") } }},
  { $lookup: {
    from: "usuarios",
    let: { usuario_id: "$usuario_id" },
    pipeline: [
      { $match: { $expr: { $eq: ["$_id", "$$usuario_id"] } }},
      { $project: { nombre: 1, email: 1 } } // Solo campos necesarios
    ],
    as: "usuario"
  }},
  { $unwind: "$usuario" }
]);

4. Explain para análisis

Usa explain() para entender cómo MongoDB ejecuta tus consultas.

// Modo executionStats: estadísticas detalladas
db.usuarios.find({ email: "usuario@ejemplo.com" })
  .explain("executionStats");

// Analizar resultados importantes:
// - executionTimeMillis: tiempo de ejecución
// - totalDocsExamined: documentos examinados
// - totalKeysExamined: claves de índice examinadas
// - stage: IXSCAN (usa índice) vs COLLSCAN (escaneo completo)

// Ejemplo de resultado óptimo:
// {
//   "executionStats": {
//     "executionTimeMillis": 2,
//     "totalDocsExamined": 1,
//     "totalKeysExamined": 1,
//     "executionStages": {
//       "stage": "IXSCAN" // ✅ Usa índice
//     }
//   }
// }

// Ejemplo de resultado malo:
// {
//   "executionStats": {
//     "executionTimeMillis": 1500,
//     "totalDocsExamined": 1000000,
//     "totalKeysExamined": 0,
//     "executionStages": {
//       "stage": "COLLSCAN" // ❌ Escaneo completo
//     }
//   }
// }

5. Evita operaciones costosas

// ❌ Evitar: $where con JavaScript (muy lento)
db.usuarios.find({ $where: "this.edad > 18" });

// ✅ Mejor: Operadores nativos
db.usuarios.find({ edad: { $gt: 18 } });

// ❌ Evitar: Regex sin ancla al inicio
db.productos.find({ nombre: /producto/ });

// ✅ Mejor: Regex con ancla (puede usar índice)
db.productos.find({ nombre: /^producto/ });

// ❌ Evitar: $ne y $nin (no usan índices eficientemente)
db.usuarios.find({ estado: { $ne: "inactivo" } });

// ✅ Mejor: Especificar valores positivos
db.usuarios.find({ estado: { $in: ["activo", "pendiente"] } });

6. Sharding para escalado horizontal

Sharding distribuye datos a través de múltiples servidores. Usado por empresas para escalar a petabytes.

// 1. Habilitar sharding en la base de datos
sh.enableSharding("midb");

// 2. Crear índice en shard key
db.usuarios.createIndex({ pais: 1, _id: 1 });

// 3. Shard collection
sh.shardCollection("midb.usuarios", { pais: 1, _id: 1 });

// Estrategias de shard key:

// ✅ Buena: Alta cardinalidad, distribución uniforme
// Ejemplo: { usuario_id: 1, fecha: 1 }

// ❌ Mala: Baja cardinalidad
// Ejemplo: { estado: 1 } // Solo pocos valores

// ❌ Mala: Monotónicamente creciente
// Ejemplo: { _id: 1 } // Todos los inserts van al mismo shard

// ✅ Buena: Hash sharding para distribución uniforme
sh.shardCollection("midb.eventos", { _id: "hashed" });

7. Read/Write Concerns

Balancea consistencia vs rendimiento según tus necesidades.

// Write Concern: garantías de escritura
db.pedidos.insertOne(
  { producto: "laptop", precio: 1000 },
  { writeConcern: { w: "majority", j: true, wtimeout: 5000 } }
);
// w: "majority" - mayoría de nodos confirman
// j: true - escrito en journal (durabilidad)
// wtimeout: timeout en ms

// Read Concern: garantías de lectura
db.pedidos.find({ estado: "pendiente" })
  .readConcern("majority");
// "local" - más rápido, puede leer datos no replicados
// "majority" - solo datos confirmados por mayoría
// "linearizable" - más lento, garantía de orden total

// Read Preference: de dónde leer
db.productos.find().readPref("secondaryPreferred");
// "primary" - solo del primario
// "secondary" - solo de secundarios
// "secondaryPreferred" - secundarios si disponibles, sino primario

8. Connection Pooling

const { MongoClient } = require('mongodb');

const client = new MongoClient(uri, {
  maxPoolSize: 50,        // Máximo de conexiones
  minPoolSize: 10,        // Mínimo de conexiones
  maxIdleTimeMS: 30000,   // Tiempo antes de cerrar conexión inactiva
  waitQueueTimeoutMS: 5000 // Timeout esperando conexión del pool
});

await client.connect();

9. Schema Design Patterns

Patrón de Embedding (Documentos embebidos)

// ✅ Bueno para relaciones 1-a-pocos
{
  _id: 1,
  nombre: "Juan",
  direcciones: [
    { calle: "Main St", ciudad: "NYC" },
    { calle: "2nd Ave", ciudad: "LA" }
  ]
}

Patrón de Referencia

// ✅ Bueno para relaciones muchos-a-muchos
// Usuario
{ _id: 1, nombre: "Juan" }

// Pedidos (referencia)
{ _id: 101, usuario_id: 1, total: 100 }
{ _id: 102, usuario_id: 1, total: 200 }

Patrón de Bucket

// ✅ Bueno para series temporales
{
  sensor_id: 123,
  fecha: ISODate("2025-07-22"),
  mediciones: [
    { timestamp: ISODate("2025-07-22T10:00:00"), temp: 20 },
    { timestamp: ISODate("2025-07-22T10:01:00"), temp: 21 },
    // ... hasta 1000 mediciones por documento
  ]
}

10. Monitoreo y profiling

// Habilitar profiling
db.setProfilingLevel(1, { slowms: 100 }); // Log queries > 100ms

// Ver queries lentas
db.system.profile.find()
  .sort({ ts: -1 })
  .limit(10)
  .pretty();

// Estadísticas de colección
db.usuarios.stats();

// Estadísticas de índices
db.usuarios.aggregate([{ $indexStats: {} }]);

// Monitorear operaciones actuales
db.currentOp();

// Matar operación lenta
db.killOp(operationId);

Caso de estudio: Optimización real

Problema: Consulta de dashboard tardaba 45 segundos

// ❌ Query original (45 segundos)
db.eventos.find({
  tipo: "compra",
  fecha: { $gte: new Date("2025-01-01") }
}).sort({ fecha: -1 });

// Análisis con explain:
// - COLLSCAN: 10M documentos examinados
// - Sin índice

Solución aplicada:

// 1. Crear índice compuesto
db.eventos.createIndex({ tipo: 1, fecha: -1 });

// 2. Usar proyección
db.eventos.find(
  { tipo: "compra", fecha: { $gte: new Date("2025-01-01") } },
  { usuario_id: 1, total: 1, fecha: 1 }
).sort({ fecha: -1 });

// ✅ Resultado: 150ms (300x más rápido)
// - IXSCAN: 50K documentos examinados
// - Usa índice compuesto

Conclusión

La optimización de MongoDB requiere un enfoque holístico que incluye:

  • Índices estratégicos: Reducen tiempo de consulta hasta 1000x
  • Sharding: Permite escalar horizontalmente a petabytes
  • Schema design: Embedding vs referencias según el caso de uso
  • Agregaciones optimizadas: Filtrar temprano, proyectar solo lo necesario
  • Monitoreo continuo: Profiling y explain para identificar cuellos de botella

Empresas como eBay y Adobe manejan billones de documentos aplicando estas técnicas. Con la optimización correcta, MongoDB puede escalar a cualquier volumen de datos manteniendo rendimiento sub-segundo.

GL

Gustavo Leyva

Desarrollador de Software

← Volver al blog