<!-- Canonical: https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx
Language: es
Content-SHA256: 82783e8beb484c708acc3a0c47e60f90d2c4c68e77fbc05567a7d5d5add31a10
-->

Databases

# Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx

2026-01-108 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/time-series-databases-timescaledb-questdb-influx>)

## El Problema con las Bases de Datos de Propósito General para Series Temporales

Los datos de series temporales tienen características específicas que los hacen hostiles a los RDBMS tradicionales:

1. **Escrituras append-mostly**: Los datos llegan cronológicamente y rara vez se actualizan.
2. **Dominan las queries por rango temporal**: "Dame la última hora de datos" es el 95% de las queries.
3. **Cardinalidad masiva**: 50K sensores x 4 métricas x 1 muestra/seg = 200K filas/seg.
4. **Los datos viejos pierden valor**: La última hora es hot, el último mes es warm, el último año es cold.

Los índices B-tree de PostgreSQL están diseñados para acceso aleatorio. Cuando teners 4 mil millones de filas ordenadas por tiempo, insertar datos nuevos al final del índice mientras consulta la partición más reciente genera una contención de I/O enorme.

## TimescaleDB: La Extensión de Series Temporales de PostgreSQL

[TimescaleDB](<https://www.timescaledb.com/>) es la elección pragmática. Es una extensión de PostgreSQL, así que se conserva con tu SQL, tus librerías de conexión, tus JOINs con datos relacionales, tus herramientas de backup, todo. La magia está en las hypertables.

### Hypertables y Chunks

Una hypertable parece una tabla PostgreSQL normal, pero por abajo está automáticamente particionada en chunks por intervalo temporal:

```sql
-- Crear la hypertable
CREATE TABLE sensor_data (
 time TIMESTAMPTZ NOT NULL,
 sensor_id TEXT NOT NULL,
 temperature DOUBLE PRECISION,
 humidity DOUBLE PRECISION,
 pressure DOUBLE PRECISION,
 vibration DOUBLE PRECISION
);

-- Convertir a hypertable con chunks de 6 horas
SELECT create_hypertable('sensor_data', 'time',
 chunk_time_interval => INTERVAL '6 hours'
);

-- Agregar partición espacial para inserts paralelos
SELECT add_dimension('sensor_data', 'sensor_id',
 number_partitions => 16
);

-- Política de compresión: comprimir chunks de más de 24 horas
ALTER TABLE sensor_data SET (
 timescaledb.compress,
 timescaledb.compress_segmentby = 'sensor_id',
 timescaledb.compress_orderby = 'time DESC'
);

SELECT add_compression_policy('sensor_data', INTERVAL '24 hours');

-- Política de retención: dropear chunks de más de 90 días
SELECT add_retention_policy('sensor_data', INTERVAL '90 days');
```

El chunk time interval es crítico. Cada chunk es su propia tabla PostgreSQL con sus propios índices y su propio storage. Cuando consulta ` WHERE time > now() - INTERVAL '1 hour' `, TimescaleDB solo toca los chunks relevantes. En un deploy de 50K sensores, eso convirtió un full table scan de 4 mil millones de filas en un scan de unas 720K filas.

### Continuous Aggregates

Esta es la característica más destacada de TimescaleDB para dashboards. En vez de recomputar agregados en cada query, los materializás:

```sql
-- Crear una vista materializada que se auto-refresca
CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT
 time_bucket('1 hour', time) AS bucket,
 sensor_id,
 avg(temperature) AS avg_temp,
 min(temperature) AS min_temp,
 max(temperature) AS max_temp,
 avg(humidity) AS avg_humidity,
 count(*) AS sample_count
FROM sensor_data
GROUP BY bucket, sensor_id;

-- Política de refresh: recomputar las últimas 2 horas cada 30 minutos
SELECT add_continuous_aggregate_policy('sensor_hourly',
 start_offset => INTERVAL '2 hours',
 end_offset => INTERVAL '30 minutes',
 schedule_interval => INTERVAL '30 minutes'
);

-- Consultar el agregado en vez de los datos crudos
SELECT bucket, avg_temp, min_temp, max_temp
FROM sensor_hourly
WHERE sensor_id = 'sensor-42'
 AND bucket > now() - INTERVAL '7 days'
ORDER BY bucket;
```

Queries de dashboard que tardaban 8 segundos sobre datos crudos ahora devuelven en 12ms. No es un error de tipeo.

## QuestDB: Construida para Velocidad de Ingesta

[QuestDB](<https://questdb.io/>) es una base de series temporales columnar escrita en Java y C++. Habla PostgreSQL wire protocol y InfluxDB Line Protocol, pero internamente es un motor completamente distinto optimizado para cargas de trabajo append-only.

### El Pipeline de Ingesta

La interfaz de InfluxDB Line Protocol (ILP) de QuestDB sobre TCP es la forma más rápida de ingestar datos:

```python
# questdb_ingest.py - ingesta de alto throughput
from questdb.ingress import Sender, IngressError, TimestampNanos
import time
import random

def generate_sensor_batch(batch_size: int, sensors: list[str]):
 now_ns = time.time_ns()
 readings = []
 for i in range(batch_size):
 readings.append({
 'sensor_id': random.choice(sensors),
 'temperature': 20.0 + random.gauss(0, 5),
 'humidity': 50.0 + random.gauss(0, 10),
 'pressure': 1013.25 + random.gauss(0, 2),
 'vibration': abs(random.gauss(0, 0.5)),
 'timestamp': now_ns + (i * 1_000_000)
 })
 return readings

def ingest_batch(sender: Sender, readings: list[dict]):
 for r in readings:
 sender.row(
 'sensor_data',
 symbols={'sensor_id': r['sensor_id']},
 columns={
 'temperature': r['temperature'],
 'humidity': r['humidity'],
 'pressure': r['pressure'],
 'vibration': r['vibration'],
 },
 at=TimestampNanos(r['timestamp'])
 )
 sender.flush()

sensors = [f'sensor-{i:05d}' for i in range(50_000)]
conf = 'http::addr=localhost:9000;auto_flush_rows=50000;auto_flush_interval=1000;'
with Sender.from_conf(conf) as sender:
 while True:
 batch = generate_sensor_batch(50_000, sensors)
 ingest_batch(sender, batch)
```

### Extensiones SQL de QuestDB

QuestDB agrega sintaxis SQL específica para series temporales que es genuinamente útil:

```sql
-- SAMPLE BY: agregación por buckets temporales (como time_bucket pero nativo)
SELECT
 sensor_id,
 avg(temperature) avg_temp,
 min(temperature) min_temp,
 max(temperature) max_temp,
 count() sample_count
FROM sensor_data
WHERE sensor_id = 'sensor-00042'
SAMPLE BY 1h
ORDER BY timestamp;

-- LATEST ON: obtener el valor más reciente por sensor (extremadamente rápido)
SELECT * FROM sensor_data
LATEST ON timestamp PARTITION BY sensor_id;

-- ASOF JOIN: join de series temporales sin timestamps exactos
SELECT
 s.timestamp,
 s.sensor_id,
 s.temperature,
 a.alert_type,
 a.threshold
FROM sensor_data s
ASOF JOIN alerts a ON (s.sensor_id = a.sensor_id);
```

La query ` LATEST ON ` vale la pena por sí sola. Obtener el estado actual de 50K sensores en PostgreSQL requiere o un subquery con ` DISTINCT ON ` (lento) o mantener una tabla separada de "estado actual" (complejo). QuestDB lo hace en milisegundos de un dígito porque trackea la última fila por partición nativamente.

## InfluxDB IOx: La Reescritura del Motor de Almacenamiento

InfluxDB 3.x (motor IOx) es una reescritura desde cero en Rust usando Apache Arrow y DataFusion. Reemplaza el motor de storage TSM con un sistema columnar basado en Parquet.

```python
# influxdb3_ingest.py
from influxdb_client_3 import InfluxDBClient3
import numpy as np

client = InfluxDBClient3(
 host="https://us-east-1-1.aws.cloud2.influxdata.com",
 token="your-token",
 org="your-org",
 database="iot_pipeline"
)

# Query con SQL (respaldado por DataFusion)
df = client.query("""
 SELECT
 date_bin('1 hour', time) AS bucket,
 sensor_id,
 avg(temperature) AS avg_temp,
 approx_percentile_cont(temperature, 0.95) AS p95_temp,
 count(*) AS samples
 FROM sensor_data
 WHERE time > now() - INTERVAL '24 hours'
 AND sensor_id IN ('sensor-00001', 'sensor-00042', 'sensor-01337')
 GROUP BY bucket, sensor_id
 ORDER BY bucket DESC
""").to_pandas()
```

Lo destacado de IOx es ` approx_percentile_cont `. Computar percentiles sobre datos de series temporales es algo que se necesita todo el tiempo para monitoreo de SLOs, pero es caro en bases de datos tradicionales. IOx usa t-digest sketches por debajo, dándote percentiles aproximados en memoria constante.

## El Benchmark: 1M Inserts/seg

Se armó un benchmark en tres instancias idénticas [AWS](<https://aws.com/>) r6g.2xlarge (8 vCPU, 64GB RAM, gp3 SSD a 16,000 IOPS):

```
Ingesta sostenida (200K filas/seg, test de 24h):
┌──────────────┬─────────────┬───────────┬────────────┬─────────────┐
│ Base │ Latencia Avg│ p99 Lat │ Uso CPU │ Escritura │
├──────────────┼─────────────┼───────────┼────────────┼─────────────┤
│ TimescaleDB │ 4.2ms/batch │ 18ms │ 45% │ 380 MB/hr │
│ QuestDB │ 1.1ms/batch │ 4.8ms │ 28% │ 210 MB/hr │
│ InfluxDB IOx │ 3.8ms/batch │ 22ms │ 38% │ 290 MB/hr │
└──────────────┴─────────────┴───────────┴────────────┴─────────────┘

Ingesta en ráfaga (1M filas/seg, ráfaga de 10 min):
┌──────────────┬──────────────┬─────────────┬──────────────────────┐
│ Base │ ¿Sostenido? │ Pico Real │ Notas │
├──────────────┼──────────────┼─────────────┼──────────────────────┤
│ TimescaleDB │ Sí (c/ tuning)│ 1.05M │ Necesitó 16 space │
│ │ │ │ partitions + 8 │
│ │ │ │ writers paralelos │
│ QuestDB │ Sí │ 1.4M │ Nodo único, modo WAL │
│ │ │ │ sin tuning especial │
│ InfluxDB IOx │ Casi │ 920K │ Presión de compaction│
│ │ │ │ a ~950K │
└──────────────┴──────────────┴─────────────┴──────────────────────┘
```

QuestDB ganó el benchmark de ingesta cruda por un margen amplio. En un solo nodo sin configuración especial más allá de habilitar modo WAL, sostuvo 1.4M inserts/seg.

### Performance de Queries

La velocidad de ingesta es solo la mitad de la historia:

```
Query: "Temperatura promedio por sensor, última 1 hora"
┌──────────────┬──────────────┬───────────────────────────┐
│ Base │ Tiempo Resp │ Notas │
├──────────────┼──────────────┼───────────────────────────┤
│ TimescaleDB │ 340ms │ Usando continuous aggregate│
│ │ 2.8s │ Query crudo, sin agregado │
│ QuestDB │ 180ms │ SAMPLE BY, nativo │
│ InfluxDB IOx │ 220ms │ DataFusion, scan columnar │
└──────────────┴──────────────┴───────────────────────────┘

Query: "Último valor por sensor (50K sensores)"
┌──────────────┬──────────────┬───────────────────────────┐
│ Base │ Tiempo Resp │ Notas │
├──────────────┼──────────────┼───────────────────────────┤
│ TimescaleDB │ 4.2s │ DISTINCT ON, doloroso │
│ QuestDB │ 8ms │ LATEST ON, instantáneo │
│ InfluxDB IOx │ 1.8s │ GROUP BY + last() │
└──────────────┴──────────────┴───────────────────────────┘
```

Ese resultado de ` LATEST ON ` de QuestDB es real. 8ms para obtener el estado actual de 50,000 sensores de 17 mil millones de filas.

## El Pipeline en Producción

Así queda la arquitectura final para un proyecto IoT representativo:

```
Sensores (50K)
 → MQTT Broker (EMQX cluster)
 → Kafka (3 nodos, 12 particiones)
 → QuestDB (ingesta, datos hot, últimos 7 días)
 → TimescaleDB (analytics, datos warm, últimos 90 días, continuous aggregates)
 → Export a S3 Parquet (datos cold, archivo histórico)
```

QuestDB maneja los datos hot (últimos 7 días) por su velocidad de ingesta y la performance de ` LATEST ON ` para dashboards en tiempo real. TimescaleDB manejó la capa analítica con continuous aggregates alimentando dashboards de Grafana. Los datos viejos se exportaban a Parquet en S3 por eficiencia de costos.

## Qué Sería Diferente Hoy

Arrancando este proyecto hoy, valdría la pena considerar seriamente correr solo QuestDB con sus features enterprise más nuevos para el hot path, y usar InfluxDB IOx para la capa analítica ya que el motor DataFusion maneja analytics complejos mejor que el subset de SQL de QuestDB. TimescaleDB sigue siendo la recomendación cuando se necesitan joins con datos relacionales, es PostgreSQL al fin y al cabo, pero para cargas de trabajo puramente de series temporales, los motores de propósito específico tienen una ventaja clara de performance.

En un deploy, el pipeline lleva catorce meses corriendo sin un incidente mayor. El DBA duerme toda la noche. Ese es el mejor benchmark que existe.

## Preguntas frecuentes

### ¿Puedo usar PostgreSQL en vez de una base de series temporales dedicada?

Para volúmenes bajos (menos de 10K inserts/seg), PostgreSQL con particionamiento e índices adecuados anda bien. TimescaleDB es literalmente PostgreSQL con extensiones, así que no se está abandonando el ecosistema. Pero cuando llega a tasas altas de ingesta, los motores de almacenamiento columnar de QuestDB e InfluxDB IOx genuinamente superan a los stores orientados a filas.

### ¿Cuál base de series temporales es mejor para IoT?

QuestDB por velocidad de ingesta bruta en un solo nodo. TimescaleDB si se necesita compatibilidad SQL y el ecosistema PostgreSQL. InfluxDB si tu equipo ya usa el TICK stack o se necesita downsampling y políticas de retención built-in.

### ¿Cómo manejo el backfill de datos históricos?

TimescaleDB lo maneja nativamente ya que los chunks de hypertable son solo particiones. QuestDB requiere que los datos estén aproximadamente ordenados por tiempo para performance óptima, utilizar el setting de commit lag 'o3' (out-of-order). InfluxDB IOx maneja bien escrituras fuera de orden pero a costa de más trabajo de compactación.

## Tips

1. **Tip:** El ` chunk_time_interval ` de TimescaleDB debería matchear tu rango de query más común. Si consulta las últimas 24h mayormente, establecer chunks de 12h, no de 1 semana.
2. **Tip:** El modo ` WAL ` de QuestDB es obligatorio para escrituras concurrentes. Sin él, se está limitado a un solo thread de escritura. Establecer ` cairo.wal.enabled.default=true ` en server.conf.
3. **Tip:** No utilizar tags de InfluxDB para campos de alta cardinalidad. Si tu tag tiene más de 100K valores únicos, debería ser un field. Este solo error mató más deploys de InfluxDB que cualquier otra cosa.
4. **Tip:** Para las tres bases, agrupar tus inserts. Pasar de fila por fila a batches de 5000 típicamente te da una mejora de throughput de ` 50-100x `.

- time-series
- timescaledb
- questdb
- influxdb
- iot
- data-pipeline
- performance

[Volver al Blog](<https://lucioduran.com/es/blog>)
