{"schemaVersion":1,"id":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx","title":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","description":"Armando un pipeline real de IoT que banca 1M inserts/seg, comparando TimescaleDB hypertables, el motor columnar de QuestDB e InfluxDB IOx con benchmarks reales y trampas de producción.","language":"es","datePublished":"2026-01-10","dateModified":null,"contentFormat":"text/markdown","content":"Databases\n\n# Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx\n\n2026-01-108 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/time-series-databases-timescaledb-questdb-influx>)\n\n## El Problema con las Bases de Datos de Propósito General para Series Temporales\n\nLos datos de series temporales tienen características específicas que los hacen hostiles a los RDBMS tradicionales:\n\n1. **Escrituras append-mostly**: Los datos llegan cronológicamente y rara vez se actualizan.\n2. **Dominan las queries por rango temporal**: \"Dame la última hora de datos\" es el 95% de las queries.\n3. **Cardinalidad masiva**: 50K sensores x 4 métricas x 1 muestra/seg = 200K filas/seg.\n4. **Los datos viejos pierden valor**: La última hora es hot, el último mes es warm, el último año es cold.\n\nLos í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.\n\n## TimescaleDB: La Extensión de Series Temporales de PostgreSQL\n\n[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.\n\n### Hypertables y Chunks\n\nUna hypertable parece una tabla PostgreSQL normal, pero por abajo está automáticamente particionada en chunks por intervalo temporal:\n\n```sql\n-- Crear la hypertable\nCREATE TABLE sensor_data (\n time TIMESTAMPTZ NOT NULL,\n sensor_id TEXT NOT NULL,\n temperature DOUBLE PRECISION,\n humidity DOUBLE PRECISION,\n pressure DOUBLE PRECISION,\n vibration DOUBLE PRECISION\n);\n\n-- Convertir a hypertable con chunks de 6 horas\nSELECT create_hypertable('sensor_data', 'time',\n chunk_time_interval => INTERVAL '6 hours'\n);\n\n-- Agregar partición espacial para inserts paralelos\nSELECT add_dimension('sensor_data', 'sensor_id',\n number_partitions => 16\n);\n\n-- Política de compresión: comprimir chunks de más de 24 horas\nALTER TABLE sensor_data SET (\n timescaledb.compress,\n timescaledb.compress_segmentby = 'sensor_id',\n timescaledb.compress_orderby = 'time DESC'\n);\n\nSELECT add_compression_policy('sensor_data', INTERVAL '24 hours');\n\n-- Política de retención: dropear chunks de más de 90 días\nSELECT add_retention_policy('sensor_data', INTERVAL '90 days');\n```\n\nEl 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.\n\n### Continuous Aggregates\n\nEsta es la característica más destacada de TimescaleDB para dashboards. En vez de recomputar agregados en cada query, los materializás:\n\n```sql\n-- Crear una vista materializada que se auto-refresca\nCREATE MATERIALIZED VIEW sensor_hourly\nWITH (timescaledb.continuous) AS\nSELECT\n time_bucket('1 hour', time) AS bucket,\n sensor_id,\n avg(temperature) AS avg_temp,\n min(temperature) AS min_temp,\n max(temperature) AS max_temp,\n avg(humidity) AS avg_humidity,\n count(*) AS sample_count\nFROM sensor_data\nGROUP BY bucket, sensor_id;\n\n-- Política de refresh: recomputar las últimas 2 horas cada 30 minutos\nSELECT add_continuous_aggregate_policy('sensor_hourly',\n start_offset => INTERVAL '2 hours',\n end_offset => INTERVAL '30 minutes',\n schedule_interval => INTERVAL '30 minutes'\n);\n\n-- Consultar el agregado en vez de los datos crudos\nSELECT bucket, avg_temp, min_temp, max_temp\nFROM sensor_hourly\nWHERE sensor_id = 'sensor-42'\n AND bucket > now() - INTERVAL '7 days'\nORDER BY bucket;\n```\n\nQueries de dashboard que tardaban 8 segundos sobre datos crudos ahora devuelven en 12ms. No es un error de tipeo.\n\n## QuestDB: Construida para Velocidad de Ingesta\n\n[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.\n\n### El Pipeline de Ingesta\n\nLa interfaz de InfluxDB Line Protocol (ILP) de QuestDB sobre TCP es la forma más rápida de ingestar datos:\n\n```python\n# questdb_ingest.py - ingesta de alto throughput\nfrom questdb.ingress import Sender, IngressError, TimestampNanos\nimport time\nimport random\n\ndef generate_sensor_batch(batch_size: int, sensors: list[str]):\n now_ns = time.time_ns()\n readings = []\n for i in range(batch_size):\n readings.append({\n 'sensor_id': random.choice(sensors),\n 'temperature': 20.0 + random.gauss(0, 5),\n 'humidity': 50.0 + random.gauss(0, 10),\n 'pressure': 1013.25 + random.gauss(0, 2),\n 'vibration': abs(random.gauss(0, 0.5)),\n 'timestamp': now_ns + (i * 1_000_000)\n })\n return readings\n\ndef ingest_batch(sender: Sender, readings: list[dict]):\n for r in readings:\n sender.row(\n 'sensor_data',\n symbols={'sensor_id': r['sensor_id']},\n columns={\n 'temperature': r['temperature'],\n 'humidity': r['humidity'],\n 'pressure': r['pressure'],\n 'vibration': r['vibration'],\n },\n at=TimestampNanos(r['timestamp'])\n )\n sender.flush()\n\nsensors = [f'sensor-{i:05d}' for i in range(50_000)]\nconf = 'http::addr=localhost:9000;auto_flush_rows=50000;auto_flush_interval=1000;'\nwith Sender.from_conf(conf) as sender:\n while True:\n batch = generate_sensor_batch(50_000, sensors)\n ingest_batch(sender, batch)\n```\n\n### Extensiones SQL de QuestDB\n\nQuestDB agrega sintaxis SQL específica para series temporales que es genuinamente útil:\n\n```sql\n-- SAMPLE BY: agregación por buckets temporales (como time_bucket pero nativo)\nSELECT\n sensor_id,\n avg(temperature) avg_temp,\n min(temperature) min_temp,\n max(temperature) max_temp,\n count() sample_count\nFROM sensor_data\nWHERE sensor_id = 'sensor-00042'\nSAMPLE BY 1h\nORDER BY timestamp;\n\n-- LATEST ON: obtener el valor más reciente por sensor (extremadamente rápido)\nSELECT * FROM sensor_data\nLATEST ON timestamp PARTITION BY sensor_id;\n\n-- ASOF JOIN: join de series temporales sin timestamps exactos\nSELECT\n s.timestamp,\n s.sensor_id,\n s.temperature,\n a.alert_type,\n a.threshold\nFROM sensor_data s\nASOF JOIN alerts a ON (s.sensor_id = a.sensor_id);\n```\n\nLa 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.\n\n## InfluxDB IOx: La Reescritura del Motor de Almacenamiento\n\nInfluxDB 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.\n\n```python\n# influxdb3_ingest.py\nfrom influxdb_client_3 import InfluxDBClient3\nimport numpy as np\n\nclient = InfluxDBClient3(\n host=\"https://us-east-1-1.aws.cloud2.influxdata.com\",\n token=\"your-token\",\n org=\"your-org\",\n database=\"iot_pipeline\"\n)\n\n# Query con SQL (respaldado por DataFusion)\ndf = client.query(\"\"\"\n SELECT\n date_bin('1 hour', time) AS bucket,\n sensor_id,\n avg(temperature) AS avg_temp,\n approx_percentile_cont(temperature, 0.95) AS p95_temp,\n count(*) AS samples\n FROM sensor_data\n WHERE time > now() - INTERVAL '24 hours'\n AND sensor_id IN ('sensor-00001', 'sensor-00042', 'sensor-01337')\n GROUP BY bucket, sensor_id\n ORDER BY bucket DESC\n\"\"\").to_pandas()\n```\n\nLo 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.\n\n## El Benchmark: 1M Inserts/seg\n\nSe armó un benchmark en tres instancias idénticas [AWS](<https://aws.com/>) r6g.2xlarge (8 vCPU, 64GB RAM, gp3 SSD a 16,000 IOPS):\n\n```\nIngesta sostenida (200K filas/seg, test de 24h):\n┌──────────────┬─────────────┬───────────┬────────────┬─────────────┐\n│ Base │ Latencia Avg│ p99 Lat │ Uso CPU │ Escritura │\n├──────────────┼─────────────┼───────────┼────────────┼─────────────┤\n│ TimescaleDB │ 4.2ms/batch │ 18ms │ 45% │ 380 MB/hr │\n│ QuestDB │ 1.1ms/batch │ 4.8ms │ 28% │ 210 MB/hr │\n│ InfluxDB IOx │ 3.8ms/batch │ 22ms │ 38% │ 290 MB/hr │\n└──────────────┴─────────────┴───────────┴────────────┴─────────────┘\n\nIngesta en ráfaga (1M filas/seg, ráfaga de 10 min):\n┌──────────────┬──────────────┬─────────────┬──────────────────────┐\n│ Base │ ¿Sostenido? │ Pico Real │ Notas │\n├──────────────┼──────────────┼─────────────┼──────────────────────┤\n│ TimescaleDB │ Sí (c/ tuning)│ 1.05M │ Necesitó 16 space │\n│ │ │ │ partitions + 8 │\n│ │ │ │ writers paralelos │\n│ QuestDB │ Sí │ 1.4M │ Nodo único, modo WAL │\n│ │ │ │ sin tuning especial │\n│ InfluxDB IOx │ Casi │ 920K │ Presión de compaction│\n│ │ │ │ a ~950K │\n└──────────────┴──────────────┴─────────────┴──────────────────────┘\n```\n\nQuestDB 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.\n\n### Performance de Queries\n\nLa velocidad de ingesta es solo la mitad de la historia:\n\n```\nQuery: \"Temperatura promedio por sensor, última 1 hora\"\n┌──────────────┬──────────────┬───────────────────────────┐\n│ Base │ Tiempo Resp │ Notas │\n├──────────────┼──────────────┼───────────────────────────┤\n│ TimescaleDB │ 340ms │ Usando continuous aggregate│\n│ │ 2.8s │ Query crudo, sin agregado │\n│ QuestDB │ 180ms │ SAMPLE BY, nativo │\n│ InfluxDB IOx │ 220ms │ DataFusion, scan columnar │\n└──────────────┴──────────────┴───────────────────────────┘\n\nQuery: \"Último valor por sensor (50K sensores)\"\n┌──────────────┬──────────────┬───────────────────────────┐\n│ Base │ Tiempo Resp │ Notas │\n├──────────────┼──────────────┼───────────────────────────┤\n│ TimescaleDB │ 4.2s │ DISTINCT ON, doloroso │\n│ QuestDB │ 8ms │ LATEST ON, instantáneo │\n│ InfluxDB IOx │ 1.8s │ GROUP BY + last() │\n└──────────────┴──────────────┴───────────────────────────┘\n```\n\nEse resultado de ` LATEST ON ` de QuestDB es real. 8ms para obtener el estado actual de 50,000 sensores de 17 mil millones de filas.\n\n## El Pipeline en Producción\n\nAsí queda la arquitectura final para un proyecto IoT representativo:\n\n```\nSensores (50K)\n → MQTT Broker (EMQX cluster)\n → Kafka (3 nodos, 12 particiones)\n → QuestDB (ingesta, datos hot, últimos 7 días)\n → TimescaleDB (analytics, datos warm, últimos 90 días, continuous aggregates)\n → Export a S3 Parquet (datos cold, archivo histórico)\n```\n\nQuestDB 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.\n\n## Qué Sería Diferente Hoy\n\nArrancando 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.\n\nEn 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.\n\n## Preguntas frecuentes\n\n### ¿Puedo usar PostgreSQL en vez de una base de series temporales dedicada?\n\nPara 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.\n\n### ¿Cuál base de series temporales es mejor para IoT?\n\nQuestDB 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.\n\n### ¿Cómo manejo el backfill de datos históricos?\n\nTimescaleDB 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.\n\n## Tips\n\n1. **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.\n2. **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.\n3. **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.\n4. **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 `.\n\n- time-series\n- timescaledb\n- questdb\n- influxdb\n- iot\n- data-pipeline\n- performance\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)","headings":[{"level":1,"text":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#title"},{"level":2,"text":"El Problema con las Bases de Datos de Propósito General para Series Temporales","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-problema-con-las-bases-de-datos-de-propsito-general-para-series-temporales"},{"level":2,"text":"TimescaleDB: La Extensión de Series Temporales de PostgreSQL","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#timescaledb-la-extensin-de-series-temporales-de-postgresql"},{"level":3,"text":"Hypertables y Chunks","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#hypertables-y-chunks"},{"level":3,"text":"Continuous Aggregates","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#continuous-aggregates"},{"level":2,"text":"QuestDB: Construida para Velocidad de Ingesta","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#questdb-construida-para-velocidad-de-ingesta"},{"level":3,"text":"El Pipeline de Ingesta","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-pipeline-de-ingesta"},{"level":3,"text":"Extensiones SQL de QuestDB","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#extensiones-sql-de-questdb"},{"level":2,"text":"InfluxDB IOx: La Reescritura del Motor de Almacenamiento","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#influxdb-iox-la-reescritura-del-motor-de-almacenamiento"},{"level":2,"text":"El Benchmark: 1M Inserts/seg","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-benchmark-1m-insertsseg"},{"level":3,"text":"Performance de Queries","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#performance-de-queries"},{"level":2,"text":"El Pipeline en Producción","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-pipeline-en-produccin"},{"level":2,"text":"Qué Sería Diferente Hoy","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#qu-sera-diferente-hoy"},{"level":2,"text":"Preguntas frecuentes","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#questions"},{"level":3,"text":"¿Puedo usar PostgreSQL en vez de una base de series temporales dedicada?","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-1"},{"level":3,"text":"¿Cuál base de series temporales es mejor para IoT?","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-2"},{"level":3,"text":"¿Cómo manejo el backfill de datos históricos?","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#article-tips"}],"sections":[{"heading":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","level":1,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx","content":"Databases"},{"heading":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","level":1,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#title","content":"2026-01-108 min lectura\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nTambién disponible en: [English](<https://lucioduran.com/blog/time-series-databases-timescaledb-questdb-influx>)"},{"heading":"El Problema con las Bases de Datos de Propósito General para Series Temporales","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-problema-con-las-bases-de-datos-de-propsito-general-para-series-temporales","content":"Los datos de series temporales tienen características específicas que los hacen hostiles a los RDBMS tradicionales:\n\n1. **Escrituras append-mostly**: Los datos llegan cronológicamente y rara vez se actualizan.\n2. **Dominan las queries por rango temporal**: \"Dame la última hora de datos\" es el 95% de las queries.\n3. **Cardinalidad masiva**: 50K sensores x 4 métricas x 1 muestra/seg = 200K filas/seg.\n4. **Los datos viejos pierden valor**: La última hora es hot, el último mes es warm, el último año es cold.\n\nLos í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."},{"heading":"TimescaleDB: La Extensión de Series Temporales de PostgreSQL","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#timescaledb-la-extensin-de-series-temporales-de-postgresql","content":"[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."},{"heading":"Hypertables y Chunks","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#hypertables-y-chunks","content":"Una hypertable parece una tabla PostgreSQL normal, pero por abajo está automáticamente particionada en chunks por intervalo temporal:\n\n```sql\n-- Crear la hypertable\nCREATE TABLE sensor_data (\n time TIMESTAMPTZ NOT NULL,\n sensor_id TEXT NOT NULL,\n temperature DOUBLE PRECISION,\n humidity DOUBLE PRECISION,\n pressure DOUBLE PRECISION,\n vibration DOUBLE PRECISION\n);\n\n-- Convertir a hypertable con chunks de 6 horas\nSELECT create_hypertable('sensor_data', 'time',\n chunk_time_interval => INTERVAL '6 hours'\n);\n\n-- Agregar partición espacial para inserts paralelos\nSELECT add_dimension('sensor_data', 'sensor_id',\n number_partitions => 16\n);\n\n-- Política de compresión: comprimir chunks de más de 24 horas\nALTER TABLE sensor_data SET (\n timescaledb.compress,\n timescaledb.compress_segmentby = 'sensor_id',\n timescaledb.compress_orderby = 'time DESC'\n);\n\nSELECT add_compression_policy('sensor_data', INTERVAL '24 hours');\n\n-- Política de retención: dropear chunks de más de 90 días\nSELECT add_retention_policy('sensor_data', INTERVAL '90 days');\n```\n\nEl 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."},{"heading":"Continuous Aggregates","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#continuous-aggregates","content":"Esta es la característica más destacada de TimescaleDB para dashboards. En vez de recomputar agregados en cada query, los materializás:\n\n```sql\n-- Crear una vista materializada que se auto-refresca\nCREATE MATERIALIZED VIEW sensor_hourly\nWITH (timescaledb.continuous) AS\nSELECT\n time_bucket('1 hour', time) AS bucket,\n sensor_id,\n avg(temperature) AS avg_temp,\n min(temperature) AS min_temp,\n max(temperature) AS max_temp,\n avg(humidity) AS avg_humidity,\n count(*) AS sample_count\nFROM sensor_data\nGROUP BY bucket, sensor_id;\n\n-- Política de refresh: recomputar las últimas 2 horas cada 30 minutos\nSELECT add_continuous_aggregate_policy('sensor_hourly',\n start_offset => INTERVAL '2 hours',\n end_offset => INTERVAL '30 minutes',\n schedule_interval => INTERVAL '30 minutes'\n);\n\n-- Consultar el agregado en vez de los datos crudos\nSELECT bucket, avg_temp, min_temp, max_temp\nFROM sensor_hourly\nWHERE sensor_id = 'sensor-42'\n AND bucket > now() - INTERVAL '7 days'\nORDER BY bucket;\n```\n\nQueries de dashboard que tardaban 8 segundos sobre datos crudos ahora devuelven en 12ms. No es un error de tipeo."},{"heading":"QuestDB: Construida para Velocidad de Ingesta","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#questdb-construida-para-velocidad-de-ingesta","content":"[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."},{"heading":"El Pipeline de Ingesta","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-pipeline-de-ingesta","content":"La interfaz de InfluxDB Line Protocol (ILP) de QuestDB sobre TCP es la forma más rápida de ingestar datos:\n\n```python\n# questdb_ingest.py - ingesta de alto throughput\nfrom questdb.ingress import Sender, IngressError, TimestampNanos\nimport time\nimport random\n\ndef generate_sensor_batch(batch_size: int, sensors: list[str]):\n now_ns = time.time_ns()\n readings = []\n for i in range(batch_size):\n readings.append({\n 'sensor_id': random.choice(sensors),\n 'temperature': 20.0 + random.gauss(0, 5),\n 'humidity': 50.0 + random.gauss(0, 10),\n 'pressure': 1013.25 + random.gauss(0, 2),\n 'vibration': abs(random.gauss(0, 0.5)),\n 'timestamp': now_ns + (i * 1_000_000)\n })\n return readings\n\ndef ingest_batch(sender: Sender, readings: list[dict]):\n for r in readings:\n sender.row(\n 'sensor_data',\n symbols={'sensor_id': r['sensor_id']},\n columns={\n 'temperature': r['temperature'],\n 'humidity': r['humidity'],\n 'pressure': r['pressure'],\n 'vibration': r['vibration'],\n },\n at=TimestampNanos(r['timestamp'])\n )\n sender.flush()\n\nsensors = [f'sensor-{i:05d}' for i in range(50_000)]\nconf = 'http::addr=localhost:9000;auto_flush_rows=50000;auto_flush_interval=1000;'\nwith Sender.from_conf(conf) as sender:\n while True:\n batch = generate_sensor_batch(50_000, sensors)\n ingest_batch(sender, batch)\n```"},{"heading":"Extensiones SQL de QuestDB","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#extensiones-sql-de-questdb","content":"QuestDB agrega sintaxis SQL específica para series temporales que es genuinamente útil:\n\n```sql\n-- SAMPLE BY: agregación por buckets temporales (como time_bucket pero nativo)\nSELECT\n sensor_id,\n avg(temperature) avg_temp,\n min(temperature) min_temp,\n max(temperature) max_temp,\n count() sample_count\nFROM sensor_data\nWHERE sensor_id = 'sensor-00042'\nSAMPLE BY 1h\nORDER BY timestamp;\n\n-- LATEST ON: obtener el valor más reciente por sensor (extremadamente rápido)\nSELECT * FROM sensor_data\nLATEST ON timestamp PARTITION BY sensor_id;\n\n-- ASOF JOIN: join de series temporales sin timestamps exactos\nSELECT\n s.timestamp,\n s.sensor_id,\n s.temperature,\n a.alert_type,\n a.threshold\nFROM sensor_data s\nASOF JOIN alerts a ON (s.sensor_id = a.sensor_id);\n```\n\nLa 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."},{"heading":"InfluxDB IOx: La Reescritura del Motor de Almacenamiento","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#influxdb-iox-la-reescritura-del-motor-de-almacenamiento","content":"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.\n\n```python\n# influxdb3_ingest.py\nfrom influxdb_client_3 import InfluxDBClient3\nimport numpy as np\n\nclient = InfluxDBClient3(\n host=\"https://us-east-1-1.aws.cloud2.influxdata.com\",\n token=\"your-token\",\n org=\"your-org\",\n database=\"iot_pipeline\"\n)\n\n# Query con SQL (respaldado por DataFusion)\ndf = client.query(\"\"\"\n SELECT\n date_bin('1 hour', time) AS bucket,\n sensor_id,\n avg(temperature) AS avg_temp,\n approx_percentile_cont(temperature, 0.95) AS p95_temp,\n count(*) AS samples\n FROM sensor_data\n WHERE time > now() - INTERVAL '24 hours'\n AND sensor_id IN ('sensor-00001', 'sensor-00042', 'sensor-01337')\n GROUP BY bucket, sensor_id\n ORDER BY bucket DESC\n\"\"\").to_pandas()\n```\n\nLo 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."},{"heading":"El Benchmark: 1M Inserts/seg","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-benchmark-1m-insertsseg","content":"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):\n\n```\nIngesta sostenida (200K filas/seg, test de 24h):\n┌──────────────┬─────────────┬───────────┬────────────┬─────────────┐\n│ Base │ Latencia Avg│ p99 Lat │ Uso CPU │ Escritura │\n├──────────────┼─────────────┼───────────┼────────────┼─────────────┤\n│ TimescaleDB │ 4.2ms/batch │ 18ms │ 45% │ 380 MB/hr │\n│ QuestDB │ 1.1ms/batch │ 4.8ms │ 28% │ 210 MB/hr │\n│ InfluxDB IOx │ 3.8ms/batch │ 22ms │ 38% │ 290 MB/hr │\n└──────────────┴─────────────┴───────────┴────────────┴─────────────┘\n\nIngesta en ráfaga (1M filas/seg, ráfaga de 10 min):\n┌──────────────┬──────────────┬─────────────┬──────────────────────┐\n│ Base │ ¿Sostenido? │ Pico Real │ Notas │\n├──────────────┼──────────────┼─────────────┼──────────────────────┤\n│ TimescaleDB │ Sí (c/ tuning)│ 1.05M │ Necesitó 16 space │\n│ │ │ │ partitions + 8 │\n│ │ │ │ writers paralelos │\n│ QuestDB │ Sí │ 1.4M │ Nodo único, modo WAL │\n│ │ │ │ sin tuning especial │\n│ InfluxDB IOx │ Casi │ 920K │ Presión de compaction│\n│ │ │ │ a ~950K │\n└──────────────┴──────────────┴─────────────┴──────────────────────┘\n```\n\nQuestDB 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."},{"heading":"Performance de Queries","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#performance-de-queries","content":"La velocidad de ingesta es solo la mitad de la historia:\n\n```\nQuery: \"Temperatura promedio por sensor, última 1 hora\"\n┌──────────────┬──────────────┬───────────────────────────┐\n│ Base │ Tiempo Resp │ Notas │\n├──────────────┼──────────────┼───────────────────────────┤\n│ TimescaleDB │ 340ms │ Usando continuous aggregate│\n│ │ 2.8s │ Query crudo, sin agregado │\n│ QuestDB │ 180ms │ SAMPLE BY, nativo │\n│ InfluxDB IOx │ 220ms │ DataFusion, scan columnar │\n└──────────────┴──────────────┴───────────────────────────┘\n\nQuery: \"Último valor por sensor (50K sensores)\"\n┌──────────────┬──────────────┬───────────────────────────┐\n│ Base │ Tiempo Resp │ Notas │\n├──────────────┼──────────────┼───────────────────────────┤\n│ TimescaleDB │ 4.2s │ DISTINCT ON, doloroso │\n│ QuestDB │ 8ms │ LATEST ON, instantáneo │\n│ InfluxDB IOx │ 1.8s │ GROUP BY + last() │\n└──────────────┴──────────────┴───────────────────────────┘\n```\n\nEse resultado de ` LATEST ON ` de QuestDB es real. 8ms para obtener el estado actual de 50,000 sensores de 17 mil millones de filas."},{"heading":"El Pipeline en Producción","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#el-pipeline-en-produccin","content":"Así queda la arquitectura final para un proyecto IoT representativo:\n\n```\nSensores (50K)\n → MQTT Broker (EMQX cluster)\n → Kafka (3 nodos, 12 particiones)\n → QuestDB (ingesta, datos hot, últimos 7 días)\n → TimescaleDB (analytics, datos warm, últimos 90 días, continuous aggregates)\n → Export a S3 Parquet (datos cold, archivo histórico)\n```\n\nQuestDB 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."},{"heading":"Qué Sería Diferente Hoy","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#qu-sera-diferente-hoy","content":"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.\n\nEn 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."},{"heading":"Preguntas frecuentes","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#questions","content":""},{"heading":"¿Puedo usar PostgreSQL en vez de una base de series temporales dedicada?","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-1","content":"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."},{"heading":"¿Cuál base de series temporales es mejor para IoT?","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-2","content":"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."},{"heading":"¿Cómo manejo el backfill de datos históricos?","level":3,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#question-3","content":"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."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#article-tips","content":"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.\n2. **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.\n3. **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.\n4. **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 `.\n\n- time-series\n- timescaledb\n- questdb\n- influxdb\n- iot\n- data-pipeline\n- performance\n\n[Volver al Blog](<https://lucioduran.com/es/blog>)"}],"links":[{"text":"English","url":"https://lucioduran.com/blog/time-series-databases-timescaledb-questdb-influx"},{"text":"TimescaleDB","url":"https://www.timescaledb.com/"},{"text":"QuestDB","url":"https://questdb.io/"},{"text":"AWS","url":"https://aws.com/"},{"text":"Volver al Blog","url":"https://lucioduran.com/es/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx"},"headline":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","description":"Armando un pipeline real de IoT que banca 1M inserts/seg, comparando TimescaleDB hypertables, el motor columnar de QuestDB e InfluxDB IOx con benchmarks reales y trampas de producción.","datePublished":"2026-01-10","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://statics.lucioduran.com/images/lucioduran-social-card.jpg","url":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx","inLanguage":"es","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Databases","keywords":"time-series, timescaledb, questdb, influxdb, iot, data-pipeline, performance","wordCount":1543,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"en","url":"https://lucioduran.com/blog/time-series-databases-timescaledb-questdb-influx"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Inicio","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/es/blog"},{"@type":"ListItem","position":3,"name":"Bases de Datos de Series Temporales: TimescaleDB Hypertables, QuestDB Columnar e InfluxDB IOx","item":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"¿Puedo usar PostgreSQL en vez de una base de series temporales dedicada?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cuál base de series temporales es mejor para IoT?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"¿Cómo manejo el backfill de datos históricos?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}]}],"contentHash":"82783e8beb484c708acc3a0c47e60f90d2c4c68e77fbc05567a7d5d5add31a10","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/es/blog/time-series-databases-timescaledb-questdb-influx","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":72715,"contentBytes":15748}}
