Visión general
Las bases de datos NoSQL abandonaron el rígido modelo de tabla-fila en favor de esquemas flexibles optimizados para patrones de acceso concretos. Los almacenes de documentos (MongoDB), clave-valor (Redis), columnas anchas (Cassandra, DynamoDB) y grafos (Neo4j) siguen principios de modelado distintos, y un esquema que funciona de maravilla en uno puede ser un desastre en otro.
Esta guía es para ingenieros que ya conocen el modelado relacional y necesitan diseñar — o arreglar — un esquema NoSQL. Cubre la decisión central de todo modelo NoSQL (embeber o referenciar), el patrón estrella de cada familia de motores y los límites operativos que convierten un diseño limpio en un incidente de producción cuando los ignoras.
Cuándo usar esta guía
Recurre a esta guía cuando:
- Tu esquema evoluciona con frecuencia y las migraciones en un modelo relacional serían dolorosas
- Tus patrones de lectura son conocidos de antemano y quieres servir cada uno con una sola consulta
- Necesitas escalado horizontal más allá de lo que ofrece un primario relacional
- Tus datos son naturalmente jerárquicos (documentos) o cargados de relaciones (grafos)
- Necesidades de throughput extremo o latencia de milisegundos de un dígito justifican un almacén especializado
Si aún estás decidiendo qué motor NoSQL encaja con tu carga de trabajo, lee primero Selección de Base NoSQL — MongoDB, DynamoDB, Cassandra y vuelve aquí para el trabajo de modelado.
La regla central: modela para tus consultas
En el modelado relacional normalizas primero y respondes consultas con joins. NoSQL invierte ese orden: anotas los patrones de acceso que tu aplicación realmente ejecuta y luego das forma a los datos para que cada uno se resuelva en una sola lectura.
Concretamente, antes de tocar un esquema, enumera:
- Cada consulta que corre tu app — con su frecuencia. Una consulta que se ejecuta 10.000 veces por segundo merece una estructura dedicada; una que corre cada hora puede tolerar un escaneo o un join en la aplicación.
- La cardinalidad de cada relación — ¿es uno-a-pocos (los tags de un post), uno-a-muchos (los ítems de una orden) o uno-a-infinito (los eventos de un usuario)? La cardinalidad es lo que decide entre embeber y referenciar.
- La proporción lectura/escritura — los datos de lectura intensa pueden desnormalizarse agresivamente; los de escritura intensa pagan cada campo duplicado en cada actualización.
- Los requisitos de consistencia — qué campos pueden estar desactualizados unos segundos y cuáles ni un segundo.
Un modelo normalizado responde “¿qué son los datos?”. Un modelo NoSQL responde “¿cómo se leen?”. Si tu primera pregunta de modelado es sobre entidades y no sobre consultas, todavía estás diseñando para SQL.
Embebido vs Referenciado
La elección fundamental en el modelado de documentos: ¿anidas los datos relacionados dentro del documento padre, o los guardas aparte y los enlazas por ID?
| Enfoque | Mejor para | Compromiso |
|---|---|---|
| Embebido | Uno-a-pocos, datos leídos juntos, raramente actualizados por separado | Documentos más grandes, duplicación al actualizar |
| Referenciado | Uno-a-muchos, crecimiento ilimitado, actualizaciones independientes | Requiere joins a nivel de aplicación |
// MongoDB: Embebido (orden con ítems)
{
_id: "order-001",
customerId: "cust-123",
items: [
{ productId: "p1", name: "Widget", qty: 2, price: 10.00 },
{ productId: "p2", name: "Gadget", qty: 1, price: 25.00 }
],
total: 45.00
}
// MongoDB: Referenciado (colecciones separadas)
// colección orders
{ _id: "order-001", customerId: "cust-123", itemIds: ["li-1", "li-2"] }
// colección line_items
{ _id: "li-1", productId: "p1", name: "Widget", qty: 2, price: 10.00 }
{ _id: "li-2", productId: "p2", name: "Gadget", qty: 1, price: 25.00 }
El árbol de decisión de arriba codifica las reglas prácticas que valen para cualquier base de documentos:
- Embebe cuando la lista hija es pequeña — las direcciones de un usuario (2-3), los tags de un post (5-10). Todo lo acotado por una regla de negocio, y no por el crecimiento de usuarios, es seguro de embeber.
- Referencia cuando la cardinalidad es ilimitada — comentarios, eventos, feeds de actividad. Una lista embebida que crece para siempre acaba chocando con el límite de tamaño del documento y obliga a una migración de emergencia bajo carga.
- Cuidado con los límites duros — los documentos de MongoDB tienen tope de 16 MB, y WiredTiger reescribe el documento completo en cada actualización. Un documento de 200 KB con 1.000 comentarios embebidos significa que cada edición de comentario los reescribe todos.
- La duplicación no es gratis en escritura — embeber copia datos; cuando esos datos cambian, actualizas cada copia. Si el nombre de un producto aparece en 50.000 documentos de órdenes, renombrarlo es una migración, no un UPDATE.
También hay un punto medio que conviene conocer: referencia extendida — embebes unos pocos campos (nombre, avatar) para mostrarlos mientras referencias la entidad completa. Lecturas de una sola consulta sin duplicar todo lo que cambia.
Diseño de tabla única en DynamoDB
DynamoDB no tiene joins en absoluto, así que las entidades relacionadas viven en una tabla, separadas por prefijos de clave en lugar de colecciones.
// DynamoDB: Tabla única con GSI sobrecargado
{
"PK": "USER#123",
"SK": "PROFILE",
"name": "Alice",
"email": "alice@example.com"
}
{
"PK": "USER#123",
"SK": "ORDER#001",
"total": 45.00,
"status": "shipped"
}
{
"PK": "ORDER#001",
"SK": "DETAIL",
"items": [...]
}
// Consultar todas las órdenes de un usuario
Query PK = "USER#123" AND begins_with(SK, "ORDER#")
// Consultar detalles de la orden
Query PK = "ORDER#001"
Dos cosas hacen o rompen un diseño de tabla única:
Particiones calientes. DynamoDB particiona datos por clave de partición. Si USER#123 concentra el 40 % del tráfico, esa partición se convierte en un techo de throughput que ninguna capacidad provisionada arregla. Las claves de alta cardinalidad (IDs de usuario, timestamps con entropía) reparten la carga; las de baja cardinalidad (status, type) la concentran. Cuando una clave caliente es inevitable, usa write sharding — añade un sufijo a la clave (ORDER#001#shard-3) y reparte las lecturas entre los sufijos.
Sobrecarga de GSI. Un Global Secondary Index puede servir un patrón de acceso completamente distinto sobre los mismos ítems. Guarda SK = "ORDER#001" bajo GSI1PK = "USER#123" y el índice te da órdenes-por-usuario gratis. Una tabla, muchas formas de consulta — ese es el truco completo. El coste: cada GSI replica almacenamiento y añade amplificación de escritura, así que diseña índices solo para las consultas que realmente ejecutas.
Patrón de fila ancha en Cassandra
El modelo mental de Cassandra es “mapa ordenado dentro de una partición”. Tú eliges la clave de partición, y las filas dentro de esa partición se guardan pre-ordenadas por columnas de clustering — que es exactamente lo que hace baratas las consultas de series temporales.
-- Datos de series temporales: una fila por sensor, columnas por buckets de tiempo
CREATE TABLE sensor_readings (
sensor_id UUID,
day DATE,
hour INT,
minute INT,
temperature DOUBLE,
humidity DOUBLE,
PRIMARY KEY ((sensor_id, day), hour, minute)
) WITH CLUSTERING ORDER BY (hour DESC, minute DESC);
-- Consulta: últimas 24 horas de un sensor
SELECT * FROM sensor_readings
WHERE sensor_id = ? AND day >= ?;
La clave primaria hace todo el trabajo aquí. (sensor_id, day) es la clave de partición — decide qué nodo guarda los datos y mantiene el día de un sensor contiguo. hour, minute son columnas de clustering que ordenan dentro de la partición, así que “últimas lecturas” es una lectura secuencial sin índice. ALLOW FILTERING es la escotilla de escape que parece tentadora en desarrollo y destruye clusters en producción: escanea todas las particiones.
Dos límites a respetar:
- Tamaño de partición. Mantén las particiones por debajo de ~100 MB y ~100k filas. Un sensor que escribe cada segundo alcanza 86.400 filas al día — por eso el modelo agrupa por día en lugar de particionar solo por sensor.
- Tombstones. Los borrados escriben marcadores en lugar de eliminar datos al momento. Las cargas con muchos borrados acumulan tombstones que las lecturas tienen que saltarse; usa TTL (
INSERT ... USING TTL 86400) o tablas por bucket temporal que puedas borrar enteras en lugar de borrar filas.
Patrones de estructuras de datos en Redis
El modelado en Redis no va de tablas — va de elegir la estructura que hace tu consulta O(1) u O(log n). Elegir la estructura es el diseño del esquema.
# Leaderboard con sorted sets
import redis
r = redis.Redis()
r.zadd('leaderboard:2024', {'alice': 1500, 'bob': 1200, 'charlie': 1800})
top_players = r.zrevrange('leaderboard:2024', 0, 9, withscores=True)
# Rate limiter con ventana deslizante
pipe = r.pipeline()
pipe.zremrangebyscore('rate:user:123', 0, time.time() - 60)
pipe.zcard('rate:user:123')
current_count = pipe.execute()[1]
if current_count < 100:
r.zadd('rate:user:123', {str(time.time()): time.time()})
El mapeo que cubre la mayoría de casos:
- Sorted Set — leaderboards, rate limiters, colas de prioridad. Las operaciones de ranking son O(log n);
ZREVRANGEte da un top-N con puntuaciones en una llamada. - Hash — objetos y contadores (
HINCRBY,HGETALL). Más barato que serializar un blob JSON por usuario. - String + TTL — cachés, sesiones, claves de idempotencia.
SET key val EX 3600 NXes el patrón completo. - Pub/Sub o Streams — fan-out transitorio vs logs de eventos persistentes. Streams guarda historial y consumer groups; Pub/Sub descarta mensajes para suscriptores desconectados.
El compromiso que nadie presupuesta al principio: todo vive en RAM. Un diseño “barato” que guarda 10 GB en Redis cuesta dinero de verdad en servicios gestionados — modela para políticas de expulsión (allkeys-lru para cachés, volatile-ttl para datos que expiran) y pon TTL a todo lo que debería caducar.
Modelado de grafos en Neo4j
Las bases de grafos dan la vuelta a la pregunta una vez más: en lugar de embeber o referenciar, modelas las relaciones como ciudadanos de primera clase. En Neo4j guardas nodos (entidades) y relaciones (aristas tipadas con propiedades), y las consultas recorren aristas en lugar de hacer joins entre tablas.
// Encontrar amigos de amigos que dieron like a un post
MATCH (u:User {id: "user-123"})-[:FRIEND]->(f:User)-[:LIKED]->(p:Post)
WHERE p.publishedAt > date("2026-01-01")
RETURN DISTINCT f.name, p.title
LIMIT 10;
Modela para recorridos, no para entidades: una arista (:User)-[:FRIEND]->(:User) convierte “amigos de amigos” en un paseo de dos saltos, donde el equivalente relacional es un join recursivo de coste exponencial. Crea relaciones en la dirección que recorren tus consultas, pon los atributos que filtras a menudo en las propias aristas (como SINCE en FRIEND), y evita los super-nodos — un solo nodo con 10 millones de relaciones frena cualquier recorrido que lo toque.
Selección de patrón de un vistazo
| Motor | Patrón estrella | Tu decisión clave | Límite duro a vigilar |
|---|---|---|---|
| MongoDB | Embebido vs referenciado | Cardinalidad de cada relación | 16 MB por documento |
| DynamoDB | Tabla única | Cardinalidad de la clave de partición | Throughput de partición caliente |
| Cassandra | Filas anchas | Clave de partición + orden de clustering | ~100 MB por partición, tombstones |
| Redis | Mapeo de estructuras | Qué estructura responde la consulta | Todo vive en RAM |
| Neo4j | Relaciones primero | Dirección y propiedades de aristas | Super-nodos |
Cuándo NoSQL es la elección equivocada
El contrapunto honesto, porque modelar alrededor de una mala elección es peor que elegir la base correcta:
- Consultas ad-hoc y reporting. Si los usuarios de negocio necesitan hacer preguntas que nadie anticipó, un motor relacional con joins gana siempre. NoSQL optimiza para patrones de acceso conocidos.
- Transacciones complejas multi-entidad. Las invariantes estilo bancario entre entidades son para lo que existen las bases ACID. Los patrones saga y outbox lo manejan, pero son esfuerzo de ingeniería que gastas en infraestructura, no en producto.
- Datos pequeños y bien entendidos. Un dataset de 50 GB con tres relaciones es una aburrida tabla de Postgres, no un sistema distribuido. Aburrido está bien.
Elige NoSQL cuando los patrones de acceso son conocidos, el argumento de escala o flexibilidad es real, y el equipo está listo para hacerse cargo del modelo operativo — no porque sea el default moderno.
Ejemplo trabajado: plataforma de blog con MongoDB
Teoría aparte — aquí va una decisión de esquema real trabajada de punta a punta.
Sistema: Plataforma de blog con 1M posts, 10M comentarios
Patrones de acceso:
1. Leer post completo con autor y comentarios (90% de lecturas)
2. Listar posts por autor
3. Listar posts por tag
4. Buscar posts por texto
Modelo embebido (post + comentarios):
db.posts.insertOne({
_id: "post-001",
title: "Patrones NoSQL en la práctica",
slug: "patrones-nosql-practica",
author: {
id: "user-123",
name: "Ana García",
avatar: "/avatars/ana.jpg"
},
tags: ["nosql", "mongodb", "modelado"],
content: "Contenido del post...",
comments: [
{
id: "c-001",
author: "Carlos",
text: "Excelente artículo",
createdAt: ISODate("2026-03-01"),
likes: 12
},
{
id: "c-002",
author: "Beatriz",
text: "Tengo una pregunta",
createdAt: ISODate("2026-03-02"),
likes: 3
}
],
commentCount: 2,
publishedAt: ISODate("2026-03-01"),
updatedAt: ISODate("2026-03-05")
});
Índices:
db.posts.createIndex({ slug: 1 }, { unique: true });
db.posts.createIndex({ "author.id": 1, publishedAt: -1 });
db.posts.createIndex({ tags: 1, publishedAt: -1 });
db.posts.createIndex({ title: "text", content: "text" });
Consultas:
// Post completo por slug (una sola consulta)
db.posts.findOne({ slug: "patrones-nosql-practica" });
// Posts por autor (paginados)
db.posts.find({ "author.id": "user-123" })
.sort({ publishedAt: -1 })
.skip(0)
.limit(10);
// Posts por tag
db.posts.find({ tags: "mongodb" })
.sort({ publishedAt: -1 })
.limit(20);
Los cuatro patrones de acceso mapean directamente a cuatro índices — esa es la regla de “modela para las consultas” funcionando. Fíjate en el objeto author embebido: es el truco de la referencia extendida, que mantiene nombre y avatar desnormalizados para mostrarlos mientras author.id sigue enlazando al registro completo del usuario.
La única elección frágil aquí son los comentarios embebidos. Funciona al lanzar — los posts promedian una docena de comentarios — pero un hilo viral o una ola de spam hará crecer el array sin tope:
| Criterio | Embeber | Referenciar |
|---|---|---|
| Comentarios por post | < 100 | > 1000 |
| Frecuencia de actualización | Baja | Alta |
| Tamaño del comentario | Corto | Largo |
| Consultas independientes de comentarios | No | Sí |
Disparador de migración: pasa a una colección comments separada cuando los documentos se acerquen al límite de 16 MB, cuando la latencia de escritura se degrade en las ediciones de comentarios, o cuando el producto pida paginación de comentarios independiente del post. Mantén commentCount desnormalizado en el post de todas formas — evita una consulta COUNT en cada lectura.
Consistencia eventual entre colecciones
Una vez que referencias en lugar de embeber, la consistencia entre documentos pasa a ser tu responsabilidad. Dos patrones la manejan:
Patrón Outbox. Cuando actualizas una colección, escribes un documento de evento en una colección outbox dentro de la misma transacción. Un proceso aparte lee el outbox y aplica el cambio a las colecciones dependientes — por ejemplo, actualizar un authorName desnormalizado en 10.000 posts cuando el usuario se renombra. Entrega garantizada, sin carrera de doble escritura.
Change streams. Los change streams de MongoDB te permiten reaccionar a las escrituras sin un outbox: escuchas users, actualizas las desnormalizaciones aguas abajo con cada evento. Operacionalmente más simple, pero el consumidor debe ser idempotente — los streams pueden reenviar eventos tras fallos.
Para actualizaciones multi-documento genuinamente atómicas, MongoDB soporta transacciones multi-documento desde la versión 4.0. Funcionan, pero tienen coste de rendimiento — resérvalas para invariantes reales (dinero moviéndose entre cuentas), no como default.
Errores comunes
- Aplicar modelado relacional a NoSQL — normalizar para consistencia es correcto en SQL; desnormalizar para lecturas es correcto aquí. Un esquema NoSQL “limpio” y normalizado solo traslada el coste del join a tu capa de aplicación.
- Arrays embebidos sin tope — una lista que crece para siempre causa hinchazón de documentos y acaba chocando con el límite de tamaño. Comentarios, eventos y feeds casi siempre pertenecen a su propia colección.
- Ignorar los patrones de acceso — diseñar alrededor de entidades en lugar de consultas convierte cada lectura en un escaneo o una búsqueda multi-salto.
- Sin estrategia de paginación —
skip()se vuelve más lento a medida que crecen los offsets. Usa paginación por keyset (filtrar porido timestamp del último ítem) para conjuntos grandes. - Tratar todas las bases NoSQL igual — el embebido de MongoDB, la tabla única de DynamoDB y las filas anchas de Cassandra son enfoques fundamentalmente distintos. Un patrón aprendido en uno no se traslada a los otros.
- Claves de partición de baja cardinalidad — en DynamoDB y Cassandra, claves como
statusocountryconcentran la carga en pocas particiones. Elige claves de alta cardinalidad o divide artificialmente.
Solución de problemas
- Consulta lenta en MongoDB tras añadir un índice: revisa
explain("executionStats")— puede que el índice no cubra el orden de sort, o que la consulta filtre un campo no indexado. El orden del índice compuesto importa: campos de igualdad primero, luego sort, luego rango. - Throttling en DynamoDB con capacidad provisionada: casi seguro tienes una partición caliente. Revisa
ThrottledRequestspor clave en CloudWatch y considera write sharding o una clave mejor. - Latencia de lectura de Cassandra subiendo con las semanas: normalmente es acumulación de tombstones por borrados o churn de TTL. Verifícalo con
nodetool cfstats(tombstones por lectura) y pasa a tablas por bucket temporal o TTLs más largos. - Memoria de Redis creciendo sin tope: revisa
INFO memoryy audita claves sin TTL (SCAN+TTL). Define una política de expulsión y caduca todo lo que no sea permanente. - Datos eventualmente consistentes que nunca convergen: tu consumidor de outbox o listener de change streams probablemente muere en silencio. Alerta sobre el lag del outbox — eventos sin procesar más viejos que un umbral significan un consumidor roto.
Checklist de producción
- Mide la salud de las particiones: monitorea requests throttled de DynamoDB y tamaños de partición de Cassandra antes de que sean incidentes.
- Sigue el crecimiento de los documentos: alerta sobre documentos que se acercan a los límites — 16 MB es un fallo duro, no una degradación.
- Pon TTL a todo lo que caduca: cachés, sesiones, buckets de rate limit. Las claves sin tope son facturas sin tope.
- Prueba las migraciones bajo carga: las migraciones embebido → referenciado tocan todos los documentos; ensaya con un dataset de tamaño de producción en staging.
- Documenta las desnormalizaciones: cada campo duplicado necesita una nota sobre quién mantiene la sincronía — código, outbox o manual.
Lectura adicional
- Modelado de datos en MongoDB — docs oficiales
- Mejores prácticas de DynamoDB — AWS
- Modelado de datos en Cassandra — docs Apache
- Tipos de datos de Redis — docs oficiales
- Patrón Event Sourcing — los patrones outbox y change streams combinan de forma natural con diseños orientados a eventos
Preguntas frecuentes
¿Cuándo uso un almacén de documentos vs una base relacional?
Usa documentos cuando dominen la flexibilidad de esquema, los datos jerárquicos y las cargas de lectura. Usa relacional cuando se requieran transacciones ACID, joins complejos y esquema estricto.
¿Puedo forzar integridad referencial en NoSQL?
En general no, no a nivel de base de datos. Las aplicaciones deben hacer cumplir las restricciones, o usar patrones de consistencia eventual como transacciones saga.
¿Cómo migro esquema en NoSQL?
Usa migración perezosa: actualiza el código de la aplicación para manejar ambos formatos, viejo y nuevo, y migra los datos en lectura o en procesos en segundo plano.
¿Cuál es el tamaño máximo de documento en MongoDB y qué pasa si lo alcanzo?
16 MB por documento. Las escrituras que lo excederían fallan — no puedes añadir más allá del límite. Diseña las listas ilimitadas como colecciones aparte antes de acercarte a él, no después del primer fallo.
¿Cómo pagino en NoSQL sin OFFSET?
Usa paginación por keyset (cursor): recuerda la clave de orden del último ítem mostrado y consulta los ítems posteriores. Se mantiene casi O(1) a cualquier profundidad donde skip() se degrada linealmente.
¿Debería desnormalizarlo todo por velocidad de lectura?
Desnormaliza lo que lees junto y actualizas raramente. Cada campo duplicado es un campo que debes mantener sincronizado — desnormaliza para los patrones de acceso que tienes, no para los hipotéticos.
Recursos Relacionados
Guía de Selección NoSQL — MongoDB, DynamoDB, Cassandra
Guía práctica para elegir la base de datos NoSQL correcta. Compara documentos, clave-valor, columnas anchas y grafos con criterios de selección.
GuideGuía de Diseño de Bases de Datos
Guía práctica para diseñar bases de datos relacionales con normalización, indexación y modelado de relaciones.
GuideACID vs BASE — Modelos de Consistencia Explicados
Guía práctica que compara los modelos de consistencia ACID y BASE: cuándo elegir consistencia fuerte, cuándo aceptar la eventual y cómo afecta al diseño.
GuideBases de Datos de Grafos
Guia practica de bases de datos de grafos: modelo de grafo de propiedades, lenguaje Cypher, patrones de modelado y cuando elegir Neo4j sobre bases relacionales.
GuideGuía TSDB: InfluxDB, TimescaleDB y ClickHouse
Guía práctica de TSDB: cuándo usar una base de datos de series temporales, modelo de datos, retención y cómo elegir entre InfluxDB, TimescaleDB y ClickHouse.
RecipeCaching con Redis
Cómo implementar caching de aplicaciones usando Redis para rendimiento y escalabilidad.