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.
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.
Gustavo Leyva
Desarrollador de Software