Datos y bases de datos
MongoDB y el modelo de documentos: diseño de esquema, agregación y sharding
Intermedio9 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
MongoDB no es una base de datos relacional a la que le han quitado las restricciones: premia otro instinto de modelado, en el que la unidad de almacenamiento es aquello que lees de una sola vez. Esta guía cubre el modelo de documentos y BSON, los patrones de diseño de esquema, el pipeline de agregación, la elección de índices, la durabilidad en un replica set, la elección de la shard key y dónde encajan de verdad las transacciones multidocumento.
Lo que aprenderás
- Explicar el modelo de documentos, los tipos BSON y los límites que condicionan un diseño
- Elegir entre embeber y referenciar, y aplicar los patrones extended reference y bucket
- Escribir pipelines de agregación que filtren pronto y den forma a los datos en el servidor
- Diseñar índices con la regla igualdad-orden-rango y verificarlos con explain
- Razonar sobre las elecciones en un replica set, los read y write concerns, el sharding y las transacciones
MongoDB es la base de datos que más a menudo se adopta por el motivo equivocado y se abandona por el correcto. El motivo equivocado es «no queremos definir un esquema»; el motivo correcto para irse es descubrir, dos años después, que los datos eran relacionales desde el principio. Usado con criterio, sin embargo, el modelo de documentos resuelve un problema real: cuando lo que guardas y lo que lees son el mismo objeto autocontenido, mantenerlo en un único documento elimina joins, idas y vueltas a la base de datos y código de mapeo.
Esta guía cubre MongoDB 8 y da por hecho que ya conoces el modelado relacional. Los hábitos que se trasladan son los que tienen que ver con los patrones de acceso; los que tienen que ver con las formas normales, en su mayoría, no, y saber distinguir unos de otros es justo en lo que consiste toda la habilidad.
Documentos, BSON y lo que implican
Un documento es un conjunto de pares campo-valor almacenado en BSON, una codificación binaria de JSON con tipos adicionales: ObjectId (un identificador de 12 bytes que incluye una marca de tiempo), Date, Decimal128 para importes exactos, Binary, enteros de 32 y 64 bits y expresiones regulares. Los documentos viven en colecciones y las colecciones, en una base de datos. Cada documento tiene un _id único, indexado automáticamente.
{
"_id": { "$oid": "665f1a2b7c9e4f0012ab34cd" },
"sku": "BK-1042",
"title": "Field Guide to Estuaries",
"price": { "$numberDecimal": "24.90" },
"tags": ["nature", "reference"],
"dimensions": { "widthMm": 130, "heightMm": 198 },
"stock": [
{ "warehouse": "LYON", "quantity": 12 },
{ "warehouse": "PORTO", "quantity": 3 }
]
}
Tres propiedades de esa estructura determinan todo lo demás. Los campos pueden anidarse sin límite, así que un agregado entero cabe en un solo registro. Los arrays son ciudadanos de primera clase y se pueden indexar, así que una relación uno a muchos no necesita una segunda colección. Y un documento está limitado a 16 MB, que es una restricción de diseño disfrazada de límite técnico: cualquier array que pueda crecer sin límite es un error de modelado, no un problema de capacidad.
Una escritura sobre un solo documento es atómica, por muy anidado que esté. Esa garantía es la razón de que el modelado documental funcione: si los datos que deben cambiar juntos viven en un mismo documento, obtienes comportamiento transaccional gratis.
Los documentos no son «sin esquema» en ningún sentido útil: tu código depende de que los campos existan y tengan el tipo esperado. Declara esa dependencia:
db.createCollection("product", {
validator: { $jsonSchema: {
bsonType: "object",
required: ["sku", "title", "price"],
properties: {
sku: { bsonType: "string", pattern: "^[A-Z]{2}-[0-9]{4}$" },
price: { bsonType: "decimal", description: "list price, excl. tax" }
}
}},
validationLevel: "strict"
});
Leer y escribir
mongosh es el shell, y las API de los drivers lo reflejan muy de cerca.
db.product.insertOne({ sku: "BK-1042", title: "Field Guide to Estuaries", price: NumberDecimal("24.90") });
db.product.find({ tags: "nature", "price": { $lt: NumberDecimal("30") } })
.sort({ title: 1 }).limit(20);
// Buscar un mismo elemento del array, no dos condiciones independientes
db.product.find({ stock: { $elemMatch: { warehouse: "LYON", quantity: { $gt: 0 } } } });
db.product.updateOne(
{ sku: "BK-1042", "stock.warehouse": "LYON" },
{ $inc: { "stock.$.quantity": -1 }, $set: { updatedAt: new Date() } }
);
Hay dos detalles que despistan a quien empieza. find({ "stock.warehouse": "LYON", "stock.quantity": { $gt: 0 } }) encuentra un documento en el que algún elemento tiene ese almacén y algún elemento tiene stock, no necesariamente el mismo; lo que querías era $elemMatch. Y los operadores de actualización ($set, $inc, $push, $pull, $addToSet) modifican los campos en su sitio, mientras que pasar un documento a secas lo reemplaza por completo y borra sin avisar todo lo que hayas omitido. Cuando una actualización anidada no se comporta como esperas, pegar el documento en un formateador de JSON para ver su forma real es más rápido que forzar la vista con la salida del shell.
Modelado: embeber o referenciar
El instinto relacional es normalizar hasta que cada hecho viva en un único sitio. El instinto documental es guardar junto lo que se lee junto y aceptar la duplicación cuando el duplicado es inmutable o barato de refrescar. Ninguno de los dos acierta siempre; la pregunta es siempre qué pide la aplicación.
Embebe cuando el hijo no tiene vida propia, su número está acotado y se lee con su padre: las líneas de un pedido dentro del pedido, una dirección dentro de un cliente, el stock por almacén dentro de un producto. Una lectura, una escritura atómica, ningún join.
Referencia cuando el hijo es grande, no está acotado, lo comparten varios padres o se consulta por separado: un autor referenciado por miles de libros o un registro de auditoría que crece sin fin.
Cuatro patrones cubren la mayor parte del terreno intermedio:
- Extended reference. Guarda la referencia más los dos o tres campos que siempre muestras:
{ "authorId": ..., "authorName": "..." }. Así eliminas la búsqueda de la ruta de lectura. Es desnormalización, así que decide de antemano cómo se propaga un cambio de nombre: a menudo con un job en segundo plano y, a veces, de ninguna manera, porque el valor es histórico. - Bucket. Para series temporales de gran volumen, agrupa muchas lecturas en un documento por dispositivo y hora en lugar de un documento por lectura. Menos documentos, un índice más pequeño y muchísima menos sobrecarga. Las colecciones time series nativas de MongoDB implementan esto internamente.
- Computed. Guarda un total acumulado o un contador junto a los datos en bruto y mantenlo con
$incal escribir, en lugar de agregar en cada lectura. - Subset. Mantén en el documento padre los diez elementos del array más recientes o más relevantes y el historial completo en una colección aparte, para que el documento más consultado siga siendo pequeño.
El antipatrón que conviene nombrar explícitamente es el array sin límite: comentarios embebidos en un post, eventos embebidos en un usuario. Acaba superando los 16 MB y, mucho antes, cada actualización reescribe un documento que no para de crecer. El razonamiento relacional sobre claves y dependencias que se explica en diseño de bases de datos y normalización sigue ayudando aquí: estás eligiendo qué dependencias colocar físicamente juntas, no fingiendo que no existen.
El pipeline de agregación
Una agregación es una secuencia de etapas (stages), cada una de las cuales transforma el flujo de documentos que recibe. Es donde MongoDB hace el trabajo que SQL hace con GROUP BY, los joins y las funciones de ventana.
db.order.aggregate([
{ $match: { placedAt: { $gte: ISODate("2026-01-01") }, status: "SHIPPED" } },
{ $unwind: "$lines" },
{ $group: {
_id: "$lines.sku",
units: { $sum: "$lines.quantity" },
revenue: { $sum: { $multiply: ["$lines.quantity", "$lines.unitPrice"] } }
}},
{ $sort: { revenue: -1 } },
{ $limit: 10 },
{ $lookup: { from: "product", localField: "_id", foreignField: "sku", as: "product" } },
{ $project: { _id: 0, sku: "$_id", units: 1, revenue: 1, title: { $first: "$product.title" } } }
]);
La regla más importante es poner $match primero, y $sort justo después si un índice puede servir a ambos: son las únicas etapas que pueden usar un índice, y solo mientras sigan al principio del pipeline. Todo lo que va después de $unwind o $group opera sobre documentos intermedios materializados.
Otras etapas que conviene conocer: $facet ejecuta varios subpipelines sobre la misma entrada, que es como calculas resultados y sus recuentos agregados en una sola ida y vuelta; $bucket y $bucketAuto construyen histogramas; $merge y $out escriben los resultados en una colección, lo que convierte un pipeline en una vista materializada programada. $lookup hace un left outer join, y funciona, pero un pipeline que necesita tres te está diciendo que el modelo es relacional.
Índices
Las reglas de indexación se parecen a las relacionales de la guía de planes de ejecución, con algunos matices propios de MongoDB:
- Los índices compuestos siguen la regla ESR: primero los campos de igualdad (Equality), después los de ordenación (Sort) y por último los de rango (Range). Poner la ordenación antes que el rango es lo que evita una ordenación en memoria, y MongoDB aborta las ordenaciones que superan los 100 MB salvo que permitas el uso de disco.
- Los índices multikey son lo que obtienes automáticamente al indexar un campo de tipo array; cada elemento se convierte en una entrada. No puedes combinar dos campos array en un mismo índice compuesto.
- Los índices parciales (
partialFilterExpression) indexan solo los documentos que cumplen el filtro, ideal para un pequeño subconjunto «pendiente» de una colección grande. Sparse es la versión más antigua y menos precisa. - Los índices TTL borran documentos cuando ha pasado cierto tiempo desde un campo de fecha, que es como caducas sesiones o logs sin un job de purga.
- Los índices wildcard cubren nombres de campo desconocidos en atributos definidos por el usuario. Son cómodos, y mucho más grandes que un índice específico.
- Los índices text y vector permiten búsquedas por palabra clave y por similitud; para una relevancia de texto completo seria, sigue siendo mejor un motor dedicado, como explica la guía de Solr.
Verifica con explain, leyéndolo igual que un plan relacional:
db.order.find({ status: "SHIPPED", placedAt: { $gte: ISODate("2026-01-01") } })
.sort({ placedAt: -1 })
.explain("executionStats");
Compara totalKeysExamined, totalDocsExamined y nReturned. Si los documentos examinados superan con mucho a los devueltos, el índice filtra mal; si la etapa ganadora es un COLLSCAN, el índice ni siquiera se está usando.
Replica sets, sharding y consistencia
Un replica set es un grupo de procesos mongod que guardan los mismos datos: un primario que acepta las escrituras y secundarios que replican su oplog. Si el primario deja de estar accesible, los miembros restantes celebran una elección y una mayoría elige un nuevo primario, normalmente en pocos segundos. Ejecuta siempre un número impar de miembros con voto repartidos en dominios de fallo distintos, y recurre a un árbitro solo como último recurso.
La durabilidad y la frescura se eligen en cada operación, no con ajustes globales del servidor:
- Write concern
{ w: "majority" }confirma la escritura solo cuando la tiene una mayoría, lo que la hace duradera ante un failover.w: 1confirma en el primario, y la escritura puede revertirse (rollback) si ese primario cae. Añadej: truepara exigir que el journal esté en disco. - Read concern
"local"devuelve lo que tenga el nodo;"majority"devuelve solo datos que ya no pueden revertirse;"snapshot"ofrece una vista coherente de un instante concreto dentro de una transacción. - Read preference decide qué miembro atiende la lectura.
secondaryPreferredreparte la carga a costa de posibles datos desactualizados: un buen compromiso para reporting, uno malo para flujos en los que tienes que leer tu propia escritura.
El sharding reparte una colección entre shards según una shard key. Cada shard es a su vez un replica set; los routers mongos dirigen las consultas y los servidores de configuración guardan los metadatos. Una consulta que incluye la shard key se enruta a un solo shard; una que no la incluye se difunde a todos, y esa es la diferencia entre un milisegundo y un fan-out por todo el clúster.
Elige la shard key según tres criterios: alta cardinalidad, distribución uniforme de las escrituras y presencia en tus filtros de consulta habituales. Un timestamp falla en el segundo: todas las inserciones caen en el shard que posee el rango más alto. Aplicar un hash a la clave reparte las escrituras de forma uniforme, pero destruye las consultas por rango. En la práctica, una clave compuesta como { tenantId: 1, orderId: "hashed" } suele cumplir los tres. Las versiones modernas permiten refinar una shard key e incluso hacer resharding de una colección en producción, pero es una operación pesada; intenta acertar aproximadamente desde el principio. Y no hagas sharding antes de tiempo: un replica set bien indexado en un hardware decente aguanta mucho más de lo que la mayoría de los equipos supone, y el sharding añade un peso operativo real, un tema al que vuelve la guía de arquitectura big data.
Las transacciones abarcan varios documentos y colecciones con aislamiento snapshot, tanto en replica sets como en clústeres con sharding. Son la herramienta adecuada para una transferencia entre dos cuentas guardadas en dos documentos. Son la herramienta equivocada para algo que un modelo mejor habría convertido en una actualización de un solo documento, y tienen un coste real en latencia y contención de bloqueos.
Puntos clave
- Un documento es atómico en escritura, así que guarda junto lo que cambia junto; el límite de 16 MB es una restricción de modelado que prohíbe los arrays sin límite.
- Embebe los datos acotados, dependientes y que se leen juntos; referencia los datos compartidos o sin límite; usa los patrones extended reference, bucket, computed y subset para los casos intermedios.
- Los pipelines de agregación deben empezar con
$match(y un$sortbien colocado) para usar un índice;$facet,$mergey$bucketcubren las necesidades de reporting sin idas y vueltas adicionales. - Construye los índices compuestos en orden igualdad-orden-rango y confirma con
explain("executionStats")que los documentos examinados se mantienen cerca de los devueltos. - Write concern, read concern y read preference se deciden en cada operación;
w: "majority"es lo que hace que una escritura sobreviva a un failover. - Haz sharding solo cuando no quede otra, con una clave de alta cardinalidad, escrituras bien repartidas y presencia en tus filtros habituales, y valida con
$jsonSchemael esquema del que ya dependes.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿De verdad MongoDB no tiene esquema?
- Por defecto no impone ningún esquema en escritura, que no es lo mismo que no tenerlo. Tu código de aplicación, tus índices y cada consulta que escribes codifican un esquema, lo declares o no. Las colecciones admiten reglas de validación con JSON Schema, y aplicarlas a los campos de los que dependes convierte una corrupción silenciosa en un error inmediato, que es normalmente lo que quieres en producción.
- ¿Es seguro usar transacciones de MongoDB en código normal?
- Sí, desde la versión 4.0 en replica sets y la 4.2 en clústeres con sharding, y ofrecen aislamiento snapshot con commit de todo o nada. También son más caras que una escritura sobre un solo documento, que ya es atómica. Trátalas como la excepción para operaciones que de verdad abarcan varios documentos, no como el envoltorio por defecto, y diseña el modelo para que la mayoría de las actualizaciones toquen un único documento.
- ¿Cómo elijo una shard key?
- Necesitas alta cardinalidad, una distribución uniforme de las escrituras y, idealmente, que coincida con el filtro de tus consultas más habituales para que las lecturas vayan a un solo shard. Una clave monótonamente creciente, como un timestamp, manda todas las inserciones al mismo shard; aplicarle un hash arregla la distribución pero rompe las consultas por rango. Una clave compuesta que combine un tenant o una categoría con un componente hasheado suele ser el mejor compromiso.
- ¿Cuándo debería usar MongoDB en lugar de PostgreSQL?
- Cuando tus datos llegan de verdad como documentos autocontenidos y de forma variable que lees completos, y cuando escalar horizontalmente las escrituras importa más que hacer joins entre muchas entidades. Si tus patrones de acceso son relacionales —relaciones muchos a muchos, joins ad hoc, necesidades fuertes de reporting—, un motor relacional con una columna jsonb suele servirte mejor.