Ja sabem què és NoSQL, per què existeix i com s'opera cadascuna de les seves quatre famílies. I tanmateix, amb tot això, encara no sabem fer el més important: dissenyar bé.
Aquesta és la lliçó on més projectes es torcen. La sintaxi de MongoDB s'aprèn en una tarda; el modelatge documental és on es decideix si el sistema anirà bé d'aquí a dos anys o si caldrà reescriure'l. I el parany és que un mal disseny funciona perfectament el primer dia: amb 200 documents tot és ràpid i no falla res. El dany apareix quan la col·lecció creix, quan un document s'acosta al seu límit de mida o quan algú descobreix que el nom d'una sucursal està duplicat en 40.000 documents i acaba de canviar.
Invertirem l'ordre mental que vam aprendre al mòdul 2, definirem amb precisió què és un agregat, resoldrem la decisió més important del modelatge documental —incrustar o referenciar— amb criteris explícits en lloc d'intuïció, aprendrem els cinc patrons de disseny que resolen gairebé tots els casos reals i els quatre antipatrons que els espatllen, i recuperarem part de les garanties perdudes amb la validació d'esquema.
El lliurable al final és concret: el disseny definitiu de les tres col·leccions de BiblioRed —ressenyes, cataleg i activitat— amb documents d'exemple i la justificació de cada decisió.
Contingut
- El canvi de mentalitat: del domini a les consultes
- L'agregat: unitat de lectura, escriptura i atomicitat
- La decisió central: incrustar o referenciar
- Un a pocs, un a molts, un a moltíssims
- Duplicació controlada i com mantenir-la coherent
- Patró: referència estesa
- Patró: subconjunt
- Patró: agrupació (bucket)
- Patró: valor atípic (outlier)
- Patró: camp calculat
- Antipatrons que cal reconèixer
- Validació d'esquema amb
$jsonSchema - Versionatge de documents i evolució de l'esquema
- Els índexs, breument
- Lliurable: el disseny final de les col·leccions de BiblioRed
- Errors habituals i consells
- Exercicis
- Conclusió
- El canvi de mentalitat: del domini a les consultes
Al mòdul 2 vam seguir, sense anomenar-lo, un mètode molt concret:
- Identificar les entitats del domini: sucursals, socis, llibres, exemplars, préstecs.
- Donar a cadascuna la seva taula, amb les seves claus i les seves relacions.
- Normalitzar per eliminar la redundància (ho formalitzarem al mòdul 5).
- I després escriure les consultes, amb la confiança que el model aguantarà qualsevol pregunta.
Aquest últim punt és la gran virtut del model relacional: un esquema ben normalitzat respon a preguntes que ningú no havia previst. El disseny és independent de l'ús.
A NoSQL l'ordre s'inverteix:
- Enumerar les consultes que l'aplicació necessita servir, amb la seva freqüència i la seva exigència de latència.
- Dissenyar els documents perquè cada consulta freqüent es resolgui amb un sol accés.
- Acceptar la duplicació que calgui per aconseguir-ho.
- I després comprovar que les entitats del domini continuen sent reconeixibles.
flowchart LR
subgraph REL["Model relacional"]
R1["Entitats<br/>del domini"] --> R2["Taules<br/>normalitzades"] --> R3["Consultes<br/>(qualsevol)"]
end
subgraph DOC["Model documental"]
D1["Consultes<br/>de l'aplicació"] --> D2["Documents<br/>a mida"] --> D3["Entitats<br/>reconeixibles"]
end
Això té una conseqüència incòmoda que convé acceptar com abans millor: el mateix domini admet dissenys documentals completament diferents segons com es consulti. No existeix "el model correcte d'una biblioteca" a MongoDB; existeix el model correcte per al portal de BiblioRed amb aquestes consultes. Si demà canvia radicalment l'ús, el model pot quedar obsolet encara que el domini no hagi canviat.
Per això el primer pas del modelatge documental no és dibuixar entitats, és escriure la llista de consultes. La de BiblioRed, prioritzada:
| # | Consulta | Freqüència | Exigència |
|---|---|---|---|
| C1 | Fitxa completa d'un material amb les seves dades i les seves 5 millors ressenyes | Molt alta | < 50 ms |
| C2 | Totes les ressenyes d'un material, paginades | Alta | < 100 ms |
| C3 | Ressenyes escrites per un soci (el seu perfil) | Mitjana | < 200 ms |
| C4 | Cercar materials per títol, autor o etiqueta | Molt alta | < 100 ms |
| C5 | Publicar una ressenya / votar una ressenya com a útil | Mitjana | < 100 ms |
| C6 | Registrar un esdeveniment d'activitat | Molt alta (escriptura) | < 10 ms |
| C7 | Activitat d'un soci en un rang de dates | Baixa | < 1 s |
| C8 | Termes més cercats del mes | Baixa (informe) | < 10 s |
Tot el que ve a continuació és la resposta a aquesta taula.
- L'agregat: unitat de lectura, escriptura i atomicitat
Vam introduir el concepte a la lliçó 03-01. Ara el precisem, perquè és l'eina principal de treball.
Un agregat és un conjunt de dades que compleix les tres condicions alhora:
- Es llegeix junt: l'aplicació gairebé mai no necessita una part sense les altres.
- S'escriu junt: els canvis afecten el conjunt de manera coherent.
- Té una arrel: una entitat principal que dóna identitat al conjunt i per la qual s'hi accedeix.
I d'aquí surt la propietat que governa tot el disseny:
L'agregat és la frontera de l'atomicitat. A MongoDB, l'escriptura d'un document és atòmica: o s'aplica sencera o no s'aplica. Tot el que quedi dins del document s'actualitza d'una peça, sense transacció. Tot el que quedi fora necessita una transacció explícita o quedarà exposat a incoherències temporals.
Aquesta frase és el criteri de disseny més útil que t'endús de la lliçó. Quan dubtis entre posar una cosa dins o fora, pregunta't: necessito que això canviï de manera atòmica juntament amb la resta? Si la resposta és sí, dins.
Exemple a BiblioRed. Una ressenya amb el seu text, la seva puntuació, les seves etiquetes i el seu recompte de vots és un agregat: quan un lector edita la seva ressenya, canvia el text i les etiquetes alhora, i ningú no ha de veure el text nou amb les etiquetes velles. En canvi, la ressenya i la fitxa del material no formen un agregat: la fitxa s'edita des del panell del bibliotecari, la ressenya des del portal públic, amb ritmes i responsables diferents.
- La decisió central: incrustar o referenciar
Tota la pràctica del modelatge documental es concentra en aquesta pregunta: quan dues entitats estan relacionades, el fill va dins del document del pare o a la seva pròpia col·lecció amb una referència?
Incrustar
{
"_id": "MAT-0331",
"titol": "El mapa del temps",
"ressenyes": [
{ "soci_id": 14, "nom": "Marta Alsina", "puntuacio": 5, "text": "Una novel·la que..." },
{ "soci_id": 15, "nom": "Ivan Pereda", "puntuacio": 3, "text": "Comença molt bé..." }
]
}Referenciar
{ "_id": "RES-1001", "material_id": "MAT-0331", "soci_id": 14, "puntuacio": 5, "text": "Una novel·la que..." }flowchart TD
subgraph EMB["INCRUSTAR"]
E1["Document MAT-0331<br/>titol + ressenyes[ ]<br/>1 lectura, tot junt"]
end
subgraph REF["REFERENCIAR"]
R1["cataleg<br/>MAT-0331"] -.->|material_id| R2["ressenyes<br/>RES-1001, RES-1002, ...<br/>2 lectures, creixen sense límit"]
end
Els sis criteris de decisió
No ho decideixis per intuïció. Recorre aquests sis criteris en ordre:
| # | Criteri | Afavoreix incrustar | Afavoreix referenciar |
|---|---|---|---|
| 1 | Cardinalitat | Pocs fills, amb topall conegut | Molts o il·limitats |
| 2 | Es consulta el fill per separat? | No: sempre amb el pare | Sí: té vida pròpia |
| 3 | Volatilitat | El fill canvia poc | El fill canvia molt o creix constantment |
| 4 | Mida | El conjunt queda molt per sota de 16 MB | S'hi acosta o el supera |
| 5 | Creixement | Acotat per naturalesa | Il·limitat en el temps |
| 6 | Atomicitat | Ha de canviar juntament amb el pare | Pot canviar de manera independent |
El criteri 5 mereix un avís especial, perquè és el que més sistemes ha trencat: tot el que creix amb el temps i no té topall acaba sent un problema si està incrustat. Les ressenyes d'un llibre popular, els esdeveniments d'activitat, els missatges d'un xat, l'historial de préstecs. Encara que avui siguin tres, d'aquí a cinc anys en seran milers. I hi ha un cost que no es veu venir: MongoDB, en actualitzar un document que ha crescut, pot haver de reescriure'l sencer al disc. Un document de 8 MB al qual s'afegeix un element de 200 bytes costa reescriure 8 MB.
Un criteri auxiliar molt pràctic
Si el fill no té sentit sense el pare i desapareix amb ell, gairebé sempre va incrustat. Una adreça sense el seu soci no significa res: dins. Una ressenya, en canvi, té identitat pròpia —s'enllaça, es vota, es denuncia, apareix al perfil del seu autor—: fora.
- Un a pocs, un a molts, un a moltíssims
La manera més ràpida d'aplicar els sis criteris és classificar la relació per la seva cardinalitat. És la regla pràctica més citada del modelatge documental i funciona sorprenentment bé.
| Tipus | Cardinalitat orientativa | Recomanació | Exemple a BiblioRed |
|---|---|---|---|
| Un a pocs | Fins a ~100, amb topall natural | Incrustar l'objecte complet | Un material i les seves 3–8 etiquetes; un material i les seves dades de portada; un soci i les seves 2 adreces |
| Un a molts | Centenars o milers, amb creixement moderat | Referenciar, i guardar al pare un subconjunt dels fills més rellevants | Un material i les seves ressenyes; un material i els seus exemplars |
| Un a moltíssims | Desenes de milers o sense límit | Referenciar des del fill, mai guardar la llista al pare | Un soci i els seus esdeveniments d'activitat; un material i les seves consultes |
La diferència entre les dues últimes files és subtil i crucial. A un a molts, el pare pot guardar la llista d'identificadors dels seus fills, perquè hi cap. A un a moltíssims, aquesta llista seria un array de 40.000 elements que creix cada dia: l'enllaç ha d'anar només al fill, que apunta cap amunt.
Aplicat a BiblioRed, cas per cas:
Material i etiquetes → incrustar. Un material té entre tres i vuit etiquetes, no creixen sense control, es mostren sempre amb la fitxa i no es consulten per separat (la cerca per etiqueta es resol amb un índex sobre l'array incrustat, no amb una altra col·lecció). Els sis criteris apunten a incrustar.
Material i ressenyes → referenciar, amb subconjunt. Les ressenyes creixen sense topall, es consulten per separat (perfil del soci, moderació), canvien sovint (vots útils, edicions, respostes) i són l'entitat principal de la consulta C2. Van a la seva pròpia col·lecció. Però la consulta C1 —la més freqüent del sistema— vol la fitxa amb les seves millors ressenyes en un sol accés, així que el document del material guarda a més una còpia de les cinc millors. Això és el patró de subconjunt de l'apartat 7.
Material i exemplars → ni una cosa ni l'altra. Els exemplars físics, amb el seu estat i la seva sucursal, es queden a PostgreSQL. Estan íntimament lligats als préstecs, que són transaccionals. El catàleg de MongoDB guarda només un comptador desnormalitzat de disponibilitat per poder pintar "3 disponibles a Centre" sense consultar l'altra base. Reconèixer que una dada no ha de migrar també és una decisió de modelatge.
Soci i activitat → un a moltíssims. Cada esdeveniment és un document (o millor, un element dins d'un document d'agrupació, apartat 8) que apunta al soci. El document del soci no guarda cap llista d'esdeveniments.
- Duplicació controlada i com mantenir-la coherent
Al mòdul 2 la redundància era l'enemic. En el modelatge documental és una eina. Però és una eina amb tall, i cal agafar-la pel mànec.
La duplicació es justifica quan el cost de mantenir-la és menor que el cost de reconstruir la dada a cada lectura. I aquest càlcul depèn gairebé sempre d'una sola pregunta: amb quina freqüència canvia la dada duplicada, comparada amb la freqüència amb què es llegeix?
Hi ha tres categories de dada duplicada, i es tracten de manera diferent:
Categoria A: dades immutables per naturalesa
No canvien mai, així que duplicar-les és gratis.
L'ISBN d'un llibre i el seu any de publicació són fixos. Copia'ls sense remordiment.
Categoria B: dades històriques, que han de congelar-se
Aquí la duplicació no és una optimització: és correcció semàntica.
{
"_id": "RES-1001",
"soci": { "soci_id": 14, "nom_mostrat": "Marta Alsina" },
"data": "2026-03-14T10:25:00Z"
}Si la Marta canvia de cognom el 2027, cal reescriure les seves 34 ressenyes? Depèn de què representi el camp:
- Si és "el nom que es mostra ara", sí: cal propagar-ho.
- Si és "qui va signar això el març del 2026", no: és una dada històrica i reescriure-la seria falsificar-la.
És la mateixa distinció que en una factura: el preu del producte en el moment de la venda es copia a la línia de factura i no es toca mai més, encara que el catàleg canviï demà. Decidir explícitament en quina categoria cau cada camp duplicat —i escriure-ho a la documentació del projecte— evita discussions i errors més endavant.
Categoria C: dades vives que han de propagar-se
Són les perilloses. El títol d'un material apareix a les seves ressenyes; si el bibliotecari el corregeix, hi ha ressenyes amb el títol vell.
Tres estratègies, amb el seu cost:
| Estratègia | Com funciona | Quan fer-la servir |
|---|---|---|
| Propagació immediata | En canviar l'original, un updateMany actualitza totes les còpies |
La dada canvia molt de tant en tant i les còpies són poques |
| Propagació diferida | S'encua el canvi i un procés l'aplica en segon pla | Moltes còpies; es tolera un desfasament de minuts |
| Sense propagació, amb relectura | La còpia és només una pista; la vista crítica rellegeix l'original | El desfasament és inacceptable en algun punt concret |
// Propagació immediata: el bibliotecari corregeix un títol amb una errada
db.cataleg.updateOne(
{ _id: "MAT-0331" },
{ $set: { titol: "El mapa del temps" } }
)
// ...i les còpies a les ressenyes s'actualitzen a continuació
db.ressenyes.updateMany(
{ material_id: "MAT-0331" },
{ $set: { titol_material: "El mapa del temps" } }
){ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
{ acknowledged: true, matchedCount: 47, modifiedCount: 47 }I aquí apareix amb tota la seva cruesa el preu de NoSQL que vam anunciar a 03-01: aquestes dues operacions no són atòmiques entre si. Si el procés cau entre la primera i la segona, el catàleg té el títol nou i 47 ressenyes el vell. A PostgreSQL aquest problema senzillament no existiria, perquè el títol seria en un sol lloc.
Les defenses disponibles: embolcallar totes dues escriptures en una transacció de MongoDB (possible des del 2018, amb cost de rendiment), o —més habitual— dissenyar el sistema perquè el desfasament temporal sigui tolerable i programar un procés periòdic de reconciliació que detecti i corregeixi les divergències.
Regla d'or: duplica només el que es mostra, mai el que es fa servir per decidir. Duplicar el títol d'un material per pintar-lo en una llista és raonable. Duplicar-ne el preu o l'estat de disponibilitat per prendre una decisió de negoci a partir de la còpia, no.
- Patró: referència estesa
Problema. Referenciar és correcte, però obliga a una segona consulta per mostrar quatre dades del document referenciat.
Solució. Al costat de la referència, copiar els pocs camps que la vista necessita. Ni tots ni cap: els que es pinten.
// SENSE el patró: dues consultes per cada ressenya mostrada
db.ressenyes.find({ material_id: "MAT-0331" })
db.cataleg.findOne({ _id: "MAT-0331" }) // només per saber el títol i la portada
// AMB referència estesa: una sola consulta
db.ressenyes.findOne({ _id: "RES-1001" }){
"_id": "RES-1001",
"material": {
"material_id": "MAT-0331",
"titol": "El mapa del temps",
"portada": "/img/catalogo/0331-s.webp",
"tipus": "llibre"
},
"soci": { "soci_id": 14, "nom_mostrat": "Marta Alsina" },
"puntuacio": 5,
"text": "Una novel·la que juga amb el temps sense marejar el lector."
}Criteri de selecció de camps: copia el que sigui estable i es mostri. El títol i el tipus són estables; la portada canvia molt de tant en tant. No copiïs la sinopsi (llarga i editable) ni el nombre d'exemplars disponibles (canvia a cada préstec). Per a això hi ha la referència.
Quan aplicar-lo: és el patró més usat i més útil del modelatge documental. Qualsevol llista que mostri elements de dues col·leccions n'és candidata.
- Patró: subconjunt
Problema. El document del material podria contenir totes les seves ressenyes, però un títol popular en té 800 i la fitxa només mostra les cinc millors. Incrustar 800 documents per pintar-ne cinc és carregar 160 vegades més dades de les necessàries a la consulta més freqüent del sistema.
Solució. Guardar al pare una còpia del subconjunt que la vista necessita, i el conjunt complet a la seva pròpia col·lecció.
{
"_id": "MAT-0331",
"titol": "El mapa del temps",
"valoracio": { "mitjana": 4.3, "total_ressenyes": 812 },
"ressenyes_destacades": [
{ "ressenya_id": "RES-1001", "nom": "Marta Alsina", "puntuacio": 5,
"extracte": "Una novel·la que juga amb el temps sense marejar el lector.", "vots_utils": 41 },
{ "ressenya_id": "RES-1244", "nom": "Núria Bastos", "puntuacio": 5,
"extracte": "L'ambientació victoriana està molt cuidada.", "vots_utils": 33 },
{ "ressenya_id": "RES-1533", "nom": "Ivan Pereda", "puntuacio": 4,
"extracte": "Es gaudeix més si coneixes la novel·la de Wells.", "vots_utils": 28 }
]
}Amb això, la consulta C1 —la fitxa completa, la més freqüent del portal— és un sol findOne. Qui premi "veure les 812 ressenyes" farà una segona consulta a ressenyes, però això només ho fa una fracció dels visitants.
Com es manté el subconjunt. Cada vegada que una ressenya puja de vots, es comprova si hauria d'entrar al destacat:
// Recalcular el destacat d'un material després d'un canvi a les seves ressenyes
const top = db.ressenyes.find(
{ material_id: "MAT-0331", estat: "publicada", spoiler: false },
{ _id: 1, "soci.nom_mostrat": 1, puntuacio: 1, text: 1, vots_utils: 1 }
).sort({ vots_utils: -1 }).limit(3).toArray()
db.cataleg.updateOne(
{ _id: "MAT-0331" },
{ $set: { ressenyes_destacades: top.map(r => ({
ressenya_id: r._id,
nom: r.soci.nom_mostrat,
puntuacio: r.puntuacio,
extracte: r.text.substring(0, 140),
vots_utils: r.vots_utils
})) } }
)Aquest recàlcul no ha de ser immediat: pot executar-se cada pocs minuts en segon pla. Que una ressenya trigui deu minuts a aparèixer al destacat no molesta ningú, i a canvi s'evita executar el recàlcul a cada vot.
Quan aplicar-lo: sempre que la vista principal necessiti només els N primers d'una col·lecció gran.
- Patró: agrupació (bucket)
Problema. El registre d'activitat genera 17 milions d'esdeveniments l'any. Un document per esdeveniment significa 17 milions de documents diminuts, cadascun amb la sobrecàrrega del seu _id, la seva entrada d'índex i les seves metadades internes. En molts casos, la sobrecàrrega pesa més que la dada.
Solució. Agrupar els esdeveniments d'una mateixa entitat i un mateix període en un sol document contenidor.
{
"_id": "ACT-14-2026-08-02",
"soci_id": 14,
"dia": "2026-08-02",
"sucursal_id": 1,
"num_esdeveniments": 4,
"primer_esdeveniment": "2026-08-02T09:14:02Z",
"ultim_esdeveniment": "2026-08-02T09:31:55Z",
"esdeveniments": [
{ "t": "2026-08-02T09:14:02Z", "tipus": "cerca", "terme": "jules verne" },
{ "t": "2026-08-02T09:14:31Z", "tipus": "fitxa", "material_id": "MAT-0331" },
{ "t": "2026-08-02T09:22:10Z", "tipus": "filtre", "camp": "idioma", "valor": "ca" },
{ "t": "2026-08-02T09:31:55Z", "tipus": "fitxa", "material_id": "MAT-0412" }
]
}La inserció d'un esdeveniment es converteix en una única operació que crea el contenidor si no existeix i afegeix l'esdeveniment si ja existeix:
db.activitat.updateOne(
{ _id: "ACT-14-2026-08-02" },
{
$push: { esdeveniments: { t: new Date(), tipus: "fitxa", material_id: "MAT-0508" } },
$inc: { num_esdeveniments: 1 },
$max: { ultim_esdeveniment: new Date() },
$setOnInsert: { soci_id: 14, dia: "2026-08-02", sucursal_id: 1 }
},
{ upsert: true }
)upsert: true és la clau: si el document del dia no existeix, es crea; si existeix, s'actualitza. $setOnInsert posa els camps fixos només en la creació, per no reescriure'ls cada vegada.
Les xifres del canvi, amb les estimacions de BiblioRed:
| Un document per esdeveniment | Amb agrupació per soci i dia | |
|---|---|---|
| Documents l'any | ~17.000.000 | ~624.000 (12.000 socis × ~52 dies actius) |
| Entrades d'índex per esdeveniment | 1 o més | ~0,04 |
| Espai de sobrecàrrega | Molt alt | Baix |
| "Activitat del soci 14 el 2 d'agost" | Buscar N documents | Un findOne |
| Escriptura d'un esdeveniment | insertOne |
updateOne amb upsert |
Com triar la mida del contenidor. Per soci i dia és el natural a BiblioRed. Però un usuari molt intensiu podria generar centenars d'esdeveniments diaris, així que cal posar-hi un topall: quan un contenidor arriba a, per exemple, 500 esdeveniments, se n'obre un altre (ACT-14-2026-08-02-2). Un contenidor sense topall es converteix en un array il·limitat, que és justament l'antipatró de l'apartat següent.
Quan aplicar-lo: sèries d'esdeveniments, telemetria, mesuraments, registres d'auditoria. És la resposta documental al problema que a 03-02 vam adjudicar a Cassandra, i la raó que BiblioRed pugui resoldre'l sense desplegar una altra base.
- Patró: valor atípic (outlier)
Problema. El 99,8 % dels materials de BiblioRed té menys de 50 ressenyes, i per a ells incrustar-les seria perfecte. Però tres o quatre supervendes en tenen milers. Si es dissenya per al cas extrem, es penalitzen els 39.996 materials normals; si es dissenya per al cas normal, els quatre extrems trenquen el sistema.
Solució. Dissenyar per al cas comú i marcar els excepcionals amb un indicador que activi un camí alternatiu.
{
"_id": "MAT-0412",
"titol": "Els pilars de la Terra",
"ressenyes": [ "...les primeres 50, incrustades..." ],
"ressenyes_desbordades": true,
"total_ressenyes": 1240
}// L'aplicació consulta segons l'indicador
const mat = db.cataleg.findOne({ _id: "MAT-0412" })
let ressenyes = mat.ressenyes
if (mat.ressenyes_desbordades) {
ressenyes = db.ressenyes.find({ material_id: mat._id })
.sort({ vots_utils: -1 }).limit(50).toArray()
}Cost: l'aplicació té dos camins de lectura, i això és complexitat real que cal documentar i provar. Per això aquest patró s'aplica només quan la distribució és realment asimètrica i el cas extrem és una minoria diminuta. Si el 20 % dels materials desborda, no hi ha valor atípic: hi ha un mal disseny i toca referenciar per a tothom.
- Patró: camp calculat
Problema. La fitxa d'un material mostra la seva puntuació mitjana. Calcular-la a cada visita significa recórrer les seves 812 ressenyes amb una agregació, per a una pàgina que es demana milers de vegades al dia.
Solució. Guardar el resultat ja calculat al document i actualitzar-lo quan canviïn les dades d'origen.
{
"_id": "MAT-0331",
"titol": "El mapa del temps",
"valoracio": {
"mitjana": 4.3,
"total_ressenyes": 812,
"suma_puntuacions": 3492,
"distribucio": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 },
"actualitzat": "2026-08-02T09:00:00Z"
}
}Fixa't en suma_puntuacions: guardar la suma a més de la mitjana permet actualitzar-la incrementalment, sense recórrer res.
// Arriba una ressenya nova amb puntuació 5
db.cataleg.updateOne(
{ _id: "MAT-0331" },
{
$inc: {
"valoracio.total_ressenyes": 1,
"valoracio.suma_puntuacions": 5,
"valoracio.distribucio.5": 1
},
$currentDate: { "valoracio.actualitzat": true }
}
)
// La mitjana es recalcula a partir de dos números, no de 813 documents
db.cataleg.updateOne(
{ _id: "MAT-0331" },
[ { $set: { "valoracio.mitjana": {
$round: [ { $divide: ["$valoracio.suma_puntuacions", "$valoracio.total_ressenyes"] }, 2 ] } } } ]
)
db.cataleg.findOne({ _id: "MAT-0331" }, { _id: 0, valoracio: 1 }){
valoracio: {
mitjana: 4.3,
total_ressenyes: 813,
suma_puntuacions: 3497,
distribucio: { '1': 12, '2': 31, '3': 88, '4': 264, '5': 418 },
actualitzat: ISODate('2026-08-02T09:47:22.108Z')
}
}Risc: el camp calculat pot desviar-se de la realitat si alguna escriptura falla o si s'esborra una ressenya sense descomptar-la. Defensa habitual: un procés nocturn que recalcula des de zero i corregeix. Un comptador que només puja mai no torna sol al seu lloc.
Quan aplicar-lo: quan la relació lectures/escriptures és molt alta. Aquí és de milers a un; el patró es paga sol.
- Antipatrons que cal reconèixer
11.1 Arrays sense cota
L'antipatró número u, i el més fàcil de cometre.
{
"_id": "MAT-0412",
"titol": "Els pilars de la Terra",
"consultes": [ "...41.238 elements i pujant..." ]
}Què passa, en ordre d'aparició: el document creix fins a acostar-se al límit de 16 MB; cada $push obliga a reescriure un document cada vegada més gran; les lectures transfereixen megabytes per fer servir dos camps; els índexs sobre l'array es disparen de mida; i un dia una escriptura falla amb BSONObjectTooLarge i no hi ha arranjament ràpid.
Senyal d'alarma: si no pots dir el nombre màxim d'elements que tindrà un array, no l'incrustis. Referencia o agrupa.
11.2 Documents gegants
Encara que no hi hagi arrays il·limitats, un document pot engreixar-se per acumulació: la sinopsi completa, la portada en base64, el text extret del PDF, l'històric de canvis... tot dins de la fitxa del material.
Conseqüència: cada lectura de la fitxa —per mostrar el títol i la portada en miniatura— transfereix el document sencer des del disc a la memòria i d'allà a la xarxa. La memòria cau del servidor s'omple de dades que ningú no mira, i se'n desallotgen documents que sí que es fan servir.
Regla: les dades grans que es consulten molt de tant en tant van a la seva pròpia col·lecció, o directament fora de la base de dades —les imatges, a un magatzem d'objectes o a un sistema de fitxers, amb la URL al document—.
11.3 Col·leccions massives de documents diminuts
L'extrem contrari: 17 milions de documents de 80 bytes. La sobrecàrrega per document (identificador, entrades d'índex, metadades) supera la dada útil, els índexs no caben en memòria i les consultes per rang obliguen a llegir milions de documents dispersos.
Solució: el patró d'agrupació de l'apartat 8.
11.4 Fer servir MongoDB com si fos relacional
És l'antipatró més car perquè no dóna la cara: el sistema funciona, simplement funciona pitjor del que funcionaria PostgreSQL.
// Cinc col·leccions normalitzades i una canalització que les cus amb $lookup
db.ressenyes.aggregate([
{ $lookup: { from: "socis", localField: "soci_id", foreignField: "_id", as: "soci" } },
{ $lookup: { from: "cataleg", localField: "material_id", foreignField: "_id", as: "material" } },
{ $lookup: { from: "autors", localField: "material.autor_id", foreignField: "_id", as: "autor" } },
{ $lookup: { from: "etiquetes", localField: "etiqueta_ids", foreignField: "_id", as: "etiquetes" } },
{ $unwind: "$soci" }, { $unwind: "$material" }
])Aquest codi és un esquema relacional escrit a MongoDB, i hereta el pitjor dels dos mons: la lentitud d'unir sense les optimitzacions d'un planificador relacional madur, i la manca d'integritat referencial de la base documental. Si el teu disseny acaba aquí, la conclusió correcta no és "cal optimitzar la canalització": és "aquest domini volia PostgreSQL".
$lookup és legítim per a informes ocasionals i processos per lots. No ho és com a mecanisme habitual de la ruta de lectura principal.
| Antipatró | Símptoma que veuràs | Correcció |
|---|---|---|
| Array sense cota | Documents que creixen sense parar; escriptures lentes | Referenciar o agrupar |
| Document gegant | Lectures que transfereixen molt per fer servir poc | Treure les dades grans |
| Documents diminuts massius | Índexs enormes, consultes per rang lentes | Patró d'agrupació |
| Relacional disfressat | $lookup a totes les consultes |
Redissenyar o tornar a SQL |
- Validació d'esquema amb
$jsonSchema
$jsonSchemaL'esquema flexible és un avantatge mentre l'equip és disciplinat. MongoDB permet recuperar part de la xarxa de seguretat de manera voluntària i gradual, que és justament el que es necessita: regles fortes on importen, llibertat on convé.
db.createCollection("ressenyes", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["material_id", "soci", "puntuacio", "text", "data", "estat", "esquema_v"],
properties: {
esquema_v: { bsonType: "int", minimum: 1, description: "versió del document" },
material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
soci: {
bsonType: "object",
required: ["soci_id", "nom_mostrat"],
properties: {
soci_id: { bsonType: "int", minimum: 1 },
nom_mostrat: { bsonType: "string", maxLength: 80 }
}
},
puntuacio: { bsonType: "int", minimum: 1, maximum: 5 },
text: { bsonType: "string", minLength: 10, maxLength: 4000 },
etiquetes: { bsonType: "array", maxItems: 10, items: { bsonType: "string" } },
estat: { enum: ["publicada", "pendent", "oculta", "denunciada"] },
data: { bsonType: "date" }
}
}
},
validationLevel: "moderate",
validationAction: "error"
})Prova que funciona:
db.ressenyes.insertOne({
material_id: "MAT-0331",
soci: { soci_id: 14, nom_mostrat: "Marta Alsina" },
puntuacio: 9, // fora del rang 1–5
text: "Curta", // menys de 10 caràcters
data: "2026-08-02", // cadena, no data
estat: "publicada",
esquema_v: 1
})MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ObjectId('66ab21f35c9e1b2f3d4a6c90'),
details: {
operatorName: '$jsonSchema',
schemaRulesNotSatisfied: [
{ operatorName: 'properties', propertiesNotSatisfied: [
{ propertyName: 'puntuacio', description: 'maximum: 5', consideredValue: 9 },
{ propertyName: 'text', description: 'minLength: 10', consideredValue: 'Curta' },
{ propertyName: 'data', description: 'bsonType: date', consideredValue: '2026-08-02' }
]}
]
}
}Tres errors capturats en l'escriptura, que és exactament on volíem. El de la data com a cadena és el més valuós: és l'error de la lliçó 03-01 que arruïna tota agregació posterior, i aquí no arriba ni a entrar.
Els dos paràmetres de comportament:
| Paràmetre | Valor | Efecte |
|---|---|---|
validationLevel |
strict |
Valida totes les insercions i totes les actualitzacions |
moderate |
Valida les insercions i només les actualitzacions de documents que ja complien | |
off |
No valida | |
validationAction |
error |
Rebutja l'escriptura |
warn |
L'accepta i anota un avís al registre |
Estratègia recomanada per a una col·lecció que ja té dades: començar amb validationAction: "warn" per descobrir quants documents incomplirien sense trencar res, corregir-los, i només llavors passar a error. I fer servir moderate mentre quedin documents antics per migrar, per no bloquejar les actualitzacions del que encara no compleix.
Què no fa $jsonSchema, per no crear falses expectatives: no comprova que material_id apunti a un material existent. La integritat referencial continua sense existir. Valida forma, no referències.
- Versionatge de documents i evolució de l'esquema
En una base relacional, canviar la forma de les dades és un ALTER TABLE que afecta totes les files alhora, amb el seu bloqueig i la seva finestra. En una documental hi ha una alternativa molt més còmoda: no migrar res i conviure amb diverses versions.
La tècnica és senzilla i consisteix en un camp:
{ "_id": "RES-1001", "esquema_v": 2, "puntuacio": 5, "text": "...",
"etiquetes": ["novella historica"], "vots_utils": 41, "spoiler": false }L'aplicació llegeix el camp i sap què esperar:
function normalitzarRessenya(doc) {
if (doc.esquema_v === 1) {
return { ...doc, etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 }
}
return doc
}Tres estratègies de migració, en ordre d'agressivitat:
| Estratègia | Com funciona | Quan |
|---|---|---|
| Mandrosa (lazy) | El document s'actualitza a la versió nova la propera vegada que s'escriu | El més habitual: cost zero, migració progressiva |
| En segon pla | Un procés recorre la col·lecció per lots i va actualitzant | Quan convé acabar en un termini, sense aturar el servei |
| Massiva | Un updateMany sobre tota la col·lecció |
Només si són pocs documents o hi ha finestra d'aturada |
// Migració mandrosa: en afegir un camp nou, s'actualitza la versió
db.ressenyes.updateOne(
{ _id: "RES-0450" },
{ $set: { etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 } }
)
// Migració en segon pla, per lots de 1000
db.ressenyes.updateMany(
{ esquema_v: 1 },
{ $set: { etiquetes: [], vots_utils: 0, spoiler: false, esquema_v: 2 } }
)// Comprovar com va la migració
db.ressenyes.aggregate([ { $group: { _id: "$esquema_v", n: { $sum: 1 } } }, { $sort: { _id: 1 } } ])L'advertiment imprescindible: conviure amb diverses versions té un cost, i és el codi que les gestiona. Si l'equip acumula sis versions actives, la funció de normalització es converteix en un laberint de condicionals que ningú no gosa tocar. Conviu amb dues, potser tres, i tanca les migracions: quan el recompte d'una versió antiga arriba a zero, esborra la seva branca de codi.
- Els índexs, breument
Tot el que hem dit sobre modelatge suposa que les consultes es resolen eficientment, i això depèn dels índexs tant a MongoDB com a PostgreSQL.
La bona notícia és que la lògica és la mateixa que aprendràs a la lliçó 06-03: un índex és una estructura auxiliar —normalment un arbre B— que evita recórrer totes les dades; accelera les lectures, alenteix les escriptures, ocupa espai, i l'ordre dels camps en un índex compost determina quines consultes pot servir.
Les particularitats documentals, en quatre línies:
- Es pot indexar qualsevol camp, encara que estigui imbricat:
db.ressenyes.createIndex({ "soci.soci_id": 1 }). - Es pot indexar el contingut d'un array (índex multiclau): una entrada per element. És el que fa ràpida la cerca per etiqueta.
- L'índex TTL esborra documents automàticament passat un temps, i és el que BiblioRed farà servir per caducar l'activitat als dos anys.
explain()és l'equivalent d'EXPLAINen SQL i diu si la consulta ha fet servir un índex o ha recorregut la col·lecció sencera.
db.ressenyes.createIndex({ material_id: 1, vots_utils: -1 })
db.ressenyes.createIndex({ etiquetes: 1 })
db.activitat.createIndex({ dia: 1 }, { expireAfterSeconds: 63072000 }) // 2 anysNo hi aprofundim més: els índexs tenen la seva lliçó pròpia al mòdul 6.
- Lliurable: el disseny final de les col·leccions de BiblioRed
Aquí hi ha el resultat d'aplicar tot l'anterior. Tres col·leccions a la base bibliored de MongoDB, cada decisió justificada.
15.1 Col·lecció cataleg
Arrel de l'agregat: el material. Respon a: C1 (fitxa completa) i C4 (cerca).
{
"_id": "MAT-0331",
"esquema_v": 2,
"tipus": "llibre",
"titol": "El mapa del temps",
"titol_normalitzat": "el mapa del temps",
"idioma": "es",
"any_publicacio": 2008,
"sinopsi": "Al Londres del 1896, un jove aristòcrata busca la manera de viatjar al passat...",
"portada": { "gran": "/img/catalogo/0331-g.webp", "miniatura": "/img/catalogo/0331-s.webp" },
"autors": [
{ "autor_id": 77, "nom_complet": "Félix J. Palma", "rol": "autor" }
],
"etiquetes": ["novella historica", "ciencia ficcio", "epoca victoriana", "premiat"],
"metadades": {
"isbn": "9788401339097",
"editorial": "Edicions Vallmar",
"pagines": 612,
"enquadernacio": "tapa dura"
},
"disponibilitat": {
"total_exemplars": 6,
"per_sucursal": { "1": 2, "2": 1, "3": 2, "4": 1 },
"actualitzat": "2026-08-02T06:00:00Z"
},
"valoracio": {
"mitjana": 4.3,
"total_ressenyes": 812,
"suma_puntuacions": 3492,
"distribucio": { "1": 12, "2": 31, "3": 88, "4": 264, "5": 417 }
},
"ressenyes_destacades": [
{ "ressenya_id": "RES-1001", "nom": "Marta Alsina", "puntuacio": 5,
"extracte": "Una novel·la que juga amb el temps sense marejar el lector.", "vots_utils": 41 },
{ "ressenya_id": "RES-1244", "nom": "Núria Bastos", "puntuacio": 5,
"extracte": "L'ambientació victoriana està molt cuidada.", "vots_utils": 33 }
],
"alta": "2024-11-03T10:00:00Z",
"actiu": true
}I un document d'un altre tipus, a la mateixa col·lecció, perquè es vegi l'heterogeneïtat:
{
"_id": "MAT-0802",
"esquema_v": 2,
"tipus": "revista",
"titol": "Vallmar Cultural",
"titol_normalitzat": "vallmar cultural",
"idioma": "ca",
"any_publicacio": 2026,
"portada": { "miniatura": "/img/catalogo/0802-s.webp" },
"etiquetes": ["cultura local", "hemeroteca"],
"metadades": {
"issn": "2604-1188",
"numero": 42,
"volum": 7,
"periodicitat": "mensual"
},
"disponibilitat": { "total_exemplars": 4, "per_sucursal": { "1": 1, "2": 1, "3": 1, "4": 1 } },
"valoracio": { "mitjana": 0, "total_ressenyes": 0, "suma_puntuacions": 0 },
"ressenyes_destacades": [],
"alta": "2026-05-11T09:04:00Z",
"actiu": true
}| Decisió | Justificació |
|---|---|
_id natural "MAT-0331" |
Llegible en registres i URL; estalvia un índex addicional |
metadades com a subdocument lliure |
Aïlla el que és específic de cada tipus de material; afegir "còmic" el 2027 no toca la resta del document |
autors incrustat amb nom |
Un a pocs + referència estesa: la fitxa mostra el nom sense anar a una altra col·lecció |
etiquetes com a array de cadenes |
Un a pocs amb topall; índex multiclau resol C4 |
ressenyes_destacades |
Patró de subconjunt: C1 es resol amb un sol findOne |
valoracio amb suma i distribució |
Patró de camp calculat, actualitzable amb $inc sense recórrer res |
disponibilitat desnormalitzada |
Còpia de només lectura de PostgreSQL, refrescada cada hora; la veritat continua a exemplars |
titol_normalitzat |
Sense accents ni majúscules, per a cerques insensibles als accents |
| Sinopsi incrustada | És text d'uns pocs kilobytes i es mostra a la mateixa fitxa: no justifica una altra col·lecció |
| Portades com a rutes, no com a binaris | Antipatró de document gegant evitat: les imatges viuen fora |
Índexs: { titol_normalitzat: 1 }, { etiquetes: 1 }, { tipus: 1, "valoracio.mitjana": -1 }, { "autors.autor_id": 1 }.
15.2 Col·lecció ressenyes
Arrel de l'agregat: la ressenya. Respon a: C2, C3 i C5.
{
"_id": "RES-1001",
"esquema_v": 2,
"material": {
"material_id": "MAT-0331",
"titol": "El mapa del temps",
"tipus": "llibre",
"portada": "/img/catalogo/0331-s.webp"
},
"soci": {
"soci_id": 14,
"nom_mostrat": "Marta Alsina",
"sucursal_id": 1
},
"puntuacio": 5,
"text": "Una novel·la que juga amb el temps sense marejar el lector. L'ambientació victoriana està molt cuidada i els tres actes se sostenen sols.",
"etiquetes": ["novella historica", "ciencia ficcio"],
"spoiler": false,
"estat": "publicada",
"vots_utils": 41,
"votants": [15, 16, 22, 31],
"resposta_bibliotecari": {
"sucursal_id": 1,
"text": "Si t'ha agradat, a la sucursal Centre tenim la continuació disponible.",
"data": "2026-03-16T09:10:00Z"
},
"data": "2026-03-14T10:25:00Z",
"editada": null
}| Decisió | Justificació |
|---|---|
Col·lecció pròpia, no incrustada a cataleg |
Un a molts sense topall, amb vida pròpia (perfil, moderació) i alta volatilitat |
material com a referència estesa |
Quatre camps estables permeten pintar el perfil del soci (C3) sense consultar cataleg |
soci.nom_mostrat duplicat |
Categoria B: és "qui va signar això", dada històrica congelada expressament |
vots_utils + votants incrustats |
Un vot ha de canviar comptador i llista atòmicament: van dins de l'agregat |
votants amb només el soci_id |
Array acotat a la pràctica; si un dia desbordés, passaria a col·lecció pròpia (valor atípic) |
resposta_bibliotecari com a subdocument opcional |
Zero o una per ressenya; el camp simplement no existeix si no n'hi ha |
estat amb valors tancats |
Permet moderar sense esborrar; validat amb enum a $jsonSchema |
etiquetes incrustades |
Un a pocs amb topall de 10 imposat per la validació |
Índexs: { "material.material_id": 1, vots_utils: -1 }, { "soci.soci_id": 1, data: -1 }, { estat: 1, data: -1 }, { etiquetes: 1 }.
15.3 Col·lecció activitat
Arrel de l'agregat: el conjunt d'esdeveniments d'un soci en un dia. Respon a: C6, C7 i C8.
{
"_id": "ACT-14-2026-08-02",
"esquema_v": 1,
"soci_id": 14,
"dia": "2026-08-02T00:00:00Z",
"sucursal_id": 1,
"num_esdeveniments": 5,
"primer_esdeveniment": "2026-08-02T09:14:02Z",
"ultim_esdeveniment": "2026-08-02T09:41:07Z",
"esdeveniments": [
{ "t": "2026-08-02T09:14:02Z", "tipus": "cerca", "terme": "jules verne", "resultats": 7 },
{ "t": "2026-08-02T09:14:31Z", "tipus": "fitxa", "material_id": "MAT-0331" },
{ "t": "2026-08-02T09:22:10Z", "tipus": "filtre", "camp": "idioma", "valor": "ca" },
{ "t": "2026-08-02T09:31:55Z", "tipus": "fitxa", "material_id": "MAT-0412" },
{ "t": "2026-08-02T09:41:07Z", "tipus": "reserva", "material_id": "MAT-0412" }
]
}| Decisió | Justificació |
|---|---|
| Patró d'agrupació per soci i dia | De ~17 M documents anuals a ~624 K; C7 es resol amb un findOne |
_id compost "ACT-<soci>-<data>" |
Determinista: permet l'upsert sense buscar abans |
Noms de camp curts (t) dins d'esdeveniments |
En un array de milers d'elements, els noms de camp es repeteixen a cadascun i pesen |
Comptadors num_esdeveniments, primer_esdeveniment, ultim_esdeveniment |
Responen sense obrir l'array |
| Topall de 500 esdeveniments per contenidor | Impedeix l'antipatró d'array sense cota; en superar-lo s'obre -2 |
sucursal_id copiat del soci |
Permet l'informe per sucursal sense consultar PostgreSQL |
Índex TTL sobre dia a 2 anys |
L'activitat antiga s'esborra sola; caducitat com a propietat de la dada |
Sense referència a ressenyes ni cataleg |
L'activitat és un registre immutable: no necessita coherència amb res |
Índexs: { soci_id: 1, dia: -1 }, { dia: 1 } amb expireAfterSeconds: 63072000, { "esdeveniments.material_id": 1 }.
15.4 El mapa complet
flowchart TD
subgraph PG["PostgreSQL — biblioredb (la veritat transaccional)"]
T["sucursals · socis · autors · llibres<br/>exemplars · prestecs · reserves"]
end
subgraph MG["MongoDB — bibliored (contingut i activitat)"]
C["cataleg<br/>_id MAT-nnnn"]
R["ressenyes<br/>_id RES-nnnn"]
A["activitat<br/>_id ACT-soci-data"]
end
T -->|sincronització horària:<br/>disponibilitat| C
R -->|patró subconjunt:<br/>ressenyes_destacades| C
R -.->|referència estesa:<br/>material_id| C
A -.->|referència:<br/>material_id, soci_id| C
Fixa't en l'estil de les fletxes: les contínues són còpies de dades que cal mantenir coherents; les discontínues són referències que l'aplicació resol quan cal. Cada fletxa contínua és una responsabilitat de coherència que algú ha d'assumir al codi, i per això convé que en siguin poques i estiguin documentades. Aquí n'hi ha exactament dues.
Errors Habituals i Consells
Error 1: començar dibuixant entitats.
És el reflex que portem del model relacional i aquí condueix a col·leccions normalitzades cosides amb $lookup. Comença sempre per la llista de consultes.
Error 2: incrustar "perquè és el que es fa a MongoDB". Incrustar és correcte per a un a pocs. Per a un a molts i un a moltíssims és una bomba de rellotgeria que esclata en producció, no en desenvolupament.
Error 3: duplicar sense decidir la categoria de la dada. Cada camp duplicat ha d'estar classificat: immutable, històric congelat o viu amb propagació. Si no ho escrius, d'aquí a sis mesos ningú no sabrà si cal actualitzar-lo.
Error 4: confiar que l'esquema flexible es documenta sol.
No es documenta. Escriu el $jsonSchema encara que el posis en warn: és documentació executable del contracte de la col·lecció.
Error 5: provar el disseny només amb dades de joguina. Un disseny amb 200 documents sempre sembla bo. Genera 500.000 documents sintètics amb la distribució real esperada —inclosos els valors atípics— i mesura abans de donar res per bo.
Error 6: oblidar que l'aplicació és ara la responsable.
No hi ha cap FOREIGN KEY que impedeixi una ressenya sobre un material inexistent. Si l'esborrat d'un material ha d'arrossegar les seves ressenyes, aquest CASCADE l'escrius tu.
Consell 1: escriu la fitxa de decisió de cada col·lecció. Arrel de l'agregat, consultes que serveix, què s'incrusta i per què, què es duplica i de quina categoria és, índexs. Mitja pàgina per col·lecció que estalvia setmanes.
Consell 2: anomena els camps de manera consistent i curta on es repeteixen molt.
Dins d'un array de milers d'elements, t en lloc de timestamp estalvia megabytes reals. Fora d'aquí, prioritza la llegibilitat.
Consell 3: posa el camp esquema_v des del primer document.
Costa quatre bytes i el dia que el necessitis —i el necessitaràs— t'estalviarà una migració massiva a cegues.
Consell 4: revisa el disseny quan canviïn les consultes, no quan canviï el domini. És el corol·lari de l'apartat 1 i l'avís més important de la lliçó: al món documental, un canvi en com es fan servir les dades pot obligar a redissenyar encara que les dades siguin les mateixes.
Exercicis
Exercici 1
BiblioRed vol afegir clubs de lectura: grups amb nom, sucursal de reunió, entre 8 i 25 socis membres, un llibre assignat cada mes i un fil de comentaris per cada llibre llegit (uns 30–60 comentaris per llibre, i el club pot portar anys en marxa). Decideix per a cada relació si incrustes o referencies, justificant-ho amb els sis criteris de l'apartat 3, i escriu el document d'exemple de la col·lecció clubs.
Exercici 2
Aquest document té quatre problemes de disseny. Identifica'ls, digues quin antipatró o mal criteri representa cadascun i proposa el disseny corregit.
{
"_id": ObjectId("..."),
"soci_id": 14,
"nom": "Marta Alsina",
"email": "[email protected]",
"foto_perfil_base64": "iVBORw0KGgoAAAANSUhEUgAAB...(1,8 MB)...",
"historial_prestecs": [ "...318 préstecs des del 2019, un per element..." ],
"esdeveniments_navegacio": [ "...11.402 esdeveniments, un per element..." ],
"ressenyes_escrites": [ "...34 ressenyes amb el seu text complet duplicat..." ]
}Exercici 3
Escriu la validació $jsonSchema de la col·lecció activitat dissenyada a l'apartat 15.3, exigint: soci_id enter positiu obligatori; dia de tipus data obligatori; num_esdeveniments enter entre 0 i 500; esdeveniments array obligatori de màxim 500 elements, on cada element tingui obligatòriament t (data) i tipus (un de cerca, fitxa, filtre, reserva, descarrega). Després explica per què convindria desplegar-la amb validationAction: "warn" abans que amb "error".
Solucions
Solució 1
Club i els seus membres → incrustar. Cardinalitat de 8 a 25, amb topall natural imposat pel mateix reglament del club (criteri 1: pocs). Es mostren sempre amb la fitxa del club i no es consulten per separat (criteri 2). Canvien poc: algú entra o surt unes quantes vegades l'any (criteri 3). Ocupen uns pocs kilobytes (criteri 4). El creixement està acotat pel màxim de places (criteri 5). I les altes i baixes han de ser coherents amb el recompte de places lliures, cosa que afavoreix l'atomicitat del mateix document (criteri 6). Sis de sis a favor d'incrustar, amb referència estesa al nom del soci.
Club i el seu calendari de lectures → incrustar, però vigilant. Dotze llibres l'any. Al cap de cinc anys són seixanta elements: continua sent un a pocs i hi cap de sobres. Es mostra sencer a la fitxa del club ("què hem llegit"). S'incrusta, i es deixa anotat que si un club superés els ~200 elements convindria separar l'històric antic.
Club i comentaris de cada llibre → referenciar.
Aquí el criteri 5 mana: 40 comentaris per llibre × 12 llibres l'any × diversos anys és creixement sense topall (criteris 1 i 5). A més tenen vida pròpia —es moderen, es responen, s'enllacen— (criteri 2), canvien amb freqüència (criteri 3) i no necessiten canviar atòmicament amb el club (criteri 6). Col·lecció pròpia comentaris_club, amb el subconjunt dels tres últims incrustat al club per poder mostrar "última activitat" sense una segona consulta.
{
"_id": "CLUB-007",
"esquema_v": 1,
"nom": "Els dimarts de Vallmar",
"sucursal_id": 3,
"dia_reunio": "dimarts",
"hora_reunio": "19:00",
"places": 25,
"actiu": true,
"membres": [
{ "soci_id": 14, "nom_mostrat": "Marta Alsina", "alta": "2025-09-02T00:00:00Z", "rol": "coordinadora" },
{ "soci_id": 16, "nom_mostrat": "Núria Bastos", "alta": "2025-10-07T00:00:00Z", "rol": "membre" },
{ "soci_id": 15, "nom_mostrat": "Ivan Pereda", "alta": "2026-01-13T00:00:00Z", "rol": "membre" }
],
"num_membres": 3,
"calendari": [
{ "mes": "2026-06", "material_id": "MAT-0331", "titol": "El mapa del temps", "estat": "llegit" },
{ "mes": "2026-07", "material_id": "MAT-0412", "titol": "Els pilars de la Terra", "estat": "llegit" },
{ "mes": "2026-08", "material_id": "MAT-0508", "titol": "L'ombra del far", "estat": "en curs" }
],
"ultims_comentaris": [
{ "comentari_id": "COM-4471", "soci_id": 16, "nom_mostrat": "Núria Bastos",
"material_id": "MAT-0412", "extracte": "El capítol de la catedral mereix rellegir-se.",
"data": "2026-07-28T20:14:00Z" }
],
"total_comentaris": 187,
"creat": "2025-09-02T00:00:00Z"
}Solució 2
| # | Problema | Antipatró o criteri incomplert | Correcció |
|---|---|---|---|
| 1 | foto_perfil_base64 d'1,8 MB dins del document |
Document gegant: cada lectura del soci, encara que només se'n vulgui el nom, transfereix 1,8 MB i desallotja la memòria cau | Guardar la imatge en un magatzem d'objectes o al sistema de fitxers i deixar només la ruta: "foto": "/img/socios/14.webp" |
| 2 | historial_prestecs amb 318 elements i creixent |
Array sense cota + dada que no hauria de ser aquí: els préstecs són transaccionals i viuen a PostgreSQL | Eliminar-lo del document. Si cal un resum per al perfil, un camp calculat: "estadistiques": { "total_prestecs": 318, "ultim": "2026-07-19" } |
| 3 | esdeveniments_navegacio amb 11.402 elements |
Array sense cota en la seva forma més greu: creix cada dia i sense límit. És un a moltíssims | Col·lecció activitat amb patró d'agrupació (apartat 15.3). El document del soci no guarda cap llista |
| 4 | ressenyes_escrites amb el text complet duplicat |
Duplicació de categoria C mal aplicada: el text és voluminós, editable i ja viu a ressenyes; tota edició obligaria a actualitzar dos llocs |
Referenciar. Si el perfil necessita mostrar les últimes, aplicar subconjunt amb només ressenya_id, títol del material, puntuació i extracte |
{
"_id": 14,
"esquema_v": 2,
"nom_mostrat": "Marta Alsina",
"email": "[email protected]",
"sucursal_id": 1,
"foto": "/img/socios/14.webp",
"preferencies": { "idioma": "ca", "avisos_email": true },
"estadistiques": {
"total_prestecs": 318,
"total_ressenyes": 34,
"ultim_prestec": "2026-07-19T00:00:00Z",
"actualitzat": "2026-08-02T06:00:00Z"
},
"ressenyes_recents": [
{ "ressenya_id": "RES-1001", "material_id": "MAT-0331", "titol": "El mapa del temps",
"puntuacio": 5, "extracte": "Una novel·la que juga amb el temps sense marejar...",
"data": "2026-03-14T10:25:00Z" }
]
}Un detall deliberat: _id és 14, el mateix identificador que el soci_id de PostgreSQL. Quan una entitat existeix a les dues bases, compartir l'identificador és la decisió que menys disgustos dóna.
Solució 3
db.runCommand({
collMod: "activitat",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["soci_id", "dia", "num_esdeveniments", "esdeveniments", "esquema_v"],
properties: {
esquema_v: { bsonType: "int", minimum: 1 },
soci_id: { bsonType: "int", minimum: 1 },
dia: { bsonType: "date" },
sucursal_id: { bsonType: "int", minimum: 1, maximum: 4 },
num_esdeveniments: { bsonType: "int", minimum: 0, maximum: 500 },
esdeveniments: {
bsonType: "array",
maxItems: 500,
items: {
bsonType: "object",
required: ["t", "tipus"],
properties: {
t: { bsonType: "date" },
tipus: { enum: ["cerca", "fitxa", "filtre", "reserva", "descarrega"] },
terme: { bsonType: "string", maxLength: 200 },
material_id: { bsonType: "string", pattern: "^MAT-[0-9]{4}$" },
camp: { bsonType: "string" },
valor: { bsonType: "string" },
resultats: { bsonType: "int", minimum: 0 }
}
}
}
}
}
},
validationLevel: "moderate",
validationAction: "warn"
})Fixa't en dues coses del disseny de la validació. Primer, maxItems: 500 converteix en regla comprovada pel servidor el topall que a l'apartat 15.3 era només una convenció: l'array sense cota deixa de ser possible per descuit. Segon, els camps terme, material_id, camp i valor no són a required: cada tipus d'esdeveniment fa servir uns i no uns altres, i aquesta heterogeneïtat és precisament el que fa que aquesta dada visqui a MongoDB.
Per què desplegar primer amb warn. La col·lecció ja té dades escrites abans que existís la validació, i és pràcticament segur que algunes incompleixen: esdeveniments antics amb t guardada com a cadena, un tipus que es deia "consulta" abans de canviar-li el nom, contenidors generats en proves amb més de 500 elements. Amb validationAction: "error" aquestes escriptures començarien a fallar de cop en producció i a la ruta d'escriptura més freqüent del sistema (C6, el registre d'esdeveniments, amb exigència de menys de 10 ms). Amb warn, MongoDB accepta l'escriptura i anota l'incompliment al registre: durant uns dies es recullen els avisos, es quantifica el problema, es corregeixen els documents antics i les rutes de codi que els generen, i només llavors es passa a error amb la certesa que no es trencarà res.
I validationLevel: "moderate" complementa l'estratègia: mentre duri la neteja, les actualitzacions de documents que ja incomplien no es bloquegen, així que el procés de correcció pot treballar sense barallar-se amb la seva pròpia validació.
Conclusió
Aquesta ha estat la lliçó de disseny, i el seu contingut es resumeix així:
- L'ordre s'inverteix: en el model relacional es modela el domini i després es consulta; en el documental es parteix de la llista de consultes i es dissenyen documents que les resolguin en un sol accés. El mateix domini admet dissenys diferents segons el seu ús.
- Un agregat es llegeix junt, s'escriu junt i té una arrel. I sobretot: és la frontera de l'atomicitat. El que és dins del document canvia d'una peça; el que és fora necessita transacció o tolerància al desfasament.
- Incrustar o referenciar es decideix amb sis criteris: cardinalitat, si el fill es consulta per separat, volatilitat, mida enfront del límit de 16 MB, creixement acotat o il·limitat, i necessitat d'atomicitat conjunta.
- Un a pocs → incrustar. Un a molts → referenciar amb subconjunt al pare. Un a moltíssims → referenciar només des del fill, sense llista al pare.
- La duplicació és una eina, no un error, i cada camp duplicat pertany a una categoria: immutable (gratis), històric congelat (correcte per semàntica) o viu (exigeix propagació immediata, diferida o relectura). Duplica el que es mostra, mai el que es fa servir per decidir.
- Cinc patrons: referència estesa (copiar els pocs camps que es pinten), subconjunt (els N primers al pare), agrupació (sèries d'esdeveniments en contenidors per període, amb
upserti$setOnInsert), valor atípic (indicador per a la minoria desbordada) i camp calculat (guardar l'agregat i mantenir-lo amb$inc). - Quatre antipatrons: arrays sense cota, documents gegants, col·leccions massives de documents diminuts i MongoDB fet servir com a base relacional a força de
$lookup—el diagnòstic correcte del qual sol ser "això volia PostgreSQL"—. $jsonSchemarecupera de manera voluntària i gradual part de la xarxa de seguretat perduda: tipus, rangs, camps obligatoris, patrons i valors tancats, ambvalidationLevelivalidationActionregulables. No valida referències: la integritat referencial continua sent cosa de l'aplicació.- El versionatge amb
esquema_vpermet evolucionar sense migració massiva, amb estratègies mandrosa, en segon pla o massiva. Conviu amb dues o tres versions i tanca les migracions. - I el lliurable:
cataleg(arrel el material, ambmetadadeslliures per tipus,valoraciocalculada,ressenyes_destacadescom a subconjunt idisponibilitatcopiada de PostgreSQL),ressenyes(arrel la ressenya, ambmaterialisocicom a referències esteses i vots incrustats per atomicitat) iactivitat(arrel el conjunt soci-dia, amb patró d'agrupació,_iddeterminista, topall de 500 esdeveniments i índex TTL a dos anys).
Ja tenim les dues meitats del curs construïdes: biblioredb a PostgreSQL i bibliored a MongoDB, cadascuna amb el disseny que li correspon. A la lliçó 03-04, Comparació entre Bases de Dades Relacionals i No Relacionals, les posem cara a cara dimensió per dimensió i hi afegim les peces teòriques que hem anat ajornant: el teorema CAP sense el malentès habitual i el seu refinament PACELC, el contrast entre ACID i BASE, què significa realment la consistència eventual per al lector que acaba de publicar una ressenya, els nivells de consistència ajustables de MongoDB, i el matís decisiu que la frontera s'ha difuminat —PostgreSQL guarda documents amb jsonb i MongoDB té transaccions multidocument—. Tancarem amb una guia de decisió honesta, els mites més repetits desmuntats i el nom de l'arquitectura a la qual ha arribat BiblioRed: la persistència poliglota.
Fonaments de Bases de Dades
Mòdul 1: Introducció a les Bases de Dades
- Conceptes Bàsics de Bases de Dades
- Tipus de Bases de Dades
- Història i Evolució de les Bases de Dades
- Sistemes Gestors de Bases de Dades i Arquitectura
Mòdul 2: Bases de Dades Relacionals
- Model Relacional
- Llenguatge SQL
- Operacions Bàsiques en SQL
- Consultes Multitaula: JOIN i Subconsultes
- Agregació i Agrupació de Dades
- Integritat Referencial
Mòdul 3: Bases de Dades No Relacionals
- Introducció a NoSQL
- Tipus de Bases de Dades NoSQL
- Modelatge de Dades en NoSQL
- Comparació entre Bases de Dades Relacionals i No Relacionals
Mòdul 4: Disseny d'Esquemes
- Principis de Disseny d'Esquemes
- Diagrames Entitat-Relació (ER)
- Transformació de Diagrames ER a Esquemes Relacionals
- Tipus de Dades i Restriccions
Mòdul 5: Normalització
Mòdul 6: Transaccions, Rendiment i Seguretat
- Transaccions i Propietats ACID
- Concurrència i Nivells d'Aïllament
- Índexs i Optimització de Consultes
- Seguretat, Permisos i Còpies de Seguretat
Mòdul 7: Exercicis Pràctics
- Exercicis de SQL
- Exercicis de Disseny d'Esquemes
- Exercicis de Normalització
- Exercicis de Consultes Avançades i Transaccions
Mòdul 8: Casos d'Estudi
- Cas d'Estudi: Base de Dades Relacional
- Cas d'Estudi: Base de Dades No Relacional
- Cas d'Estudi: Persistència Poliglota
