La lliçó anterior va acabar amb una llista de tres fracassos: la telemetria GPS de cada trajecte, la fitxa enriquida de les estacions i les incidències amb estructura variable. Tres coses que l'esquema relacional de VallBici resol malament, no per estar mal dissenyat, sinó perquè són dades que no demanen el que el model relacional sap donar.

Aquesta lliçó agafa aquestes tres peces exactes —ni una més— i les modela a MongoDB aplicant el mètode de disseny dirigit per consultes de 03-03. No redissenya el nucli transaccional: abonaments, trajectes, cobraments, ancoratges i bicicletes es queden a PostgreSQL, i la primera part de la lliçó explica per què aquella frontera és la decisió més important de tot el capítol.

Al final, cada decisió de disseny es compara amb com va quedar a 08-01, s'enumera amb precisió què s'ha perdut i es posa sobre la taula l'alternativa que un professional experimentat plantejaria a la reunió: i per què no ho fem tot a PostgreSQL amb jsonb i PostGIS? És una pregunta legítima i mereix una resposta amb arguments, no un arronsament d'espatlles.

I acaba amb un problema obert que encara no sabrem resoldre: a partir d'avui hi haurà dues bases de dades amb informació de la mateixa bicicleta, i ningú no ha dit quina mana.

Contingut

  1. Què surt de PostgreSQL i per què no tot
  2. El mètode: primer les consultes, després els documents
  3. La col·lecció estacions: la fitxa enriquida
  4. La col·lecció trajectes_telemetria: el patró bucket
  5. La col·lecció incidencies: estructura variable amb validació
  6. Operacions d'escriptura: telemetria en temps real
  7. Aggregation pipelines: els informes d'operacions
  8. Consultes geoespacials: el que el relacional feia pitjor
  9. Índexs, explain() i caducitat amb TTL
  10. El que s'ha perdut i com es mitiga
  11. La comparació honesta: PostgreSQL amb jsonb i PostGIS
  12. Errors Habituals i Consells
  13. Exercicis
  14. Conclusió

  1. Què surt de PostgreSQL i per què no tot

Comencem pel que no es fa, perquè és on s'equivoca la majoria.

La decisió equivocada és "migrem VallBici a MongoDB".

Si algú la proposa a la reunió, aquests són els arguments concrets —no genèrics— en contra en aquest sistema:

  1. El cobrament ha de ser ACID i multidocument. Tancar un trajecte toca quatre coses: el trajecte, l'ancoratge, la bicicleta i el cobrament. A PostgreSQL és una transacció i no hi ha res més a dir. MongoDB té transaccions multidocument des de la 4.0 (ho vam veure a 03-04), però paguen un cost de rendiment notable i són l'excepció, no el mode normal de treball. Construir un sistema de cobrament sobre l'excepció d'un motor és triar malament.
  2. Les restriccions que sostenen VallBici són declaratives. EXCLUDE USING gist per als abonaments solapats, l'índex únic parcial per als trajectes oberts, la clau forana composta del discriminant de la jerarquia. MongoDB no en té cap de les tres. Serien codi d'aplicació, i ja vam veure a 08-01 què passa amb les regles que depenen del codi sota concurrència.
  3. Les consultes de l'ajuntament són impredictibles. Un model documental es dissenya per respondre ràpid a un conjunt conegut de consultes. L'ajuntament demanarà l'any que ve un encreuament que avui ningú no ha imaginat, i aquí l'esquema normalitzat guanya sense discussió: és la virtut que 03-03 destacava del relacional, respondre preguntes que ningú no va preveure.
  4. No hi ha problema d'escala al nucli. 24.000 persones abonades i 1,6 milions de trajectes anuals caben folgadament en un PostgreSQL d'un sol servidor. El sharding de 03-01 resol un problema que VallBici no té.

El criteri de què sí que cal treure

Un conjunt de dades és candidat a sortir del relacional quan compleix diverses d'aquestes condicions. Cap no n'hi ha prou per si sola:

Condició Telemetria Fitxa d'estació Incidències Trajectes (nucli)
Estructura variable entre registres No No
Volum molt alt d'escriptures (272 M/any) No No Mitjà
Es llegeix sempre com un tot, pel seu identificador No
No participa en transaccions de diners No
No necessita integritat referencial estricta Parcial No
Caduca i s'esborra en bloc No No No
Consultes conegudes i estables No
Veredicte Surt Surt Surt Es queda

Les tres primeres columnes compleixen cinc o més condicions; la quarta no en compleix pràcticament cap. La frontera no és ideològica: és aquesta taula.

flowchart LR
    subgraph pg["PostgreSQL · es queda com esta (08-01)"]
        direction TB
        P1["abonaments · trajectes · cobraments"]
        P2["bicicletes · ancoratges · estacions"]
        P3["ordres_taller (fet comptable)"]
    end
    subgraph mg["MongoDB · les tres peces que surten"]
        direction TB
        M1["estacions<br/>fitxa enriquida"]
        M2["trajectes_telemetria<br/>patro bucket"]
        M3["incidencies<br/>detall variable"]
    end
    P2 -. "estacio_id com a _id" .-> M1
    P1 -. "trajecte_id com a _id" .-> M2
    P3 -. "ordre_taller_id" .-> M3
    P2 -. "matricula i model<br/>copiats (referencia estesa)" .-> M2

Les fletxes discontínues no són claus foranes: són acords. Van totes en el mateix sentit i cap no la imposa el motor. Qui les manté, amb quin retard i què passa quan fallen és el contingut íntegre de la lliçó 08-03.

Un matís important sobre les incidències. A 08-01 existeix ordres_taller, amb el seu cost NUMERIC i el seu índex únic d'ordre oberta. Aquella taula es queda: és el registre comptable del taller i participa en el pressupost municipal. El que surt és el part detallat de la incidència —l'estructura variable amb fotos, mesures i observacions—, que viurà a MongoDB referenciant l'ordre. Aquella distinció entre "el fet comptable" i "el detall heterogeni del fet" és una de les divisions més útils que hi ha, i s'aplica a moltíssims dominis.

  1. El mètode: primer les consultes, després els documents

A 03-03 vam invertir l'ordre mental del mòdul 2: en el model documental no es parteix de les entitats del domini, es parteix de les consultes. Així que abans d'escriure cap document, la llista, amb la seva freqüència i la seva exigència de latència.

# Consulta Qui Freqüència Latència
Q1 Fitxa completa d'una estació per a l'app App mòbil 60.000/dia < 50 ms
Q2 Estacions a menys de 500 m de la meva posició App mòbil 40.000/dia < 80 ms
Q3 Estacions d'un districte amb filtres (accessible, coberta, amb bomba) App mòbil 8.000/dia < 100 ms
Q4 Tota la traça GPS d'un trajecte Suport / mapa 2.000/dia < 200 ms
Q5 Distància total recorreguda per una bicicleta en un període Operacions 200/dia < 1 s
Q6 Mapa de calor de recorreguts per districte Operacions 30/dia < 10 s
Q7 Incidències obertes d'un tipus, amb el seu detall Taller 500/dia < 200 ms
Q8 Incidències per model de bicicleta, últims 90 dies Operacions 20/dia < 5 s
Q9 Sèrie de bateria d'una bici elèctrica durant un trajecte Taller 100/dia < 300 ms

Dues observacions abans de dissenyar:

  • Q1, Q4, Q7 i Q9 llegeixen un objecte sencer pel seu identificador. Són les que el model documental serveix amb un sol accés a disc, i són el 95 % del volum. Aquesta dada tota sola ja justifica la decisió.
  • Q5, Q6 i Q8 són agregacions analítiques. Són poques i toleren segons. No cal dissenyar els documents per a elles; cal assegurar-se que puguin respondre's.

La regla de 03-03: dissenya per a les consultes freqüents, tolera les rares.

  1. La col·lecció estacions: la fitxa enriquida

db.estacions.insertOne({
  _id: 12,                              // el MATEIX estacio_id de PostgreSQL. Veure nota a sota.
  codi: "VB-012",
  nom: "Estació 12 · Moll Nord",
  adreca: "Passeig del Moll 14",
  districte: { id: 1, nom: "Port" },       // referència estesa: id + el que es pinta
  ubicacio: {                              // GeoJSON: obligatori per a l'índex 2dsphere
    type: "Point",
    coordinates: [-3.200000, 40.116000]    // longitud primer! És l'ordre de GeoJSON
  },
  capacitat: { ancoratges: 24, coberta: true },
  accessibilitat: {
    rampa: true,
    ample_pas_cm: 120,
    paviment: "llamborda",
    observacions: "Vorada de 4 cm a l'accés sud"
  },
  serveis: ["bomba_aire", "panell_informatiu", "carrega_electrica"],
  horari: {                                // varia per estació: aquí no hi ha esquema fix
    tipus: "restringit",
    obertura: "06:00", tancament: "01:00",
    excepcions: [{ data: "2026-09-08", motiu: "Festes del Port", tancada: true }]
  },
  fotos: [
    { url: "https://cdn.vallmar.example/est/012-1.webp", tipus: "general", alt: "Vista general" },
    { url: "https://cdn.vallmar.example/est/012-2.webp", tipus: "acces",   alt: "Accés sud" }
  ],
  poi_propers: ["Estació marítima", "Mercat del Port"],
  mantenidora: { empresa: "Serveis Vallmar SL", contracte: "2026-014", telefon: "900 000 000" },
  esquema_v: 2,                            // versionat de documents (03-03)
  actualitzat: ISODate("2026-06-14T09:12:00Z")
});

Les decisions, una a una

L'_id és l'estacio_id de PostgreSQL, no un ObjectId. És la decisió més conseqüent del document. Avantatges: la unió entre els dos mons és trivial, no cal un índex extra sobre un camp estacio_id, i una escriptura repetida amb el mateix _id és naturalment idempotent. Inconvenient: obliga que l'estació existeixi abans a PostgreSQL. S'accepta, perquè PostgreSQL és la font de la veritat de l'inventari — i aquella frase, que aquí sona innocent, és el tema sencer de 08-03.

districte incrustat com a subdocument amb id i nom: patró de referència estesa. El nom del districte es pinta a la fitxa; anar-lo a buscar a una altra col·lecció per dues paraules no té sentit. Es copia només el que es pinta, no el document sencer del districte. El risc de duplicació és el que 03-03 advertia: si l'ajuntament reanomena un districte cal actualitzar 60 documents. Amb cinc districtes que canvien de nom cada mai, és un risc acceptable i l'updateMany corresponent és una línia.

fotos incrustat, no referenciat. Apliquem els criteris de 03-03: són poques (2-6 per estació), es llegeixen sempre amb l'estació, no es consulten mai per separat i no creixen sense límit. És un "un a pocs" de manual. Incrustar.

accessibilitat i horari com a subdocuments de forma lliure. Aquí hi ha el motiu pel qual aquesta col·lecció existeix. En el relacional, paviment: "llamborda" i ample_pas_cm: 120 serien dues columnes més que només tenen valor en algunes estacions. Aquí, l'estació que no té rampa simplement no porta el camp, i afegir demà carril_bici_directe: true no requereix cap migració.

El que NO s'incrusta: les bicicletes presents. La temptació és enorme —"així l'app demana un document i ja ho té tot"— i és un antipatró dels quatre que 03-03 marcava: un array que canvia desenes de vegades per hora dins d'un document que es llegeix 60.000 vegades al dia. Cada desbloqueig reescriuria el document sencer, invalidant la memòria cau i provocant contenció. La disponibilitat en temps real no viu aquí; a 08-03 veuràs on viu.

Comparació amb 08-01

Aspecte PostgreSQL (08-01) MongoDB (aquí)
Atributs opcionals Columnes nul·les o EAV Camps absents, sense cost
Afegir carril_bici_directe ALTER TABLE en producció Escriure el camp
Fotos Taula estacions_fotos + JOIN Array incrustat
Llegir la fitxa completa 3-4 JOIN Un findOne
"Estacions amb més de 20 ancoratges" Trivial i indexat Trivial i indexat
"Estacions sense rampa" Trivial Requereix pensar en el camp absent

Aquella última fila és la contrapartida honesta: en el documental, "no té rampa" i "no sabem si té rampa" s'assemblen perillosament. Es resol amb validació d'esquema, que veurem a la col·lecció d'incidències.

  1. La col·lecció trajectes_telemetria: el patró bucket

Aquest és el cas on l'elecció d'estructura canvia el sistema en dos ordres de magnitud, així que primer fem el càlcul que descarta l'opció ingènua.

Per què un document per punt GPS és un antipatró

Un trajecte mitjà dura 14 minuts amb una posició cada 5 segons: 168 punts. Amb 1,6 milions de trajectes l'any:

Estratègia Documents/any Mida de document Sobrecost d'_id + índex Llegir una traça (Q4)
Un document per punt 269 milions ~90 B útils, ~200 B reals ≈ 32 GB només en índexs 168 documents, 168 entrades d'índex
Un document per trajecte (bucket) 1,6 milions ~14 KB ≈ 190 MB en índexs 1 document, 1 accés

Un document per punt multiplica per 168 el nombre de documents, multiplica per més de 100 el cost d'índexs i converteix la consulta més freqüent sobre aquestes dades en la recuperació de 168 objectes que cal ordenar. És l'antipatró que 03-03 anomenava document massa petit: les metadades pesen més que la dada.

El disseny triat

db.trajectes_telemetria.insertOne({
  _id: NumberLong(884213),               // el trajecte_id de PostgreSQL
  bicicleta: { id: 417, matricula: "VB-0417", tipus: "electrica", model: "Ciclmar E-Vall" },
  estacio_origen: 12,
  ts_inici: ISODate("2026-06-14T06:12:04Z"),
  ts_fi:    ISODate("2026-06-14T06:26:31Z"),
  finestra: 0,                            // 0 = trajecte normal; veure desbordament a sota
  n_punts: 168,
  distancia_m: 3402,
  bbox: { min: [-3.2041, 40.1102], max: [-3.1908, 40.1194] },  // camp calculat
  bateria: { inici_pct: 88, fi_pct: 79 },
  punts: [
    { t: 0,   p: [-3.2000, 40.1160], v: 0.0,  b: 88 },   // t = segons des de ts_inici
    { t: 5,   p: [-3.2003, 40.1163], v: 3.2,  b: 88 },
    { t: 10,  p: [-3.2008, 40.1168], v: 5.1,  b: 88 },
    // ... 165 més
    { t: 867, p: [-3.1908, 40.1102], v: 0.0,  b: 79 }
  ],
  esdeveniments: [
    { t: 412, tipus: "frenada_brusca", g: 0.42 },
    { t: 690, tipus: "sortida_de_carril" }
  ],
  esquema_v: 1
});

Noms de camp curts: t, p, v, b. No és coqueteria. MongoDB emmagatzema el nom de cada camp en cada element de l'array. Amb timestamp, posicio, velocitat i bateria, els noms pesarien uns 30 bytes per punt × 168 punts × 1,6 M trajectes ≈ 8 GB l'any només a repetir paraules. Amb noms d'una lletra, uns 900 MB. És una de les poquíssimes situacions en què sacrificar llegibilitat de camp està justificat, i la contrapartida és documentar-ho bé.

t és un desplaçament en segons, no una data. Un ISODate ocupa 8 bytes i un enter petit 1 o 2. La data absoluta es reconstrueix sumant-la a ts_inici. Mateix raonament.

bbox i distancia_m són camps calculats (patró de camp calculat de 03-03). S'emplenen en tancar el trajecte i eviten haver de recórrer els 168 punts cada vegada que algú pregunta "quant va recórrer?". És la mateixa lògica que bicis_disponibles a 08-01: es paga amb una redundància que cal mantenir i es cobra en cada lectura.

bicicleta incrustada amb quatre camps: referència estesa una altra vegada. Per què copiar la matrícula i el model si són a PostgreSQL? Perquè Q8 —incidències per model— i Q5 —distància per bicicleta— es resoldrien d'una altra manera amb un $lookup impossible: la taula de bicicletes és en un altre motor. Copiar el model aquí és el que permet agrupar per model sense sortir de MongoDB. El preu: si una bici canvia de model (no passa) o se li corregeix la matrícula (passa rarament), hi ha documents històrics amb el valor antic. Per a dades històriques això és correcte, no un error: el trajecte es va fer amb la matrícula que tenia llavors. És exactament el mateix argument de la tarifa congelada de 08-01.

El desbordament: finestra

El límit dur d'un document a MongoDB són 16 MB. Un trajecte normal ocupa 14 KB, així que hi sobra lloc. Però VallBici té un cas rar: la ruta turística de cap de setmana, amb trajectes de cinc hores. 5 h × 720 punts/h = 3.600 punts ≈ 300 KB. Encara hi cap, però un trajecte patològic —una bici oblidada en un camió durant dos dies amb el GPS emetent— podria acostar-se al límit.

La regla operativa, aplicant el patró bucket amb sostre:

Màxim 900 punts per document (75 minuts). En superar-lo, s'obre un document nou amb finestra: 1, finestra: 2

// _id compost per a les finestres addicionals
{ _id: { trajecte: NumberLong(884213), finestra: 1 }, ... }

Q4 passa llavors de ser un findOne a un find amb sort, però només per al 0,3 % de trajectes que desborden. És l'aplicació literal del patró outlier de 03-03: no es dissenya el sistema sencer per al cas rar; es dissenya per al cas normal i es marca el rar amb una bandera.

  1. La col·lecció incidencies: estructura variable amb validació

// Incidència de frens
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34cd"),
  ordre_taller_id: NumberLong(30412),   // referència a ordres_taller, a PostgreSQL
  bicicleta: { id: 417, matricula: "VB-0417", tipus: "mecanica", model: "Norvent Urbana2" },
  tipus: "frens",
  estat: "oberta",
  gravetat: 3,
  reportada_per: { canal: "app_usuari", abonament_id: 10233 },
  ts_obertura: ISODate("2026-06-14T07:02:11Z"),
  estacio: 12,
  detall: {                              // ← lliure: depèn de "tipus"
    fre: "posterior",
    pastilla_mm: 1.2,
    cable_folganca_mm: 6,
    prova_frenada_m: 8.4
  },
  esquema_v: 1
}

// Incidència de bateria: MATEIX tipus de document, "detall" completament diferent
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34ce"),
  ordre_taller_id: NumberLong(30413),
  bicicleta: { id: 512, matricula: "VB-0512", tipus: "electrica", model: "Ciclmar E-Vall" },
  tipus: "bateria",
  estat: "en_taller",
  gravetat: 4,
  reportada_per: { canal: "telemetria" },
  ts_obertura: ISODate("2026-06-14T09:41:00Z"),
  detall: {
    cicles_carrega: 812,
    voltatge_v: 33.1,
    capacitat_restant_pct: 61,
    n_serie: "BT-2024-00871",
    celles_defectuoses: [3, 7]
  }
}

// Incidència de vandalisme
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34cf"),
  ordre_taller_id: NumberLong(30414),
  bicicleta: { id: 733, matricula: "VB-0733", tipus: "electrica", model: "Ciclmar E-Vall" },
  tipus: "vandalisme",
  estat: "oberta",
  gravetat: 5,
  reportada_per: { canal: "operari", operari: "JMR" },
  ts_obertura: ISODate("2026-06-13T22:15:00Z"),
  detall: {
    atestat: "PL-2026-4471",
    parts_afectades: ["seient", "quadre", "pantalla"],
    fotos: ["https://cdn.vallmar.example/inc/4471-1.webp"],
    cost_estimat_eur: 240
  }
}

El $jsonSchema: validar el comú, deixar lliure l'específic

L'equilibri que 03-03 defensava. Sense validació, MongoDB accepta qualsevol cosa i al cap de sis mesos hi ha documents amb tipus: "Frens", tipus: "fre" i gravetat: "alta". Amb validació excessiva, es perd l'únic avantatge que justificava fer servir MongoDB.

db.createCollection("incidencies", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["ordre_taller_id", "bicicleta", "tipus", "estat", "gravetat", "ts_obertura"],
    properties: {
      ordre_taller_id: { bsonType: "long" },
      bicicleta: {
        bsonType: "object",
        required: ["id", "matricula", "tipus", "model"],
        properties: {
          id:        { bsonType: "int" },
          matricula: { bsonType: "string", pattern: "^VB-[0-9]{4}$" },
          tipus:     { enum: ["mecanica", "electrica"] },
          model:     { bsonType: "string" }
        }
      },
      tipus:    { enum: ["frens","bateria","roda","electronica","vandalisme","altres"] },
      estat:    { enum: ["oberta","en_taller","resolta","descartada"] },
      gravetat: { bsonType: "int", minimum: 1, maximum: 5 },
      ts_obertura: { bsonType: "date" },
      detall:   { bsonType: "object" }       // ← objecte, i res més. Deliberat.
    }
  }},
  validationLevel: "strict",
  validationAction: "error"
});

La línia clau és detall: { bsonType: "object" }. S'exigeix que sigui un objecte —no una cadena ni un array— i no es diu absolutament res sobre el seu contingut. Afegir demà tipus: "gps" amb un detall de tres camps nous requereix una sola cosa: afegir "gps" a l'enum. Cap ALTER TABLE, cap finestra d'aturada.

Prova que funciona:

db.incidencies.insertOne({ ordre_taller_id: NumberLong(1), tipus: "frens",
   estat: "oberta", gravetat: 9, ts_obertura: new Date(),
   bicicleta: { id: 417, matricula: "VB-0417", tipus: "mecanica", model: "Norvent Urbana2" }});
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ObjectId('...'),
  details: { operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [ { operatorName: 'properties',
      propertiesNotSatisfied: [ { propertyName: 'gravetat',
        details: [ { operatorName: 'maximum', specifiedAs: { maximum: 5 },
                     reason: 'comparison failed', consideredValue: 9 } ] } ] } ] }
}

Comparat amb 08-01: allà, gravetat BETWEEN 1 AND 5 hauria estat un CHECK de vint caràcters. Aquí són dotze línies de JSON. La validació en el documental costa més d'escriure i cal voler-la explícitament, i per això tantes col·leccions acaben sense cap.

  1. Operacions d'escriptura: telemetria en temps real

La bicicleta emet una posició cada 5 segons. S'escriu cada punt tan bon punt arriba?

// Opció A — un $push per punt. 269 milions d'escriptures l'any.
db.trajectes_telemetria.updateOne(
  { _id: NumberLong(884213) },
  { $push: { punts: { t: 415, p: [-3.1991, 40.1151], v: 4.8, b: 84 } },
    $inc:  { n_punts: 1 } }
);

Funciona, però té dos problemes mesurables. El primer: cada $push reescriu el document si ha crescut més enllà de l'espai reservat, i un document que creix de 200 B a 14 KB en 168 passos es reubica diverses vegades. El segon: són 269 milions d'operacions de xarxa i de WAL (aquí, del journal) a l'any.

L'opció triada és el microlot a la passarel·la de telemetria: l'aplicació acumula els punts de 30 segons en memòria i n'escriu sis de cop.

db.trajectes_telemetria.updateOne(
  { _id: NumberLong(884213) },
  { $push: { punts: { $each: [
        { t: 415, p: [-3.1991, 40.1151], v: 4.8, b: 84 },
        { t: 420, p: [-3.1988, 40.1148], v: 5.0, b: 84 },
        { t: 425, p: [-3.1984, 40.1145], v: 5.1, b: 84 },
        { t: 430, p: [-3.1980, 40.1141], v: 4.9, b: 84 },
        { t: 435, p: [-3.1977, 40.1138], v: 4.7, b: 83 },
        { t: 440, p: [-3.1974, 40.1134], v: 4.4, b: 83 }
      ], $slice: 900 } },                        // ← el sostre del bucket, imposat pel motor
    $inc: { n_punts: 6 },
    $max: { ultim_t: 440 }
  },
  { upsert: true }
);
{ acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null }

Els quatre detalls que importen:

  • $each converteix sis escriptures en una. Redueix el nombre d'operacions en un factor de 6 i el risc de reubicació del document.
  • $slice: 900 és el sostre del bucket imposat pel mateix motor: l'array mai no passa de 900 elements, passi el que passi amb l'aplicació. És l'equivalent documental d'un CHECK.
  • $max: { ultim_t: 440 } fa l'escriptura idempotent en l'ordre: si un lot arriba tard i desordenat per la xarxa, ultim_t no retrocedeix.
  • upsert: true permet que el primer lot creï el document. Si l'esdeveniment de "trajecte iniciat" es perdés, la telemetria no es perd amb ell.

El que $push no arregla, i cal dir-ho: el $each no és idempotent. Si la passarel·la reintenta un lot perquè no va rebre confirmació, els sis punts s'afegeixen dues vegades. Amb dades de posició això és tolerable —dos punts idèntics consecutius no canvien cap conclusió— i per això s'accepta. Si fossin imports econòmics, no ho seria, i aquesta és exactament la raó per la qual els diners es van quedar a PostgreSQL.

El tancament del trajecte

db.trajectes_telemetria.updateOne(
  { _id: NumberLong(884213) },
  [ { $set: {
        ts_fi: "$$NOW",
        n_punts: { $size: "$punts" },
        bbox: {
          min: [ { $min: { $map: { input: "$punts", in: { $arrayElemAt: ["$$this.p", 0] } } } },
                 { $min: { $map: { input: "$punts", in: { $arrayElemAt: ["$$this.p", 1] } } } } ],
          max: [ { $max: { $map: { input: "$punts", in: { $arrayElemAt: ["$$this.p", 0] } } } },
                 { $max: { $map: { input: "$punts", in: { $arrayElemAt: ["$$this.p", 1] } } } } ]
        },
        "bateria.fi_pct": { $last: "$punts.b" }
      } } ]
);

És una actualització amb pipeline d'agregació (disponible des de MongoDB 4.2): permet calcular els camps derivats dins del motor, sense endur-se els 168 punts a l'aplicació. És l'equivalent documental d'una columna generada.

  1. Aggregation pipelines: els informes d'operacions

Q5 — Distància recorreguda per bicicleta en un període

db.trajectes_telemetria.aggregate([
  { $match: { ts_inici: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
  { $group: { _id: { id: "$bicicleta.id", matricula: "$bicicleta.matricula",
                     model: "$bicicleta.model" },
              km:       { $sum: { $divide: ["$distancia_m", 1000] } },
              viatges:  { $sum: 1 },
              km_mitja: { $avg: { $divide: ["$distancia_m", 1000] } } } },
  { $sort: { km: -1 } },
  { $limit: 5 },
  { $project: { _id: 0, matricula: "$_id.matricula", model: "$_id.model",
                km: { $round: ["$km", 1] }, viatges: 1,
                km_mitja: { $round: ["$km_mitja", 2] } } }
]);
[
  { viatges: 214, matricula: 'VB-0512', model: 'Ciclmar E-Vall',  km: 741.2, km_mitja: 3.46 },
  { viatges: 198, matricula: 'VB-0733', model: 'Ciclmar E-Vall',  km: 688.4, km_mitja: 3.48 },
  { viatges: 231, matricula: 'VB-0417', model: 'Norvent Urbana2', km: 655.9, km_mitja: 2.84 },
  { viatges: 205, matricula: 'VB-0388', model: 'Norvent Urbana2', km: 601.3, km_mitja: 2.93 },
  { viatges: 187, matricula: 'VB-0041', model: 'Norvent Urbana2', km: 573.0, km_mitja: 3.06 }
]

Etapa per etapa:

Etapa Què fa Equivalent SQL
$match Filtra per període. Va primer sempre: redueix el conjunt abans de treballar i pot fer servir índex WHERE
$group Agrupa per bici i acumula GROUP BY + SUM/AVG/COUNT
$sort Ordena per quilòmetres ORDER BY
$limit Es queda amb cinc LIMIT
$project Dona forma a la sortida Llista del SELECT
-- L'equivalent a PostgreSQL, si aquestes dades hi fossin
SELECT b.matricula, m.nom AS model,
       ROUND(SUM(t.distancia_m)/1000.0, 1) AS km, COUNT(*) AS viatges,
       ROUND(AVG(t.distancia_m)/1000.0, 2) AS km_mitja
  FROM telemetria t JOIN bicicletes b USING (bicicleta_id)
                    JOIN models_bici m USING (model_id)
 WHERE t.ts_inici >= DATE '2026-06-01' AND t.ts_inici < DATE '2026-07-01'
 GROUP BY b.matricula, m.nom ORDER BY km DESC LIMIT 5;

Gairebé la mateixa consulta. La diferència real: el SQL necessita dos JOIN per arribar al model, i el pipeline no en necessita cap perquè el model està copiat dins del document. Això és l'intercanvi central del model documental, en una frase: duplicació a canvi d'accés local.

Q6 — Mapa de calor de recorreguts per districte

db.trajectes_telemetria.aggregate([
  { $match: { ts_inici: { $gte: ISODate("2026-06-01"), $lt: ISODate("2026-07-01") } } },
  { $unwind: "$punts" },                                    // 1 doc → 168 docs
  { $project: {
      cella: {                                              // graella de ~0,001° ≈ 110 m
        lon: { $round: [ { $arrayElemAt: ["$punts.p", 0] }, 3 ] },
        lat: { $round: [ { $arrayElemAt: ["$punts.p", 1] }, 3 ] }
      },
      hora: { $hour: { date: "$ts_inici", timezone: "Europe/Madrid" } }
  } },
  { $group: { _id: { cella: "$cella", punta: { $in: ["$hora", [7,8,9,17,18,19]] } },
              passades: { $sum: 1 } } },
  { $match: { passades: { $gte: 500 } } },
  { $sort: { passades: -1 } }, { $limit: 8 }
]);
[
  { _id: { cella: { lon: -3.198, lat: 40.116 }, punta: true  }, passades: 41822 },
  { _id: { cella: { lon: -3.197, lat: 40.117 }, punta: true  }, passades: 39140 },
  { _id: { cella: { lon: -3.198, lat: 40.116 }, punta: false }, passades: 22410 },
  ...
]

El $unwind és l'etapa perillosa i cal entendre per què. Converteix cada document de trajecte en 168 documents, un per punt. Sobre 45.000 trajectes d'un mes són 7,5 milions de documents intermedis. És exactament el motiu de les dues precaucions que porta el pipeline: el $match va abans del $unwind (si fos després, es desplegarien els 1,6 milions de trajectes de l'any) i el $project redueix cada punt a dos números arrodonits abans d'agrupar.

És també la resposta al perquè Q6 tolera 10 segons i s'executa 30 vegades al dia, no 30.000. Un pipeline amb $unwind sobre milions de documents no és una consulta d'aplicació: és un informe.

Q8 — Incidències per model de bicicleta

db.incidencies.aggregate([
  { $match: { ts_obertura: { $gte: new Date(Date.now() - 90*24*3600*1000) } } },
  { $group: {
      _id: { model: "$bicicleta.model", tipus: "$tipus" },
      n: { $sum: 1 },
      gravetat_mitjana: { $avg: "$gravetat" },
      bicis: { $addToSet: "$bicicleta.id" } } },
  { $group: {
      _id: "$_id.model",
      total: { $sum: "$n" },
      per_tipus: { $push: { tipus: "$_id.tipus", n: "$n",
                            gravetat: { $round: ["$gravetat_mitjana", 1] } } },
      bicis_afectades: { $sum: { $size: "$bicis" } } } },
  { $sort: { total: -1 } }
]);
[
  { _id: 'Norvent Urbana2', total: 412, bicis_afectades: 188,
    per_tipus: [ { tipus: 'frens', n: 201, gravetat: 3.1 },
                 { tipus: 'roda',  n: 142, gravetat: 2.4 },
                 { tipus: 'altres', n: 69, gravetat: 1.8 } ] },
  { _id: 'Ciclmar E-Vall', total: 287, bicis_afectades: 121,
    per_tipus: [ { tipus: 'bateria',     n: 158, gravetat: 3.9 },
                 { tipus: 'electronica', n:  74, gravetat: 3.2 },
                 { tipus: 'frens',       n:  55, gravetat: 2.7 } ] }
]

El doble $group és el patró que cal aprendre d'aquí. El primer agrupa per (model, tipus); el segon torna a agrupar per model i fa servir $push per ficar els resultats del primer en un array imbricat. El resultat és una estructura jeràrquica que en SQL requeriria o bé dues consultes, o bé una amb funcions de finestra i un pivot manual amb FILTER com el que vas fer a 07-04. Produir jerarquies és on el pipeline d'agregació guanya clarament a SQL.

I fixa't en què fa possible la consulta: bicicleta.model està copiat a cada incidència. Sense aquella duplicació, agrupar per model exigiria anar a PostgreSQL, i no hi ha manera de fer-ho des d'un pipeline.

  1. Consultes geoespacials: el que el relacional feia pitjor

Q2 —"estacions a menys de 500 m"— és la consulta que a 08-01 quedava pitjor resolta. Amb latitud i longitud com a NUMERIC, l'única sortida sense extensions és una fórmula de Haversine al SELECT, que no pot fer servir cap índex: obliga a calcular la distància a les 60 estacions i filtrar després.

A MongoDB és una línia d'índex i una consulta.

db.estacions.createIndex({ ubicacio: "2dsphere" });
ubicacio_2dsphere
db.estacions.find(
  { ubicacio: { $near: {
      $geometry: { type: "Point", coordinates: [-3.1995, 40.1158] },
      $maxDistance: 500 } },
    "capacitat.coberta": true },
  { nom: 1, adreca: 1, "capacitat.ancoratges": 1 }
);
[
  { _id: 12, nom: 'Estació 12 · Moll Nord', adreca: 'Passeig del Moll 14',
    capacitat: { ancoratges: 24 } },
  { _id: 41, nom: 'Estació 41 · Llotja',    adreca: 'Av. de la Llotja 3',
    capacitat: { ancoratges: 20 } }
]

Tres coses gratis en aquella consulta: els resultats vénen ordenats per distància sense demanar-ho, el $maxDistance és en metres sobre l'esfera (no en graus), i el filtre addicional capacitat.coberta es combina amb el geoespacial sense cerimònia.

Per a Q3, "estacions dins del polígon del districte", l'operador és un altre:

db.estacions.find({ ubicacio: { $geoWithin: { $geometry: {
  type: "Polygon",
  coordinates: [[ [-3.210,40.108], [-3.190,40.108],
                  [-3.190,40.122], [-3.210,40.122], [-3.210,40.108] ]]
}}}}).count();
14

$near ordena per proximitat i necessita índex; $geoWithin no ordena i pot funcionar sense ell, encara que va molt millor amb ell. I l'error clàssic, que mereix lletra negreta perquè el comet tothom la primera vegada: GeoJSON és [longitud, latitud], en aquest ordre. A l'inrevés, Vallmar apareix a l'oceà Índic i les consultes retornen zero resultats sense donar cap error.

  1. Índexs, explain() i caducitat amb TTL

db.estacions.createIndex({ "districte.id": 1, "capacitat.coberta": 1 });    // Q3
db.trajectes_telemetria.createIndex({ "bicicleta.id": 1, ts_inici: -1 });   // Q5
db.trajectes_telemetria.createIndex({ ts_inici: 1 });                       // Q6
db.incidencies.createIndex({ estat: 1, tipus: 1, ts_obertura: -1 });        // Q7
db.incidencies.createIndex({ "bicicleta.model": 1, ts_obertura: -1 });      // Q8
db.incidencies.createIndex({ ordre_taller_id: 1 }, { unique: true });       // integritat

L'ordre dels camps en un índex compost segueix la mateixa regla que a PostgreSQL (06-03): igualtat primer, rang o ordenació al final. { estat: 1, tipus: 1, ts_obertura: -1 } serveix per a Q7 completa, i també per a "totes les incidències obertes" —prefix de l'índex—, però no per a "totes les incidències de tipus frens", perquè tipus no és prefix. És idèntic al que passava amb els índexs compostos en B-tree.

Comprovació amb explain():

db.incidencies.find({ estat: "oberta", tipus: "frens" })
              .sort({ ts_obertura: -1 }).explain("executionStats").executionStats;
{
  executionSuccess: true,
  nReturned: 23,
  executionTimeMillis: 1,
  totalKeysExamined: 23,
  totalDocsExamined: 23,
  executionStages: { stage: 'FETCH', ... inputStage: { stage: 'IXSCAN',
    indexName: 'estat_1_tipus_1_ts_obertura_-1',
    keyPattern: { estat: 1, tipus: 1, ts_obertura: -1 } } }
}

Com es llegeix. totalKeysExamined == totalDocsExamined == nReturned és el resultat perfecte: l'índex va localitzar exactament les 23 files necessàries, sense descarts. I no hi ha etapa SORT, perquè l'índex ja lliura l'ordre demanat. La lectura és la mateixa que feies amb EXPLAIN ANALYZE: si totalDocsExamined fos 40.000 per retornar-ne 23, tindries l'equivalent d'un Rows Removed by Filter alt.

TTL: caducar la telemetria vella

L'ajuntament conserva la telemetria 180 dies; després només interessen els agregats. A PostgreSQL això seria un DELETE mensual sobre 45 milions de files, amb el seu VACUUM corresponent i la seva finestra de manteniment, o bé particionat per rang.

A MongoDB és un índex:

db.trajectes_telemetria.createIndex(
  { ts_inici: 1 },
  { expireAfterSeconds: 15552000, name: "ttl_180_dies" }   // 180 dies
);

Un procés intern recorre l'índex cada 60 segons i esborra el vençut. Tres advertiments, perquè el TTL sorprèn qui no els coneix:

  1. L'esborrat no és puntual. Un document pot sobreviure fins a un minut —o més, si hi ha càrrega— després del seu venciment. No serveix per a requisits legals d'esborrat "exacte".
  2. El camp ha de ser una data. Si ts_inici fos una cadena, l'índex funciona però no esborra res, silenciosament.
  3. En un conjunt de rèpliques només esborra el primari, i les rèpliques reben l'esborrat per replicació. És el correcte, però significa que un secundari endarrerit té documents que "ja no existeixen".

Abans que el TTL esborri res, un procés mensual consolida el que sí que cal conservar:

db.trajectes_telemetria.aggregate([
  { $match: { ts_inici: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") } } },
  { $group: { _id: { bici: "$bicicleta.id", mes: "2026-01" },
              km: { $sum: { $divide: ["$distancia_m", 1000] } }, viatges: { $sum: 1 } } },
  { $merge: { into: "telemetria_mensual", on: "_id", whenMatched: "replace" } }
]);

$merge és l'equivalent documental d'una vista materialitzada de 05-04: el detall caduca, el resum roman.

  1. El que s'ha perdut i com es mitiga

Un cas d'estudi que només explica els avantatges és publicitat. Aquestes són les quatre pèrdues reals, amb la seva mitigació i amb allò que la mitigació no aconsegueix.

Pèrdua 1 — No hi ha clau forana cap a bicicletes

A 08-01, trajectes.bicicleta_id REFERENCES bicicletes garantia que no pot existir un trajecte d'una bici inexistent. Aquí, trajectes_telemetria.bicicleta.id = 417 és un número. Si algú esborra la bici 417 de PostgreSQL, MongoDB no se n'assabenta i guarda telemetria òrfena per sempre.

Mitigació: (a) el $jsonSchema obliga que el camp existeixi i tingui el format correcte, cosa que evita l'error de teclat però no el referencial; (b) ON DELETE RESTRICT a PostgreSQL fa que les bicis no s'esborrin mai, només es marquin estat = 'baixa' — i aquella decisió, presa a 08-01 per motius comptables, resulta ser també la que protegeix la coherència entre motors; (c) una reconciliació nocturna que llista els bicicleta.id diferents de MongoDB i comprova que tots existeixen a PostgreSQL.

// Pas 1: treure els ids que MongoDB creu conèixer
db.trajectes_telemetria.distinct("bicicleta.id");
// → [1, 2, 3, ... 900, 947]
-- Pas 2: contrastar-los contra la veritat
SELECT unnest(ARRAY[1,2,3,...,900,947]) AS id
EXCEPT
SELECT bicicleta_id FROM bicicletes;
 id
-----
 947

Aquell 947 és un orfe: telemetria d'una bicicleta que no existeix. La reconciliació no ho impedeix; ho detecta. És una diferència de naturalesa, no de grau: s'ha passat d'una garantia a una alarma.

Pèrdua 2 — No hi ha JOIN, i $lookup no és la solució

$lookup uneix dues col·leccions de la mateixa base de dades MongoDB. No pot unir amb PostgreSQL. Punt. Qualsevol informe que creui telemetria amb imports cobrats necessita:

Opció Com Quan fer-la servir
Unir a l'aplicació Consultar els dos motors i combinar en memòria Pocs registres, consulta puntual
Duplicar el camp necessari Copiar model i matricula al document (el que vam fer) Camp estable i molt consultat
Magatzem analític Bolcar-hi tots dos a un tercer sistema per a informes Informes que creuen de debò

I encara que les col·leccions fossin al mateix MongoDB, $lookup no és un JOIN equivalent: s'executa com un bucle sobre la col·lecció externa, no aprofita estadístiques i no hi ha planificador que reordeni res. És una eina per enriquir resultats ja filtrats, no per consultar dues col·leccions grans alhora. La regla pràctica: si el teu pipeline comença amb un $lookup sobre milions de documents, el model està malament.

Pèrdua 3 — La coherència queda en mans de l'aplicació

Que bicicleta.model digui "Norvent Urbana2" quan a PostgreSQL hi posa una altra cosa no ho impedeix res. La mitigació és de procés, no de motor:

  • Escriptures idempotents: l'_id és el trajecte_id, així que reescriure el mateix document dues vegades produeix el mateix resultat. Això val or quan hi ha reintents.
  • Un sol escriptor per col·lecció: només el servei de telemetria escriu a trajectes_telemetria. Res de tres serveis diferents tocant la mateixa col·lecció "perquè és més ràpid".
  • Reconciliació programada amb informe de discrepàncies, com la de dalt.
  • esquema_v a cada document: quan la forma del document canviï, els antics continuen sent llegibles i se sap quins cal migrar.

Pèrdua 4 — Les restriccions riques no existeixen

No hi ha EXCLUDE USING gist, no hi ha índex únic parcial amb predicat arbitrari, no hi ha CHECK entre columnes expressat amb la naturalitat de SQL. $jsonSchema valida forma i rangs; no valida relacions entre documents ni condicions temporals.

Mitigació: posar a MongoDB només dades on aquelles restriccions no calguin. Cosa que, si t'hi fixes, és precisament el criteri de l'apartat 1 llegit a l'inrevés. El disseny coherent és el que no necessita les garanties que el seu motor no dona.

  1. La comparació honesta: PostgreSQL amb jsonb i PostGIS

La pregunta incòmoda que tocava fer-se: tot el d'aquesta lliçó es pot fer a PostgreSQL. jsonb guarda documents amb índexs GIN; PostGIS fa geoespacial millor que MongoDB; el particionat per rang caduca dades millor que el TTL. Per què no quedar-se en un sol motor?

Criteri PostgreSQL + jsonb + PostGIS MongoDB Qui guanya a VallBici
Fitxa heterogènia d'estació jsonb + índex GIN: funciona bé Natiu Empat
Consultes geoespacials PostGIS és més potent (rutes, topologia, projeccions) 2dsphere cobreix el bàsic PostgreSQL
Telemetria: volum d'escriptura Alt cost per fila (WAL, MVCC, autovacuum) Menys cost, $push sobre document MongoDB
Caducar dades antigues Particionat + DETACH PARTITION: eficientíssim TTL: còmode, menys control PostgreSQL
Afegir un tipus d'incidència jsonb: sense migració Sense migració Empat
Creuar telemetria amb cobraments JOIN real, un sol motor Impossible sense sortir a l'aplicació PostgreSQL
Escalat horitzontal d'escriptura Requereix feina (Citus, particionat, rèpliques) Sharding natiu (03-01) MongoDB
Peces per operar Una Dues PostgreSQL
Persones necessàries a l'equip Una competència Dues competències PostgreSQL

Compta els vots: PostgreSQL guanya en més files. I la conclusió honesta és aquesta:

Si VallBici tingués 6 estacions i 90 bicicletes, la resposta correcta seria quedar-se a PostgreSQL amb jsonb i PostGIS, sense discussió.

El que decanta la balança al VallBici real són dues files, no nou: 272 milions de punts GPS l'any i la previsió municipal de duplicar la flota en tres anys. Aquell volum d'escriptura de dades que no necessiten transaccions ni integritat referencial és el cas per al qual existeix un magatzem documental, i és l'única raó de pes.

Els senyals que dirien "torna a PostgreSQL, això no compensava":

  1. La telemetria resulta que es consulta creuada amb els cobraments constantment.
  2. El volum s'estanca en un nivell que un PostgreSQL ben particionat aguanta.
  3. L'equip no aconsegueix mantenir competència real en els dos motors.
  4. Apareixen requisits geoespacials complexos (rutes, isòcrones) que PostGIS faria i 2dsphere no.

Cap d'aquells senyals no és vergonyós. Desfer una decisió d'arquitectura quan les dades canvien és competència professional, no fracàs.

Errors Habituals i Consells

Error 1: migrar-ho tot. El cas complet d'aquesta lliçó són tres col·leccions. Ningú no va tocar el cobrament. Un projecte que comença amb "passarem la base de dades a MongoDB" en lloc de "aquests tres conjunts de dades encaixen millor en documents" ja ha pres la decisió abans d'analitzar-la.

Error 2: un document per punt GPS. El càlcul de l'apartat 4 és l'argument: 269 milions de documents davant d'1,6 milions. Quan les dades arriben com una sèrie temporal associada a alguna cosa, el bucket és gairebé sempre la resposta.

Error 3: un bucket sense sostre. El límit de 16 MB no avisa fins que l'assoleixes, i llavors l'escriptura falla en producció. $slice al $push i una bandera de finestra.

Error 4: [latitud, longitud]. GeoJSON és [lon, lat]. No dona error: simplement retorna zero resultats i et fa perdre una tarda.

Error 5: $unwind abans de $match. Multiplica els documents i després filtra. Pot convertir un pipeline de 8 segons en un de 8 minuts.

Error 6: crear la col·lecció sense validador "perquè ja ho valida l'aplicació". És la mateixa frase que a 08-01 justificava no posar CHECK, i acaba igual. En documental és pitjor, perquè el dany s'acumula silenciosament durant mesos.

Consell 1: escriu la taula de consultes abans que el primer document. Les nou files de l'apartat 2 van determinar les tres col·leccions, els camps duplicats i els sis índexs.

Consell 2: noms de camp curts només dins d'arrays grans. A punts està justificat pels 8 GB anuals. Posar n en comptes de nom a estacions és illegibilitat gratuïta: són 60 documents.

Consell 3: _id amb significat sempre que puguis. Fer servir el trajecte_id de PostgreSQL com a _id dona idempotència gratis, estalvia un índex i fa òbvia la correspondència entre motors.

Consell 4: llegeix explain() amb els mateixos ulls que EXPLAIN ANALYZE. totalDocsExamined davant de nReturned és el Rows Removed by Filter de MongoDB, i una etapa SORT en memòria és l'avís que falta un índex.

Consell 5: escriu avui el procés de reconciliació. Quan hi ha dos motors, la pregunta no és si divergiran, sinó quan i quant trigaràs a assabentar-te'n.

Exercicis

Exercici 1 — Valoracions d'estació

L'app permetrà puntuar una estació d'1 a 5 amb un comentari opcional i fins a tres etiquetes (bruta, mal_illuminada, ancoratges_durs…). Es preveuen ~200 valoracions diàries. La fitxa de l'estació ha de mostrar la nota mitjana i les tres últimes valoracions.

  1. Decideix si les valoracions s'incrusten a estacions o van en una col·lecció pròpia, amb els criteris de 03-03.
  2. Escriu el document resultant i els índexs necessaris.
  3. Escriu l'operació que registra una valoració nova i manté actualitzat el que es mostra a la fitxa.

Exercici 2 — Detecció de trajectes anòmals

Operacions vol detectar trajectes sospitosos: velocitat mitjana superior a 35 km/h (la bici ha anat en un vehicle), més de 20 minuts amb velocitat 0 enmig del trajecte, o una bicicleta elèctrica que perd més d'un 40 % de bateria en menys de 15 minuts.

Escriu un pipeline d'agregació que retorni els trajectes de l'últim dia que compleixin alguna de les tres condicions, amb la condició que han disparat i les dades de la bicicleta.

Exercici 3 — El camp que va quedar obsolet

Auditoria descobreix que 41.000 documents de trajectes_telemetria tenen bicicleta.matricula amb el format antic (0417 en comptes de VB-0417), perquè durant dues setmanes la passarel·la va escriure malament el camp.

  1. Escriu la consulta que els localitza.
  2. Escriu l'actualització que els corregeix sense endur-se els documents a l'aplicació.
  3. Explica per què aquest problema no hauria pogut passar a l'esquema relacional de 08-01, i quin mecanisme de MongoDB l'hauria pogut evitar.

Solucions

Solució 1

1. Col·lecció pròpia amb subconjunt incrustat. Els criteris de 03-03 donen una resposta clara: 200 valoracions diàries × 60 estacions al llarg d'anys és un "un a moltíssims" sense sostre, i un array il·limitat dins d'un document llegit 60.000 vegades al dia és l'antipatró d'array no acotat. Però la fitxa necessita les tres últimes, i una segona consulta a la ruta més calenta de l'app és un cost real.

La resposta és el patró subconjunt: col·lecció valoracions amb tot, i a estacions un resum amb les tres últimes i els agregats.

2. El disseny:

// Col·lecció completa
{ _id: ObjectId("..."), estacio_id: 12, abonament_id: 10233, puntuacio: 4,
  comentari: "Ancoratge 7 molt dur", etiquetes: ["ancoratges_durs"],
  ts: ISODate("2026-06-14T18:22:00Z") }

// Al document de l'estació
{ _id: 12, /* ... */
  valoracions_resum: {
    n: 1284, mitjana: 4.12, suma: 5290,
    etiquetes_top: [ { t: "ancoratges_durs", n: 91 }, { t: "bruta", n: 44 } ],
    ultimes: [ { puntuacio: 4, comentari: "Ancoratge 7 molt dur",
                 ts: ISODate("2026-06-14T18:22:00Z") } ]   // màxim 3
  } }

db.valoracions.createIndex({ estacio_id: 1, ts: -1 });
db.valoracions.createIndex({ abonament_id: 1, estacio_id: 1, ts: -1 });

suma es guarda a més de mitjana per poder recalcular la mitjana incrementalment sense recórrer 1.284 documents.

3. L'escriptura, en dues operacions:

db.valoracions.insertOne({ estacio_id: 12, abonament_id: 10233, puntuacio: 4,
    comentari: "Ancoratge 7 molt dur", etiquetes: ["ancoratges_durs"], ts: new Date() });

db.estacions.updateOne(
  { _id: 12 },
  [ { $set: {
      "valoracions_resum.n":    { $add: [ { $ifNull: ["$valoracions_resum.n", 0] }, 1 ] },
      "valoracions_resum.suma": { $add: [ { $ifNull: ["$valoracions_resum.suma", 0] }, 4 ] },
      "valoracions_resum.ultimes": {
        $slice: [ { $concatArrays: [
                    [ { puntuacio: 4, comentari: "Ancoratge 7 molt dur", ts: "$$NOW" } ],
                    { $ifNull: ["$valoracions_resum.ultimes", []] } ] }, 3 ] }
  } },
    { $set: { "valoracions_resum.mitjana": { $round: [ { $divide: [
        "$valoracions_resum.suma", "$valoracions_resum.n" ] }, 2 ] } } } ]
);

Dues etapes de pipeline perquè la segona necessita els valors que fixa la primera. $concatArrays + $slice: 3 manté les tres últimes al davant i descarta la resta. No són atòmiques entre si: si falla la segona, hi ha una valoració registrada que no apareix al resum. Es mitiga amb un recàlcul nocturn del resum, que és el preu conegut de tot camp calculat en documental.

Solució 2

db.trajectes_telemetria.aggregate([
  { $match: { ts_inici: { $gte: new Date(Date.now() - 24*3600*1000) },
              ts_fi: { $exists: true } } },
  { $set: {
      durada_s:    { $divide: [ { $subtract: ["$ts_fi", "$ts_inici"] }, 1000 ] },
      punts_aturat: { $size: { $filter: { input: "$punts", cond: { $eq: ["$$this.v", 0] } } } },
      caiguda_bateria: { $subtract: ["$bateria.inici_pct", "$bateria.fi_pct"] } } },
  { $set: {
      vel_mitjana_kmh: { $cond: [ { $gt: ["$durada_s", 0] },
                       { $multiply: [ { $divide: ["$distancia_m", "$durada_s"] }, 3.6 ] }, 0 ] },
      minuts_aturat: { $divide: [ { $multiply: ["$punts_aturat", 5] }, 60 ] } } },
  { $match: { $or: [
      { vel_mitjana_kmh: { $gt: 35 } },
      { minuts_aturat: { $gt: 20 } },
      { $and: [ { "bicicleta.tipus": "electrica" },
                { caiguda_bateria: { $gt: 40 } },
                { durada_s: { $lt: 900 } } ] } ] } },
  { $project: {
      matricula: "$bicicleta.matricula", model: "$bicicleta.model",
      km: { $round: [ { $divide: ["$distancia_m", 1000] }, 2 ] },
      vel_mitjana_kmh: { $round: ["$vel_mitjana_kmh", 1] },
      minuts_aturat: { $round: ["$minuts_aturat", 0] },
      caiguda_bateria: 1,
      motiu: { $switch: { branches: [
          { case: { $gt: ["$vel_mitjana_kmh", 35] }, then: "velocitat_impossible" },
          { case: { $gt: ["$minuts_aturat", 20] },   then: "aturada_prolongada" } ],
          default: "consum_bateria_anomal" } } } },
  { $sort: { vel_mitjana_kmh: -1 } }
]);
[
  { _id: 891044, matricula: 'VB-0233', model: 'Norvent Urbana2', km: 18.4,
    vel_mitjana_kmh: 47.2, minuts_aturat: 1, motiu: 'velocitat_impossible' },
  { _id: 890877, matricula: 'VB-0512', model: 'Ciclmar E-Vall', km: 2.1,
    vel_mitjana_kmh: 5.4, minuts_aturat: 34, motiu: 'aturada_prolongada' },
  { _id: 890912, matricula: 'VB-0733', model: 'Ciclmar E-Vall', km: 3.8,
    vel_mitjana_kmh: 12.9, minuts_aturat: 2, caiguda_bateria: 46,
    motiu: 'consum_bateria_anomal' }
]

Tres punts a destacar: el $match inicial va primer i fa servir l'índex sobre ts_inici; el $filter compta punts aturats sense $unwind, que és la forma correcta d'operar sobre arrays quan no cal desplegar-los; i $switch etiqueta el motiu, respectant el mateix ordre de prioritat que el $or.

Solució 3

1. Localitzar-los:

db.trajectes_telemetria.countDocuments({ "bicicleta.matricula": { $not: /^VB-\d{4}$/ } });
41000

2. Corregir-los al servidor:

db.trajectes_telemetria.updateMany(
  { "bicicleta.matricula": /^\d{4}$/ },
  [ { $set: { "bicicleta.matricula": { $concat: ["VB-", "$bicicleta.matricula"] } } } ]
);
{ acknowledged: true, matchedCount: 41000, modifiedCount: 41000 }

El pipeline d'actualització permet construir el valor nou a partir de l'antic dins del motor. Sense ell, caldria llegir 41.000 documents, transformar-los a l'aplicació i reescriure'ls: uns minuts de xarxa i de memòria per a una cosa que aquí són dos segons. Fixa't que el filtre de l'updateMany és /^\d{4}$/ i no el $not de la consulta: cal corregir només els que tenen el format antic conegut, no qualsevol cosa que no encaixi.

3. Per què no hauria passat en el relacional, i què ho hauria evitat aquí. A 08-01 la matrícula no està copiada enlloc: viu només a bicicletes.matricula, amb el seu CHAR(7) UNIQUE, i trajectes hi arriba per clau forana. Una dada que existeix una sola vegada no es pot escriure malament a la còpia, perquè no hi ha còpia. La duplicació que aquí ens dona rendiment a Q5 i Q8 és exactament la que va permetre l'error.

El que ho hauria evitat a MongoDB: un $jsonSchema a trajectes_telemetria amb pattern: "^VB-[0-9]{4}$" sobre bicicleta.matricula, igual que el que sí que vam posar a incidencies. La col·lecció d'incidències estava protegida i la de telemetria no, i l'error va aparèixer just on faltava el validador. No és casualitat: és la regla.

Conclusió

Has agafat tres peces concretes del sistema de VallBici —telemetria, fitxa d'estació i incidències— i les has resolt a MongoDB amb el mètode de 03-03: primer les nou consultes, després els documents. El resultat són tres col·leccions amb decisions defensables: estacions amb referències esteses i subdocuments lliures, trajectes_telemetria amb patró bucket, sostre de 900 punts i camps calculats, i incidencies amb $jsonSchema que valida el comú i deixa lliure l'específic.

I has vist de què es paga cada avantatge. bicicleta.model copiat dins de cada document és el que permet agrupar per model sense sortir del motor; també és el que va produir 41.000 matrícules mal escrites de l'exercici 3. El patró bucket redueix 269 milions de documents a 1,6 milions; també obliga a vigilar un límit de 16 MB que no existia. El TTL caduca la telemetria amb un índex; també esborra quan li va bé, no quan tu ho dius. En modelatge documental no hi ha decisions gratis: hi ha decisions el preu de les quals coneixes i decisions el preu de les quals descobriràs en producció.

La comparació de l'apartat 11 és la part que convé recordar d'aquí a un any. PostgreSQL amb jsonb i PostGIS guanya en més criteris dels que perd, i per a un VallBici petit seria la resposta correcta sense matisos. El que justifica el segon motor no és l'elegància del model documental: són 272 milions d'escriptures anuals que no necessiten ni transaccions ni integritat referencial. Quan algú et proposi afegir una base de dades a un sistema, aquella és la pregunta a fer — quin número justifica això?— i si no hi ha número, no hi ha motiu.

Queda un problema obert, i és el més difícil dels tres. A partir d'ara, la bicicleta 417 té dades en dos llocs: la seva matrícula, el seu model i el seu estat són a PostgreSQL, i també estan copiats dins de milers de documents de MongoDB. L'estació 12 té onze columnes a PostgreSQL i una fitxa de trenta camps a MongoDB. Ningú no ha dit quina mana. Quan les dues discrepin —que discreparan— quina versió és la vertadera? Amb quin retard es propaga un canvi? Què passa si el procés de sincronització cau un dimarts a la nit? I encara falta ficar a l'equació Redis, que guardarà la disponibilitat en temps real, i Elasticsearch, que servirà la cerca d'estacions per nom i adreça.

Això és la persistència poliglota, i és la lliçó 08-03: no com es dissenya cada motor —això ja està fet— sinó com es decideix qui és la font de la veritat de cada dada, com se sincronitzen, què continua funcionant quan un d'ells cau i quan convé desmuntar tota aquesta arquitectura i tornar a una sola base de dades.

© Copyright 2026. Tots els drets reservats