DevGuideTK
← Trilha Backend

Séries temporais

Bancos otimizados para medições ao longo do tempo, com retenção e agregação.

O código desta página foi executado nas cinco linguagens contra um TimescaleDB 2.32, sobre o PostgreSQL 17. As medições do nível Conceito vêm do mesmo banco.

A hipertabela

CREATE TABLE leituras (
  sensor_id int NOT NULL,
  ts        timestamptz NOT NULL,
  valor     double precision NOT NULL
);
-- Divide a tabela em pedaços de um dia, pela coluna de tempo
SELECT create_hypertable('leituras', by_range('ts', INTERVAL '1 day'));
CREATE INDEX ON leituras (sensor_id, ts DESC);

Gravar em lote e consultar por hora, em cada linguagem

Cada versão grava seis horas de leituras de um sensor, uma por minuto, numa única operação, e depois consulta a média, o máximo e a quantidade por hora com time_bucket. A forma de gravar em lote muda com o driver: COPY onde ele é fácil de usar, unnest com vetores no Node e o lote do JDBC no Java.

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// Um INSERT por lote: os três vetores viram linhas com unnest
async function gravarLote(leituras: { sensorId: number; ts: Date; valor: number }[]) {
  await pool.query(
    `INSERT INTO leituras (sensor_id, ts, valor)
     SELECT * FROM unnest($1::int[], $2::timestamptz[], $3::float8[])`,
    [leituras.map((l) => l.sensorId), leituras.map((l) => l.ts), leituras.map((l) => l.valor)],
  );
}

// Média, máximo e quantidade por hora de um sensor, num intervalo
async function porHora(sensorId: number, inicio: Date, fim: Date) {
  const { rows } = await pool.query(
    `SELECT time_bucket('1 hour', ts) AS hora, round(avg(valor)::numeric, 2) AS media,
            max(valor) AS maximo, count(*) AS n
       FROM leituras
      WHERE sensor_id = $1 AND ts >= $2 AND ts < $3
      GROUP BY hora ORDER BY hora`,
    [sensorId, inicio, fim],
  );
  return rows;
}

// Seis horas de leituras de um sensor, uma por minuto
const inicio = new Date("2026-11-15T00:00:00Z");
const lote = Array.from({ length: 360 }, (_, m) => ({
  sensorId: 42, ts: new Date(inicio.getTime() + m * 60_000), valor: 20 + (m % 60) / 10,
}));
await pool.query("DELETE FROM leituras WHERE sensor_id = 42");
await gravarLote(lote);
for (const r of await porHora(42, inicio, new Date("2026-11-15T06:00:00Z"))) {
  console.log(r.hora.toISOString(), r.media, r.maximo, r.n);
}
await pool.end();

O resultado, nas cinco linguagens

As cinco versões gravaram as 360 leituras e devolveram as mesmas seis linhas:

2026-11-15T00:00:00Z  22.95  25.9  60
2026-11-15T01:00:00Z  22.95  25.9  60
...
2026-11-15T05:00:00Z  22.95  25.9  60

Cada hora tem 60 leituras, de 20,0 a 25,9, com média 22,95.

O que muda de uma linguagem para outra

Gravar em loteDatas
TypeScriptINSERT ... SELECT * FROM unnest($1::int[], $2::timestamptz[], $3::float8[])Date, sempre em UTC por dentro
Pythoncursor.copy("COPY ... FROM STDIN") e write_rowdatetime com tzinfo
GoCopyFrom com pgx.CopyFromSlicetime.Time
JavaaddBatch e executeBatch, com reWriteBatchedInserts=true na URLOffsetDateTime
C#BeginBinaryImportAsync com COPY ... (FORMAT BINARY)DateTime com DateTimeKind.Utc

O COPY é a forma mais rápida de gravar no PostgreSQL: no teste, 10 mil medições levaram 44 ms com COPY e 10,1 segundos com um INSERT confirmado por medição. O unnest manda um único INSERT com três vetores, e o reWriteBatchedInserts faz o driver JDBC juntar o lote em poucos comandos. Os dois evitam uma ida e volta por linha; o teste não mediu a velocidade deles contra o COPY.

Nas cinco, o carimbo de tempo é timestamptz e as datas da aplicação estão em UTC. No C#, segundo a documentação do Npgsql (versão 6 em diante), um DateTime sem DateTimeKind.Utc é recusado ao gravar em timestamptz, justamente para evitar o erro de fuso do tópico de modelagem.

Retenção, compressão e resumos

As políticas que automatizam o que o nível Conceito mediu à mão:

-- Comprimir pedaços com mais de 7 dias, agrupando por sensor
ALTER TABLE leituras SET (
  timescaledb.compress,
  timescaledb.compress_segmentby = 'sensor_id',
  timescaledb.compress_orderby = 'ts DESC'
);
SELECT add_compression_policy('leituras', INTERVAL '7 days');

-- Média por hora, mantida pelo banco
CREATE MATERIALIZED VIEW leituras_por_hora WITH (timescaledb.continuous) AS
SELECT sensor_id, time_bucket('1 hour', ts) AS hora,
       avg(valor) AS media, max(valor) AS maximo, count(*) AS n
  FROM leituras
 GROUP BY sensor_id, hora;

SELECT add_continuous_aggregate_policy('leituras_por_hora',
  start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour',
  schedule_interval => INTERVAL '30 minutes');

-- Apagar as leituras brutas com mais de 30 dias; o resumo por hora fica
SELECT add_retention_policy('leituras', INTERVAL '30 days');

No teste, a compressão, o resumo contínuo e o drop_chunks foram aplicados com os comandos manuais equivalentes (compress_chunk, a criação do resumo com WITH DATA e refresh_continuous_aggregate). As chamadas add_*_policy acima, que agendam essas tarefas, não foram executadas no teste.

Lista de revisão

Modelo

  • A tabela é particionada por tempo (hipertabela ou particionamento nativo)?
  • O carimbo de tempo é timestamptz, com o horário da medição, e não o da gravação?
  • Há índice pela coluna que filtra junto com o tempo?

Gravação

  • As medições são gravadas em lotes (COPY, vetores ou lote do driver)?
  • Medições que chegam atrasadas ou repetidas são tratadas?

Ciclo de vida

  • Há política de retenção por resolução, sem DELETE em massa?
  • Os pedaços antigos são comprimidos?
  • Os painéis leem resumos, e o time sabe se eles mostram os dados mais recentes?
  • Os resumos guardam soma e contagem, para combinar médias corretamente?

Métricas

  • Os rótulos têm poucos valores possíveis, sem identificadores de usuário ou de pedido?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Gravar e consultar séries temporais

    Quero implementar a gravação e a consulta de medições na minha aplicação.
    
    Pergunte qual linguagem, driver e banco eu uso, e de onde chegam as medições.
    
    Vamos em etapas:
    1. A tabela particionada pelo tempo, com o índice certo.
    2. A gravação em lotes, por tempo ou por quantidade, com a forma mais rápida do meu driver.
    3. As consultas por intervalo, com agregação por hora ou por dia.
    4. Medições atrasadas e repetidas.
    5. Um teste de carga que compare gravar linha a linha e em lote.
  • Configurar retenção, compressão e resumos

    Quero automatizar a retenção, a compressão e os resumos das minhas séries temporais.
    
    Pergunte qual banco e versão eu uso, o volume e a política de retenção desejada.
    
    Escreva comigo, com os comandos completos:
    1. A compressão dos pedaços antigos, com o agrupamento certo.
    2. Os resumos por hora e por dia, com soma e contagem.
    3. As políticas de atualização dos resumos.
    4. A retenção do bruto e dos resumos.
    5. Como acompanhar se as tarefas estão rodando.
  • Revisar uma implementação de séries temporais

    Vou colar abaixo o esquema, o código de gravação e as consultas de séries temporais de uma aplicação. Revise.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. A tabela é particionada por tempo?
    2. A gravação é feita em lotes?
    3. A retenção usa descarte de pedaços, e não DELETE em massa?
    4. Há compressão dos dados antigos?
    5. Os painéis leem resumos, combinados corretamente?
    6. Os carimbos de tempo têm fuso e vêm da medição?
    7. Nas métricas, algum rótulo tem valores ilimitados?
    
    Para cada problema, descreva o efeito em produção e mostre a correção.
    
    Esquema e código:
    [cole aqui]