TimescaleDB: séries temporais no PostgreSQL

TimescaleDB é uma extensão do PostgreSQL que transforma tabelas comuns em hypertables, particionadas automaticamente por tempo. Para dados de séries temporais (métricas, eventos, logs), é 10-100× mais rápido que PostgreSQL puro e elimina a necessidade de InfluxDB ou Prometheus TSDB para muitos casos de uso.

Instalar TimescaleDB

Adicionar o TimescaleDB ao PostgreSQL existente:

bash
# Ubuntu 22.04/24.04 — instalar TimescaleDB:
sudo apt install -y gnupg postgresql-common apt-transport-https lsb-release wget

# Adicionar repositório:
sudo sh -c "echo 'deb https://packagecloud.io/timescale/timescaledb/ubuntu/ $(lsb_release -cs) main' > /etc/apt/sources.list.d/timescaledb.list"
wget --quiet -O - https://packagecloud.io/timescale/timescaledb/gpgkey | sudo apt-key add -
sudo apt update

# Instalar para o PostgreSQL instalado:
sudo apt install -y timescaledb-2-postgresql-16

# Configurar (adiciona timescaledb ao shared_preload_libraries):
sudo timescaledb-tune --quiet --yes

# Reiniciar PostgreSQL:
sudo systemctl restart postgresql

# Ativar a extensão no banco:
psql -U postgres -d producao -c "CREATE EXTENSION IF NOT EXISTS timescaledb;"

# Docker — usar imagem oficial com TimescaleDB:
# image: timescale/timescaledb:latest-pg16

Criar hypertable para métricas

Converter tabela de métricas em hypertable particionada por tempo:

sql
-- Criar tabela de métricas de servidor:
CREATE TABLE metricas_servidor (
  time        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  servidor    TEXT NOT NULL,
  cpu_pct     DOUBLE PRECISION,
  memoria_mb  BIGINT,
  disk_io_mb  DOUBLE PRECISION,
  rede_mbps   DOUBLE PRECISION
);

-- Converter em hypertable (particionada automaticamente por time):
SELECT create_hypertable('metricas_servidor', 'time',
  chunk_time_interval => INTERVAL '1 day'  -- nova partição a cada 1 dia
);

-- Ver partições criadas:
SELECT * FROM timescaledb_information.chunks WHERE hypertable_name = 'metricas_servidor';

-- Inserir dados:
INSERT INTO metricas_servidor (servidor, cpu_pct, memoria_mb)
VALUES ('vps-prod-01', 45.2, 3840);

-- Ingestão em massa (TimescaleDB otimiza automaticamente):
INSERT INTO metricas_servidor (time, servidor, cpu_pct)
SELECT
  NOW() - (i || ' seconds')::INTERVAL,
  'vps-prod-01',
  random() * 100
FROM generate_series(1, 1000000) i;

Queries otimizadas para séries temporais

Funções especiais do TimescaleDB para análise temporal:

sql
-- time_bucket: agrupar por período (equivalente ao DATE_TRUNC mas mais flexível):
SELECT
  time_bucket('5 minutes', time) AS periodo,
  servidor,
  avg(cpu_pct) AS cpu_media,
  max(cpu_pct) AS cpu_pico,
  min(cpu_pct) AS cpu_minimo
FROM metricas_servidor
WHERE time > NOW() - INTERVAL '1 hour'
GROUP BY periodo, servidor
ORDER BY periodo DESC;

-- first() e last(): primeiro/último valor no período:
SELECT
  time_bucket('1 hour', time) AS hora,
  first(cpu_pct, time) AS cpu_inicio_hora,
  last(cpu_pct, time) AS cpu_fim_hora
FROM metricas_servidor
WHERE servidor = 'vps-prod-01'
GROUP BY hora ORDER BY hora DESC LIMIT 24;

-- Continuous Aggregates: views materializadas que atualizam automaticamente:
CREATE MATERIALIZED VIEW metricas_por_hora
WITH (timescaledb.continuous) AS
SELECT
  time_bucket('1 hour', time) AS hora,
  servidor,
  avg(cpu_pct) AS cpu_media,
  max(cpu_pct) AS cpu_pico,
  count(*) AS amostras
FROM metricas_servidor
GROUP BY hora, servidor;

-- Política de refresh automático:
SELECT add_continuous_aggregate_policy('metricas_por_hora',
  start_offset => INTERVAL '3 hours',
  end_offset   => INTERVAL '1 hour',
  schedule_interval => INTERVAL '1 hour');

-- Consultar a view (muito mais rápida que a tabela original):
SELECT * FROM metricas_por_hora
WHERE hora > NOW() - INTERVAL '7 days'
ORDER BY hora DESC;

Compressão e retenção de dados

Comprimir dados históricos e expirar automaticamente:

sql
-- Ativar compressão para chunks antigos:
ALTER TABLE metricas_servidor SET (
  timescaledb.compress,
  timescaledb.compress_segmentby = 'servidor',  -- agrupar por servidor
  timescaledb.compress_orderby = 'time DESC'
);

-- Política de compressão automática (comprimir chunks com mais de 7 dias):
SELECT add_compression_policy('metricas_servidor', INTERVAL '7 days');

-- Ver taxa de compressão:
SELECT
  chunk_name,
  pg_size_pretty(before_compression_total_bytes) AS antes,
  pg_size_pretty(after_compression_total_bytes) AS depois,
  round(100.0 * after_compression_total_bytes / before_compression_total_bytes, 1) AS pct
FROM chunk_compression_stats('metricas_servidor');
-- Resultado típico: 90% de redução de espaço!

-- Política de retenção (deletar dados com mais de 90 dias):
SELECT add_retention_policy('metricas_servidor', INTERVAL '90 days');

-- Ver políticas ativas:
SELECT * FROM timescaledb_information.jobs;

-- Comprimir/descomprimir manualmente:
SELECT compress_chunk(chunk => '"_timescaledb_internal"."_hyper_1_1_chunk"');
SELECT decompress_chunk(chunk => '"_timescaledb_internal"."_hyper_1_1_chunk"');

Integrar com Node.js e enviar métricas

API para coletar e consultar métricas com TimescaleDB:

typescript
// Enviar métricas do servidor para o TimescaleDB:
import { Pool } from 'pg'
import os from 'os'

const db = new Pool({ connectionString: process.env.DATABASE_URL })

async function coletarMetricas() {
  const cpus = os.cpus()
  const totalMem = os.totalmem()
  const freeMem = os.freemem()

  // Calcular uso de CPU:
  const cpuPct = cpus.reduce((acc, cpu) => {
    const total = Object.values(cpu.times).reduce((a, b) => a + b, 0)
    return acc + (100 - (100 * cpu.times.idle / total))
  }, 0) / cpus.length

  await db.query(
    `INSERT INTO metricas_servidor (servidor, cpu_pct, memoria_mb)
     VALUES ($1, $2, $3)`,
    [
      process.env.HOSTNAME ?? 'unknown',
      Math.round(cpuPct * 100) / 100,
      Math.round((totalMem - freeMem) / 1024 / 1024),
    ]
  )
}

// Coletar a cada 30 segundos:
setInterval(coletarMetricas, 30_000)

// API para consultar métricas das últimas 24h:
app.get('/api/metricas/:servidor', async (req, res) => {
  const { rows } = await db.query(
    `SELECT
       time_bucket('5 minutes', time) AS periodo,
       avg(cpu_pct) AS cpu,
       avg(memoria_mb) AS memoria
     FROM metricas_servidor
     WHERE servidor = $1 AND time > NOW() - INTERVAL '24 hours'
     GROUP BY periodo ORDER BY periodo`,
    [req.params.servidor]
  )
  res.json(rows)
})

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

TimescaleDB: SQL completo, extensão do PostgreSQL (dados no banco que você já usa), bom para dados que misturam séries temporais com dados relacionais. InfluxDB: banco dedicado a séries temporais, queries em Flux/InfluxQL, melhor para ingestão de altíssimo volume (> 1M pontos/s). Prometheus: métricas de curto prazo (15 dias padrão), pull-based, integrado com alertas. Para a maioria das VPS com Grafana: TimescaleDB como Prometheus remote storage é uma combinação poderosa.

Cada hypertable é dividida em chunks, tabelas PostgreSQL normais particionadas por intervalo de tempo. Com chunk_time_interval = "1 day": cada dia de dados fica em uma tabela separada internamente. Vantagens: queries que filtram por tempo recente tocam apenas poucos chunks (não a tabela inteira), compressão e retenção operam por chunk, e o planejador PostgreSQL executa queries muito mais eficientemente.

Sim, mas com refresh incremental automático. Views materializadas normais precisam ser atualizadas completamente (REFRESH MATERIALIZED VIEW). Continuous Aggregates do TimescaleDB: atualizam apenas o período novo desde o último refresh, muito mais eficiente para dados que crescem continuamente. O add_continuous_aggregate_policy agenda o refresh automático com start_offset e end_offset configuráveis.

A maioria: hypertables, compression, continuous aggregates, retention policies, time_bucket e funções de análise. Features apenas na Enterprise: multi-node (cluster distribuído), algumas funções de análise avançada (Skewness, Kurtosis), compressão com dicionário. Para self-hosting em VPS: Community Edition cobre praticamente todos os casos de uso.

A tabela precisa ter uma coluna de timestamp. Comandos: (1) ALTER TABLE eventos ADD COLUMN IF NOT EXISTS criado_em TIMESTAMPTZ DEFAULT NOW(); (2) SELECT create_hypertable("eventos", "criado_em", migrate_data => true, chunk_time_interval => INTERVAL "1 week"); O migrate_data => true copia os dados existentes para os chunks. Para tabelas grandes (> 10 GB): faça em manutenção, o processo pode levar horas e consome I/O significativo.