En la lección 01-02 vimos las cuatro familias NoSQL desde muy arriba: qué son y para qué sirven, en un par de párrafos cada una. En la lección anterior entendimos el porqué del movimiento y dimos los primeros pasos con MongoDB. Ahora toca bajar al suelo.

"NoSQL" no es una tecnología: es una etiqueta que agrupa cuatro modelos de datos radicalmente distintos entre sí. Una base documental y una base de grafos se parecen tanto como una hoja de cálculo y un mapa de metro. Decir "vamos a usar NoSQL" es tan poco informativo como decir "vamos a usar un vehículo": la pregunta útil es cuál, y para qué.

En esta lección recorremos las cuatro familias con operación real y ejecutable: documental con MongoDB, clave-valor con Redis, columnar con Cassandra y grafos con Neo4j. De cada una veremos su modelo de datos, cómo se consulta, qué productos la representan, sus fortalezas, sus límites y —lo que hilará toda la lección— qué pieza de BiblioRed le encaja. Al final, una tabla comparativa y un árbol de decisión para elegir.

Contenido

  1. Las cuatro familias de un vistazo
  2. Bases documentales: MongoDB
  3. Bases clave-valor: Redis
  4. Bases columnares: Cassandra
  5. Bases de grafos: Neo4j
  6. Familias especializadas que este curso no vuelve a tratar
  7. Tabla comparativa de las cuatro familias
  8. Árbol de decisión
  9. El reparto final de BiblioRed
  10. Errores comunes y consejos
  11. Ejercicios
  12. Conclusión

  1. Las cuatro familias de un vistazo

Las cuatro se pueden ordenar por la riqueza de la estructura que entienden: desde la que no entiende nada del valor que guarda hasta la que entiende las relaciones entre entidades.

Familia Unidad de datos Qué "entiende" el motor Consulta típica
Clave-valor Par clave → valor Solo la clave. El valor es opaco "Dame el valor de esta clave"
Documental Documento tipo JSON Toda la estructura interna del documento "Dame los documentos donde puntuacion > 4"
Columnar Fila ancha en una familia de columnas La clave de partición y el orden dentro de ella "Dame las filas de esta partición entre estas dos fechas"
Grafos Nodo + relación + propiedades Las conexiones entre entidades "Dame lo que leyeron quienes leyeron esto"

Y las cuatro piezas de BiblioRed que buscan acomodo:

  • Reseñas y catálogo enriquecido → estructura rica, heterogénea, consultada por contenido → documental.
  • Portadas y sesiones del portal → acceso por clave, muy frecuente, con caducidad → clave-valor.
  • Registro de actividad → altísimo volumen de escritura, consulta por socio y rango de fechas → columnar.
  • Motor de recomendaciones → recorrer relaciones entre lectores y títulos → grafos.

Una advertencia antes de empezar, para que esta lección no se lea mal: BiblioRed no va a desplegar cuatro bases de datos. Va a desplegar PostgreSQL y MongoDB. Redis, Cassandra y Neo4j aparecen aquí porque son la mejor forma de entender qué resuelve cada familia y porque el día que BiblioRed crezca, sabrá reconocer el momento. Confundir "entender una familia" con "instalarla" es el camino más rápido a una arquitectura ingobernable.

  1. Bases documentales: MongoDB

2.1 Modelo de datos

La unidad es el documento: una estructura tipo JSON —guardada en BSON, como vimos en 03-01— que admite anidamiento y arrays. Los documentos viven en colecciones, que no imponen esquema.

Tres propiedades que definen la familia:

  1. El documento es autocontenido. Todo lo que la aplicación necesita para mostrar una entidad cabe dentro.
  2. El motor entiende el interior. Puede filtrar, ordenar, indexar y agregar por cualquier campo, incluso anidado o dentro de un array. Esto es lo que separa a una base documental de una clave-valor que guardase JSON: para la segunda, el valor es una bolsa de bytes opaca.
  3. La escritura de un documento es atómica, sin necesidad de transacción.

Productos representativos: MongoDB (el dominante y nuestra referencia), Couchbase, Amazon DocumentDB, RavenDB, Firestore. Y una nota importante: PostgreSQL con el tipo jsonb hace muchas de estas cosas sin dejar de ser relacional; lo compararemos con detalle en la lección 03-04.

2.2 Operadores de consulta

Seguimos sobre la colección resenas que creamos en la lección anterior. Un filtro es un documento; cada campo es una condición y varios campos se combinan con Y lógico. Cuando la condición no es una igualdad, el valor pasa a ser un documento con un operador que empieza por $.

Operador Significado Equivalente SQL
$eq Igual (implícito si escribes el valor directo) =
$ne Distinto <>
$gt / $gte Mayor / mayor o igual > / >=
$lt / $lte Menor / menor o igual < / <=
$in / $nin Está / no está en la lista IN / NOT IN
$exists El campo existe (o no) en el documento sin equivalente directo
$regex Coincide con una expresión regular LIKE / ~
$and / $or / $not Combinaciones lógicas AND / OR / NOT
$size El array tiene N elementos sin equivalente directo
$all El array contiene todos estos elementos requiere varios EXISTS
// Reseñas con puntuación mayor que 3
db.resenas.find({ puntuacion: { $gt: 3 } })
[
  { _id: ObjectId('...c82'), titulo_libro: 'El mapa del tiempo', puntuacion: 5, socio: { socio_id: 14, nombre: 'Marta Alsina' }, ... },
  { _id: ObjectId('...c84'), titulo_libro: 'Los pilares de la Tierra', puntuacion: 5, socio: { socio_id: 16, nombre: 'Nuria Bastos' }, ... },
  { _id: ObjectId('...c85'), titulo_libro: 'Los pilares de la Tierra', puntuacion: 4, socio: { socio_id: 14, nombre: 'Marta Alsina' }, ... }
]
-- Equivalente relacional
SELECT * FROM resenas WHERE puntuacion > 3;
// Reseñas de dos socios concretos
db.resenas.find({ "socio.socio_id": { $in: [15, 16] } })
[
  { _id: ObjectId('...c83'), socio: { socio_id: 15, nombre: 'Iván Pereda' }, puntuacion: 3, ... },
  { _id: ObjectId('...c84'), socio: { socio_id: 16, nombre: 'Nuria Bastos' }, puntuacion: 5, ... }
]
// Búsqueda por texto en el cuerpo de la reseña, sin distinguir mayúsculas
db.resenas.find({ texto: { $regex: "catedral", $options: "i" } })
[
  { _id: ObjectId('...c84'), titulo_libro: 'Los pilares de la Tierra',
    texto: 'Mil páginas que se pasan volando. La construcción de la catedral engancha.', ... }
]
// Reseñas que tienen respuesta del bibliotecario
db.resenas.find({ respuesta_bibliotecario: { $exists: true } })
[
  { _id: ObjectId('...c84'), respuesta_bibliotecario: { nombre: 'Equipo Sucursal Sur', ... }, ... }
]

$exists no tiene equivalente en SQL, y por una razón de fondo: en una tabla, la columna existe siempre; como mucho vale NULL. En una colección, un campo puede sencillamente no estar. Es la diferencia entre "no tiene valor" y "esta entidad no tiene ese concepto", y en el mundo documental son dos cosas distintas.

// Reseñas con AMBAS etiquetas a la vez
db.resenas.find({ etiquetas: { $all: ["novela histórica", "para regalar"] } })
[
  { _id: ObjectId('...c84'), etiquetas: [ 'novela histórica', 'para regalar', 'clásico moderno' ], ... }
]
// Combinación con O lógico: muy buenas o muy votadas
db.resenas.find({ $or: [ { puntuacion: 5 }, { votos_utiles: { $gte: 10 } } ] })
[
  { _id: ObjectId('...c82'), puntuacion: 5, votos_utiles: 7, ... },
  { _id: ObjectId('...c84'), puntuacion: 5, votos_utiles: 12, ... }
]

2.3 Proyección, orden y límite

El segundo argumento de find es la proyección: qué campos devolver. 1 incluye, 0 excluye.

db.resenas.find(
  { libro_id: 412 },
  { _id: 0, "socio.nombre": 1, puntuacion: 1, votos_utiles: 1 }
).sort({ votos_utiles: -1 }).limit(2)
[
  { socio: { nombre: 'Nuria Bastos' }, puntuacion: 5, votos_utiles: 12 },
  { socio: { nombre: 'Marta Alsina' }, puntuacion: 4, votos_utiles: 4 }
]
-- Equivalente relacional
SELECT s.nombre, r.puntuacion, r.votos_utiles
FROM resenas r INNER JOIN socios s ON s.socio_id = r.socio_id
WHERE r.libro_id = 412
ORDER BY r.votos_utiles DESC
LIMIT 2;

Reglas de la proyección que conviene memorizar:

  • No se pueden mezclar inclusiones y exclusiones en la misma proyección, con una única excepción: _id: 0 se puede combinar con inclusiones.
  • El _id se devuelve siempre salvo que lo excluyas explícitamente.
  • .sort(), .limit() y .skip() se encadenan sobre el cursor y equivalen a ORDER BY, LIMIT y OFFSET. En sort, 1 es ascendente y -1 descendente.

2.4 Actualizaciones: $set, $push, $inc

Aquí aparece una diferencia grande respecto a SQL: en MongoDB no se reescribe el documento entero, se aplican operadores de actualización sobre partes de él.

Operador Qué hace
$set Asigna un valor a un campo (lo crea si no existe)
$unset Elimina un campo del documento
$inc Incrementa (o decrementa, con negativo) un número
$push Añade un elemento a un array
$addToSet Añade a un array solo si no está ya
$pull Elimina de un array los elementos que cumplan una condición
$currentDate Pone la fecha actual del servidor
// Un lector marca como útil la reseña de Nuria: contador +1
db.resenas.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { $inc: { votos_utiles: 1 } }
)
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1
}
// Añadir una etiqueta sin duplicar y registrar la edición
db.resenas.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  {
    $addToSet: { etiquetas: "edad media" },
    $currentDate: { editada: true }
  }
)

db.resenas.findOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { _id: 0, etiquetas: 1, votos_utiles: 1, editada: 1 }
)
{
  etiquetas: [ 'novela histórica', 'para regalar', 'clásico moderno', 'edad media' ],
  votos_utiles: 13,
  editada: ISODate('2026-08-02T09:41:07.512Z')
}

Detente un segundo en lo que acaba de pasar. El campo editada no existía en ningún documento de la colección y ahora existe en uno. No ha hecho falta un ALTER TABLE, no ha habido bloqueo y los otros tres documentos no se han enterado. Eso es el esquema flexible en su forma más práctica.

// Añadir un comentario al array de respuestas de una reseña
db.resenas.updateOne(
  { _id: ObjectId('66ab14b25c9e1b2f3d4a6c82') },
  {
    $push: {
      comentarios: {
        socio_id: 15,
        nombre: "Iván Pereda",
        texto: "Coincido, el final es lo mejor.",
        fecha: ISODate("2026-04-05T17:30:00Z")
      }
    }
  }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// updateMany: normalizar una etiqueta en toda la colección
db.resenas.updateMany(
  { etiquetas: "novela histórica" },
  { $set: { "etiquetas.$": "novela-historica" } }
)
{ acknowledged: true, matchedCount: 4, modifiedCount: 4 }

El operador posicional $ se refiere al primer elemento del array que ha coincidido con el filtro. Es la forma de modificar un elemento concreto sin reescribir el array entero.

// Borrado
db.resenas.deleteOne({ _id: ObjectId('66ab15c15c9e1b2f3d4a6c85') })
{ acknowledged: true, deletedCount: 1 }

Cuidado con un detalle que ha causado incidentes reales: deleteMany({}) con filtro vacío borra la colección entera y no pide confirmación. En SQL, DELETE FROM tabla hace lo mismo, pero al menos hay una transacción que se puede deshacer con ROLLBACK. Aquí, salvo que estés dentro de una transacción explícita, no la hay.

2.5 La primera aggregation pipeline

Las consultas de resumen —lo que en la lección 02-05 hicimos con GROUP BY— se hacen en MongoDB con el marco de agregación: una tubería de etapas, donde cada etapa recibe documentos, los transforma y se los pasa a la siguiente.

Objetivo: la puntuación media y el número de reseñas de cada libro, ordenado de mejor a peor.

-- Lo que haríamos en PostgreSQL (lección 02-05)
SELECT libro_id, COUNT(*) AS total, ROUND(AVG(puntuacion), 2) AS media
FROM resenas
GROUP BY libro_id
HAVING COUNT(*) >= 1
ORDER BY media DESC;
db.resenas.aggregate([
  { $match: { spoiler: false } },
  { $group: {
      _id: "$libro_id",
      titulo: { $first: "$titulo_libro" },
      total: { $sum: 1 },
      media: { $avg: "$puntuacion" },
      votos: { $sum: "$votos_utiles" }
  }},
  { $sort: { media: -1 } }
])
[
  { _id: 412, titulo: 'Los pilares de la Tierra', total: 1, media: 5, votos: 13 },
  { _id: 331, titulo: 'El mapa del tiempo', total: 2, media: 4, votos: 9 }
]

Traducción etapa por etapa, que es la mejor forma de entenderlo:

Etapa de la tubería Equivalente SQL Qué hace aquí
$match WHERE Se queda solo con las reseñas sin spoiler
$group GROUP BY Agrupa por libro_id y calcula los acumulados
$sort ORDER BY Ordena por media descendente
$project Lista del SELECT Elige y calcula los campos de salida
$limit LIMIT Corta el resultado
$unwind (no tiene equivalente) Convierte cada elemento de un array en un documento propio
$lookup LEFT JOIN Trae documentos de otra colección

Dos convenciones de sintaxis que hay que fijar:

  • "$campo" con dólar delante significa "el valor de ese campo", no la cadena literal. "$puntuacion" es el número; "puntuacion" sería el texto.
  • En $group, _id es la clave de agrupación, no un identificador. _id: "$libro_id" es literalmente el GROUP BY libro_id. Si pones _id: null, agrupas todo en una sola fila, que es el SELECT AVG(...) FROM tabla sin GROUP BY.

Un ejemplo más, con $unwind, que no tiene contrapartida limpia en SQL: las etiquetas más usadas.

db.resenas.aggregate([
  { $unwind: "$etiquetas" },
  { $group: { _id: "$etiquetas", usos: { $sum: 1 } } },
  { $sort: { usos: -1, _id: 1 } },
  { $limit: 5 }
])
[
  { _id: 'novela-historica', usos: 3 },
  { _id: 'clásico moderno', usos: 1 },
  { _id: 'edad media', usos: 1 },
  { _id: 'para regalar', usos: 1 }
]

$unwind ha "desplegado" la reseña de Nuria, que tenía cuatro etiquetas, en cuatro documentos idénticos salvo por la etiqueta. Después, $group cuenta. Es exactamente el trabajo que en el modelo relacional haría la tabla intermedia resenas_etiquetas que evitamos al embeber el array.

2.6 Fortalezas, límites y encaje en BiblioRed

Fortalezas

  • Estructuras heterogéneas y anidadas sin tablas auxiliares ni columnas nulas.
  • El esquema evoluciona sin migración.
  • Lectura de una entidad completa en un solo acceso.
  • Lenguaje de consulta rico: filtros, agregación, índices sobre cualquier campo.

Límites

  • Cruzar colecciones ($lookup) es caro y no debe ser la norma.
  • Sin integridad referencial declarativa.
  • El límite de 16 MB por documento acota lo que puedes embeber.
  • Las transacciones multidocumento existen, pero cuestan rendimiento; el diseño debe minimizar su necesidad.

Encaje en BiblioRed: las reseñas y el catálogo enriquecido. Son entidades autocontenidas, heterogéneas, que se leen enteras y cambian de forma con frecuencia. Es el encaje perfecto, y por eso MongoDB es la base NoSQL que BiblioRed va a desplegar de verdad.

  1. Bases clave-valor: Redis

3.1 Modelo de datos

El modelo más simple que existe: un diccionario gigante y distribuido. Una clave, un valor. El motor no sabe nada del contenido del valor —para él es una cadena o una estructura, pero no algo que se pueda filtrar por su interior—.

Esa renuncia radical compra una cosa: velocidad. Redis mantiene todos los datos en memoria y responde en microsegundos, con cifras habituales de más de 100.000 operaciones por segundo en una máquina modesta.

Productos representativos: Redis (nuestra referencia), Valkey (su bifurcación de código abierto), Memcached (más simple aún), Amazon DynamoDB (clave-valor con capacidades documentales), etcd (configuración de clústeres).

3.2 Operaciones básicas y caducidad

# Arrancar un Redis en Docker y entrar a su cliente
docker run -d --name redis-biblioRed -p 6379:6379 redis:7
docker exec -it redis-biblioRed redis-cli
127.0.0.1:6379>
// Guardar y recuperar la ficha lista para pintar de un material del catálogo
SET catalogo:MAT-0331:ficha "{\"titulo\":\"El mapa del tiempo\",\"portada\":\"/img/0331.webp\"}"
GET catalogo:MAT-0331:ficha
OK
"{\"titulo\":\"El mapa del tiempo\",\"portada\":\"/img/0331.webp\"}"

La operación estrella de esta familia es la caducidad, y merece su propio apartado porque es lo que la diferencia de todo lo demás:

// Guardar con caducidad de 1 hora (3600 segundos)
SET catalogo:MAT-0331:ficha "{...}" EX 3600

// Consultar cuánto le queda de vida
TTL catalogo:MAT-0331:ficha
OK
(integer) 3597
// Sesión del portal: caduca sola a los 30 minutos de inactividad
SET sesion:8f3a2b1c "{\"socio_id\":14,\"nombre\":\"Marta Alsina\",\"sucursal\":1}" EX 1800

// Cada petición del socio renueva la ventana
EXPIRE sesion:8f3a2b1c 1800
TTL sesion:8f3a2b1c
OK
(integer) 1
(integer) 1800

Por qué la caducidad es una función de primera clase. En una base relacional, "borrar lo que haya caducado" es una tarea programada que alguien tiene que escribir, vigilar y ejecutar; y mientras no se ejecuta, los datos caducados siguen ocupando espacio y ensuciando consultas. En Redis, la caducidad es una propiedad de la clave: el sistema la elimina solo, sin intervención. Para una caché —donde el dato antiguo no es un error, solo es inútil— y para sesiones —donde el dato debe desaparecer por seguridad—, esa diferencia es la razón misma de elegir la herramienta.

3.3 Estructuras de datos

Redis no es solo cadenas. Su verdadero valor son las estructuras nativas, cada una con operaciones atómicas propias.

Estructura Qué es Uso en BiblioRed
String Cadena o número Ficha del catálogo en caché, contadores
List Lista ordenada, con acceso por los extremos Cola de trabajos: portadas pendientes de redimensionar
Set Conjunto sin orden ni duplicados Materiales disponibles ahora mismo en la sucursal Norte
Sorted Set Conjunto ordenado por puntuación Ranking de los más consultados de la semana
Hash Diccionario de campos dentro de una clave Datos de la sesión, campo a campo
Counter Un String con operaciones atómicas Consultas de hoy sobre un material
// HASH: la sesión, campo a campo, sin reescribir todo el objeto
HSET sesion:8f3a2b1c socio_id 14 nombre "Marta Alsina" sucursal 1 idioma es
HGET sesion:8f3a2b1c nombre
HGETALL sesion:8f3a2b1c
(integer) 4
"Marta Alsina"
1) "socio_id"
2) "14"
3) "nombre"
4) "Marta Alsina"
5) "sucursal"
6) "1"
7) "idioma"
8) "es"
// CONTADOR: incremento atómico, sin condiciones de carrera
INCR consultas:MAT-0331:2026-08-02
INCR consultas:MAT-0331:2026-08-02
INCR consultas:MAT-0331:2026-08-02
GET consultas:MAT-0331:2026-08-02
(integer) 1
(integer) 2
(integer) 3
"3"

INCR es atómico: si mil socios abren la ficha a la vez, el contador acaba en mil exactos. Hacer esto en SQL requiere UPDATE ... SET n = n + 1 con su bloqueo de fila y su coste transaccional; aquí es una operación de microsegundos.

// SORTED SET: ranking de materiales más consultados de la semana
ZINCRBY ranking:semana:31 3 "MAT-0331"
ZINCRBY ranking:semana:31 8 "MAT-0412"
ZINCRBY ranking:semana:31 5 "MAT-0801"
ZREVRANGE ranking:semana:31 0 2 WITHSCORES
(integer) 3
(integer) 8
(integer) 5
1) "MAT-0412"
2) "8"
3) "MAT-0801"
4) "5"
5) "MAT-0331"
6) "3"

Un ranking siempre ordenado, actualizado en el momento y consultable en tiempo constante. La alternativa relacional es un ORDER BY COUNT(*) DESC sobre millones de filas cada vez que alguien mira la página de inicio.

// SET: qué materiales están disponibles ahora en la sucursal Norte
SADD disponibles:sucursal:2 "EJ-3081" "EJ-3084" "EJ-3090"
SISMEMBER disponibles:sucursal:2 "EJ-3084"
SCARD disponibles:sucursal:2
SREM disponibles:sucursal:2 "EJ-3084"
SCARD disponibles:sucursal:2
(integer) 3
(integer) 1
(integer) 3
(integer) 1
(integer) 2

3.4 Fortalezas, límites y encaje en BiblioRed

Fortalezas: latencia de microsegundos, operaciones atómicas sobre estructuras, caducidad nativa, modelo trivial de entender y operar.

Límites: solo se consulta por clave (no hay "dame todo lo que tenga puntuación 5"); los datos viven en memoria, así que la RAM es el techo y el coste; la persistencia existe (instantáneas y registro de operaciones) pero está pensada para recuperación, no como almacén principal.

Encaje en BiblioRed: caché de las fichas y portadas del catálogo —evita ir a MongoDB en cada visita a una página muy visitada— y sesiones del portal —que deben caducar solas—. Nunca como fuente de verdad: si Redis se vacía, BiblioRed debe seguir funcionando, solo que más despacio. Esa es la prueba del algodón de una caché bien planteada.

  1. Bases columnares: Cassandra

4.1 Modelo de datos

El nombre "columnar" despista. Aquí no significa "orientada a columnas para análisis" (eso son ClickHouse o Amazon Redshift), sino familias de columnas: filas identificadas por una clave, agrupadas físicamente, donde cada fila puede tener columnas distintas y muy numerosas.

Las dos piezas que hay que entender son:

  • Clave de partición: decide en qué nodo vive la fila. Todas las filas con la misma clave de partición están juntas en el mismo nodo y contiguas en disco.
  • Clave de agrupamiento (clustering key): decide el orden de las filas dentro de la partición.
flowchart TD
    subgraph N1["Nodo 1"]
        P1["Partición socio_id=14<br/>ordenada por fecha DESC<br/>→ 8.400 eventos contiguos"]
    end
    subgraph N2["Nodo 2"]
        P2["Partición socio_id=15<br/>ordenada por fecha DESC<br/>→ 6.100 eventos contiguos"]
    end
    subgraph N3["Nodo 3"]
        P3["Partición socio_id=16<br/>ordenada por fecha DESC<br/>→ 9.700 eventos contiguos"]
    end
    Q["Consulta:<br/>actividad del socio 15<br/>del último mes"] --> N2

Esa contigüidad física es todo el secreto: leer "los 50 últimos eventos del socio 15" es una lectura secuencial de disco dentro de una sola partición en un solo nodo. No hay búsqueda dispersa, no hay JOIN, no hay coordinación entre máquinas.

Productos representativos: Apache Cassandra (nuestra referencia), ScyllaDB (compatible y más rápida), Apache HBase, Google Bigtable (el artículo de 2006 que originó la familia), Amazon Keyspaces.

4.2 CQL: parecido a SQL, reglas distintas

Cassandra se consulta con CQL, que se escribe casi como SQL. Esa familiaridad es una trampa amable: la sintaxis es parecida, las reglas no.

docker run -d --name cassandra-biblioRed -p 9042:9042 cassandra:5
docker exec -it cassandra-biblioRed cqlsh
-- Un "keyspace" es el equivalente aproximado de una base de datos
CREATE KEYSPACE bibliored
WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': 3 };

USE bibliored;

-- La tabla de actividad del catálogo
CREATE TABLE actividad_por_socio (
    socio_id     int,
    fecha_evento timestamp,
    evento_id    uuid,
    tipo         text,
    termino      text,
    material_id  text,
    sucursal_id  int,
    PRIMARY KEY ((socio_id), fecha_evento, evento_id)
) WITH CLUSTERING ORDER BY (fecha_evento DESC, evento_id ASC);

Lee con cuidado esa clave primaria, porque contiene toda la lección:

  • (socio_id), entre paréntesis propios, es la clave de partición. Todos los eventos de un socio viven juntos.
  • fecha_evento y evento_id son las claves de agrupamiento: ordenan dentro de la partición, de más reciente a más antiguo.
  • evento_id está ahí para desempatar: sin él, dos eventos en el mismo milisegundo se sobrescribirían.
INSERT INTO actividad_por_socio (socio_id, fecha_evento, evento_id, tipo, termino, sucursal_id)
VALUES (15, '2026-08-02 09:14:02', uuid(), 'busqueda', 'julio verne', 2);

INSERT INTO actividad_por_socio (socio_id, fecha_evento, evento_id, tipo, material_id, sucursal_id)
VALUES (15, '2026-08-02 09:14:31', uuid(), 'ficha', 'MAT-0331', 2);

SELECT fecha_evento, tipo, termino, material_id
FROM actividad_por_socio
WHERE socio_id = 15
LIMIT 5;
 fecha_evento                    | tipo     | termino     | material_id
---------------------------------+----------+-------------+-------------
 2026-08-02 09:14:31.000000+0000 |    ficha |        null |    MAT-0331
 2026-08-02 09:14:02.000000+0000 | busqueda | julio verne |        null

(2 rows)

Ahora, las reglas que rompen las expectativas de quien viene de SQL:

-- Esto FALLA
SELECT * FROM actividad_por_socio WHERE termino = 'julio verne';
InvalidRequest: Error from server: code=2200 [Invalid query]
message="Cannot execute this query as it might involve data filtering and thus
may have unpredictable performance. If you want to execute this query despite the
performance unpredictability, use ALLOW FILTERING"

Cassandra se niega a ejecutar una consulta que no pueda resolver de forma eficiente. No hay WHERE libre sobre cualquier columna, no hay JOIN, no hay subconsultas y el ORDER BY solo puede seguir el orden de agrupamiento ya definido. Y ese error, lejos de ser una limitación molesta, es una de las mejores decisiones de diseño del producto: te avisa en desarrollo de que tu consulta no escala, en vez de dejar que lo descubras en producción con diez millones de filas.

4.3 Diseño dirigido por la consulta

De la restricción anterior sale el principio central de la familia:

En Cassandra no se diseña un modelo de datos y luego se consulta. Se parte de la lista de consultas y se crea una tabla por consulta, duplicando los datos tantas veces como haga falta.

Si BiblioRed también necesita "los términos más buscados de un día", no se añade un índice: se crea otra tabla, con otra clave de partición, alimentada con la misma escritura.

CREATE TABLE actividad_por_dia (
    dia          date,
    fecha_evento timestamp,
    evento_id    uuid,
    socio_id     int,
    tipo         text,
    termino      text,
    PRIMARY KEY ((dia), fecha_evento, evento_id)
) WITH CLUSTERING ORDER BY (fecha_evento DESC, evento_id ASC);

SELECT termino, COUNT(*) FROM actividad_por_dia
WHERE dia = '2026-08-02' AND tipo = 'busqueda'
GROUP BY dia
ALLOW FILTERING;

A alguien que viene del módulo 5 —que aún no hemos visto— esto le parecerá una herejía: es duplicación pura y dura. Y lo es, deliberadamente. En Cassandra el disco es barato, las escrituras son baratísimas y lo caro es la lectura ineficiente. Duplicar es la estrategia, no el error. Es la manifestación más extrema del principio de modelar a partir de las consultas que estudiaremos a fondo en la lección 03-03.

4.4 Fortalezas, límites y encaje en BiblioRed

Fortalezas: escritura extraordinariamente rápida y sostenida; escalado lineal (el doble de nodos, aproximadamente el doble de capacidad); sin nodo primario —todos los nodos aceptan escrituras, así que no hay punto único de fallo—; replicación entre centros de datos integrada; caducidad por fila (TTL) nativa.

Límites: consultas rígidas, atadas al diseño de la clave; sin JOIN ni agregaciones libres; duplicación masiva que la aplicación debe mantener coherente; operación de un clúster no trivial; una consulta nueva puede exigir una tabla nueva y un reproceso de todo el histórico.

Encaje en BiblioRed: el registro de actividad. Diecisiete millones de eventos anuales, escritura continua, lectura por socio y rango de fechas, sin necesidad de integridad referencial y con valor que decae con el tiempo. Es el caso de uso canónico de la familia.

Ahora bien, seamos consecuentes con lo que dijimos en 03-01: 17 millones de documentos al año no justifican desplegar y operar un clúster Cassandra. MongoDB los absorbe sin dificultad con el patrón de agrupación que veremos en 03-03. Cassandra entraría en escena si BiblioRed pasara de una red municipal a una red autonómica con cientos de millones de eventos. Conocer el criterio es lo que te permite tomar esa decisión el día que llegue.

  1. Bases de grafos: Neo4j

5.1 Modelo de datos

Tres elementos y ya está:

  • Nodos: las entidades (un socio, un libro, un autor). Llevan una o más etiquetas (:Socio, :Libro) y propiedades.
  • Relaciones: las conexiones entre nodos. Tienen tipo (LEYO, ESCRIBIO), dirección y también propiedades propias.
  • Propiedades: pares clave-valor en nodos y relaciones.

La diferencia decisiva con el modelo relacional: en una base relacional, una relación se calcula en el momento de la consulta comparando valores de clave ajena; en una base de grafos, la relación está materializada como un puntero físico. Recorrerla no cuesta una búsqueda, cuesta seguir una referencia. A eso se le llama adyacencia sin índice, y es la razón de que los recorridos profundos sean tan rápidos.

Productos representativos: Neo4j (nuestra referencia), Amazon Neptune, ArangoDB, JanusGraph, Memgraph.

5.2 Cypher: MATCH, WHERE, RETURN

Cypher se lee dibujando. () es un nodo, -[]-> es una relación dirigida.

docker run -d --name neo4j-biblioRed -p 7474:7474 -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/biblioRed2026 neo4j:5
docker exec -it neo4j-biblioRed cypher-shell -u neo4j -p biblioRed2026
// Crear los nodos de socios y libros
CREATE (m:Socio {socio_id: 14, nombre: 'Marta Alsina', sucursal: 1}),
       (i:Socio {socio_id: 15, nombre: 'Iván Pereda', sucursal: 2}),
       (n:Socio {socio_id: 16, nombre: 'Nuria Bastos', sucursal: 3}),
       (l1:Libro {libro_id: 331, titulo: 'El mapa del tiempo', isbn: '9788401339097'}),
       (l2:Libro {libro_id: 412, titulo: 'Los pilares de la Tierra', isbn: '9788401337208'}),
       (l3:Libro {libro_id: 508, titulo: 'La sombra del faro', isbn: '9788401338441'}),
       (l4:Libro {libro_id: 613, titulo: 'Cuadernos de Vallmar', isbn: '9788401339554'})
Added 7 nodes, Created 7 labels, Set 23 properties
// Crear las relaciones de lectura, con la valoración como propiedad de la relación
MATCH (m:Socio {socio_id: 14}), (i:Socio {socio_id: 15}), (n:Socio {socio_id: 16}),
      (l1:Libro {libro_id: 331}), (l2:Libro {libro_id: 412}),
      (l3:Libro {libro_id: 508}), (l4:Libro {libro_id: 613})
CREATE (m)-[:LEYO {puntuacion: 5, fecha: date('2026-03-14')}]->(l1),
       (m)-[:LEYO {puntuacion: 4, fecha: date('2026-04-02')}]->(l2),
       (i)-[:LEYO {puntuacion: 3, fecha: date('2026-03-22')}]->(l1),
       (i)-[:LEYO {puntuacion: 5, fecha: date('2026-05-08')}]->(l3),
       (n)-[:LEYO {puntuacion: 5, fecha: date('2026-03-24')}]->(l2),
       (n)-[:LEYO {puntuacion: 4, fecha: date('2026-06-01')}]->(l4)
Created 6 relationships, Set 12 properties
// Consulta simple: qué ha leído Marta
MATCH (s:Socio {nombre: 'Marta Alsina'})-[r:LEYO]->(l:Libro)
RETURN l.titulo AS titulo, r.puntuacion AS puntuacion
ORDER BY r.puntuacion DESC
+-------------------------------------------+
| titulo                     | puntuacion   |
+-------------------------------------------+
| "El mapa del tiempo"       | 5            |
| "Los pilares de la Tierra" | 4            |
+-------------------------------------------+

5.3 El recorrido de varios saltos: la recomendación

Aquí es donde la familia se gana el sueldo. La pregunta de negocio de BiblioRed es: "lectores como tú también leyeron". Formalmente: partiendo de un socio, ir a los libros que ha leído, de ahí a los otros socios que también los leyeron, y de ahí a los libros que esos socios leyeron y el nuestro no.

Son tres saltos en el grafo.

flowchart LR
    M["Socio<br/>Marta Alsina"] -->|LEYO| L1["Libro<br/>El mapa del tiempo"]
    M -->|LEYO| L2["Libro<br/>Los pilares de la Tierra"]
    I["Socio<br/>Iván Pereda"] -->|LEYO| L1
    I -->|LEYO| L3["Libro<br/>La sombra del faro<br/>★ RECOMENDADO"]
    N["Socio<br/>Nuria Bastos"] -->|LEYO| L2
    N -->|LEYO| L4["Libro<br/>Cuadernos de Vallmar<br/>★ RECOMENDADO"]
MATCH (yo:Socio {socio_id: 14})-[:LEYO]->(:Libro)<-[:LEYO]-(otro:Socio)-[:LEYO]->(sugerencia:Libro)
WHERE NOT (yo)-[:LEYO]->(sugerencia)
  AND yo <> otro
RETURN sugerencia.titulo   AS recomendacion,
       COUNT(DISTINCT otro) AS lectores_afines,
       COLLECT(DISTINCT otro.nombre) AS quienes
ORDER BY lectores_afines DESC
+----------------------------------------------------------------------+
| recomendacion            | lectores_afines | quienes                 |
+----------------------------------------------------------------------+
| "La sombra del faro"     | 1               | ["Iván Pereda"]         |
| "Cuadernos de Vallmar"   | 1               | ["Nuria Bastos"]        |
+----------------------------------------------------------------------+

Fíjate en la primera línea del MATCH: es literalmente el dibujo del camino. Lees "yo leí un libro que fue leído por otro que leyó una sugerencia" siguiendo las flechas. Y WHERE NOT (yo)-[:LEYO]->(sugerencia) es un anti-patrón de camino: descarta lo que Marta ya ha leído, igual que el anti-join de la lección 02-04 descartaba filas con NOT EXISTS.

5.4 Por qué esto no es un problema de JOIN

El equivalente SQL de esa consulta, sobre el esquema del módulo 2, sería aproximadamente:

SELECT l2.titulo, COUNT(DISTINCT p2.socio_id) AS lectores_afines
FROM prestamos p1
INNER JOIN ejemplares e1 ON e1.ejemplar_id = p1.ejemplar_id
INNER JOIN ejemplares e2 ON e2.libro_id    = e1.libro_id
INNER JOIN prestamos  p2 ON p2.ejemplar_id = e2.ejemplar_id AND p2.socio_id <> 14
INNER JOIN prestamos  p3 ON p3.socio_id    = p2.socio_id
INNER JOIN ejemplares e3 ON e3.ejemplar_id = p3.ejemplar_id
INNER JOIN libros     l2 ON l2.libro_id    = e3.libro_id
WHERE p1.socio_id = 14
  AND NOT EXISTS (
      SELECT 1 FROM prestamos px
      INNER JOIN ejemplares ex ON ex.ejemplar_id = px.ejemplar_id
      WHERE px.socio_id = 14 AND ex.libro_id = l2.libro_id
  )
GROUP BY l2.titulo
ORDER BY lectores_afines DESC;

Seis JOIN y una subconsulta correlacionada para tres saltos. Funciona, es correcto y con los datos de BiblioRed responderá rápido. El problema es la curva:

Saltos En SQL En un grafo
1 (qué he leído) 1 JOIN, inmediato Inmediato
2 (quién más lo leyó) 3 JOIN, rápido Inmediato
3 (qué leyeron ellos) 6 JOIN, aceptable Rápido
4 (y qué leyó su círculo) 9 JOIN, empieza a doler Rápido
5+ Inviable en la práctica Sigue siendo viable

Cada salto en SQL añade uno o más JOIN, y el coste de cada JOIN depende del tamaño total de las tablas. En un grafo, cada salto sigue punteros desde los nodos que ya tienes: el coste depende del número de vecinos, no del tamaño de la base. Por eso la profundidad es gratis en un grafo y carísima en SQL.

Regla práctica: hasta dos saltos, SQL. A partir de tres, y sobre todo si la profundidad es variable, grafo.

5.5 Fortalezas, límites y encaje en BiblioRed

Fortalezas: recorridos profundos a coste casi constante; el modelo se dibuja igual que se piensa; relaciones con propiedades propias; excelente para recomendaciones, detección de fraude, redes sociales, análisis de dependencias y árboles genealógicos u organizativos.

Límites: mala elección para agregaciones masivas sobre todos los nodos; escalado horizontal más difícil que en las otras familias (repartir un grafo entre máquinas sin cortar relaciones es un problema duro); ecosistema y talento más pequeños; suele ser una base secundaria alimentada desde la principal.

Encaje en BiblioRed: el motor de recomendaciones. Y con la misma honestidad de antes: BiblioRed no lo va a desplegar de momento; con 12.000 socios, una consulta SQL nocturna que precalcule recomendaciones y las deje en una colección de MongoDB resuelve el problema con una pieza menos que operar. Neo4j entra cuando las recomendaciones deban ser interactivas, personalizadas y de profundidad variable.

  1. Familias especializadas que este curso no vuelve a tratar

Fuera de las cuatro grandes hay tres familias que aparecen constantemente en arquitecturas reales. Las nombramos para que las reconozcas, sin desarrollarlas.

Motores de búsqueda (Elasticsearch, OpenSearch, Solr). Técnicamente son documentales, pero su motor está construido sobre un índice invertido —una estructura que va de cada palabra a los documentos que la contienen—. Eso les da relevancia ordenada, tolerancia a erratas, resaltado de coincidencias, sinónimos y facetas. Si BiblioRed quisiera un buscador del catálogo de calidad profesional, con sugerencias mientras se escribe y corrección de "Julo Verne" a "Julio Verne", esta sería la herramienta. MongoDB tiene índices de texto que cubren lo básico; un motor de búsqueda cubre lo exigente.

Series temporales (InfluxDB, TimescaleDB, Prometheus). Optimizadas para datos de la forma (instante, medida, valor) con escritura continua y consulta por ventanas. Comprimen extraordinariamente bien porque los valores contiguos se parecen, y traen agregación por intervalos y caducidad automática de los datos antiguos. TimescaleDB es una extensión de PostgreSQL, lo que la hace especialmente cómoda si ya tienes la relacional. El registro de actividad de BiblioRed también podría vivir aquí.

Vectoriales (Pinecone, Weaviate, Qdrant, pgvector). Guardan vectores numéricos —representaciones de significado generadas por modelos de lenguaje— y responden a "¿qué es lo más parecido a esto?" mediante búsqueda de vecinos próximos. Son la pieza que permitiría a BiblioRed responder a "quiero algo parecido a El mapa del tiempo pero más corto" sin que ninguna palabra coincida. Es la familia más joven y la de crecimiento más rápido.

  1. Tabla comparativa de las cuatro familias

Dimensión Documental Clave-valor Columnar Grafos
Producto de referencia MongoDB Redis Cassandra Neo4j
Unidad de datos Documento BSON Par clave → valor Fila ancha en partición Nodo y relación
¿El motor ve el interior? Sí, completo No, opaco Sí, por columnas Sí, nodos y aristas
Lenguaje Consultas + agregación Órdenes (GET, SET…) CQL Cypher
Consulta por campo cualquiera No Solo con la clave definida
Agregaciones Sí (pipeline) Limitadas Muy limitadas Sí, pero no es su fuerte
Escritura Rápida Muy rápida Extremadamente rápida Moderada
Escalado horizontal Bueno Bueno Excelente y lineal Difícil
Esquema Flexible Inexistente Fijo por tabla, flexible por fila Flexible
Relaciones entre entidades Referencias manuales No No Su razón de ser
Persistencia Disco Memoria (con volcado) Disco Disco
Caducidad nativa Sí (índice TTL) Sí, central Sí (por fila) No
Encaje en BiblioRed Reseñas y catálogo Caché y sesiones Registro de actividad Recomendaciones
Se despliega en BiblioRed No, de momento No, de momento No, de momento

  1. Árbol de decisión

flowchart TD
    A["¿Qué necesito guardar?"] --> B{"¿Las relaciones entre<br/>entidades son el objeto<br/>principal de la consulta?"}
    B -->|Sí, 3+ saltos| G["GRAFOS — Neo4j<br/>recomendaciones, fraude, redes"]
    B -->|No| C{"¿Accedo siempre<br/>por una clave conocida?"}
    C -->|Sí, y es efímero| KV["CLAVE-VALOR — Redis<br/>caché, sesiones, contadores"]
    C -->|No| D{"¿Volumen enorme de escritura<br/>con consultas conocidas<br/>de antemano?"}
    D -->|Sí| CO["COLUMNAR — Cassandra<br/>eventos, telemetría, registros"]
    D -->|No| E{"¿Estructura rica,<br/>heterogénea y<br/>consultada por contenido?"}
    E -->|Sí| DOC["DOCUMENTAL — MongoDB<br/>catálogos, perfiles, contenidos"]
    E -->|No| REL["RELACIONAL — PostgreSQL<br/>la respuesta por defecto"]

Presta atención a la última rama: cuando ninguna de las cuatro familias encaja con claridad, la respuesta correcta es la relacional. No es un consuelo, es el criterio profesional. Lo desarrollaremos con más argumentos en la lección 03-04.

  1. El reparto final de BiblioRed

Uniendo lo decidido en 01-02 con lo que hemos visto aquí:

Pieza del sistema Familia idónea Decisión real de BiblioRed Por qué
Socios, préstamos, reservas, ejemplares Relacional PostgreSQL Integridad, transacciones, consultas impredecibles
Reseñas de lectores Documental MongoDB Estructura cambiante, agregado autocontenido
Catálogo enriquecido Documental MongoDB Metadatos heterogéneos por tipo de material
Registro de actividad Columnar MongoDB con patrón de agrupación El volumen aún no justifica un clúster Cassandra
Caché de fichas y sesiones Clave-valor Aplazado Aún no hay problema de latencia que resolver
Recomendaciones Grafos Precálculo nocturno en SQL → MongoDB 12.000 socios no justifican otra base más

Dos bases de datos, cuatro necesidades cubiertas y un criterio escrito para cuando cada aplazamiento deje de ser razonable. Esa mezcla deliberada de motores se llama persistencia políglota; la nombraremos formalmente en la lección 03-04 y será el caso de estudio completo de la 08-03.

Errores Comunes y Consejos

Error 1: usar una base de grafos porque el dominio "tiene relaciones". Todos los dominios tienen relaciones; para eso existe el modelo relacional. El grafo gana cuando el recorrido de profundidad variable es la consulta principal. Un par de JOIN no justifican otra base de datos.

Error 2: usar Redis como almacén principal. Es memoria: si el proceso cae y la persistencia no estaba bien configurada, se pierden datos. Regla: si vaciar Redis pierde información que no puedes reconstruir, lo estás usando mal.

Error 3: escribir CQL como si fuera SQL. La sintaxis engaña. En Cassandra no hay JOIN, el WHERE solo funciona sobre las columnas de la clave y ORDER BY está atado al orden de agrupamiento. Si te ves escribiendo ALLOW FILTERING para que una consulta funcione, el problema no es la consulta: es el modelo de datos.

Error 4: confundir "columnar" con "analítica". Cassandra (familias de columnas) está pensada para operación transaccional a gran escala. ClickHouse o Redshift (almacenamiento por columnas) están pensadas para análisis OLAP, que vimos en 01-02. Comparten adjetivo y no propósito.

Error 5: usar $lookup de MongoDB como si fuera un JOIN normal. Funciona, pero delata que el diseño de documentos no es el adecuado. En la lección siguiente lo veremos catalogado como anti-patrón.

Consejo 1: diseña las claves de Redis con una convención jerárquica. entidad:identificador:aspecto, por ejemplo catalogo:MAT-0331:ficha o sesion:8f3a2b1c. Sin esa disciplina, en seis meses nadie sabrá qué hay dentro de una clave llamada m331.

Consejo 2: en Cassandra, escribe la lista de consultas antes que el CREATE TABLE. Es el orden inverso al relacional y es obligatorio. Una consulta no prevista puede costar una tabla nueva y reprocesar todo el histórico.

Consejo 3: prueba las cuatro familias en Docker. Una tarde levantando Redis, Cassandra y Neo4j en contenedores y trasteando con veinte documentos enseña más que cualquier comparativa escrita. Y docker rm -f lo deja todo como estaba.

Consejo 4: cada base de datos añadida es una base de datos que operar. Copias de seguridad, actualizaciones, supervisión, permisos, un experto en el equipo. Antes de añadir la tercera, pregúntate si el problema no se resuelve con un índice en la primera.

Ejercicios

Ejercicio 1

BiblioRed quiere una página "Lo más valorado del mes" con, para cada libro, su puntuación media y su número de reseñas, considerando solo reseñas de abril de 2026 y mostrando únicamente los libros con media igual o superior a 4. Escribe la aggregation pipeline de MongoDB y su equivalente SQL, y explica a qué cláusula de SQL corresponde cada etapa.

Ejercicio 2

Para cada una de estas cuatro necesidades nuevas de BiblioRed, elige la familia NoSQL más adecuada y justifícalo en dos o tres frases:

  • (a) Un contador de "ejemplares disponibles ahora mismo" por sucursal, consultado en cada carga de la página de inicio y que debe ser rapidísimo.
  • (b) Un histórico de todos los cambios de estado de cada ejemplar (disponible, prestado, reparación, baja), con unos 300.000 cambios al año, consultado como "historia de este ejemplar".
  • (c) Detectar clubes de lectura informales: grupos de socios que coinciden repetidamente leyendo los mismos títulos en las mismas semanas.
  • (d) Fichas de autor con biografía, foto, premios, enlaces externos y bibliografía, donde cada autor tiene un conjunto distinto de datos disponibles.

Ejercicio 3

Diseña la tabla de Cassandra que resolvería esta consulta de BiblioRed: "muéstrame los últimos 20 eventos de actividad de una sucursal concreta, del más reciente al más antiguo". Escribe el CREATE TABLE, indica cuál es la clave de partición y cuál la de agrupamiento, y responde: ¿por qué esta tabla no puede responder también a "los últimos 20 eventos de un socio concreto"?

Soluciones

Solución 1

db.resenas.aggregate([
  { $match: {
      fecha: { $gte: ISODate("2026-04-01T00:00:00Z"),
               $lt:  ISODate("2026-05-01T00:00:00Z") }
  }},
  { $group: {
      _id: "$libro_id",
      titulo: { $first: "$titulo_libro" },
      media:  { $avg: "$puntuacion" },
      total:  { $sum: 1 }
  }},
  { $match: { media: { $gte: 4 } } },
  { $project: {
      _id: 0,
      libro_id: "$_id",
      titulo: 1,
      media: { $round: ["$media", 2] },
      total: 1
  }},
  { $sort: { media: -1, total: -1 } }
])
[
  { titulo: 'Los pilares de la Tierra', total: 1, libro_id: 412, media: 4 }
]
SELECT r.libro_id, l.titulo, ROUND(AVG(r.puntuacion), 2) AS media, COUNT(*) AS total
FROM resenas r
INNER JOIN libros l ON l.libro_id = r.libro_id
WHERE r.fecha >= '2026-04-01' AND r.fecha < '2026-05-01'
GROUP BY r.libro_id, l.titulo
HAVING AVG(r.puntuacion) >= 4
ORDER BY media DESC, total DESC;
Etapa Cláusula SQL Comentario
$match (1.ª) WHERE Filtra antes de agrupar. Ponerla primera es esencial: reduce el volumen que procesan las etapas siguientes y permite usar índices.
$group GROUP BY _id es la clave de agrupación. $first recupera el título, que está duplicado en cada reseña —y por eso no hace falta JOIN con libros, a diferencia del SQL—.
$match (2.ª) HAVING Filtra después de agrupar, sobre el resultado calculado. La misma etapa cumple dos papeles distintos según dónde se coloque: ahí está el orden lógico de ejecución de la lección 02-05.
$project Lista del SELECT Elige y da forma a la salida; $round es el ROUND de SQL.
$sort ORDER BY Ordenación final.

Solución 2

(a) Contador de disponibles → clave-valor (Redis). Acceso siempre por una clave conocida (disponibles:sucursal:2), lectura muy frecuente, valor diminuto y tolerancia a unos segundos de desfase. INCR/DECR son atómicos y responden en microsegundos, mientras que un COUNT(*) sobre ejemplares en cada carga de la página de inicio es un desperdicio evitable.

(b) Histórico de estados de ejemplares → columnar (Cassandra). Es una serie de eventos inmutables, con escritura continua y una consulta perfectamente conocida de antemano: por ejemplar y ordenada por fecha. Clave de partición ejemplar_id, clave de agrupamiento fecha descendente. Dicho esto, y siendo coherentes con el criterio del apartado 9: 300.000 cambios al año son pocos, y una tabla en PostgreSQL con un índice sobre (ejemplar_id, fecha) resolvería esto perfectamente durante muchos años.

(c) Detectar clubes de lectura informales → grafos (Neo4j). Buscar grupos de socios densamente conectados entre sí a través de los títulos que comparten es detección de comunidades, un problema clásico de grafos que requiere recorridos de profundidad variable. En SQL sería una autounión repetida de coste creciente; en Cypher es un patrón de camino, y Neo4j incluso trae algoritmos de comunidad ya implementados.

(d) Fichas de autor → documental (MongoDB). Estructura rica y heterogénea (un autor tiene tres premios y ninguna foto, otro cinco enlaces y una biografía larga), leída entera de una vez para pintar la ficha y consultada por su contenido ("autores nacidos en Vallmar"). Es exactamente el mismo argumento que llevó el catálogo a MongoDB, así que además reutiliza una base que BiblioRed ya opera.

Solución 3

CREATE TABLE actividad_por_sucursal (
    sucursal_id  int,
    fecha_evento timestamp,
    evento_id    uuid,
    socio_id     int,
    tipo         text,
    termino      text,
    material_id  text,
    PRIMARY KEY ((sucursal_id), fecha_evento, evento_id)
) WITH CLUSTERING ORDER BY (fecha_evento DESC, evento_id ASC);

SELECT fecha_evento, socio_id, tipo, termino, material_id
FROM actividad_por_sucursal
WHERE sucursal_id = 2
LIMIT 20;
  • Clave de partición: (sucursal_id). Toda la actividad de una sucursal vive junta en el mismo nodo.
  • Claves de agrupamiento: fecha_evento (descendente, para que los 20 últimos sean los 20 primeros de la partición) y evento_id (para desempatar eventos simultáneos).

Por qué no sirve para consultar por socio. Porque socio_id no forma parte de la clave: es una columna normal. Cassandra necesita conocer la clave de partición para saber a qué nodo ir; sin ella tendría que preguntar a todos los nodos y filtrar cada partición entera, que es precisamente lo que el motor se niega a hacer sin ALLOW FILTERING.

Y esto ilustra la regla central de la familia: una tabla por consulta. Si BiblioRed necesita las dos vistas, mantiene las dos tablas —actividad_por_socio y actividad_por_sucursal— y escribe cada evento en ambas. La duplicación no es un defecto del diseño: es el diseño.

Un aviso adicional que un buen diseñador vería: si una sucursal genera millones de eventos, su partición crecerá sin límite, y las particiones ilimitadas son un problema conocido en Cassandra. La solución habitual es una clave de partición compuesta que incluya el periodo, ((sucursal_id, mes), fecha_evento, evento_id), acotando cada partición a un mes. Es la misma idea que el patrón de agrupación (bucket) que estudiaremos en la lección siguiente.

Conclusión

Hemos recorrido las cuatro familias NoSQL con operación real:

  • Documentales (MongoDB): documentos BSON anidados en colecciones sin esquema. Filtros con $gt, $in, $regex, $exists, $all; proyección con 1/0; sort, limit, skip; actualizaciones por partes con $set, $inc, $push, $addToSet y el operador posicional $; y la aggregation pipeline como equivalente del GROUP BY, donde $match es WHERE antes de agrupar y HAVING después, $group agrupa con _id como clave y $unwind despliega arrays. Encaje: reseñas y catálogo.
  • Clave-valor (Redis): el motor no ve el interior del valor y a cambio responde en microsegundos. SET/GET/EXPIRE/TTL, más estructuras nativas —listas, conjuntos, conjuntos ordenados, hashes y contadores atómicos con INCR—. La caducidad es una función de primera clase, y por eso es la herramienta natural para caché y sesiones. Nunca como fuente de verdad.
  • Columnares (Cassandra): filas anchas agrupadas por clave de partición y ordenadas por clave de agrupamiento; CQL se escribe como SQL pero prohíbe JOIN, subconsultas y WHERE libre —el error de ALLOW FILTERING es un aviso de diseño, no una molestia—. El principio es una tabla por consulta, con duplicación deliberada. Encaje: registros de actividad de altísimo volumen.
  • Grafos (Neo4j): nodos, relaciones con propiedades y dirección, y Cypher, que se escribe dibujando el camino. La adyacencia sin índice hace que el coste de un salto dependa del número de vecinos y no del tamaño de la base: hasta dos saltos, SQL; a partir de tres, grafo. Encaje: el "lectores como tú también leyeron".
  • Especializadas: motores de búsqueda con índice invertido (Elasticsearch), series temporales (InfluxDB, TimescaleDB) y vectoriales (pgvector, Qdrant) para búsqueda por similitud semántica.
  • La decisión real de BiblioRed: PostgreSQL y MongoDB. Redis, Cassandra y Neo4j quedan aplazados con un criterio escrito de cuándo dejarían de estarlo. Cada base añadida es una base que operar.

Sabemos ya qué familias existen y cómo se opera cada una. Falta lo más difícil y lo que más se equivoca en la práctica: diseñar bien. En la lección 03-03, Modelado de Datos en NoSQL, invertiremos el orden que aprendimos en el mundo relacional —dejaremos de modelar el dominio para modelar a partir de las consultas—, definiremos el concepto de agregado, resolveremos la decisión central de embeber frente a referenciar con criterios explícitos, estudiaremos los patrones documentales (referencia extendida, subconjunto, agrupación, valor atípico, campo calculado) y sus anti-patrones, y entregaremos el diseño final y justificado de las colecciones de BiblioRed: resenas, catalogo y actividad.

Fundamentos de Bases de Datos

Módulo 1: Introducción a las Bases de Datos

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados