A la lliçó 01-02 vam veure les quatre famílies NoSQL des de molt amunt: què són i per a què serveixen, en un parell de paràgrafs cadascuna. A la lliçó anterior vam entendre el perquè del moviment i vam fer les primeres passes amb MongoDB. Ara toca baixar a terra.

"NoSQL" no és una tecnologia: és una etiqueta que agrupa quatre models de dades radicalment diferents entre si. Una base documental i una base de grafs s'assemblen tant com un full de càlcul i un mapa de metro. Dir "farem servir NoSQL" és tan poc informatiu com dir "farem servir un vehicle": la pregunta útil és quin, i per a què.

En aquesta lliçó recorrem les quatre famílies amb operació real i executable: documental amb MongoDB, clau-valor amb Redis, columnar amb Cassandra i grafs amb Neo4j. De cadascuna en veurem el model de dades, com es consulta, quins productes la representen, els seus punts forts, els seus límits i —el que enfilarà tota la lliçó— quina peça de BiblioRed li encaixa. Al final, una taula comparativa i un arbre de decisió per triar.

Contingut

  1. Les quatre famílies d'un cop d'ull
  2. Bases documentals: MongoDB
  3. Bases clau-valor: Redis
  4. Bases columnars: Cassandra
  5. Bases de grafs: Neo4j
  6. Famílies especialitzades que aquest curs no torna a tractar
  7. Taula comparativa de les quatre famílies
  8. Arbre de decisió
  9. El repartiment final de BiblioRed
  10. Errors habituals i consells
  11. Exercicis
  12. Conclusió

  1. Les quatre famílies d'un cop d'ull

Les quatre es poden ordenar per la riquesa de l'estructura que entenen: des de la que no entén res del valor que guarda fins a la que entén les relacions entre entitats.

Família Unitat de dades Què "entén" el motor Consulta típica
Clau-valor Parell clau → valor Només la clau. El valor és opac "Dóna'm el valor d'aquesta clau"
Documental Document tipus JSON Tota l'estructura interna del document "Dóna'm els documents on puntuacio > 4"
Columnar Fila ampla en una família de columnes La clau de partició i l'ordre dins d'ella "Dóna'm les files d'aquesta partició entre aquestes dues dates"
Grafs Node + relació + propietats Les connexions entre entitats "Dóna'm el que van llegir els qui van llegir això"

I les quatre peces de BiblioRed que busquen acomodament:

  • Ressenyes i catàleg enriquit → estructura rica, heterogènia, consultada per contingut → documental.
  • Portades i sessions del portal → accés per clau, molt freqüent, amb caducitat → clau-valor.
  • Registre d'activitat → altíssim volum d'escriptura, consulta per soci i rang de dates → columnar.
  • Motor de recomanacions → recórrer relacions entre lectors i títols → grafs.

Un advertiment abans de començar, perquè aquesta lliçó no es llegeixi malament: BiblioRed no desplegarà quatre bases de dades. Desplegarà PostgreSQL i MongoDB. Redis, Cassandra i Neo4j apareixen aquí perquè són la millor manera d'entendre què resol cada família i perquè el dia que BiblioRed creixi, sabrà reconèixer el moment. Confondre "entendre una família" amb "instal·lar-la" és el camí més ràpid cap a una arquitectura ingovernable.

  1. Bases documentals: MongoDB

2.1 Model de dades

La unitat és el document: una estructura tipus JSON —guardada en BSON, com vam veure a 03-01— que admet imbricació i arrays. Els documents viuen en col·leccions, que no imposen esquema.

Tres propietats que defineixen la família:

  1. El document és autocontingut. Tot el que l'aplicació necessita per mostrar una entitat hi cap a dins.
  2. El motor entén l'interior. Pot filtrar, ordenar, indexar i agregar per qualsevol camp, fins i tot imbricat o dins d'un array. Això és el que separa una base documental d'una clau-valor que guardés JSON: per a la segona, el valor és una bossa de bytes opaca.
  3. L'escriptura d'un document és atòmica, sense necessitat de transacció.

Productes representatius: MongoDB (el dominant i la nostra referència), Couchbase, Amazon DocumentDB, RavenDB, Firestore. I una nota important: PostgreSQL amb el tipus jsonb fa moltes d'aquestes coses sense deixar de ser relacional; ho compararem amb detall a la lliçó 03-04.

2.2 Operadors de consulta

Continuem sobre la col·lecció ressenyes que vam crear a la lliçó anterior. Un filtre és un document; cada camp és una condició i diversos camps es combinen amb I lògic. Quan la condició no és una igualtat, el valor passa a ser un document amb un operador que comença per $.

Operador Significat Equivalent SQL
$eq Igual (implícit si escrius el valor directe) =
$ne Diferent <>
$gt / $gte Més gran / més gran o igual > / >=
$lt / $lte Més petit / més petit o igual < / <=
$in / $nin És / no és a la llista IN / NOT IN
$exists El camp existeix (o no) al document sense equivalent directe
$regex Coincideix amb una expressió regular LIKE / ~
$and / $or / $not Combinacions lògiques AND / OR / NOT
$size L'array té N elements sense equivalent directe
$all L'array conté tots aquests elements exigeix diversos EXISTS
// Ressenyes amb puntuació més gran que 3
db.ressenyes.find({ puntuacio: { $gt: 3 } })
[
  { _id: ObjectId('...c82'), titol_llibre: 'El mapa del temps', puntuacio: 5, soci: { soci_id: 14, nom: 'Marta Alsina' }, ... },
  { _id: ObjectId('...c84'), titol_llibre: 'Els pilars de la Terra', puntuacio: 5, soci: { soci_id: 16, nom: 'Núria Bastos' }, ... },
  { _id: ObjectId('...c85'), titol_llibre: 'Els pilars de la Terra', puntuacio: 4, soci: { soci_id: 14, nom: 'Marta Alsina' }, ... }
]
-- Equivalent relacional
SELECT * FROM ressenyes WHERE puntuacio > 3;
// Ressenyes de dos socis concrets
db.ressenyes.find({ "soci.soci_id": { $in: [15, 16] } })
[
  { _id: ObjectId('...c83'), soci: { soci_id: 15, nom: 'Ivan Pereda' }, puntuacio: 3, ... },
  { _id: ObjectId('...c84'), soci: { soci_id: 16, nom: 'Núria Bastos' }, puntuacio: 5, ... }
]
// Cerca per text al cos de la ressenya, sense distingir majúscules
db.ressenyes.find({ text: { $regex: "catedral", $options: "i" } })
[
  { _id: ObjectId('...c84'), titol_llibre: 'Els pilars de la Terra',
    text: 'Mil pàgines que passen volant. La construcció de la catedral enganxa.', ... }
]
// Ressenyes que tenen resposta del bibliotecari
db.ressenyes.find({ resposta_bibliotecari: { $exists: true } })
[
  { _id: ObjectId('...c84'), resposta_bibliotecari: { nom: 'Equip Sucursal Sud', ... }, ... }
]

$exists no té equivalent en SQL, i per una raó de fons: en una taula, la columna existeix sempre; com a molt val NULL. En una col·lecció, un camp pot senzillament no ser-hi. És la diferència entre "no té valor" i "aquesta entitat no té aquest concepte", i al món documental són dues coses diferents.

// Ressenyes amb TOTES DUES etiquetes alhora
db.ressenyes.find({ etiquetes: { $all: ["novella historica", "per regalar"] } })
[
  { _id: ObjectId('...c84'), etiquetes: [ 'novella historica', 'per regalar', 'classic modern' ], ... }
]
// Combinació amb O lògic: molt bones o molt votades
db.ressenyes.find({ $or: [ { puntuacio: 5 }, { vots_utils: { $gte: 10 } } ] })
[
  { _id: ObjectId('...c82'), puntuacio: 5, vots_utils: 7, ... },
  { _id: ObjectId('...c84'), puntuacio: 5, vots_utils: 12, ... }
]

2.3 Projecció, ordre i límit

El segon argument de find és la projecció: quins camps s'han de retornar. 1 inclou, 0 exclou.

db.ressenyes.find(
  { llibre_id: 412 },
  { _id: 0, "soci.nom": 1, puntuacio: 1, vots_utils: 1 }
).sort({ vots_utils: -1 }).limit(2)
[
  { soci: { nom: 'Núria Bastos' }, puntuacio: 5, vots_utils: 12 },
  { soci: { nom: 'Marta Alsina' }, puntuacio: 4, vots_utils: 4 }
]
-- Equivalent relacional
SELECT s.nom, r.puntuacio, r.vots_utils
FROM ressenyes r INNER JOIN socis s ON s.soci_id = r.soci_id
WHERE r.llibre_id = 412
ORDER BY r.vots_utils DESC
LIMIT 2;

Regles de la projecció que convé memoritzar:

  • No es poden barrejar inclusions i exclusions a la mateixa projecció, amb una única excepció: _id: 0 es pot combinar amb inclusions.
  • L'_id es retorna sempre llevat que l'excloguis explícitament.
  • .sort(), .limit() i .skip() s'encadenen sobre el cursor i equivalen a ORDER BY, LIMIT i OFFSET. A sort, 1 és ascendent i -1 descendent.

2.4 Actualitzacions: $set, $push, $inc

Aquí apareix una diferència gran respecte de SQL: a MongoDB no es reescriu el document sencer, s'apliquen operadors d'actualització sobre parts d'ell.

Operador Què fa
$set Assigna un valor a un camp (el crea si no existeix)
$unset Elimina un camp del document
$inc Incrementa (o decrementa, amb negatiu) un número
$push Afegeix un element a un array
$addToSet Afegeix a un array només si no hi és ja
$pull Elimina d'un array els elements que compleixin una condició
$currentDate Hi posa la data actual del servidor
// Un lector marca com a útil la ressenya de la Núria: comptador +1
db.ressenyes.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { $inc: { vots_utils: 1 } }
)
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1
}
// Afegir una etiqueta sense duplicar i registrar l'edició
db.ressenyes.updateOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  {
    $addToSet: { etiquetes: "edat mitjana" },
    $currentDate: { editada: true }
  }
)

db.ressenyes.findOne(
  { _id: ObjectId('66ab15c15c9e1b2f3d4a6c84') },
  { _id: 0, etiquetes: 1, vots_utils: 1, editada: 1 }
)
{
  etiquetes: [ 'novella historica', 'per regalar', 'classic modern', 'edat mitjana' ],
  vots_utils: 13,
  editada: ISODate('2026-08-02T09:41:07.512Z')
}

Atura't un segon en el que acaba de passar. El camp editada no existia en cap document de la col·lecció i ara existeix en un. No ha calgut cap ALTER TABLE, no hi ha hagut bloqueig i els altres tres documents no se n'han assabentat. Això és l'esquema flexible en la seva forma més pràctica.

// Afegir un comentari a l'array de respostes d'una ressenya
db.ressenyes.updateOne(
  { _id: ObjectId('66ab14b25c9e1b2f3d4a6c82') },
  {
    $push: {
      comentaris: {
        soci_id: 15,
        nom: "Ivan Pereda",
        text: "Hi coincideixo, el final és el millor.",
        data: ISODate("2026-04-05T17:30:00Z")
      }
    }
  }
)
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// updateMany: normalitzar una etiqueta a tota la col·lecció
db.ressenyes.updateMany(
  { etiquetes: "novella historica" },
  { $set: { "etiquetes.$": "novella-historica" } }
)
{ acknowledged: true, matchedCount: 4, modifiedCount: 4 }

L'operador posicional $ es refereix al primer element de l'array que ha coincidit amb el filtre. És la manera de modificar un element concret sense reescriure l'array sencer.

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

Compte amb un detall que ha causat incidents reals: deleteMany({}) amb filtre buit esborra la col·lecció sencera i no demana confirmació. En SQL, DELETE FROM taula fa el mateix, però almenys hi ha una transacció que es pot desfer amb ROLLBACK. Aquí, llevat que siguis dins d'una transacció explícita, no n'hi ha.

2.5 La primera aggregation pipeline

Les consultes de resum —el que a la lliçó 02-05 vam fer amb GROUP BY— es fan a MongoDB amb el marc d'agregació: una canalització d'etapes, on cada etapa rep documents, els transforma i els passa a la següent.

Objectiu: la puntuació mitjana i el nombre de ressenyes de cada llibre, ordenat de millor a pitjor.

-- El que faríem a PostgreSQL (lliçó 02-05)
SELECT llibre_id, COUNT(*) AS total, ROUND(AVG(puntuacio), 2) AS mitjana
FROM ressenyes
GROUP BY llibre_id
HAVING COUNT(*) >= 1
ORDER BY mitjana DESC;
db.ressenyes.aggregate([
  { $match: { spoiler: false } },
  { $group: {
      _id: "$llibre_id",
      titol: { $first: "$titol_llibre" },
      total: { $sum: 1 },
      mitjana: { $avg: "$puntuacio" },
      vots: { $sum: "$vots_utils" }
  }},
  { $sort: { mitjana: -1 } }
])
[
  { _id: 412, titol: 'Els pilars de la Terra', total: 1, mitjana: 5, vots: 13 },
  { _id: 331, titol: 'El mapa del temps', total: 2, mitjana: 4, vots: 9 }
]

Traducció etapa per etapa, que és la millor manera d'entendre-ho:

Etapa de la canalització Equivalent SQL Què fa aquí
$match WHERE Es queda només amb les ressenyes sense spoiler
$group GROUP BY Agrupa per llibre_id i calcula els acumulats
$sort ORDER BY Ordena per mitjana descendent
$project Llista del SELECT Tria i calcula els camps de sortida
$limit LIMIT Talla el resultat
$unwind (no té equivalent) Converteix cada element d'un array en un document propi
$lookup LEFT JOIN Porta documents d'una altra col·lecció

Dues convencions de sintaxi que cal fixar:

  • "$camp" amb dòlar davant significa "el valor d'aquest camp", no la cadena literal. "$puntuacio" és el número; "puntuacio" seria el text.
  • A $group, _id és la clau d'agrupació, no un identificador. _id: "$llibre_id" és literalment el GROUP BY llibre_id. Si hi poses _id: null, agrupes tot en una sola fila, que és el SELECT AVG(...) FROM taula sense GROUP BY.

Un exemple més, amb $unwind, que no té contrapartida neta en SQL: les etiquetes més usades.

db.ressenyes.aggregate([
  { $unwind: "$etiquetes" },
  { $group: { _id: "$etiquetes", usos: { $sum: 1 } } },
  { $sort: { usos: -1, _id: 1 } },
  { $limit: 5 }
])
[
  { _id: 'novella-historica', usos: 3 },
  { _id: 'classic modern', usos: 1 },
  { _id: 'edat mitjana', usos: 1 },
  { _id: 'per regalar', usos: 1 }
]

$unwind ha "desplegat" la ressenya de la Núria, que tenia quatre etiquetes, en quatre documents idèntics tret de l'etiqueta. Després, $group compta. És exactament la feina que en el model relacional faria la taula intermèdia ressenyes_etiquetes que vam evitar en incrustar l'array.

2.6 Punts forts, límits i encaix a BiblioRed

Punts forts

  • Estructures heterogènies i imbricades sense taules auxiliars ni columnes nul·les.
  • L'esquema evoluciona sense migració.
  • Lectura d'una entitat completa en un sol accés.
  • Llenguatge de consulta ric: filtres, agregació, índexs sobre qualsevol camp.

Límits

  • Creuar col·leccions ($lookup) és car i no ha de ser la norma.
  • Sense integritat referencial declarativa.
  • El límit de 16 MB per document acota el que pots incrustar.
  • Les transaccions multidocument existeixen, però costen rendiment; el disseny ha de minimitzar-ne la necessitat.

Encaix a BiblioRed: les ressenyes i el catàleg enriquit. Són entitats autocontingudes, heterogènies, que es llegeixen senceres i canvien de forma amb freqüència. És l'encaix perfecte, i per això MongoDB és la base NoSQL que BiblioRed desplegarà de debò.

  1. Bases clau-valor: Redis

3.1 Model de dades

El model més simple que existeix: un diccionari gegant i distribuït. Una clau, un valor. El motor no sap res del contingut del valor —per a ell és una cadena o una estructura, però no una cosa que es pugui filtrar pel seu interior—.

Aquesta renúncia radical compra una cosa: velocitat. Redis manté totes les dades en memòria i respon en microsegons, amb xifres habituals de més de 100.000 operacions per segon en una màquina modesta.

Productes representatius: Redis (la nostra referència), Valkey (la seva bifurcació de codi obert), Memcached (encara més simple), Amazon DynamoDB (clau-valor amb capacitats documentals), etcd (configuració de clústers).

3.2 Operacions bàsiques i caducitat

# Arrencar un Redis en Docker i entrar al seu client
docker run -d --name redis-biblioRed -p 6379:6379 redis:7
docker exec -it redis-biblioRed redis-cli
127.0.0.1:6379>
// Guardar i recuperar la fitxa llesta per pintar d'un material del catàleg
SET cataleg:MAT-0331:fitxa "{\"titol\":\"El mapa del temps\",\"portada\":\"/img/0331.webp\"}"
GET cataleg:MAT-0331:fitxa
OK
"{\"titol\":\"El mapa del temps\",\"portada\":\"/img/0331.webp\"}"

L'operació estrella d'aquesta família és la caducitat, i mereix un apartat propi perquè és el que la diferencia de tota la resta:

// Guardar amb caducitat d'1 hora (3600 segons)
SET cataleg:MAT-0331:fitxa "{...}" EX 3600

// Consultar quanta vida li queda
TTL cataleg:MAT-0331:fitxa
OK
(integer) 3597
// Sessió del portal: caduca sola als 30 minuts d'inactivitat
SET sessio:8f3a2b1c "{\"soci_id\":14,\"nom\":\"Marta Alsina\",\"sucursal\":1}" EX 1800

// Cada petició del soci renova la finestra
EXPIRE sessio:8f3a2b1c 1800
TTL sessio:8f3a2b1c
OK
(integer) 1
(integer) 1800

Per què la caducitat és una funció de primera classe. En una base relacional, "esborrar el que hagi caducat" és una tasca programada que algú ha d'escriure, vigilar i executar; i mentre no s'executa, les dades caducades continuen ocupant espai i embrutant consultes. A Redis, la caducitat és una propietat de la clau: el sistema l'elimina sol, sense intervenció. Per a una memòria cau —on la dada antiga no és un error, només és inútil— i per a sessions —on la dada ha de desaparèixer per seguretat—, aquesta diferència és la raó mateixa de triar l'eina.

3.3 Estructures de dades

Redis no és només cadenes. El seu veritable valor són les estructures natives, cadascuna amb operacions atòmiques pròpies.

Estructura Què és Ús a BiblioRed
String Cadena o número Fitxa del catàleg en memòria cau, comptadors
List Llista ordenada, amb accés pels extrems Cua de treballs: portades pendents de redimensionar
Set Conjunt sense ordre ni duplicats Materials disponibles ara mateix a la sucursal Nord
Sorted Set Conjunt ordenat per puntuació Rànquing dels més consultats de la setmana
Hash Diccionari de camps dins d'una clau Dades de la sessió, camp a camp
Counter Un String amb operacions atòmiques Consultes d'avui sobre un material
// HASH: la sessió, camp a camp, sense reescriure tot l'objecte
HSET sessio:8f3a2b1c soci_id 14 nom "Marta Alsina" sucursal 1 idioma ca
HGET sessio:8f3a2b1c nom
HGETALL sessio:8f3a2b1c
(integer) 4
"Marta Alsina"
1) "soci_id"
2) "14"
3) "nom"
4) "Marta Alsina"
5) "sucursal"
6) "1"
7) "idioma"
8) "ca"
// COMPTADOR: increment atòmic, sense condicions de cursa
INCR consultes:MAT-0331:2026-08-02
INCR consultes:MAT-0331:2026-08-02
INCR consultes:MAT-0331:2026-08-02
GET consultes:MAT-0331:2026-08-02
(integer) 1
(integer) 2
(integer) 3
"3"

INCR és atòmic: si mil socis obren la fitxa alhora, el comptador acaba en mil exactes. Fer això en SQL exigeix UPDATE ... SET n = n + 1 amb el seu bloqueig de fila i el seu cost transaccional; aquí és una operació de microsegons.

// SORTED SET: rànquing de materials més consultats de la setmana
ZINCRBY ranquing:setmana:31 3 "MAT-0331"
ZINCRBY ranquing:setmana:31 8 "MAT-0412"
ZINCRBY ranquing:setmana:31 5 "MAT-0801"
ZREVRANGE ranquing:setmana: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 rànquing sempre ordenat, actualitzat al moment i consultable en temps constant. L'alternativa relacional és un ORDER BY COUNT(*) DESC sobre milions de files cada vegada que algú mira la pàgina d'inici.

// SET: quins materials estan disponibles ara a la sucursal Nord
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 Punts forts, límits i encaix a BiblioRed

Punts forts: latència de microsegons, operacions atòmiques sobre estructures, caducitat nativa, model trivial d'entendre i d'operar.

Límits: només es consulta per clau (no hi ha "dóna'm tot el que tingui puntuació 5"); les dades viuen en memòria, així que la RAM és el sostre i el cost; la persistència existeix (instantànies i registre d'operacions) però està pensada per a recuperació, no com a magatzem principal.

Encaix a BiblioRed: memòria cau de les fitxes i portades del catàleg —evita anar a MongoDB a cada visita d'una pàgina molt visitada— i sessions del portal —que han de caducar soles—. Mai com a font de veritat: si Redis es buida, BiblioRed ha de continuar funcionant, només que més a poc a poc. Aquesta és la prova del cotó d'una memòria cau ben plantejada.

  1. Bases columnars: Cassandra

4.1 Model de dades

El nom "columnar" despista. Aquí no significa "orientada a columnes per a anàlisi" (això són ClickHouse o Amazon Redshift), sinó famílies de columnes: files identificades per una clau, agrupades físicament, on cada fila pot tenir columnes diferents i molt nombroses.

Les dues peces que cal entendre són:

  • Clau de partició: decideix a quin node viu la fila. Totes les files amb la mateixa clau de partició són juntes al mateix node i contigües al disc.
  • Clau d'agrupament (clustering key): decideix l'ordre de les files dins de la partició.
flowchart TD
    subgraph N1["Node 1"]
        P1["Partició soci_id=14<br/>ordenada per data DESC<br/>→ 8.400 esdeveniments contigus"]
    end
    subgraph N2["Node 2"]
        P2["Partició soci_id=15<br/>ordenada per data DESC<br/>→ 6.100 esdeveniments contigus"]
    end
    subgraph N3["Node 3"]
        P3["Partició soci_id=16<br/>ordenada per data DESC<br/>→ 9.700 esdeveniments contigus"]
    end
    Q["Consulta:<br/>activitat del soci 15<br/>de l'últim mes"] --> N2

Aquesta contigüitat física és tot el secret: llegir "els 50 últims esdeveniments del soci 15" és una lectura seqüencial de disc dins d'una sola partició en un sol node. No hi ha cerca dispersa, no hi ha JOIN, no hi ha coordinació entre màquines.

Productes representatius: Apache Cassandra (la nostra referència), ScyllaDB (compatible i més ràpida), Apache HBase, Google Bigtable (l'article del 2006 que va originar la família), Amazon Keyspaces.

4.2 CQL: semblant a SQL, regles diferents

Cassandra es consulta amb CQL, que s'escriu gairebé com SQL. Aquesta familiaritat és un parany amable: la sintaxi és semblant, les regles no.

docker run -d --name cassandra-biblioRed -p 9042:9042 cassandra:5
docker exec -it cassandra-biblioRed cqlsh
-- Un "keyspace" és l'equivalent aproximat d'una base de dades
CREATE KEYSPACE bibliored
WITH replication = { 'class': 'SimpleStrategy', 'replication_factor': 3 };

USE bibliored;

-- La taula d'activitat del catàleg
CREATE TABLE activitat_per_soci (
    soci_id           int,
    data_esdeveniment timestamp,
    esdeveniment_id   uuid,
    tipus             text,
    terme             text,
    material_id       text,
    sucursal_id       int,
    PRIMARY KEY ((soci_id), data_esdeveniment, esdeveniment_id)
) WITH CLUSTERING ORDER BY (data_esdeveniment DESC, esdeveniment_id ASC);

Llegeix amb cura aquesta clau primària, perquè conté tota la lliçó:

  • (soci_id), entre parèntesis propis, és la clau de partició. Tots els esdeveniments d'un soci viuen junts.
  • data_esdeveniment i esdeveniment_id són les claus d'agrupament: ordenen dins de la partició, de més recent a més antic.
  • esdeveniment_id hi és per desempatar: sense ell, dos esdeveniments del mateix mil·lisegon se sobreescriurien.
INSERT INTO activitat_per_soci (soci_id, data_esdeveniment, esdeveniment_id, tipus, terme, sucursal_id)
VALUES (15, '2026-08-02 09:14:02', uuid(), 'cerca', 'jules verne', 2);

INSERT INTO activitat_per_soci (soci_id, data_esdeveniment, esdeveniment_id, tipus, material_id, sucursal_id)
VALUES (15, '2026-08-02 09:14:31', uuid(), 'fitxa', 'MAT-0331', 2);

SELECT data_esdeveniment, tipus, terme, material_id
FROM activitat_per_soci
WHERE soci_id = 15
LIMIT 5;
 data_esdeveniment               | tipus    | terme       | material_id
---------------------------------+----------+-------------+-------------
 2026-08-02 09:14:31.000000+0000 |    fitxa |        null |    MAT-0331
 2026-08-02 09:14:02.000000+0000 |    cerca | jules verne |        null

(2 rows)

Ara, les regles que trenquen les expectatives de qui ve de SQL:

-- Això FALLA
SELECT * FROM activitat_per_soci WHERE terme = 'jules 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 es nega a executar una consulta que no pugui resoldre de manera eficient. No hi ha WHERE lliure sobre qualsevol columna, no hi ha JOIN, no hi ha subconsultes i l'ORDER BY només pot seguir l'ordre d'agrupament ja definit. I aquest error, lluny de ser una limitació molesta, és una de les millors decisions de disseny del producte: t'avisa en desenvolupament que la teva consulta no escala, en comptes de deixar que ho descobreixis en producció amb deu milions de files.

4.3 Disseny dirigit per la consulta

De la restricció anterior surt el principi central de la família:

A Cassandra no es dissenya un model de dades i després es consulta. Es parteix de la llista de consultes i es crea una taula per consulta, duplicant les dades tantes vegades com calgui.

Si BiblioRed també necessita "els termes més cercats d'un dia", no s'hi afegeix un índex: es crea una altra taula, amb una altra clau de partició, alimentada amb la mateixa escriptura.

CREATE TABLE activitat_per_dia (
    dia               date,
    data_esdeveniment timestamp,
    esdeveniment_id   uuid,
    soci_id           int,
    tipus             text,
    terme             text,
    PRIMARY KEY ((dia), data_esdeveniment, esdeveniment_id)
) WITH CLUSTERING ORDER BY (data_esdeveniment DESC, esdeveniment_id ASC);

SELECT terme, COUNT(*) FROM activitat_per_dia
WHERE dia = '2026-08-02' AND tipus = 'cerca'
GROUP BY dia
ALLOW FILTERING;

A algú que ve del mòdul 5 —que encara no hem vist— això li semblarà una heretgia: és duplicació pura i dura. I ho és, deliberadament. A Cassandra el disc és barat, les escriptures són baratíssimes i el que és car és la lectura ineficient. Duplicar és l'estratègia, no l'error. És la manifestació més extrema del principi de modelar a partir de les consultes que estudiarem a fons a la lliçó 03-03.

4.4 Punts forts, límits i encaix a BiblioRed

Punts forts: escriptura extraordinàriament ràpida i sostinguda; escalat lineal (el doble de nodes, aproximadament el doble de capacitat); sense node primari —tots els nodes accepten escriptures, així que no hi ha punt únic de fallada—; replicació entre centres de dades integrada; caducitat per fila (TTL) nativa.

Límits: consultes rígides, lligades al disseny de la clau; sense JOIN ni agregacions lliures; duplicació massiva que l'aplicació ha de mantenir coherent; operació d'un clúster no trivial; una consulta nova pot exigir una taula nova i un reprocés de tot l'històric.

Encaix a BiblioRed: el registre d'activitat. Disset milions d'esdeveniments anuals, escriptura contínua, lectura per soci i rang de dates, sense necessitat d'integritat referencial i amb valor que decau amb el temps. És el cas d'ús canònic de la família.

Ara bé, siguem conseqüents amb el que vam dir a 03-01: 17 milions de documents l'any no justifiquen desplegar i operar un clúster Cassandra. MongoDB els absorbeix sense dificultat amb el patró d'agrupació que veurem a 03-03. Cassandra entraria en escena si BiblioRed passés d'una xarxa municipal a una xarxa autonòmica amb centenars de milions d'esdeveniments. Conèixer el criteri és el que et permet prendre aquesta decisió el dia que arribi.

  1. Bases de grafs: Neo4j

5.1 Model de dades

Tres elements i prou:

  • Nodes: les entitats (un soci, un llibre, un autor). Porten una o més etiquetes (:Soci, :Llibre) i propietats.
  • Relacions: les connexions entre nodes. Tenen tipus (VA_LLEGIR, VA_ESCRIURE), direcció i també propietats pròpies.
  • Propietats: parells clau-valor a nodes i relacions.

La diferència decisiva amb el model relacional: en una base relacional, una relació es calcula en el moment de la consulta comparant valors de clau forana; en una base de grafs, la relació està materialitzada com un punter físic. Recórrer-la no costa una cerca, costa seguir una referència. A això se'n diu adjacència sense índex, i és la raó que els recorreguts profunds siguin tan ràpids.

Productes representatius: Neo4j (la nostra referència), Amazon Neptune, ArangoDB, JanusGraph, Memgraph.

5.2 Cypher: MATCH, WHERE, RETURN

Cypher es llegeix dibuixant. () és un node, -[]-> és una relació 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 els nodes de socis i llibres
CREATE (m:Soci {soci_id: 14, nom: 'Marta Alsina', sucursal: 1}),
       (i:Soci {soci_id: 15, nom: 'Ivan Pereda', sucursal: 2}),
       (n:Soci {soci_id: 16, nom: 'Núria Bastos', sucursal: 3}),
       (l1:Llibre {llibre_id: 331, titol: 'El mapa del temps', isbn: '9788401339097'}),
       (l2:Llibre {llibre_id: 412, titol: 'Els pilars de la Terra', isbn: '9788401337208'}),
       (l3:Llibre {llibre_id: 508, titol: "L'ombra del far", isbn: '9788401338441'}),
       (l4:Llibre {llibre_id: 613, titol: 'Quaderns de Vallmar', isbn: '9788401339554'})
Added 7 nodes, Created 7 labels, Set 23 properties
// Crear les relacions de lectura, amb la valoració com a propietat de la relació
MATCH (m:Soci {soci_id: 14}), (i:Soci {soci_id: 15}), (n:Soci {soci_id: 16}),
      (l1:Llibre {llibre_id: 331}), (l2:Llibre {llibre_id: 412}),
      (l3:Llibre {llibre_id: 508}), (l4:Llibre {llibre_id: 613})
CREATE (m)-[:VA_LLEGIR {puntuacio: 5, data: date('2026-03-14')}]->(l1),
       (m)-[:VA_LLEGIR {puntuacio: 4, data: date('2026-04-02')}]->(l2),
       (i)-[:VA_LLEGIR {puntuacio: 3, data: date('2026-03-22')}]->(l1),
       (i)-[:VA_LLEGIR {puntuacio: 5, data: date('2026-05-08')}]->(l3),
       (n)-[:VA_LLEGIR {puntuacio: 5, data: date('2026-03-24')}]->(l2),
       (n)-[:VA_LLEGIR {puntuacio: 4, data: date('2026-06-01')}]->(l4)
Created 6 relationships, Set 12 properties
// Consulta simple: què ha llegit la Marta
MATCH (s:Soci {nom: 'Marta Alsina'})-[r:VA_LLEGIR]->(l:Llibre)
RETURN l.titol AS titol, r.puntuacio AS puntuacio
ORDER BY r.puntuacio DESC
+-------------------------------------------+
| titol                      | puntuacio    |
+-------------------------------------------+
| "El mapa del temps"        | 5            |
| "Els pilars de la Terra"   | 4            |
+-------------------------------------------+

5.3 El recorregut de diversos salts: la recomanació

Aquí és on la família es guanya el sou. La pregunta de negoci de BiblioRed és: "lectors com tu també van llegir". Formalment: partint d'un soci, anar als llibres que ha llegit, d'aquí als altres socis que també els van llegir, i d'aquí als llibres que aquells socis van llegir i el nostre no.

Són tres salts al graf.

flowchart LR
    M["Soci<br/>Marta Alsina"] -->|VA_LLEGIR| L1["Llibre<br/>El mapa del temps"]
    M -->|VA_LLEGIR| L2["Llibre<br/>Els pilars de la Terra"]
    I["Soci<br/>Ivan Pereda"] -->|VA_LLEGIR| L1
    I -->|VA_LLEGIR| L3["Llibre<br/>L'ombra del far<br/>★ RECOMANAT"]
    N["Soci<br/>Núria Bastos"] -->|VA_LLEGIR| L2
    N -->|VA_LLEGIR| L4["Llibre<br/>Quaderns de Vallmar<br/>★ RECOMANAT"]
MATCH (jo:Soci {soci_id: 14})-[:VA_LLEGIR]->(:Llibre)<-[:VA_LLEGIR]-(altre:Soci)-[:VA_LLEGIR]->(suggeriment:Llibre)
WHERE NOT (jo)-[:VA_LLEGIR]->(suggeriment)
  AND jo <> altre
RETURN suggeriment.titol   AS recomanacio,
       COUNT(DISTINCT altre) AS lectors_afins,
       COLLECT(DISTINCT altre.nom) AS qui
ORDER BY lectors_afins DESC
+----------------------------------------------------------------------+
| recomanacio              | lectors_afins   | qui                     |
+----------------------------------------------------------------------+
| "L'ombra del far"        | 1               | ["Ivan Pereda"]         |
| "Quaderns de Vallmar"    | 1               | ["Núria Bastos"]        |
+----------------------------------------------------------------------+

Fixa't en la primera línia del MATCH: és literalment el dibuix del camí. Llegeixes "jo vaig llegir un llibre que va ser llegit per un altre que va llegir un suggeriment" seguint les fletxes. I WHERE NOT (jo)-[:VA_LLEGIR]->(suggeriment) és un antipatró de camí: descarta el que la Marta ja ha llegit, igual que l'anti-join de la lliçó 02-04 descartava files amb NOT EXISTS.

5.4 Per què això no és un problema de JOIN

L'equivalent SQL d'aquesta consulta, sobre l'esquema del mòdul 2, seria aproximadament:

SELECT l2.titol, COUNT(DISTINCT p2.soci_id) AS lectors_afins
FROM prestecs p1
INNER JOIN exemplars e1 ON e1.exemplar_id = p1.exemplar_id
INNER JOIN exemplars e2 ON e2.llibre_id   = e1.llibre_id
INNER JOIN prestecs  p2 ON p2.exemplar_id = e2.exemplar_id AND p2.soci_id <> 14
INNER JOIN prestecs  p3 ON p3.soci_id     = p2.soci_id
INNER JOIN exemplars e3 ON e3.exemplar_id = p3.exemplar_id
INNER JOIN llibres   l2 ON l2.llibre_id   = e3.llibre_id
WHERE p1.soci_id = 14
  AND NOT EXISTS (
      SELECT 1 FROM prestecs px
      INNER JOIN exemplars ex ON ex.exemplar_id = px.exemplar_id
      WHERE px.soci_id = 14 AND ex.llibre_id = l2.llibre_id
  )
GROUP BY l2.titol
ORDER BY lectors_afins DESC;

Sis JOIN i una subconsulta correlacionada per a tres salts. Funciona, és correcte i amb les dades de BiblioRed respondrà ràpid. El problema és la corba:

Salts En SQL En un graf
1 (què he llegit) 1 JOIN, immediat Immediat
2 (qui més ho va llegir) 3 JOIN, ràpid Immediat
3 (què van llegir ells) 6 JOIN, acceptable Ràpid
4 (i què va llegir el seu cercle) 9 JOIN, comença a fer mal Ràpid
5+ Inviable a la pràctica Continua sent viable

Cada salt en SQL afegeix un o més JOIN, i el cost de cada JOIN depèn de la mida total de les taules. En un graf, cada salt segueix punters des dels nodes que ja tens: el cost depèn del nombre de veïns, no de la mida de la base. Per això la profunditat és gratis en un graf i caríssima en SQL.

Regla pràctica: fins a dos salts, SQL. A partir de tres, i sobretot si la profunditat és variable, graf.

5.5 Punts forts, límits i encaix a BiblioRed

Punts forts: recorreguts profunds a cost gairebé constant; el model es dibuixa igual que es pensa; relacions amb propietats pròpies; excel·lent per a recomanacions, detecció de frau, xarxes socials, anàlisi de dependències i arbres genealògics o organitzatius.

Límits: mala elecció per a agregacions massives sobre tots els nodes; escalat horitzontal més difícil que a les altres famílies (repartir un graf entre màquines sense tallar relacions és un problema dur); ecosistema i talent més petits; sol ser una base secundària alimentada des de la principal.

Encaix a BiblioRed: el motor de recomanacions. I amb la mateixa honestedat d'abans: BiblioRed no el desplegarà de moment; amb 12.000 socis, una consulta SQL nocturna que precalculi recomanacions i les deixi en una col·lecció de MongoDB resol el problema amb una peça menys per operar. Neo4j entra quan les recomanacions hagin de ser interactives, personalitzades i de profunditat variable.

  1. Famílies especialitzades que aquest curs no torna a tractar

Fora de les quatre grans hi ha tres famílies que apareixen constantment en arquitectures reals. Les anomenem perquè les reconeguis, sense desenvolupar-les.

Motors de cerca (Elasticsearch, OpenSearch, Solr). Tècnicament són documentals, però el seu motor està construït sobre un índex invertit —una estructura que va de cada paraula als documents que la contenen—. Això els dóna rellevància ordenada, tolerància a errades, ressaltat de coincidències, sinònims i facetes. Si BiblioRed volgués un cercador del catàleg de qualitat professional, amb suggeriments mentre s'escriu i correcció de "Jules Vern" a "Jules Verne", aquesta seria l'eina. MongoDB té índexs de text que cobreixen el bàsic; un motor de cerca cobreix l'exigent.

Sèries temporals (InfluxDB, TimescaleDB, Prometheus). Optimitzades per a dades de la forma (instant, mesura, valor) amb escriptura contínua i consulta per finestres. Comprimeixen extraordinàriament bé perquè els valors contigus s'assemblen, i porten agregació per intervals i caducitat automàtica de les dades antigues. TimescaleDB és una extensió de PostgreSQL, cosa que la fa especialment còmoda si ja tens la relacional. El registre d'activitat de BiblioRed també podria viure aquí.

Vectorials (Pinecone, Weaviate, Qdrant, pgvector). Guarden vectors numèrics —representacions de significat generades per models de llenguatge— i responen a "què és el més semblant a això?" mitjançant cerca de veïns propers. Són la peça que permetria a BiblioRed respondre a "vull una cosa semblant a El mapa del temps però més curta" sense que cap paraula coincideixi. És la família més jove i la de creixement més ràpid.

  1. Taula comparativa de les quatre famílies

Dimensió Documental Clau-valor Columnar Grafs
Producte de referència MongoDB Redis Cassandra Neo4j
Unitat de dades Document BSON Parell clau → valor Fila ampla en partició Node i relació
El motor veu l'interior? Sí, complet No, opac Sí, per columnes Sí, nodes i arestes
Llenguatge Consultes + agregació Ordres (GET, SET…) CQL Cypher
Consulta per camp qualsevol No Només amb la clau definida
Agregacions Sí (pipeline) Limitades Molt limitades Sí, però no és el seu fort
Escriptura Ràpida Molt ràpida Extremadament ràpida Moderada
Escalat horitzontal Bo Bo Excel·lent i lineal Difícil
Esquema Flexible Inexistent Fix per taula, flexible per fila Flexible
Relacions entre entitats Referències manuals No No La seva raó de ser
Persistència Disc Memòria (amb bolcat) Disc Disc
Caducitat nativa Sí (índex TTL) Sí, central Sí (per fila) No
Encaix a BiblioRed Ressenyes i catàleg Memòria cau i sessions Registre d'activitat Recomanacions
Es desplega a BiblioRed No, de moment No, de moment No, de moment

  1. Arbre de decisió

flowchart TD
    A["Què he de guardar?"] --> B{"Les relacions entre<br/>entitats són l'objecte<br/>principal de la consulta?"}
    B -->|Sí, 3+ salts| G["GRAFS — Neo4j<br/>recomanacions, frau, xarxes"]
    B -->|No| C{"Hi accedeixo sempre<br/>per una clau coneguda?"}
    C -->|Sí, i és efímer| KV["CLAU-VALOR — Redis<br/>memòria cau, sessions, comptadors"]
    C -->|No| D{"Volum enorme d'escriptura<br/>amb consultes conegudes<br/>per endavant?"}
    D -->|Sí| CO["COLUMNAR — Cassandra<br/>esdeveniments, telemetria, registres"]
    D -->|No| E{"Estructura rica,<br/>heterogènia i<br/>consultada per contingut?"}
    E -->|Sí| DOC["DOCUMENTAL — MongoDB<br/>catàlegs, perfils, continguts"]
    E -->|No| REL["RELACIONAL — PostgreSQL<br/>la resposta per defecte"]

Presta atenció a l'última branca: quan cap de les quatre famílies no encaixa amb claredat, la resposta correcta és la relacional. No és un consol, és el criteri professional. Ho desenvoluparem amb més arguments a la lliçó 03-04.

  1. El repartiment final de BiblioRed

Unint el que es va decidir a 01-02 amb el que hem vist aquí:

Peça del sistema Família idònia Decisió real de BiblioRed Per què
Socis, préstecs, reserves, exemplars Relacional PostgreSQL Integritat, transaccions, consultes impredictibles
Ressenyes de lectors Documental MongoDB Estructura canviant, agregat autocontingut
Catàleg enriquit Documental MongoDB Metadades heterogènies per tipus de material
Registre d'activitat Columnar MongoDB amb patró d'agrupació El volum encara no justifica un clúster Cassandra
Memòria cau de fitxes i sessions Clau-valor Ajornat Encara no hi ha problema de latència per resoldre
Recomanacions Grafs Precàlcul nocturn en SQL → MongoDB 12.000 socis no justifiquen una base més

Dues bases de dades, quatre necessitats cobertes i un criteri escrit per quan cada ajornament deixi de ser raonable. Aquesta barreja deliberada de motors s'anomena persistència poliglota; l'anomenarem formalment a la lliçó 03-04 i serà el cas d'estudi complet de la 08-03.

Errors Habituals i Consells

Error 1: fer servir una base de grafs perquè el domini "té relacions". Tots els dominis tenen relacions; per això existeix el model relacional. El graf guanya quan el recorregut de profunditat variable és la consulta principal. Un parell de JOIN no justifiquen una altra base de dades.

Error 2: fer servir Redis com a magatzem principal. És memòria: si el procés cau i la persistència no estava ben configurada, es perden dades. Regla: si buidar Redis fa perdre informació que no pots reconstruir, l'estàs fent servir malament.

Error 3: escriure CQL com si fos SQL. La sintaxi enganya. A Cassandra no hi ha JOIN, el WHERE només funciona sobre les columnes de la clau i ORDER BY està lligat a l'ordre d'agrupament. Si et veus escrivint ALLOW FILTERING perquè una consulta funcioni, el problema no és la consulta: és el model de dades.

Error 4: confondre "columnar" amb "analítica". Cassandra (famílies de columnes) està pensada per a operació transaccional a gran escala. ClickHouse o Redshift (emmagatzematge per columnes) estan pensades per a anàlisi OLAP, que vam veure a 01-02. Comparteixen adjectiu i no propòsit.

Error 5: fer servir $lookup de MongoDB com si fos un JOIN normal. Funciona, però delata que el disseny de documents no és l'adequat. A la lliçó següent el veurem catalogat com a antipatró.

Consell 1: dissenya les claus de Redis amb una convenció jeràrquica. entitat:identificador:aspecte, per exemple cataleg:MAT-0331:fitxa o sessio:8f3a2b1c. Sense aquesta disciplina, d'aquí a sis mesos ningú no sabrà què hi ha dins d'una clau anomenada m331.

Consell 2: a Cassandra, escriu la llista de consultes abans que el CREATE TABLE. És l'ordre invers al relacional i és obligatori. Una consulta no prevista pot costar una taula nova i reprocessar tot l'històric.

Consell 3: prova les quatre famílies en Docker. Una tarda aixecant Redis, Cassandra i Neo4j en contenidors i trastejant amb vint documents ensenya més que qualsevol comparativa escrita. I docker rm -f ho deixa tot com estava.

Consell 4: cada base de dades afegida és una base de dades per operar. Còpies de seguretat, actualitzacions, supervisió, permisos, un expert a l'equip. Abans d'afegir la tercera, pregunta't si el problema no es resol amb un índex a la primera.

Exercicis

Exercici 1

BiblioRed vol una pàgina "El més valorat del mes" amb, per a cada llibre, la seva puntuació mitjana i el seu nombre de ressenyes, considerant només ressenyes d'abril del 2026 i mostrant únicament els llibres amb mitjana igual o superior a 4. Escriu l'aggregation pipeline de MongoDB i el seu equivalent SQL, i explica a quina clàusula de SQL correspon cada etapa.

Exercici 2

Per a cadascuna d'aquestes quatre necessitats noves de BiblioRed, tria la família NoSQL més adequada i justifica-ho en dues o tres frases:

  • (a) Un comptador d'"exemplars disponibles ara mateix" per sucursal, consultat a cada càrrega de la pàgina d'inici i que ha de ser rapidíssim.
  • (b) Un històric de tots els canvis d'estat de cada exemplar (disponible, prestat, reparacio, baixa), amb uns 300.000 canvis l'any, consultat com a "història d'aquest exemplar".
  • (c) Detectar clubs de lectura informals: grups de socis que coincideixen repetidament llegint els mateixos títols les mateixes setmanes.
  • (d) Fitxes d'autor amb biografia, foto, premis, enllaços externs i bibliografia, on cada autor té un conjunt diferent de dades disponibles.

Exercici 3

Dissenya la taula de Cassandra que resoldria aquesta consulta de BiblioRed: "mostra'm els últims 20 esdeveniments d'activitat d'una sucursal concreta, del més recent al més antic". Escriu el CREATE TABLE, indica quina és la clau de partició i quina la d'agrupament, i respon: per què aquesta taula no pot respondre també a "els últims 20 esdeveniments d'un soci concret"?

Solucions

Solució 1

db.ressenyes.aggregate([
  { $match: {
      data: { $gte: ISODate("2026-04-01T00:00:00Z"),
              $lt:  ISODate("2026-05-01T00:00:00Z") }
  }},
  { $group: {
      _id: "$llibre_id",
      titol: { $first: "$titol_llibre" },
      mitjana: { $avg: "$puntuacio" },
      total:   { $sum: 1 }
  }},
  { $match: { mitjana: { $gte: 4 } } },
  { $project: {
      _id: 0,
      llibre_id: "$_id",
      titol: 1,
      mitjana: { $round: ["$mitjana", 2] },
      total: 1
  }},
  { $sort: { mitjana: -1, total: -1 } }
])
[
  { titol: 'Els pilars de la Terra', total: 1, llibre_id: 412, mitjana: 4 }
]
SELECT r.llibre_id, l.titol, ROUND(AVG(r.puntuacio), 2) AS mitjana, COUNT(*) AS total
FROM ressenyes r
INNER JOIN llibres l ON l.llibre_id = r.llibre_id
WHERE r.data >= '2026-04-01' AND r.data < '2026-05-01'
GROUP BY r.llibre_id, l.titol
HAVING AVG(r.puntuacio) >= 4
ORDER BY mitjana DESC, total DESC;
Etapa Clàusula SQL Comentari
$match (1a) WHERE Filtra abans d'agrupar. Posar-la primera és essencial: redueix el volum que processen les etapes següents i permet fer servir índexs.
$group GROUP BY _id és la clau d'agrupació. $first recupera el títol, que està duplicat a cada ressenya —i per això no cal JOIN amb llibres, a diferència del SQL—.
$match (2a) HAVING Filtra després d'agrupar, sobre el resultat calculat. La mateixa etapa fa dos papers diferents segons on es col·loqui: aquí hi ha l'ordre lògic d'execució de la lliçó 02-05.
$project Llista del SELECT Tria i dóna forma a la sortida; $round és el ROUND de SQL.
$sort ORDER BY Ordenació final.

Solució 2

(a) Comptador de disponibles → clau-valor (Redis). Accés sempre per una clau coneguda (disponibles:sucursal:2), lectura molt freqüent, valor diminut i tolerància a uns segons de desfasament. INCR/DECR són atòmics i responen en microsegons, mentre que un COUNT(*) sobre exemplars a cada càrrega de la pàgina d'inici és un malbaratament evitable.

(b) Històric d'estats d'exemplars → columnar (Cassandra). És una sèrie d'esdeveniments immutables, amb escriptura contínua i una consulta perfectament coneguda per endavant: per exemplar i ordenada per data. Clau de partició exemplar_id, clau d'agrupament data descendent. Dit això, i sent coherents amb el criteri de l'apartat 9: 300.000 canvis l'any són pocs, i una taula a PostgreSQL amb un índex sobre (exemplar_id, data) resoldria això perfectament durant molts anys.

(c) Detectar clubs de lectura informals → grafs (Neo4j). Buscar grups de socis densament connectats entre si a través dels títols que comparteixen és detecció de comunitats, un problema clàssic de grafs que exigeix recorreguts de profunditat variable. En SQL seria una autounió repetida de cost creixent; en Cypher és un patró de camí, i Neo4j fins i tot porta algoritmes de comunitat ja implementats.

(d) Fitxes d'autor → documental (MongoDB). Estructura rica i heterogènia (un autor té tres premis i cap foto, un altre cinc enllaços i una biografia llarga), llegida sencera d'una vegada per pintar la fitxa i consultada pel seu contingut ("autors nascuts a Vallmar"). És exactament el mateix argument que va portar el catàleg a MongoDB, així que a més reutilitza una base que BiblioRed ja opera.

Solució 3

CREATE TABLE activitat_per_sucursal (
    sucursal_id       int,
    data_esdeveniment timestamp,
    esdeveniment_id   uuid,
    soci_id           int,
    tipus             text,
    terme             text,
    material_id       text,
    PRIMARY KEY ((sucursal_id), data_esdeveniment, esdeveniment_id)
) WITH CLUSTERING ORDER BY (data_esdeveniment DESC, esdeveniment_id ASC);

SELECT data_esdeveniment, soci_id, tipus, terme, material_id
FROM activitat_per_sucursal
WHERE sucursal_id = 2
LIMIT 20;
  • Clau de partició: (sucursal_id). Tota l'activitat d'una sucursal viu junta al mateix node.
  • Claus d'agrupament: data_esdeveniment (descendent, perquè els 20 últims siguin els 20 primers de la partició) i esdeveniment_id (per desempatar esdeveniments simultanis).

Per què no serveix per consultar per soci. Perquè soci_id no forma part de la clau: és una columna normal. Cassandra necessita conèixer la clau de partició per saber a quin node ha d'anar; sense ella hauria de preguntar a tots els nodes i filtrar cada partició sencera, que és precisament el que el motor es nega a fer sense ALLOW FILTERING.

I això il·lustra la regla central de la família: una taula per consulta. Si BiblioRed necessita les dues vistes, manté les dues taules —activitat_per_soci i activitat_per_sucursal— i escriu cada esdeveniment a totes dues. La duplicació no és un defecte del disseny: és el disseny.

Un avís addicional que un bon dissenyador veuria: si una sucursal genera milions d'esdeveniments, la seva partició creixerà sense límit, i les particions il·limitades són un problema conegut a Cassandra. La solució habitual és una clau de partició composta que inclogui el període, ((sucursal_id, mes), data_esdeveniment, esdeveniment_id), acotant cada partició a un mes. És la mateixa idea que el patró d'agrupació (bucket) que estudiarem a la lliçó següent.

Conclusió

Hem recorregut les quatre famílies NoSQL amb operació real:

  • Documentals (MongoDB): documents BSON imbricats en col·leccions sense esquema. Filtres amb $gt, $in, $regex, $exists, $all; projecció amb 1/0; sort, limit, skip; actualitzacions per parts amb $set, $inc, $push, $addToSet i l'operador posicional $; i l'aggregation pipeline com a equivalent del GROUP BY, on $match és WHERE abans d'agrupar i HAVING després, $group agrupa amb _id com a clau i $unwind desplega arrays. Encaix: ressenyes i catàleg.
  • Clau-valor (Redis): el motor no veu l'interior del valor i a canvi respon en microsegons. SET/GET/EXPIRE/TTL, més estructures natives —llistes, conjunts, conjunts ordenats, hashes i comptadors atòmics amb INCR—. La caducitat és una funció de primera classe, i per això és l'eina natural per a memòria cau i sessions. Mai com a font de veritat.
  • Columnars (Cassandra): files amples agrupades per clau de partició i ordenades per clau d'agrupament; CQL s'escriu com SQL però prohibeix JOIN, subconsultes i WHERE lliure —l'error d'ALLOW FILTERING és un avís de disseny, no una molèstia—. El principi és una taula per consulta, amb duplicació deliberada. Encaix: registres d'activitat d'altíssim volum.
  • Grafs (Neo4j): nodes, relacions amb propietats i direcció, i Cypher, que s'escriu dibuixant el camí. L'adjacència sense índex fa que el cost d'un salt depengui del nombre de veïns i no de la mida de la base: fins a dos salts, SQL; a partir de tres, graf. Encaix: el "lectors com tu també van llegir".
  • Especialitzades: motors de cerca amb índex invertit (Elasticsearch), sèries temporals (InfluxDB, TimescaleDB) i vectorials (pgvector, Qdrant) per a cerca per similitud semàntica.
  • La decisió real de BiblioRed: PostgreSQL i MongoDB. Redis, Cassandra i Neo4j queden ajornats amb un criteri escrit de quan deixarien d'estar-ho. Cada base afegida és una base per operar.

Ja sabem quines famílies existeixen i com s'opera cadascuna. Falta el més difícil i el que més s'equivoca a la pràctica: dissenyar bé. A la lliçó 03-03, Modelatge de Dades en NoSQL, invertirem l'ordre que vam aprendre al món relacional —deixarem de modelar el domini per modelar a partir de les consultes—, definirem el concepte d'agregat, resoldrem la decisió central d'incrustar enfront de referenciar amb criteris explícits, estudiarem els patrons documentals (referència estesa, subconjunt, agrupació, valor atípic, camp calculat) i els seus antipatrons, i lliurarem el disseny final i justificat de les col·leccions de BiblioRed: ressenyes, cataleg i activitat.

© Copyright 2026. Tots els drets reservats