La lección anterior terminó con una lista de tres fracasos: la telemetría GPS de cada trayecto, la ficha enriquecida de las estaciones y las incidencias con estructura variable. Tres cosas que el esquema relacional de VallBici resuelve mal, no por estar mal diseñado, sino porque son datos que no piden lo que el modelo relacional sabe dar.

Esta lección coge esas tres piezas exactas —ni una más— y las modela en MongoDB aplicando el método de diseño dirigido por consultas de 03-03. No rediseña el núcleo transaccional: abonos, trayectos, cobros, anclajes y bicicletas se quedan en PostgreSQL, y la primera parte de la lección explica por qué esa frontera es la decisión más importante de todo el capítulo.

Al final, cada decisión de diseño se compara con cómo quedó en 08-01, se enumera con precisión qué se ha perdido y se pone sobre la mesa la alternativa que un profesional experimentado plantearía en la reunión: ¿y por qué no lo hacemos todo en PostgreSQL con jsonb y PostGIS? Es una pregunta legítima y merece una respuesta con argumentos, no un encogimiento de hombros.

Y termina con un problema abierto que no sabremos resolver todavía: a partir de hoy habrá dos bases de datos con información de la misma bicicleta, y nadie ha dicho cuál manda.

Contenido

  1. Qué sale de PostgreSQL y por qué no todo
  2. El método: primero las consultas, después los documentos
  3. La colección estaciones: la ficha enriquecida
  4. La colección trayectos_telemetria: el patrón bucket
  5. La colección incidencias: estructura variable con validación
  6. Operaciones de escritura: telemetría en tiempo real
  7. Aggregation pipelines: los informes de operaciones
  8. Consultas geoespaciales: lo que el relacional hacía peor
  9. Índices, explain() y caducidad con TTL
  10. Lo que se ha perdido y cómo se mitiga
  11. La comparación honesta: PostgreSQL con jsonb y PostGIS
  12. Errores Comunes y Consejos
  13. Ejercicios
  14. Conclusión

  1. Qué sale de PostgreSQL y por qué no todo

Empecemos por lo que no se hace, porque es donde se equivoca la mayoría.

La decisión equivocada es "migramos VallBici a MongoDB".

Si alguien la propone en la reunión, estos son los argumentos concretos —no genéricos— contra ella en este sistema:

  1. El cobro debe ser ACID y multidocumento. Cerrar un trayecto toca cuatro cosas: el trayecto, el anclaje, la bicicleta y el cobro. En PostgreSQL es una transacción y no hay más que hablar. MongoDB tiene transacciones multidocumento desde la 4.0 (lo vimos en 03-04), pero pagan un coste de rendimiento notable y son la excepción, no el modo normal de trabajo. Construir un sistema de cobro sobre la excepción de un motor es elegir mal.
  2. Las restricciones que sostienen VallBici son declarativas. EXCLUDE USING gist para los abonos solapados, el índice único parcial para los trayectos abiertos, la clave ajena compuesta del discriminante de la jerarquía. MongoDB no tiene ninguna de las tres. Serían código de aplicación, y ya vimos en 08-01 lo que pasa con las reglas que dependen del código bajo concurrencia.
  3. Las consultas del ayuntamiento son impredecibles. Un modelo documental se diseña para responder rápido a un conjunto conocido de consultas. El ayuntamiento pedirá el año que viene un cruce que hoy nadie ha imaginado, y ahí el esquema normalizado gana sin discusión: es la virtud que 03-03 destacaba del relacional, responder preguntas que nadie previó.
  4. No hay problema de escala en el núcleo. 24.000 personas abonadas y 1,6 millones de trayectos anuales caben holgadamente en un PostgreSQL de un solo servidor. El sharding de 03-01 resuelve un problema que VallBici no tiene.

El criterio de qué sí sacar

Un conjunto de datos es candidato a salir del relacional cuando cumple varias de estas condiciones. Ninguna basta por sí sola:

Condición Telemetría Ficha de estación Incidencias Trayectos (núcleo)
Estructura variable entre registros No No
Volumen muy alto de escrituras (272 M/año) No No Medio
Se lee siempre como un todo, por su identificador No
No participa en transacciones de dinero No
No necesita integridad referencial estricta Parcial No
Caduca y se borra en bloque No No No
Consultas conocidas y estables No
Veredicto Sale Sale Sale Se queda

Las tres primeras columnas cumplen cinco o más condiciones; la cuarta no cumple prácticamente ninguna. La frontera no es ideológica: es esta tabla.

flowchart LR
    subgraph pg["PostgreSQL · se queda como está (08-01)"]
        direction TB
        P1["abonos · trayectos · cobros"]
        P2["bicicletas · anclajes · estaciones"]
        P3["ordenes_taller (hecho contable)"]
    end
    subgraph mg["MongoDB · las tres piezas que salen"]
        direction TB
        M1["estaciones<br/>ficha enriquecida"]
        M2["trayectos_telemetria<br/>patrón bucket"]
        M3["incidencias<br/>detalle variable"]
    end
    P2 -. "estacion_id como _id" .-> M1
    P1 -. "trayecto_id como _id" .-> M2
    P3 -. "orden_taller_id" .-> M3
    P2 -. "matrícula y modelo<br/>copiados (referencia extendida)" .-> M2

Las flechas discontinuas no son claves ajenas: son acuerdos. Van todas en el mismo sentido y ninguna la impone el motor. Quién las mantiene, con qué retraso y qué pasa cuando fallan es el contenido íntegro de la lección 08-03.

Un matiz importante sobre las incidencias. En 08-01 existe ordenes_taller, con su coste NUMERIC y su índice único de orden abierta. Esa tabla se queda: es el registro contable del taller y participa en el presupuesto municipal. Lo que sale es el parte detallado de la incidencia —la estructura variable con fotos, medidas y observaciones—, que vivirá en MongoDB referenciando la orden. Esa distinción entre "el hecho contable" y "el detalle heterogéneo del hecho" es una de las divisiones más útiles que existen, y se aplica a muchísimos dominios.

  1. El método: primero las consultas, después los documentos

En 03-03 invertimos el orden mental del módulo 2: en el modelo documental no se parte de las entidades del dominio, se parte de las consultas. Así que antes de escribir ningún documento, la lista, con su frecuencia y su exigencia de latencia.

# Consulta Quién Frecuencia Latencia
Q1 Ficha completa de una estación para la app App móvil 60.000/día < 50 ms
Q2 Estaciones a menos de 500 m de mi posición App móvil 40.000/día < 80 ms
Q3 Estaciones de un distrito con filtros (accesible, cubierta, con bomba) App móvil 8.000/día < 100 ms
Q4 Toda la traza GPS de un trayecto Soporte / mapa 2.000/día < 200 ms
Q5 Distancia total recorrida por una bicicleta en un periodo Operaciones 200/día < 1 s
Q6 Mapa de calor de recorridos por distrito Operaciones 30/día < 10 s
Q7 Incidencias abiertas de un tipo, con su detalle Taller 500/día < 200 ms
Q8 Incidencias por modelo de bicicleta, últimos 90 días Operaciones 20/día < 5 s
Q9 Serie de batería de una bici eléctrica durante un trayecto Taller 100/día < 300 ms

Dos observaciones antes de diseñar:

  • Q1, Q4, Q7 y Q9 leen un objeto entero por su identificador. Son las que el modelo documental sirve con un solo acceso a disco, y son el 95 % del volumen. Ese dato solo ya justifica la decisión.
  • Q5, Q6 y Q8 son agregaciones analíticas. Son pocas y toleran segundos. No hay que diseñar los documentos para ellas; hay que asegurarse de que puedan responderse.

La regla de 03-03: diseña para las consultas frecuentes, tolera las raras.

  1. La colección estaciones: la ficha enriquecida

db.estaciones.insertOne({
  _id: 12,                              // el MISMO estacion_id de PostgreSQL. Ver nota abajo.
  codigo: "VB-012",
  nombre: "Estación 12 · Muelle Norte",
  direccion: "Paseo del Muelle 14",
  distrito: { id: 1, nombre: "Puerto" },   // referencia extendida: id + lo que se pinta
  ubicacion: {                             // GeoJSON: obligatorio para el índice 2dsphere
    type: "Point",
    coordinates: [-3.200000, 40.116000]    // ¡longitud primero! Es el orden de GeoJSON
  },
  capacidad: { anclajes: 24, cubierta: true },
  accesibilidad: {
    rampa: true,
    ancho_paso_cm: 120,
    pavimento: "adoquin",
    observaciones: "Bordillo de 4 cm en el acceso sur"
  },
  servicios: ["bomba_aire", "panel_informativo", "carga_electrica"],
  horario: {                               // varía por estación: aquí no hay esquema fijo
    tipo: "restringido",
    apertura: "06:00", cierre: "01:00",
    excepciones: [{ fecha: "2026-09-08", motivo: "Fiestas del Puerto", cerrada: true }]
  },
  fotos: [
    { url: "https://cdn.vallmar.example/est/012-1.webp", tipo: "general", alt: "Vista general" },
    { url: "https://cdn.vallmar.example/est/012-2.webp", tipo: "acceso",  alt: "Acceso sur" }
  ],
  poi_cercanos: ["Estación marítima", "Mercado del Puerto"],
  mantenedora: { empresa: "Serveis Vallmar SL", contrato: "2026-014", telefono: "900 000 000" },
  esquema_v: 2,                            // versionado de documentos (03-03)
  actualizado: ISODate("2026-06-14T09:12:00Z")
});

Las decisiones, una a una

_id es el estacion_id de PostgreSQL, no un ObjectId. Es la decisión más consecuente del documento. Ventajas: la unión entre los dos mundos es trivial, no hace falta un índice extra sobre un campo estacion_id, y una escritura repetida con el mismo _id es naturalmente idempotente. Inconveniente: obliga a que la estación exista antes en PostgreSQL. Se acepta, porque PostgreSQL es la fuente de la verdad del inventario — y esa frase, que aquí suena inocente, es el tema entero de 08-03.

distrito embebido como subdocumento con id y nombre: patrón de referencia extendida. El nombre del distrito se pinta en la ficha; ir a buscarlo a otra colección para dos palabras no tiene sentido. Se copia solo lo que se pinta, no el documento entero del distrito. El riesgo de duplicación es el que 03-03 advertía: si el ayuntamiento renombra un distrito hay que actualizar 60 documentos. Con cinco distritos que cambian de nombre cada nunca, es un riesgo aceptable y el updateMany correspondiente es una línea.

fotos embebido, no referenciado. Aplicamos los criterios de 03-03: son pocas (2-6 por estación), se leen siempre con la estación, no se consultan nunca por separado y no crecen sin límite. Es un "uno a pocos" de manual. Embeber.

accesibilidad y horario como subdocumentos de forma libre. Aquí está el motivo por el que esta colección existe. En el relacional, pavimento: "adoquin" y ancho_paso_cm: 120 serían dos columnas más que solo tienen valor en algunas estaciones. Aquí, la estación que no tiene rampa simplemente no lleva el campo, y añadir mañana carril_bici_directo: true no requiere ninguna migración.

Lo que NO se embebe: las bicicletas presentes. La tentación es enorme —"así la app pide un documento y ya tiene todo"— y es un anti-patrón de los cuatro que 03-03 marcaba: un array que cambia decenas de veces por hora dentro de un documento que se lee 60.000 veces al día. Cada desbloqueo reescribiría el documento entero, invalidando la caché y provocando contención. La disponibilidad en tiempo real no vive aquí; en 08-03 verás dónde vive.

Comparación con 08-01

Aspecto PostgreSQL (08-01) MongoDB (aquí)
Atributos opcionales Columnas nulas o EAV Campos ausentes, sin coste
Añadir carril_bici_directo ALTER TABLE en producción Escribir el campo
Fotos Tabla estaciones_fotos + JOIN Array embebido
Leer la ficha completa 3-4 JOIN Un findOne
"Estaciones con más de 20 anclajes" Trivial e indexado Trivial e indexado
"Estaciones sin rampa" Trivial Requiere pensar en el campo ausente

Esa última fila es la contrapartida honesta: en el documental, "no tiene rampa" y "no sabemos si tiene rampa" se parecen peligrosamente. Se resuelve con validación de esquema, que veremos en la colección de incidencias.

  1. La colección trayectos_telemetria: el patrón bucket

Este es el caso donde la elección de estructura cambia el sistema por dos órdenes de magnitud, así que primero hagamos el cálculo que descarta la opción ingenua.

Por qué un documento por punto GPS es un anti-patrón

Un trayecto medio dura 14 minutos con una posición cada 5 segundos: 168 puntos. Con 1,6 millones de trayectos al año:

Estrategia Documentos/año Tamaño de documento Overhead de _id + índice Leer una traza (Q4)
Un documento por punto 269 millones ~90 B útiles, ~200 B reales ≈ 32 GB solo en índices 168 documentos, 168 entradas de índice
Un documento por trayecto (bucket) 1,6 millones ~14 KB ≈ 190 MB en índices 1 documento, 1 acceso

Un documento por punto multiplica por 168 el número de documentos, multiplica por más de 100 el coste de índices y convierte la consulta más frecuente sobre estos datos en la recuperación de 168 objetos que hay que ordenar. Es el anti-patrón que 03-03 llamaba documento demasiado pequeño: los metadatos pesan más que el dato.

El diseño elegido

db.trayectos_telemetria.insertOne({
  _id: NumberLong(884213),               // el trayecto_id de PostgreSQL
  bicicleta: { id: 417, matricula: "VB-0417", tipo: "electrica", modelo: "Ciclmar E-Vall" },
  estacion_origen: 12,
  ts_inicio: ISODate("2026-06-14T06:12:04Z"),
  ts_fin:    ISODate("2026-06-14T06:26:31Z"),
  ventana: 0,                             // 0 = trayecto normal; ver desbordamiento abajo
  n_puntos: 168,
  distancia_m: 3402,
  bbox: { min: [-3.2041, 40.1102], max: [-3.1908, 40.1194] },  // campo calculado
  bateria: { inicio_pct: 88, fin_pct: 79 },
  puntos: [
    { t: 0,   p: [-3.2000, 40.1160], v: 0.0,  b: 88 },   // t = segundos desde ts_inicio
    { 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 }
  ],
  eventos: [
    { t: 412, tipo: "frenada_brusca", g: 0.42 },
    { t: 690, tipo: "salida_de_carril" }
  ],
  esquema_v: 1
});

Nombres de campo cortos: t, p, v, b. No es coquetería. MongoDB almacena el nombre de cada campo en cada elemento del array. Con timestamp, posicion, velocidad y bateria, los nombres pesarían unos 30 bytes por punto × 168 puntos × 1,6 M trayectos ≈ 8 GB al año solo en repetir palabras. Con nombres de una letra, unos 900 MB. Es una de las poquísimas situaciones en las que sacrificar legibilidad de campo está justificado, y la contrapartida es documentarlo bien.

t es un desplazamiento en segundos, no una fecha. Un ISODate ocupa 8 bytes y un entero pequeño 1 o 2. La fecha absoluta se reconstruye sumando a ts_inicio. Mismo razonamiento.

bbox y distancia_m son campos calculados (patrón de campo calculado de 03-03). Se rellenan al cerrar el trayecto y evitan tener que recorrer los 168 puntos cada vez que alguien pregunta "¿cuánto recorrió?". Es la misma lógica que bicis_disponibles en 08-01: se paga con una redundancia que hay que mantener y se cobra en cada lectura.

bicicleta embebida con cuatro campos: referencia extendida otra vez. ¿Por qué copiar la matrícula y el modelo si están en PostgreSQL? Porque Q8 —incidencias por modelo— y Q5 —distancia por bicicleta— se resolverían de otro modo con un $lookup imposible: la tabla de bicicletas está en otro motor. Copiar el modelo aquí es lo que permite agrupar por modelo sin salir de MongoDB. El precio: si una bici cambia de modelo (no pasa) o se corrige su matrícula (pasa raramente), hay documentos históricos con el valor antiguo. Para datos históricos eso es correcto, no un error: el trayecto se hizo con la matrícula que tenía entonces. Es exactamente el mismo argumento de la tarifa congelada de 08-01.

El desbordamiento: ventana

El límite duro de un documento en MongoDB son 16 MB. Un trayecto normal ocupa 14 KB, así que sobra sitio. Pero VallBici tiene un caso raro: la ruta turística de fin de semana, con trayectos de cinco horas. 5 h × 720 puntos/h = 3.600 puntos ≈ 300 KB. Sigue cabiendo, pero un trayecto patológico —una bici olvidada en un camión durante dos días con el GPS emitiendo— podría acercarse al límite.

La regla operativa, aplicando el patrón bucket con techo:

Máximo 900 puntos por documento (75 minutos). Al superarlo, se abre un documento nuevo con ventana: 1, ventana: 2

// _id compuesto para las ventanas adicionales
{ _id: { trayecto: NumberLong(884213), ventana: 1 }, ... }

Q4 pasa entonces de ser un findOne a un find con sort, pero solo para el 0,3 % de trayectos que desbordan. Es la aplicación literal del patrón outlier de 03-03: no se diseña el sistema entero para el caso raro; se diseña para el caso normal y se marca el raro con una bandera.

  1. La colección incidencias: estructura variable con validación

// Incidencia de frenos
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34cd"),
  orden_taller_id: NumberLong(30412),   // referencia a ordenes_taller, en PostgreSQL
  bicicleta: { id: 417, matricula: "VB-0417", tipo: "mecanica", modelo: "Norvent Urbana2" },
  tipo: "frenos",
  estado: "abierta",
  gravedad: 3,
  reportada_por: { canal: "app_usuario", abono_id: 10233 },
  ts_apertura: ISODate("2026-06-14T07:02:11Z"),
  estacion: 12,
  detalle: {                             // ← libre: depende de "tipo"
    freno: "trasero",
    pastilla_mm: 1.2,
    cable_holgura_mm: 6,
    prueba_frenada_m: 8.4
  },
  esquema_v: 1
}

// Incidencia de batería: MISMO tipo de documento, "detalle" completamente distinto
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34ce"),
  orden_taller_id: NumberLong(30413),
  bicicleta: { id: 512, matricula: "VB-0512", tipo: "electrica", modelo: "Ciclmar E-Vall" },
  tipo: "bateria",
  estado: "en_taller",
  gravedad: 4,
  reportada_por: { canal: "telemetria" },
  ts_apertura: ISODate("2026-06-14T09:41:00Z"),
  detalle: {
    ciclos_carga: 812,
    voltaje_v: 33.1,
    capacidad_restante_pct: 61,
    n_serie: "BT-2024-00871",
    celdas_defectuosas: [3, 7]
  }
}

// Incidencia de vandalismo
{
  _id: ObjectId("665a1f3c8e4b2a0012ab34cf"),
  orden_taller_id: NumberLong(30414),
  bicicleta: { id: 733, matricula: "VB-0733", tipo: "electrica", modelo: "Ciclmar E-Vall" },
  tipo: "vandalismo",
  estado: "abierta",
  gravedad: 5,
  reportada_por: { canal: "operario", operario: "JMR" },
  ts_apertura: ISODate("2026-06-13T22:15:00Z"),
  detalle: {
    atestado: "PL-2026-4471",
    partes_afectadas: ["sillin", "cuadro", "pantalla"],
    fotos: ["https://cdn.vallmar.example/inc/4471-1.webp"],
    coste_estimado_eur: 240
  }
}

El $jsonSchema: validar lo común, dejar libre lo específico

El equilibrio que 03-03 defendía. Sin validación, MongoDB acepta cualquier cosa y a los seis meses hay documentos con tipo: "Frenos", tipo: "freno" y gravedad: "alta". Con validación excesiva, se pierde la única ventaja que justificaba usar MongoDB.

db.createCollection("incidencias", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["orden_taller_id", "bicicleta", "tipo", "estado", "gravedad", "ts_apertura"],
    properties: {
      orden_taller_id: { bsonType: "long" },
      bicicleta: {
        bsonType: "object",
        required: ["id", "matricula", "tipo", "modelo"],
        properties: {
          id:        { bsonType: "int" },
          matricula: { bsonType: "string", pattern: "^VB-[0-9]{4}$" },
          tipo:      { enum: ["mecanica", "electrica"] },
          modelo:    { bsonType: "string" }
        }
      },
      tipo:     { enum: ["frenos","bateria","rueda","electronica","vandalismo","otros"] },
      estado:   { enum: ["abierta","en_taller","resuelta","descartada"] },
      gravedad: { bsonType: "int", minimum: 1, maximum: 5 },
      ts_apertura: { bsonType: "date" },
      detalle:  { bsonType: "object" }      // ← objeto, y nada más. Deliberado.
    }
  }},
  validationLevel: "strict",
  validationAction: "error"
});

La línea clave es detalle: { bsonType: "object" }. Se exige que sea un objeto —no un string ni un array— y no se dice absolutamente nada sobre su contenido. Añadir mañana tipo: "gps" con un detalle de tres campos nuevos requiere una sola cosa: añadir "gps" al enum. Ningún ALTER TABLE, ninguna ventana de parada.

Prueba de que funciona:

db.incidencias.insertOne({ orden_taller_id: NumberLong(1), tipo: "frenos",
   estado: "abierta", gravedad: 9, ts_apertura: new Date(),
   bicicleta: { id: 417, matricula: "VB-0417", tipo: "mecanica", modelo: "Norvent Urbana2" }});
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ObjectId('...'),
  details: { operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [ { operatorName: 'properties',
      propertiesNotSatisfied: [ { propertyName: 'gravedad',
        details: [ { operatorName: 'maximum', specifiedAs: { maximum: 5 },
                     reason: 'comparison failed', consideredValue: 9 } ] } ] } ] }
}

Comparado con 08-01: allí, gravedad BETWEEN 1 AND 5 habría sido un CHECK de veinte caracteres. Aquí son doce líneas de JSON. La validación en el documental cuesta más de escribir y hay que quererla explícitamente, y por eso tantas colecciones acaban sin ninguna.

  1. Operaciones de escritura: telemetría en tiempo real

La bicicleta emite una posición cada 5 segundos. ¿Se escribe cada punto en cuanto llega?

// Opción A — un $push por punto. 269 millones de escrituras al año.
db.trayectos_telemetria.updateOne(
  { _id: NumberLong(884213) },
  { $push: { puntos: { t: 415, p: [-3.1991, 40.1151], v: 4.8, b: 84 } },
    $inc:  { n_puntos: 1 } }
);

Funciona, pero tiene dos problemas medibles. El primero: cada $push reescribe el documento si ha crecido más allá del espacio reservado, y un documento que crece de 200 B a 14 KB en 168 pasos se reubica varias veces. El segundo: son 269 millones de operaciones de red y de WAL (aquí, del journal) al año.

La opción elegida es el micro-lote en la pasarela de telemetría: la aplicación acumula los puntos de 30 segundos en memoria y escribe seis de golpe.

db.trayectos_telemetria.updateOne(
  { _id: NumberLong(884213) },
  { $push: { puntos: { $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 techo del bucket, impuesto por el motor
    $inc: { n_puntos: 6 },
    $max: { ultimo_t: 440 }
  },
  { upsert: true }
);
{ acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null }

Los cuatro detalles que importan:

  • $each convierte seis escrituras en una. Reduce el número de operaciones en un factor de 6 y el riesgo de reubicación del documento.
  • $slice: 900 es el techo del bucket impuesto por el propio motor: el array nunca pasa de 900 elementos, pase lo que pase con la aplicación. Es el equivalente documental de un CHECK.
  • $max: { ultimo_t: 440 } hace la escritura idempotente en el orden: si un lote llega tarde y desordenado por la red, ultimo_t no retrocede.
  • upsert: true permite que el primer lote cree el documento. Si el evento de "trayecto iniciado" se perdiera, la telemetría no se pierde con él.

Lo que $push no arregla, y hay que decirlo: el $each no es idempotente. Si la pasarela reintenta un lote porque no recibió confirmación, los seis puntos se añaden dos veces. Con datos de posición eso es tolerable —dos puntos idénticos consecutivos no cambian ninguna conclusión— y por eso se acepta. Si fueran importes económicos, no lo sería, y esa es exactamente la razón por la que el dinero se quedó en PostgreSQL.

El cierre del trayecto

db.trayectos_telemetria.updateOne(
  { _id: NumberLong(884213) },
  [ { $set: {
        ts_fin: "$$NOW",
        n_puntos: { $size: "$puntos" },
        bbox: {
          min: [ { $min: { $map: { input: "$puntos", in: { $arrayElemAt: ["$$this.p", 0] } } } },
                 { $min: { $map: { input: "$puntos", in: { $arrayElemAt: ["$$this.p", 1] } } } } ],
          max: [ { $max: { $map: { input: "$puntos", in: { $arrayElemAt: ["$$this.p", 0] } } } },
                 { $max: { $map: { input: "$puntos", in: { $arrayElemAt: ["$$this.p", 1] } } } } ]
        },
        "bateria.fin_pct": { $last: "$puntos.b" }
      } } ]
);

Es una actualización con pipeline de agregación (disponible desde MongoDB 4.2): permite calcular los campos derivados dentro del motor, sin traerse los 168 puntos a la aplicación. Es el equivalente documental de una columna generada.

  1. Aggregation pipelines: los informes de operaciones

Q5 — Distancia recorrida por bicicleta en un periodo

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

Etapa por etapa:

Etapa Qué hace Equivalente SQL
$match Filtra por periodo. Va primero siempre: reduce el conjunto antes de trabajar y puede usar índice WHERE
$group Agrupa por bici y acumula GROUP BY + SUM/AVG/COUNT
$sort Ordena por kilómetros ORDER BY
$limit Se queda con cinco LIMIT
$project Da forma a la salida Lista del SELECT
-- El equivalente en PostgreSQL, si estos datos estuvieran allí
SELECT b.matricula, m.nombre AS modelo,
       ROUND(SUM(t.distancia_m)/1000.0, 1) AS km, COUNT(*) AS viajes,
       ROUND(AVG(t.distancia_m)/1000.0, 2) AS km_medio
  FROM telemetria t JOIN bicicletas b USING (bicicleta_id)
                    JOIN modelos_bici m USING (modelo_id)
 WHERE t.ts_inicio >= DATE '2026-06-01' AND t.ts_inicio < DATE '2026-07-01'
 GROUP BY b.matricula, m.nombre ORDER BY km DESC LIMIT 5;

Casi la misma consulta. La diferencia real: el SQL necesita dos JOIN para llegar al modelo, y el pipeline no necesita ninguno porque el modelo está copiado dentro del documento. Eso es el intercambio central del modelo documental, en una frase: duplicación a cambio de acceso local.

Q6 — Mapa de calor de recorridos por distrito

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

El $unwind es la etapa peligrosa y hay que entender por qué. Convierte cada documento de trayecto en 168 documentos, uno por punto. Sobre 45.000 trayectos de un mes son 7,5 millones de documentos intermedios. Es exactamente el motivo de las dos precauciones que lleva el pipeline: el $match va antes del $unwind (si fuera después, se desplegarían los 1,6 millones de trayectos del año) y el $project reduce cada punto a dos números redondeados antes de agrupar.

Es también la respuesta a por qué Q6 tolera 10 segundos y se ejecuta 30 veces al día, no 30.000. Un pipeline con $unwind sobre millones de documentos no es una consulta de aplicación: es un informe.

Q8 — Incidencias por modelo de bicicleta

db.incidencias.aggregate([
  { $match: { ts_apertura: { $gte: new Date(Date.now() - 90*24*3600*1000) } } },
  { $group: {
      _id: { modelo: "$bicicleta.modelo", tipo: "$tipo" },
      n: { $sum: 1 },
      gravedad_media: { $avg: "$gravedad" },
      bicis: { $addToSet: "$bicicleta.id" } } },
  { $group: {
      _id: "$_id.modelo",
      total: { $sum: "$n" },
      por_tipo: { $push: { tipo: "$_id.tipo", n: "$n",
                           gravedad: { $round: ["$gravedad_media", 1] } } },
      bicis_afectadas: { $sum: { $size: "$bicis" } } } },
  { $sort: { total: -1 } }
]);
[
  { _id: 'Norvent Urbana2', total: 412, bicis_afectadas: 188,
    por_tipo: [ { tipo: 'frenos', n: 201, gravedad: 3.1 },
                { tipo: 'rueda',  n: 142, gravedad: 2.4 },
                { tipo: 'otros',  n:  69, gravedad: 1.8 } ] },
  { _id: 'Ciclmar E-Vall', total: 287, bicis_afectadas: 121,
    por_tipo: [ { tipo: 'bateria',     n: 158, gravedad: 3.9 },
                { tipo: 'electronica', n:  74, gravedad: 3.2 },
                { tipo: 'frenos',      n:  55, gravedad: 2.7 } ] }
]

El doble $group es el patrón que hay que aprender de aquí. El primero agrupa por (modelo, tipo); el segundo vuelve a agrupar por modelo y usa $push para meter los resultados del primero en un array anidado. El resultado es una estructura jerárquica que en SQL requeriría o bien dos consultas, o bien una con funciones de ventana y un pivote manual con FILTER como el que hiciste en 07-04. Producir jerarquías es donde el pipeline de agregación gana claramente a SQL.

Y fíjate en lo que hace posible la consulta: bicicleta.modelo está copiado en cada incidencia. Sin esa duplicación, agrupar por modelo exigiría ir a PostgreSQL, y no hay forma de hacerlo desde un pipeline.

  1. Consultas geoespaciales: lo que el relacional hacía peor

Q2 —"estaciones a menos de 500 m"— es la consulta que en 08-01 quedaba peor resuelta. Con latitud y longitud como NUMERIC, la única salida sin extensiones es una fórmula de Haversine en el SELECT, que no puede usar ningún índice: obliga a calcular la distancia a las 60 estaciones y filtrar después.

En MongoDB es una línea de índice y una consulta.

db.estaciones.createIndex({ ubicacion: "2dsphere" });
ubicacion_2dsphere
db.estaciones.find(
  { ubicacion: { $near: {
      $geometry: { type: "Point", coordinates: [-3.1995, 40.1158] },
      $maxDistance: 500 } },
    "capacidad.cubierta": true },
  { nombre: 1, direccion: 1, "capacidad.anclajes": 1 }
);
[
  { _id: 12, nombre: 'Estación 12 · Muelle Norte', direccion: 'Paseo del Muelle 14',
    capacidad: { anclajes: 24 } },
  { _id: 41, nombre: 'Estación 41 · Lonja',        direccion: 'Av. de la Lonja 3',
    capacidad: { anclajes: 20 } }
]

Tres cosas gratis en esa consulta: los resultados vienen ordenados por distancia sin pedirlo, el $maxDistance está en metros sobre la esfera (no en grados), y el filtro adicional capacidad.cubierta se combina con el geoespacial sin ceremonia.

Para Q3, "estaciones dentro del polígono del distrito", el operador es otro:

db.estaciones.find({ ubicacion: { $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 por proximidad y necesita índice; $geoWithin no ordena y puede funcionar sin él, aunque va mucho mejor con él. Y el error clásico, que merece letra negrita porque lo comete todo el mundo la primera vez: GeoJSON es [longitud, latitud], en ese orden. Al revés, Vallmar aparece en el océano Índico y las consultas devuelven cero resultados sin dar ningún error.

  1. Índices, explain() y caducidad con TTL

db.estaciones.createIndex({ "distrito.id": 1, "capacidad.cubierta": 1 });   // Q3
db.trayectos_telemetria.createIndex({ "bicicleta.id": 1, ts_inicio: -1 });  // Q5
db.trayectos_telemetria.createIndex({ ts_inicio: 1 });                      // Q6
db.incidencias.createIndex({ estado: 1, tipo: 1, ts_apertura: -1 });        // Q7
db.incidencias.createIndex({ "bicicleta.modelo": 1, ts_apertura: -1 });     // Q8
db.incidencias.createIndex({ orden_taller_id: 1 }, { unique: true });       // integridad

El orden de los campos en un índice compuesto sigue la misma regla que en PostgreSQL (06-03): igualdad primero, rango o ordenación al final. { estado: 1, tipo: 1, ts_apertura: -1 } sirve para Q7 completa, y también para "todas las incidencias abiertas" —prefijo del índice—, pero no para "todas las incidencias de tipo frenos", porque tipo no es prefijo. Es idéntico a lo que ocurría con los índices compuestos en B-tree.

Comprobación con explain():

db.incidencias.find({ estado: "abierta", tipo: "frenos" })
              .sort({ ts_apertura: -1 }).explain("executionStats").executionStats;
{
  executionSuccess: true,
  nReturned: 23,
  executionTimeMillis: 1,
  totalKeysExamined: 23,
  totalDocsExamined: 23,
  executionStages: { stage: 'FETCH', ... inputStage: { stage: 'IXSCAN',
    indexName: 'estado_1_tipo_1_ts_apertura_-1',
    keyPattern: { estado: 1, tipo: 1, ts_apertura: -1 } } }
}

Cómo se lee. totalKeysExamined == totalDocsExamined == nReturned es el resultado perfecto: el índice localizó exactamente las 23 filas necesarias, sin descartes. Y no hay etapa SORT, porque el índice ya entrega el orden pedido. La lectura es la misma que hacías con EXPLAIN ANALYZE: si totalDocsExamined fuera 40.000 para devolver 23, tendrías el equivalente de un Rows Removed by Filter alto.

TTL: caducar la telemetría vieja

El ayuntamiento conserva la telemetría 180 días; después solo interesan los agregados. En PostgreSQL eso sería un DELETE mensual sobre 45 millones de filas, con su VACUUM correspondiente y su ventana de mantenimiento, o bien particionado por rango.

En MongoDB es un índice:

db.trayectos_telemetria.createIndex(
  { ts_inicio: 1 },
  { expireAfterSeconds: 15552000, name: "ttl_180_dias" }   // 180 días
);

Un proceso interno recorre el índice cada 60 segundos y borra lo vencido. Tres advertencias, porque el TTL sorprende a quien no las conoce:

  1. El borrado no es puntual. Un documento puede sobrevivir hasta un minuto —o más, si hay carga— tras su vencimiento. No sirve para requisitos legales de borrado "exacto".
  2. El campo debe ser una fecha. Si ts_inicio fuese un string, el índice funciona pero no borra nada, silenciosamente.
  3. En un conjunto de réplicas solo borra el primario, y las réplicas reciben el borrado por replicación. Es lo correcto, pero significa que un secundario retrasado tiene documentos que "ya no existen".

Antes de que el TTL borre nada, un proceso mensual consolida lo que sí hay que conservar:

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

$merge es el equivalente documental de una vista materializada de 05-04: el detalle caduca, el resumen permanece.

  1. Lo que se ha perdido y cómo se mitiga

Un caso de estudio que solo cuenta las ventajas es publicidad. Estas son las cuatro pérdidas reales, con su mitigación y con lo que la mitigación no consigue.

Pérdida 1 — No hay clave ajena hacia bicicletas

En 08-01, trayectos.bicicleta_id REFERENCES bicicletas garantizaba que no puede existir un trayecto de una bici inexistente. Aquí, trayectos_telemetria.bicicleta.id = 417 es un número. Si alguien borra la bici 417 de PostgreSQL, MongoDB no se entera y guarda telemetría huérfana para siempre.

Mitigación: (a) el $jsonSchema obliga a que el campo exista y tenga el formato correcto, lo que evita el error de tecleo pero no el referencial; (b) ON DELETE RESTRICT en PostgreSQL hace que las bicis no se borren nunca, solo se marquen estado = 'baja' — y esa decisión, tomada en 08-01 por motivos contables, resulta ser también la que protege la coherencia entre motores; (c) una reconciliación nocturna que lista los bicicleta.id distintos de MongoDB y comprueba que todos existen en PostgreSQL.

// Paso 1: sacar los ids que MongoDB cree conocer
db.trayectos_telemetria.distinct("bicicleta.id");
// → [1, 2, 3, ... 900, 947]
-- Paso 2: contrastarlos contra la verdad
SELECT unnest(ARRAY[1,2,3,...,900,947]) AS id
EXCEPT
SELECT bicicleta_id FROM bicicletas;
 id
-----
 947

Ese 947 es un huérfano: telemetría de una bicicleta que no existe. La reconciliación no lo impide; lo detecta. Es una diferencia de naturaleza, no de grado: se ha pasado de una garantía a una alarma.

Pérdida 2 — No hay JOIN, y $lookup no es la solución

$lookup une dos colecciones de la misma base de datos MongoDB. No puede unir con PostgreSQL. Punto. Cualquier informe que cruce telemetría con importes cobrados necesita:

Opción Cómo Cuándo usarla
Unir en la aplicación Consultar los dos motores y combinar en memoria Pocos registros, consulta puntual
Duplicar el campo necesario Copiar modelo y matricula en el documento (lo que hicimos) Campo estable y muy consultado
Almacén analítico Volcar ambos a un tercer sistema para informes Informes que cruzan de verdad

Y aunque las colecciones estuvieran en el mismo MongoDB, $lookup no es un JOIN equivalente: se ejecuta como un bucle sobre la colección externa, no aprovecha estadísticas y no hay planificador que reordene nada. Es una herramienta para enriquecer resultados ya filtrados, no para consultar dos colecciones grandes a la vez. La regla práctica: si tu pipeline empieza con un $lookup sobre millones de documentos, el modelo está mal.

Pérdida 3 — La coherencia queda en manos de la aplicación

Que bicicleta.modelo diga "Norvent Urbana2" cuando en PostgreSQL pone otra cosa no lo impide nada. La mitigación es de proceso, no de motor:

  • Escrituras idempotentes: el _id es el trayecto_id, así que reescribir el mismo documento dos veces produce el mismo resultado. Esto vale oro cuando hay reintentos.
  • Un solo escritor por colección: solo el servicio de telemetría escribe en trayectos_telemetria. Nada de que tres servicios distintos toquen la misma colección "porque es más rápido".
  • Reconciliación programada con informe de discrepancias, como la de arriba.
  • esquema_v en cada documento: cuando la forma del documento cambie, los antiguos siguen siendo legibles y se sabe cuáles migrar.

Pérdida 4 — Las restricciones ricas no existen

No hay EXCLUDE USING gist, no hay índice único parcial con predicado arbitrario, no hay CHECK entre columnas expresado con la naturalidad de SQL. $jsonSchema valida forma y rangos; no valida relaciones entre documentos ni condiciones temporales.

Mitigación: poner en MongoDB solo datos donde esas restricciones no hagan falta. Lo cual, si te fijas, es precisamente el criterio del apartado 1 leído al revés. El diseño coherente es el que no necesita las garantías que su motor no da.

  1. La comparación honesta: PostgreSQL con jsonb y PostGIS

La pregunta incómoda que tocaba hacerse: todo lo de esta lección se puede hacer en PostgreSQL. jsonb guarda documentos con índices GIN; PostGIS hace geoespacial mejor que MongoDB; el particionado por rango caduca datos mejor que el TTL. ¿Por qué no quedarse en un solo motor?

Criterio PostgreSQL + jsonb + PostGIS MongoDB Quién gana en VallBici
Ficha heterogénea de estación jsonb + índice GIN: funciona bien Nativo Empate
Consultas geoespaciales PostGIS es más potente (rutas, topología, proyecciones) 2dsphere cubre lo básico PostgreSQL
Telemetría: volumen de escritura Alto coste por fila (WAL, MVCC, autovacuum) Menor coste, $push sobre documento MongoDB
Caducar datos antiguos Particionado + DETACH PARTITION: eficientísimo TTL: cómodo, menos control PostgreSQL
Añadir un tipo de incidencia jsonb: sin migración Sin migración Empate
Cruzar telemetría con cobros JOIN real, un solo motor Imposible sin salir a la aplicación PostgreSQL
Escalado horizontal de escritura Requiere trabajo (Citus, particionado, réplicas) Sharding nativo (03-01) MongoDB
Piezas que operar Una Dos PostgreSQL
Personas necesarias en el equipo Una competencia Dos competencias PostgreSQL

Cuenta los votos: PostgreSQL gana en más filas. Y la conclusión honesta es esta:

Si VallBici tuviera 6 estaciones y 90 bicicletas, la respuesta correcta sería quedarse en PostgreSQL con jsonb y PostGIS, sin discusión.

Lo que inclina la balanza en el VallBici real son dos filas, no nueve: 272 millones de puntos GPS al año y la previsión municipal de duplicar la flota en tres años. Ese volumen de escritura de datos que no necesitan transacciones ni integridad referencial es el caso para el que existe un almacén documental, y es la única razón de peso.

Las señales que dirían "vuelve a PostgreSQL, esto no compensaba":

  1. La telemetría resulta consultarse cruzada con los cobros constantemente.
  2. El volumen se estanca en un nivel que un PostgreSQL bien particionado aguanta.
  3. El equipo no consigue mantener competencia real en los dos motores.
  4. Aparecen requisitos geoespaciales complejos (rutas, isócronas) que PostGIS haría y 2dsphere no.

Ninguna de esas señales es vergonzosa. Deshacer una decisión de arquitectura cuando los datos cambian es competencia profesional, no fracaso.

Errores Comunes y Consejos

Error 1: migrarlo todo. El caso completo de esta lección son tres colecciones. Nadie tocó el cobro. Un proyecto que empieza con "vamos a pasar la base de datos a MongoDB" en lugar de "estos tres conjuntos de datos encajan mejor en documentos" ya ha tomado la decisión antes de analizarla.

Error 2: un documento por punto GPS. El cálculo del apartado 4 es el argumento: 269 millones de documentos frente a 1,6 millones. Cuando los datos llegan como una serie temporal asociada a algo, el bucket es casi siempre la respuesta.

Error 3: un bucket sin techo. El límite de 16 MB no avisa hasta que lo alcanzas, y entonces la escritura falla en producción. $slice en el $push y una bandera de ventana.

Error 4: [latitud, longitud]. GeoJSON es [lon, lat]. No da error: simplemente devuelve cero resultados y te hace perder una tarde.

Error 5: $unwind antes de $match. Multiplica los documentos y después filtra. Puede convertir un pipeline de 8 segundos en uno de 8 minutos.

Error 6: crear la colección sin validador "porque ya lo valida la aplicación". Es la misma frase que en 08-01 justificaba no poner CHECK, y termina igual. En documental es peor, porque el daño se acumula silenciosamente durante meses.

Consejo 1: escribe la tabla de consultas antes que el primer documento. Las nueve filas del apartado 2 determinaron las tres colecciones, los campos duplicados y los seis índices.

Consejo 2: nombres de campo cortos solo dentro de arrays grandes. En puntos está justificado por los 8 GB anuales. Poner n en vez de nombre en estaciones es ilegibilidad gratuita: son 60 documentos.

Consejo 3: _id con significado siempre que puedas. Usar el trayecto_id de PostgreSQL como _id da idempotencia gratis, ahorra un índice y hace obvia la correspondencia entre motores.

Consejo 4: lee explain() con los mismos ojos que EXPLAIN ANALYZE. totalDocsExamined frente a nReturned es el Rows Removed by Filter de MongoDB, y una etapa SORT en memoria es el aviso de que falta un índice.

Consejo 5: escribe hoy el proceso de reconciliación. Cuando hay dos motores, la pregunta no es si divergirán, sino cuándo y cuánto tardarás en enterarte.

Ejercicios

Ejercicio 1 — Valoraciones de estación

La app va a permitir puntuar una estación de 1 a 5 con un comentario opcional y hasta tres etiquetas (sucia, mal_iluminada, anclajes_duros…). Se prevén ~200 valoraciones diarias. La ficha de la estación debe mostrar la nota media y las tres últimas valoraciones.

  1. Decide si las valoraciones se embeben en estaciones o van en una colección propia, con los criterios de 03-03.
  2. Escribe el documento resultante y los índices necesarios.
  3. Escribe la operación que registra una valoración nueva y mantiene actualizado lo que se muestra en la ficha.

Ejercicio 2 — Detección de trayectos anómalos

Operaciones quiere detectar trayectos sospechosos: velocidad media superior a 35 km/h (la bici ha ido en un vehículo), más de 20 minutos con velocidad 0 en mitad del trayecto, o una bicicleta eléctrica que pierde más de un 40 % de batería en menos de 15 minutos.

Escribe un pipeline de agregación que devuelva los trayectos del último día que cumplan alguna de las tres condiciones, con la condición que han disparado y los datos de la bicicleta.

Ejercicio 3 — El campo que se quedó obsoleto

Auditoría descubre que 41.000 documentos de trayectos_telemetria tienen bicicleta.matricula con el formato antiguo (0417 en vez de VB-0417), porque durante dos semanas la pasarela escribió mal el campo.

  1. Escribe la consulta que los localiza.
  2. Escribe la actualización que los corrige sin traerse los documentos a la aplicación.
  3. Explica por qué este problema no podría haber ocurrido en el esquema relacional de 08-01, y qué mecanismo de MongoDB habría podido evitarlo.

Soluciones

Solución 1

1. Colección propia con subconjunto embebido. Los criterios de 03-03 dan una respuesta clara: 200 valoraciones diarias × 60 estaciones a lo largo de años es un "uno a muchísimos" sin techo, y un array ilimitado dentro de un documento leído 60.000 veces al día es el anti-patrón de array no acotado. Pero la ficha necesita las tres últimas, y una segunda consulta en la ruta más caliente de la app es un coste real.

La respuesta es el patrón subconjunto: colección valoraciones con todo, y en estaciones un resumen con las tres últimas y los agregados.

2. El diseño:

// Colección completa
{ _id: ObjectId("..."), estacion_id: 12, abono_id: 10233, puntuacion: 4,
  comentario: "Anclaje 7 muy duro", etiquetas: ["anclajes_duros"],
  ts: ISODate("2026-06-14T18:22:00Z") }

// En el documento de la estación
{ _id: 12, /* ... */
  valoraciones_resumen: {
    n: 1284, media: 4.12, suma: 5290,
    etiquetas_top: [ { t: "anclajes_duros", n: 91 }, { t: "sucia", n: 44 } ],
    ultimas: [ { puntuacion: 4, comentario: "Anclaje 7 muy duro",
                 ts: ISODate("2026-06-14T18:22:00Z") } ]   // máximo 3
  } }

db.valoraciones.createIndex({ estacion_id: 1, ts: -1 });
db.valoraciones.createIndex({ abono_id: 1, estacion_id: 1, ts: -1 });

suma se guarda además de media para poder recalcular la media incrementalmente sin recorrer 1.284 documentos.

3. La escritura, en dos operaciones:

db.valoraciones.insertOne({ estacion_id: 12, abono_id: 10233, puntuacion: 4,
    comentario: "Anclaje 7 muy duro", etiquetas: ["anclajes_duros"], ts: new Date() });

db.estaciones.updateOne(
  { _id: 12 },
  [ { $set: {
      "valoraciones_resumen.n":     { $add: [ { $ifNull: ["$valoraciones_resumen.n", 0] }, 1 ] },
      "valoraciones_resumen.suma":  { $add: [ { $ifNull: ["$valoraciones_resumen.suma", 0] }, 4 ] },
      "valoraciones_resumen.ultimas": {
        $slice: [ { $concatArrays: [
                    [ { puntuacion: 4, comentario: "Anclaje 7 muy duro", ts: "$$NOW" } ],
                    { $ifNull: ["$valoraciones_resumen.ultimas", []] } ] }, 3 ] }
  } },
    { $set: { "valoraciones_resumen.media": { $round: [ { $divide: [
        "$valoraciones_resumen.suma", "$valoraciones_resumen.n" ] }, 2 ] } } } ]
);

Dos etapas de pipeline porque la segunda necesita los valores que fija la primera. $concatArrays + $slice: 3 mantiene las tres últimas por delante y descarta el resto. No son atómicas entre sí: si falla la segunda, hay una valoración registrada que no aparece en el resumen. Se mitiga con un recálculo nocturno del resumen, que es el precio conocido de todo campo calculado en documental.

Solución 2

db.trayectos_telemetria.aggregate([
  { $match: { ts_inicio: { $gte: new Date(Date.now() - 24*3600*1000) },
              ts_fin: { $exists: true } } },
  { $set: {
      duracion_s:  { $divide: [ { $subtract: ["$ts_fin", "$ts_inicio"] }, 1000 ] },
      puntos_parado: { $size: { $filter: { input: "$puntos", cond: { $eq: ["$$this.v", 0] } } } },
      caida_bateria: { $subtract: ["$bateria.inicio_pct", "$bateria.fin_pct"] } } },
  { $set: {
      vel_media_kmh: { $cond: [ { $gt: ["$duracion_s", 0] },
                       { $multiply: [ { $divide: ["$distancia_m", "$duracion_s"] }, 3.6 ] }, 0 ] },
      minutos_parado: { $divide: [ { $multiply: ["$puntos_parado", 5] }, 60 ] } } },
  { $match: { $or: [
      { vel_media_kmh: { $gt: 35 } },
      { minutos_parado: { $gt: 20 } },
      { $and: [ { "bicicleta.tipo": "electrica" },
                { caida_bateria: { $gt: 40 } },
                { duracion_s: { $lt: 900 } } ] } ] } },
  { $project: {
      matricula: "$bicicleta.matricula", modelo: "$bicicleta.modelo",
      km: { $round: [ { $divide: ["$distancia_m", 1000] }, 2 ] },
      vel_media_kmh: { $round: ["$vel_media_kmh", 1] },
      minutos_parado: { $round: ["$minutos_parado", 0] },
      caida_bateria: 1,
      motivo: { $switch: { branches: [
          { case: { $gt: ["$vel_media_kmh", 35] },   then: "velocidad_imposible" },
          { case: { $gt: ["$minutos_parado", 20] },  then: "parada_prolongada" } ],
          default: "consumo_bateria_anomalo" } } } },
  { $sort: { vel_media_kmh: -1 } }
]);
[
  { _id: 891044, matricula: 'VB-0233', modelo: 'Norvent Urbana2', km: 18.4,
    vel_media_kmh: 47.2, minutos_parado: 1, motivo: 'velocidad_imposible' },
  { _id: 890877, matricula: 'VB-0512', modelo: 'Ciclmar E-Vall', km: 2.1,
    vel_media_kmh: 5.4, minutos_parado: 34, motivo: 'parada_prolongada' },
  { _id: 890912, matricula: 'VB-0733', modelo: 'Ciclmar E-Vall', km: 3.8,
    vel_media_kmh: 12.9, minutos_parado: 2, caida_bateria: 46,
    motivo: 'consumo_bateria_anomalo' }
]

Tres puntos a destacar: el $match inicial va primero y usa el índice sobre ts_inicio; el $filter cuenta puntos parados sin $unwind, que es la forma correcta de operar sobre arrays cuando no hace falta desplegarlos; y $switch etiqueta el motivo, respetando el mismo orden de prioridad que el $or.

Solución 3

1. Localizarlos:

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

2. Corregirlos en el servidor:

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

El pipeline de actualización permite construir el valor nuevo a partir del antiguo dentro del motor. Sin él, habría que leer 41.000 documentos, transformarlos en la aplicación y reescribirlos: unos minutos de red y de memoria para algo que aquí son dos segundos. Fíjate en que el filtro del updateMany es /^\d{4}$/ y no el $not de la consulta: hay que corregir solo los que tienen el formato antiguo conocido, no cualquier cosa que no encaje.

3. Por qué no habría pasado en el relacional, y qué lo habría evitado aquí. En 08-01 la matrícula no está copiada en ningún sitio: vive solo en bicicletas.matricula, con su CHAR(7) UNIQUE, y trayectos la alcanza por clave ajena. Un dato que existe una sola vez no puede escribirse mal en la copia, porque no hay copia. La duplicación que aquí nos da rendimiento en Q5 y Q8 es exactamente la que permitió el error.

Lo que lo habría evitado en MongoDB: un $jsonSchema en trayectos_telemetria con pattern: "^VB-[0-9]{4}$" sobre bicicleta.matricula, igual que el que sí pusimos en incidencias. La colección de incidencias estaba protegida y la de telemetría no, y el error apareció justo donde faltaba el validador. No es casualidad: es la regla.

Conclusión

Has cogido tres piezas concretas del sistema de VallBici —telemetría, ficha de estación e incidencias— y las has resuelto en MongoDB con el método de 03-03: primero las nueve consultas, después los documentos. El resultado son tres colecciones con decisiones defendibles: estaciones con referencias extendidas y subdocumentos libres, trayectos_telemetria con patrón bucket, techo de 900 puntos y campos calculados, e incidencias con $jsonSchema que valida lo común y deja libre lo específico.

Y has visto de qué se paga cada ventaja. bicicleta.modelo copiado dentro de cada documento es lo que permite agrupar por modelo sin salir del motor; también es lo que produjo 41.000 matrículas mal escritas del ejercicio 3. El patrón bucket reduce 269 millones de documentos a 1,6 millones; también obliga a vigilar un límite de 16 MB que no existía. El TTL caduca la telemetría con un índice; también borra cuando le viene bien, no cuando tú dices. En modelado documental no hay decisiones gratis: hay decisiones cuyo precio conoces y decisiones cuyo precio descubrirás en producción.

La comparación del apartado 11 es la parte que conviene recordar dentro de un año. PostgreSQL con jsonb y PostGIS gana en más criterios de los que pierde, y para un VallBici pequeño sería la respuesta correcta sin matices. Lo que justifica el segundo motor no es la elegancia del modelo documental: son 272 millones de escrituras anuales que no necesitan ni transacciones ni integridad referencial. Cuando alguien te proponga añadir una base de datos a un sistema, esa es la pregunta a hacer — ¿qué número justifica esto?— y si no hay número, no hay motivo.

Queda un problema abierto, y es el más difícil de los tres. A partir de ahora, la bicicleta 417 tiene datos en dos sitios: su matrícula, su modelo y su estado están en PostgreSQL, y también están copiados dentro de miles de documentos de MongoDB. La estación 12 tiene once columnas en PostgreSQL y una ficha de treinta campos en MongoDB. Nadie ha dicho cuál manda. Cuando las dos discrepen —que discreparán— ¿qué versión es la verdadera? ¿Con qué retraso se propaga un cambio? ¿Qué pasa si el proceso de sincronización se cae un martes por la noche? Y todavía falta meter en la ecuación a Redis, que guardará la disponibilidad en tiempo real, y a Elasticsearch, que servirá la búsqueda de estaciones por nombre y dirección.

Eso es la persistencia políglota, y es la lección 08-03: no cómo se diseña cada motor —eso ya está hecho— sino cómo se decide quién es la fuente de la verdad de cada dato, cómo se sincronizan, qué sigue funcionando cuando uno de ellos cae y cuándo conviene desmontar toda esta arquitectura y volver a una sola base de datos.

Fundamentos de Bases de Datos

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

Módulo 2: Bases de Datos Relacionales

Módulo 3: Bases de Datos No Relacionales

Módulo 4: Diseño de Esquemas

Módulo 5: Normalización

Módulo 6: Transacciones, Rendimiento y Seguridad

Módulo 7: Ejercicios Prácticos

Módulo 8: Casos de Estudio

Módulo 9: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados