Saltar a contenido

Series temporales en MongoDB

MongoDB Time Series

Series temporales

Una serie temporal es un conjunto de valores que se miden y, por lo tanto, se almacenan de forma secuencial en el tiempo. Te recomiendo que le eches un vistazo a la sesión sobre Series Temporales, donde estudiamos sus características.

Desde la versión 5.0, MongoDB permite crear un tipo de colección específica para este tipo de datos. Estas colecciones time series definen una estructura optimizada y emplean una serie de índices que reducen significativamente el espacio necesario y aceleran las consultas que se realizan sobre los datos.

Algunas características que hacen que las colecciones de series temporales sean ideales para almacenar datos provenientes de sensores IoT son:

  • Compresión automática de datos (hasta 90% de reducción de espacio)
  • Índices optimizados para consultas temporales
  • Particionado automático basado en tiempo y metadatos
  • Operaciones de inserción altamente eficientes
  • Soporte nativo para análisis de series temporales

Las colecciones de series temporales se crean de forma explícita, y no podemos convertir una colección ordinaria a serie temporal. Eso sí, podemos programar un script para migrar los datos de una colección normal a una serie temporal.

Creando una serie temporal

A la hora de crear la colección, necesitamos indicar los siguiente campos:

  • timeField: campo obligatorio que almacena el timestamp del dato. Debe ser un objeto Date(), el cual debe existir en cada documento.

¿Qué son los metadatos?

Son información descriptiva sobre la fuente de los datos que:

  • Identifica de forma única una serie de mediciones
  • Raramente o nunca cambia de valor
  • Se utiliza frecuentemente para filtrar datos
  • metaField: campo opcional que indica dónde se almacenan los metadatos que describen la serie. Este campo puede contener un documento embebido o el dato único de valor, y normalmente identifica de forma única una serie de mediciones (un sensor, por ejemplo). Debe ser un valor que raramente cambie de valor y se puede utilizar como la segunda dimensión para filtrar los datos (considerando que la primera será el tiempo). Este campo metaField va a servir para particionar los datos, de manera que aquellos documentos que tengan el mismo metaField estarán en la misma bucket, optimizando la compresión de datos, la velocidad de consulta y la organización lógica de los datos.

  • granularity: campo opcional que ayuda a MongoDB a optimizar el almacenamiento al definir la frecuencia de los datos. Sus posibles valores son seconds (valor por defecto que se aplica si no indicamos la granularidad), minutes o hours y debe configurarse al valor más cercano entre dos lectura consecutivas de datos. Por ejemplo, si realizásemos una lectura del sensor cada cinco minutos, la granularidad debería ser minutes.

  • expireAfterSeconds: campo opcional, indicando la cantidad de segundos a partir de la creación del dato que MongoDB esperará para eliminarlo automáticamente.

Por ejemplo, vamos a crear una colección llamada serietemporal indicando el campo donde estará el timestamp y el de los metadatos:

db.createCollection( "serietemporal", {
    timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
    }
})

A continuación, ya podemos añadirle datos:

db.serietemporal.insertMany( [
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T00:00:00.000Z"),
      "temp": 16
   },
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T04:00:00.000Z"),
      "temp": 14
   },
   {
      "metadata": {
         "sensorId": 1234,
         "type": "temperatura"
      },
      "timestamp": ISODate("2023-11-07T08:00:00.000Z"),
      "temp": 13
   }
])

Y obtener los datos como en una colección normal:

db.serietemporal.findOne({ "timestamp": ISODate("2023-11-07T00:00:00.000Z") })

Obteniendo el documento:

{
  timestamp: 2023-11-07T00:00:00.000Z,
  metadata: {
    sensorId: 1234,
    type: 'temperatura'
  },
  temp: 16,
  _id: ObjectId("654a35e782c4b36cdaf13b6f")
}

Si visualizamos la colección desde MongoDB Compass o MongoAtlas veremos que aparece una pequeña leyenda informando que la colección es de tipo time-series:

Colección de tipo Time-Series

Modelado de datos IoT

El diseño correcto del campo metadata es fundamental para obtener el máximo rendimiento de las colecciones de series temporales. MongoDB agrupa internamente los datos en buckets basándose en el metaField y el tiempo, por lo que una mala elección puede degradar significativamente el rendimiento.

En el campo metadata, debemos incluir información como:

  1. Identificadores únicos y estables: sensorId, deviceId, mac_address, UUIDs de dispositivos, etc...

  2. Información de ubicación (si es estática): edificio, planta, sala, o bien las coordenadas geográficas fijas, la zona o región, etc...

  3. Clasificación del sensor: su tipo (temperatura, humedad, presion), categoría o familia del dispositivo, versión del firmware (si no cambia frecuentemente).

  4. Contexto organizacional: identificador del cliente o proyecto, departamento, área, entorno (producción, desarrollo, etc...)

Por ejemplo, un buen diseño del campo metadata podría ser:

{
  "timestamp": ISODate("2025-01-30T10:15:00.000Z"),
  "metadata": {
    "sensorId": "ESP32-001",
    "localizacion": {
      "edificio": "principal",
      "planta": 2,
      "zona": "linea-3"
    },
    "tipo": "entorno",
    "proyecto": "h2v"
  },
  "temperatura": 23.5,
  "humedad": 45.2,
  "presion": 1013.25,
  "co2": 450
}

Dicho esto, es igualmente importante saber qué NO debe ir en el campo metadata:

  1. Valores que cambian frecuentemente, como el estado del dispositivo (online, offline), estado de la batería, contadores de eventos, etc...

  2. Mediciones o valores de sensores que varían con cada lectura, como lecturas de temperatura, humedad, etc.

  3. Campos que nunca se filtran o consultan, como información de debugging no utilizada, campos derivados calculables.

  4. Arrays o listas grandes que pueden afectar la compresión y el rendimiento.

En este caso, un mal diseño del campo metadata sería:

{
  "timestamp": ISODate("2025-01-30T10:15:00.000Z"),
  "metadata": {
    "sensorId": "ESP32-001",
    "bateria": 87,              // Mal - Cambia frecuentemente
    "estado": "online",         // Mal - Cambia frecuentemente
    "ultimaLectura": 23.5,      // Mal - Es una medición
  },
  "temperatura": 23.5
}

¿Dónde colocar los metadatos?

Para decidir qué campos deben ir en el metaField, es conveniente que te hagas las siguientes preguntas sobre cada campo candidato:

Pregunta Si la respuesta es... Entonces...
¿Este valor cambia en cada lectura? Campo de medición
¿Filtro frecuentemente por este campo? Considerar metadata
¿Identifica de forma única una serie? Metadata
¿Es un valor numérico de sensor? Campo de medición
¿Cambia menos de una vez al mes? Candidato para metadata

Cardinalidad del metaField

Entendemos como cardinalidad del metaField al número de valores únicos diferentes que puede tener el metaField.

Su impacto en el rendimiento es crucial:

  • Cardinalidad baja (100-1000 valores únicos): Excelente compresión, consultas muy rápidas
  • Cardinalidad media (1000-10000): Buen rendimiento general
  • Cardinalidad alta (>100000): Puede afectar la compresión y el rendimiento

Ejemplo de cardinalidades:

// Cardinalidad baja (recomendado) - ~100 sensores
"metadata": {
  "sensorId": "sensor_001",   // 100 sensores diferentes
  "building": "A"             // 3 edificios
}

// Cardinalidad media (aceptable) - ~5000 dispositivos
"metadata": {
  "deviceId": "device_0001",  // 5000 dispositivos
  "customerId": "cust_123"    // 50 clientes
}

// Cardinalidad alta (evitar) - millones de combinaciones
"metadata": {
  "userId": "user_123456",    // Millones de usuarios
  "sessionId": "sess_xyz",    // Cada sesión única
  "requestId": "req_abc"      // Cada petición es única
}

Si tenemos una cardinalidad muy alta, podemos considerar las siguientes opciones:

  1. Agregar los datos antes de insertarlos.
  2. Usar una colección normal en lugar de una de tipo time series.
  3. Rediseñar el modelo para reducir la cardinalidad.

Casos de uso comunes

Algunos ejemplos típicos de datos IoT que se pueden modelar con series temporales en MongoDB son:

  • Caso 1: Red de sensores ambientales

    {
      "timestamp": ISODate("2025-01-30T10:00:00.000Z"),
      "metadata": {
        "sensorId": "ENV-ALI-001",
        "location": {
          "city": "Alicante",
          "region": "Valencia",
          "coords": {
            "lat": 38.3452,
            "lon": -0.4815
          }
        },
        "type": "exterior"
      },
      "temperature": {
        "celsius": 18.5,
        "fahrenheit": 65.3
      },
      "humidity": 62.3,
      "pressure": 1015.2,
      "windSpeed": 12.5,
      "windDirection": 245
    }
    
  • Caso 2: Monitorización de maquinaria industrial

    {
      "timestamp": ISODate("2025-01-30T10:00:00.000Z"),
      "metadata": {
        "machineId": "CNC-002",
        "facility": "planta-elche",
        "line": "production-A",
        "machineType": "cnc-mill"
      },
      "vibration": {
        "x": 0.045,
        "y": 0.038,
        "z": 0.052
      },
      "temperature": 45.2,
      "rpm": 3500,
      "power": 5.8,
      "status": "operating"
    }
    
  • Caso 3: Datos financieros (bolsa)

    {
      "timestamp": ISODate("2025-01-30T09:30:00.000Z"),
      "metadata": {
        "symbol": "AAPL",
        "exchange": "NASDAQ",
        "currency": "USD",
        "assetClass": "equity"
      },
      "open": 189.71,
      "high": 192.45,
      "low": 189.23,
      "close": 191.82,
      "volume": 52340000,
      "vwap": 190.87
    }
    
  • Caso 4: Domótica y sensores inteligentes

    {
      "timestamp": ISODate("2025-01-30T10:00:00.000Z"),
      "metadata": {
        "deviceId": "thermostat-living-room",
        "home": "house-123",
        "room": "living-room",
        "deviceType": "smart-thermostat"
      },
      "currentTemp": 21.5,
      "targetTemp": 22.0,
      "humidity": 45,
      "hvacMode": "heating",
      "hvacState": "on",
      "fanMode": "auto"
    }
    

Consideraciones

Las colecciones de series temporales crean y utilizan un índice interno. Además, si fuera necesario, podemos crear índices adicionales, a los que se les conoce como índices secundarios.

Las colecciones de series temporales se asocian a casos de uso donde los datos se insertan una vez, se leen muchas veces y raramente se actualizan.

Es por ello, que las operaciones de modificación y borrado en las series temporales necesitan del operador {multi:true} y {justOne:false} para aplicarse sobre múltiples documentos, y la consulta a modificar o borrar sólo debe hacerlo sobre los campos del subdocumento del atributo metaField. Así pues, sólo podemos modificar el subdocumento del campo metaField y no podemos emplear una operación upsert.

Cuidado

El tamaño máximo de un documento en una serie temporal es de 4MB, no los 16 de un documento ordinario.

Respecto al particionado, podemos emplear timeField o metaField con claves. Si utilizamos el timeField, al tratarse de un timestamp, todas las escrituras irán a la última partición. Por lo tanto, es una buena práctica combinar el timeField con uno o más campos del metaField.

Migrando datos

En los datos de ejemplo cargados en MongoDB, tenemos una colección con datos climáticos. Por ejemplo, uno de los documentos de sample_weatherdata.data es similar a:

{
  "_id": {
    "$oid": "5553a998e4b02cf7151190b8"
  },
  "st": "x+47600-047900",
  "ts": {
    "$date": "1984-03-05T13:00:00.000Z"
  },
  "position": {
    "type": "Point",
    "coordinates": [
      -47.9,
      47.6
    ]
  },
  "elevation": 9999,
  "callLetters": "VCSZ",
  "qualityControlProcess": "V020",
  "dataSource": "4",
  "type": "FM-13",
  "airTemperature": {
    "value": -3.1,
    "quality": "1"
  },
  "dewPoint": {
    "value": 999.9,
    "quality": "9"
  },
  "pressure": {
    "value": 1015.3,
    "quality": "1"
  },
  "wind": {
    "direction": {
      "angle": 999,
      "quality": "9"
    },
    "type": "9",
    "speed": {
      "rate": 999.9,
      "quality": "9"
    }
  },
  "visibility": {
    "distance": {
      "value": 999999,
      "quality": "9"
    },
    "variability": {
      "value": "N",
      "quality": "9"
    }
  },
  "skyCondition": {
    "ceilingHeight": {
      "value": 99999,
      "quality": "9",
      "determination": "9"
    },
    "cavok": "N"
  },
  "sections": [
    "AG1"
  ],
  "precipitationEstimatedObservation": {
    "discrepancy": "2",
    "estimatedWaterDepth": 999
  }
}

Por ejemplo, si quisiéramos trabajar con los datos de la temperatura del aire y colocarlos dentro de una serie temporal, el primero paso sería crear la colección

use iabd
db.createCollection( "ts_weather", {
    timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
    }
})

Y a continuación, mediante el framework de agregación, migramos los datos que nos interesen. Por ejemplo, nos vamos a traer la temperatura del aire y la presión, así como algunos metadatos del sensor para aquellas muestras que no han superado los 100 grados de temperatura (así podríamos filtrar algunos outliers):

A partir de la versión 7.0.3 podemos emplear el operador $out para exportar los datos a una colección de tipo time series:

use sample_weatherdata
db.data.aggregate([
  { $match: {
      "airTemperature.value": {$lt: 100}
    }
  },
  { $project: {
      "timestamp": "$ts",
      "metadata": {
        "posicion" : "$position",
        "elevacion" : "$elevation",
        "tipo": "$type"
      }, 
      "temp_aire": "$airTemperature.value",
      "presion": "$pressure.value"
    }
  },
  { $out: {
      db: "iabd",
      coll: "ts_weather",
      timeseries: {
        timeField: "timestamp",
        metaField: "metadata"
      }
    }
  }
])

Si no, primero crearemos una colección únicamente con los datos deseados:

use sample_weatherdata
db.data.aggregate([
  { $match: {
      "airTemperature.value": {$lt: 100}
    }
  },
  { $project: {
      "timestamp": "$ts",
      "metadata": {
        "posicion" : "$position",
        "elevacion" : "$elevation",
        "tipo": "$type"
      }, 
      "temp_aire": "$airTemperature.value",
      "presion": "$pressure.value"
    }
  },
  { $out: "ts_weather"}
])

Y a continuación, debemos exportar los datos y volver a importarlos. Así pues, exportamos mediante mongodump desde sample_weatherdata.ts_weather:

mongodump --uri="mongodb+srv://usuario:password@host/sample_weatherdata" \
  --collection=ts_weather

e importamos con mongorestore en iabd.ts_weather (que es la colección de tipo serie temporal que hemos creado en un paso anterior):

mongorestore --uri="mongodb+srv://usuario:password@host/iabd" \
  --collection=ts_weather --noIndexRestore --maintainInsertionOrder \
  dump/sample_weatherdata/ts_weather.bson

Si cambiamos a la base de datos de iabd, podemos observar cómo se ha rellenado la colección de la serie temporal:

> use iabd
< switched to db iabd
> db.ts_weather.findOne()
< {
    timestamp: 1984-03-05T12:00:00.000Z,
    metadata: {
      elevacion: 9999,
      posicion: {
        coordinates: [
          14,
          36.6
        ],
        type: 'Point'
      },
      tipo: 'FM-13'
    },
    _id: ObjectId("5553a998e4b02cf715119936"),
    temp_aire: 13,
    presion: 1008.5
  }

Agregando datos

Otros operadores de fecha

Además de $dateToParts, podemos emplear los operadores $hour, $week, $dayOfYear, $year, etc... sobre un campo de tipo fecha / timestamp.

Es muy normal agrupar los datos en rangos de fechas sobre los que realizaremos cálculos. Para ello, podemos utilizar el operador $dateToParts el cual recibe un campo date con un valor de tipo fecha/timestamp, y crea un documento con los diferentes campos por separado (año, mes, día, etc... ) mediante las propiedades year, month, day, hour, minute, second y millisecond. Además, si lo necesitamos, podemos configurar también su timezone.

Por ejemplo, sobre la colección recién importada, vamos a agrupar los datos por días y calcularemos la temperatura media del aire:

db.ts_weather.aggregate( [
   {
      $project: {
         date: {
            $dateToParts: { date: "$timestamp" }
         },
         temp_aire: 1
      }
   },
   {
      $group: {
         _id: {
            date: {
               year: "$date.year",
               month: "$date.month",
               day: "$date.day"
            }
         },
         avgTmp: { $avg: "$temp_aire" }
      }
   }
] )

Obteniendo como resultado documentos similares a:

{
  _id: {
    date: {
      year: 1984,
      month: 3,
      day: 10
    }
  },
  avgTmp: 68.94788047255038
}, 
{
  _id: {
    date: {
      year: 1984,
      month: 3,
      day: 12
    }
  },
  avgTmp: 65.79275964391691
}
...

Creando un gráfico

Si estamos trabajando con MongoAtlas, podemos hacer uso de Graphs y crear diferentes gráficos a modo de cuadro de mandos sobre la información almacenada en nuestras colecciones.

Más información en https://www.mongodb.com/docs/charts/

Referencias