¿Qué es MongoDB?
MongoDB es una base de datos de documentos. En lugar de filas en tablas, almacena documentos —objetos similares a JSON codificados como BSON— dentro de colecciones. Un documento puede contener objetos y arrays anidados, por lo que un solo registro puede describir un agregado completo: un pedido con sus artículos, o un usuario con sus direcciones.
Surgió en 2009, cuando el modelo relacional se sentía pesado para aplicaciones que crecían rápido y cambiaban de forma. Su promesa era sencilla: almacenar el objeto que tu código ya posee y escalar horizontalmente sin migraciones dolorosas. Esa promesa lo convirtió en la base de datos NoSQL predeterminada para una generación de equipos de Node.js y JavaScript.
El compromiso es real. MongoDB renuncia a los joins por defecto y traslada la validación del esquema a tu aplicación. Cuando tus datos tienen forma de documento, esto es una ventaja. Cuando son profundamente relacionales, una base de datos relacional suele ser la mejor herramienta.
Documentos, colecciones y BSON
Un documento es un conjunto ordenado de pares campo-valor. Una colección es un grupo de documentos que generalmente comparten una estructura, aunque nada los obliga a ello. Las colecciones se crean en el momento en que realizas la primera inserción en ellas.
db.users.insertOne({
email: "[email protected]",
name: "Ada",
address: { city: "London", country: "GB" },
tags: ["admin", "beta"],
});
Los valores tienen tipos BSON, no solo tipos JSON. BSON es una codificación binaria que añade ObjectId, Date, Decimal128, BinData y más, permitiéndote almacenar fechas reales y decimales precisos en lugar de strings. El orden de los campos se preserva y los nombres de los campos distinguen entre mayúsculas y minúsculas.
Un hábito útil es darle a cada documento de una colección una estructura consistente, a pesar de que el servidor permita variaciones. De este modo, obtendrás consultas e índices predecibles, manteniendo la flexibilidad para cuando necesites añadir un campo a un solo documento primero.
_id y ObjectId
Cada documento necesita un _id único. Si no proporcionas uno, el driver genera un ObjectId: un valor de 12 bytes compuesto por una marca de tiempo (timestamp), un valor aleatorio por proceso y un contador incremental. Debido a que la marca de tiempo va primero, los valores de ObjectId se ordenan aproximadamente por tiempo de creación, lo cual es conveniente para la paginación.
const { ObjectId } = require("mongodb");
const id = new ObjectId("64f1c2a9e13b4a7d8c9e0011");
id.getTimestamp(); // 2023-09-01T...
El campo _id se indexa automáticamente y siempre es único. Puedes proporcionar tu propio valor —una clave natural, una cadena UUID o una clave compuesta— cuando esto haga que las búsquedas sean más eficientes o cuando necesites inserciones idempotentes.
CRUD en la práctica
Create, read, update y delete se mapean a un conjunto reducido de métodos que se comportan de la misma manera en mongosh y en el driver de Node.js.
// Create
db.users.insertOne({ email: "[email protected]", plan: "pro" });
db.users.insertMany([
{ email: "[email protected]", plan: "free" },
{ email: "[email protected]", plan: "team" },
]);
// Read
db.users.findOne({ email: "[email protected]" });
db.users.find({ plan: "pro" }).toArray();
// Update
db.users.updateOne(
{ email: "[email protected]" },
{ $set: { plan: "team" } },
);
// Delete
db.users.deleteOne({ email: "[email protected]" });
updateOne y updateMany reciben un filtro y un documento de actualización. El documento de actualización utiliza operadores: $set reemplaza o añade campos, $unset los elimina, $inc suma a un número, $push añade elementos al final de un array, $addToSet añade solo si el campo no existe, y $pull elimina elementos coincidentes de un array.
db.users.updateOne(
{ email: "[email protected]" },
{
$set: { lastSeenAt: new Date() },
$inc: { logins: 1 },
$addToSet: { tags: "beta" },
},
);
También existe replaceOne, que reemplaza el documento completo excepto _id. Es preferible usar operadores de campo para evitar que escrituras concurrentes sobrescriban los cambios de otros usuarios.
Consultas con operadores
Un filtro es, en sí mismo, un documento. La igualdad es el comportamiento por defecto y los operadores comienzan con $.
db.orders.find({ status: "paid" }); // equality
db.orders.find({ total: { $gt: 5000, $lte: 20000 } }); // range
db.orders.find({ status: { $in: ["paid", "shipped"] } });
db.users.find({ email: { $regex: /@example\.com$/i } });
db.orders.find({ "items.sku": "KB-01" }); // nested field
db.orders.find({
items: { $elemMatch: { qty: { $gte: 2 }, price: { $lt: 3000 } } },
});
$elemMatch es importante cuando varias condiciones deben aplicarse al mismo elemento de un array. Sin ello, { "items.qty": { $gte: 2 }, "items.price": { $lt: 3000 } } podría coincidir con elementos diferentes.
Combina filtros con $and, $or y $not:
db.orders.find({
$or: [
{ status: "paid" },
{ status: "pending", total: { $lt: 1000 } },
],
});
El lenguaje de consultas es componible porque es data, no una cadena de texto. Es por eso que construir filtros en el código de la aplicación se siente natural: ensamblas un objeto y lo pasas a find.
Proyección, ordenación y paginación
Una proyección selecciona qué campos devolver. Incluye campos con 1 o exclúyelos con 0, pero nunca mezcles ambos estilos excepto para _id.
db.users.find(
{ plan: "pro" },
{ email: 1, name: 1, _id: 0 },
);
Ordena y pagina con .sort(), .skip() y .limit(). Para offsets grandes, skip se vuelve más lento porque el servidor sigue recorriendo los documentos omitidos. Es preferible usar la paginación por keyset, donde filtras basándote en el último valor obtenido.
db.orders
.find({ customerId, createdAt: { $lt: lastSeen } })
.sort({ createdAt: -1 })
.limit(20);
Los índices en los campos de ordenación hacen que tanto el filtro como el orden sean eficientes, que es precisamente donde entra la siguiente sección.
Índices: la diferencia entre lo rápido y lo inutilizable
Sin un índice, MongoDB lee cada documento de la colección: un collection scan. Con uno, busca directamente el rango coincidente. Casi cualquier problema de rendimiento en MongoDB se debe a un índice faltante o mal ordenado.
db.users.createIndex({ email: 1 }, { unique: true });
db.orders.createIndex({ customerId: 1, createdAt: -1 });
db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
db.products.createIndex({ name: "text", description: "text" });
- Los índices de campo único (single-field) aceleran las consultas de igualdad y de rango en un solo campo.
- Los índices compuestos (compound) siguen la regla ESR: primero los campos de igualdad, luego el ordenamiento (sort) y finalmente el rango.
{ customerId: 1, createdAt: -1 }sirve tanto para el filtro como para el ordenamiento. - Los índices únicos (unique) rechazan duplicados y hacen que los upserts sean seguros.
- Los índices TTL eliminan documentos después de una fecha específica, lo cual es perfecto para sesiones y tokens de un solo uso.
- Los índices de texto (text) permiten la búsqueda
$textcon stemming y scoring.
Usa explain() para ver qué hizo el planificador. Busca IXSCAN en lugar de COLLSCAN, y verifica que el número de documentos examinados sea cercano al número de documentos devueltos.
db.orders.find({ customerId, status: "paid" }).explain("executionStats");
El pipeline de agregación
Cuando una consulta no es suficiente, el framework de agregación ejecuta un pipeline de etapas. Cada etapa transforma un flujo de documentos y lo pasa a la siguiente. Las etapas más comunes son $match, $group, $sort, $project, $lookup, $unwind y $limit.
Aquí tienes un ejemplo práctico: los cinco productos con mayores ingresos en pedidos pagados.
db.orders.aggregate([
{ $match: { status: "paid" } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku",
units: { $sum: "$items.qty" },
revenue: {
$sum: { $multiply: ["$items.qty", "$items.price"] },
},
},
},
{ $sort: { revenue: -1 } },
{ $limit: 5 },
{
$project: {
_id: 0,
sku: "$_id",
units: 1,
revenue: 1,
},
},
]);
Léelo de arriba abajo. $match filtra los datos al principio para que las etapas posteriores tengan menos trabajo. $unwind convierte cada elemento del array en su propio documento. $group acumula las unidades y los ingresos por SKU. $sort y $limit mantienen los cinco primeros. $project renombra _id a sku y elimina el resto.
$lookup realiza un left outer join con otra colección, que es la forma de combinar datos referenciados:
db.orders.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer",
},
},
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.email": 1 } },
]);
Coloca siempre $match primero para que pueda utilizar un índice, y sitúa $limit lo más pronto posible según lo permita la lógica del proceso. La agregación es potente, pero un pipeline que escanea cada documento en cada etapa es una receta para tener consultas lentas.
Embeber vs referenciar
Esta es la decisión central de modelado. Pregúntate cómo se leen los datos.
Embebe (Embed) cuando los datos hijos se lean junto con el padre, se escriban con él y tengan un tamaño limitado. Los artículos de un pedido son el caso clásico: una sola lectura devuelve todo y no hay join.
{
_id: ObjectId("..."),
customerId: ObjectId("..."),
items: [
{ sku: "KB-01", qty: 1, price: 8900 },
{ sku: "MS-02", qty: 2, price: 2900 },
],
total: 14700,
}
Referencia cuando los datos son voluminosos, compartidos o se actualizan de forma independiente. Los usuarios, productos y categorías son referenciados por _id, y $lookup o una segunda consulta realiza el join.
{
_id: ObjectId("..."),
customerId: ObjectId("64f1c2a9e13b4a7d8c9e0011"),
items: [{ productId: ObjectId("..."), qty: 1, price: 8900 }],
}
La regla de oro es que los datos que se acceden juntos deben almacenarse juntos. Duplica un poco de información cuando eso convierta la lectura más común en una sola búsqueda, pero recuerda que las copias deben actualizarse en cada lugar donde residan. Y nunca embebas un array que crezca sin límite: los documentos tienen un tope de 16 MB.
Transacciones
Las escrituras de un solo documento son atómicas. Si necesitas modificar varios documentos o colecciones como una sola unidad, utiliza una transacción multi-documento. Las sesiones son el mecanismo para lograrlo.
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne(
{ _id: from },
{ $inc: { balance: -100 } },
{ session },
);
await accounts.updateOne(
{ _id: to },
{ $inc: { balance: 100 } },
{ session },
);
});
} finally {
await session.endSession();
}
Las transacciones requieren un replica set o un sharded cluster, y conllevan una carga adicional: bloqueos, una ventana de tiempo más larga y reintentos en caso de conflicto. El mejor uso de las transacciones es aquel que es poco frecuente. Si te encuentras envolviendo cada escritura en una transacción, es probable que tu modelo de datos necesite más embedding.
Replica sets y sharding
Un replica set es un grupo de nodos que contienen los mismos datos. Uno de ellos es el primario y gestiona todas las escrituras; los demás replican el oplog del primario y pueden atender lecturas. Si el primario falla, el set elige uno nuevo automáticamente. Este es el despliegue predeterminado para producción y también permite el uso de change streams y transacciones.
El sharding particiona una colección a través de múltiples replica sets mediante una shard key. Cada shard es dueño de un rango de valores de la clave. Una buena shard key tiene una alta cardinalidad, distribuye las escrituras de manera uniforme y aparece en la mayoría de las consultas para que el router pueda dirigirse a un único shard. Una clave deficiente crea un hotspot o fuerza a que cada consulta se distribuya (fan out) a todos los shards.
Elige la shard key antes de tener datos, ya que cambiarla más tarde implica migrar la colección. Para la mayoría de las aplicaciones, comienza con un replica set y recurre al sharding solo cuando un único primario ya no sea capaz de soportar la carga.
Mongoose en Node
El driver oficial de mongodb es todo lo que necesitas para realizar consultas, pero la mayoría de los equipos de Node utilizan Mongoose por sus esquemas, validación y modelos. Un esquema describe la estructura de un documento, y un modelo es la clase consultable construida a partir de él.
import mongoose from "mongoose";
const orderSchema = new mongoose.Schema({
customerId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
status: {
type: String,
enum: ["pending", "paid", "shipped"],
default: "pending",
},
items: [
{
sku: String,
qty: { type: Number, min: 1 },
price: { type: Number, min: 0 },
},
],
total: Number,
createdAt: { type: Date, default: Date.now, index: true },
});
export const Order = mongoose.model("Order", orderSchema);
const paid = await Order.find({ status: "paid" })
.sort({ createdAt: -1 })
.limit(20)
.lean();
Mongoose valida al guardar, realiza la conversión de tipos (casting) y te ofrece populate() para las referencias. Dos hábitos ayudan a mantenerlo rápido: declarar los índices que requieren las consultas y llamar a .lean() cuando solo necesites leer datos, lo que evita la creación de documentos completos de Mongoose.
Escrituras masivas y upserts
Ejecutar un comando por documento desperdicia viajes de ida y vuelta (round trips). Cuando necesitas aplicar muchos cambios, bulkWrite envía un lote en una sola llamada e informa exactamente qué sucedió.
await db.users.bulkWrite([
{
updateOne: {
filter: { email: "[email protected]" },
update: { $set: { plan: "team" } },
upsert: true,
},
},
{
insertOne: {
document: { email: "[email protected]", plan: "pro" },
},
},
{
deleteOne: { filter: { email: "[email protected]" } },
},
]);
La opción upsert es otra herramienta fundamental. Actualiza un documento que coincida o inserta uno si no existe, lo que facilita la creación de contadores e importaciones idempotentes.
db.stats.updateOne(
{ day: "2026-09-16" },
{ $inc: { visits: 1 } },
{ upsert: true },
);
Las escrituras masivas no son transacciones por defecto. Pasa { ordered: false } para continuar después de un error y recopilar cada fallo, o mantén el modo ordenado predeterminado cuando cada escritura dependa de la anterior. En cualquier caso, revisa el objeto de resultado: este contabiliza los documentos coincidentes, modificados, insertados y eliminados, y un upsert que no encontró ninguna coincidencia no se considera un error.
Change streams
Un replica set registra cada escritura en un oplog. Los change streams exponen ese log como un feed reanudable, permitiendo que una aplicación reaccione a inserciones, actualizaciones y eliminaciones sin necesidad de hacer polling.
const changeStream = db.orders.watch([
{ $match: { "fullDocument.status": "paid" } },
]);
for await (const change of changeStream) {
console.log(change.operationType, change.fullDocument._id);
}
El feed es reanudable: guarda el _id del último evento procesado como un resume token y envíalo de vuelta con resumeAfter después de un reinicio para que no se pierda ningún evento. Debido a que los change streams se ejecutan sobre el oplog, requieren un replica set y solo informan sobre los cambios que aún no han sido eliminados del log.
Dos reglas garantizan su fiabilidad. Haz que el consumidor sea idempotente, ya que un evento puede entregarse nuevamente después de una reanudación. Y mantén el procesamiento rápido o delega el trabajo a una cola, ya que un consumidor lento permite que el oplog avance más allá de su posición.
Mejores prácticas
- Modela para la lectura: embebe lo que se lee en conjunto, referencia lo que es compartido o no tiene límites.
- Indexa los campos por los que filtres y ordenes, y sigue la regla ESR para los índices compuestos.
- Proyecta siempre solo los campos que el cliente necesita; nunca devuelvas secretos.
- Coloca
$matchprimero en un pipeline de agregación para que pueda utilizar un índice. - Limita el crecimiento de los arrays y vigila el límite de 16 MB por documento.
- Valida las escrituras en la aplicación o mediante un validador JSON Schema de la colección.
- Usa
explain()antes de asumir que una consulta es correcta, y realiza pruebas con datos de tamaño real de producción. - Prefiere la paginación por keyset sobre offsets
skipgrandes.
Errores comunes
- Tratar MongoDB como si no tuviera esquema y permitir que los documentos evolucionen hacia formas incompatibles.
- Ejecutar consultas sin índices y preguntarse por qué la latencia aumenta a medida que crece la colección.
- Crear índices compuestos en el orden incorrecto, impidiendo que el ordenamiento (sort) pueda utilizarlos.
- Embeber un array que crece indefinidamente hasta que los documentos alcanzan el límite de tamaño.
- Usar
$lookupen cada solicitud en lugar de embeber los datos que se leen juntos. - Recurrir a transacciones multi-documento cuando una actualización de un solo documento sería suficiente.
- Retornar documentos completos y filtrar hashes de contraseñas o tokens.
- Elegir una shard key de baja cardinalidad y crear un hotspot de escritura.
Próximos pasos
MongoDB enseña modelado de documentos, diseño de índices y agregaciones; habilidades que son transferibles a cualquier almacén de datos. Si tus datos son profundamente relacionales y requieren joins y restricciones a nivel de base de datos, lee la guía de PostgreSQL. Para lecturas de microsegundos, expiración basada en TTL y contadores, combina MongoDB con Redis. Si quieres conectarlo a un servicio de Node, repasa los conceptos básicos de Node.js y luego compara el modelo de consultas con SQL.