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();import os
from datetime import datetime, timedelta, timezone
import psycopg
conexao = psycopg.connect(os.environ["DATABASE_URL"], autocommit=True)
def gravar_lote(leituras: list[tuple[int, datetime, float]]) -> None:
# COPY é a forma mais rápida de gravar muitas linhas no PostgreSQL
with conexao.cursor().copy("COPY leituras (sensor_id, ts, valor) FROM STDIN") as copia:
for leitura in leituras:
copia.write_row(leitura)
def por_hora(sensor_id: int, inicio: datetime, fim: datetime) -> list[tuple]:
"""Média, máximo e quantidade por hora de um sensor, num intervalo."""
return conexao.execute(
"""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 = %s AND ts >= %s AND ts < %s
GROUP BY hora ORDER BY hora""",
(sensor_id, inicio, fim),
).fetchall()
# Seis horas de leituras de um sensor, uma por minuto
inicio = datetime(2026, 11, 15, tzinfo=timezone.utc)
lote = [(42, inicio + timedelta(minutes=m), 20 + (m % 60) / 10) for m in range(360)]
conexao.execute("DELETE FROM leituras WHERE sensor_id = 42")
gravar_lote(lote)
for hora, media, maximo, n in por_hora(42, inicio, inicio + timedelta(hours=6)):
print(hora.astimezone(timezone.utc).isoformat(), media, maximo, n)package main
import (
"context"
"fmt"
"os"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type Leitura struct {
SensorID int32
Ts time.Time
Valor float64
}
// gravarLote usa o protocolo COPY, a forma mais rápida de gravar muitas linhas
func gravarLote(ctx context.Context, db *pgxpool.Pool, leituras []Leitura) error {
_, err := db.CopyFrom(ctx, pgx.Identifier{"leituras"}, []string{"sensor_id", "ts", "valor"},
pgx.CopyFromSlice(len(leituras), func(i int) ([]any, error) {
return []any{leituras[i].SensorID, leituras[i].Ts, leituras[i].Valor}, nil
}))
return err
}
type Hora struct {
Hora time.Time
Media float64
Maximo float64
N int64
}
// porHora devolve média, máximo e quantidade por hora de um sensor, num intervalo
func porHora(
ctx context.Context, db *pgxpool.Pool, sensor int, inicio, fim time.Time,
) ([]Hora, error) {
linhas, err := db.Query(ctx, `
SELECT time_bucket('1 hour', ts) AS hora, round(avg(valor)::numeric, 2)::float8 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`, sensor, inicio, fim)
if err != nil {
return nil, err
}
return pgx.CollectRows(linhas, pgx.RowToStructByPos[Hora])
}
func main() {
ctx := context.Background()
db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
defer db.Close()
// Seis horas de leituras de um sensor, uma por minuto
inicio := time.Date(2026, 11, 15, 0, 0, 0, 0, time.UTC)
var lote []Leitura
for m := range 360 {
ts := inicio.Add(time.Duration(m) * time.Minute)
lote = append(lote, Leitura{42, ts, 20 + float64(m%60)/10})
}
db.Exec(ctx, "DELETE FROM leituras WHERE sensor_id = 42")
if err := gravarLote(ctx, db, lote); err != nil {
panic(err)
}
horas, err := porHora(ctx, db, 42, inicio, inicio.Add(6*time.Hour))
if err != nil {
panic(err)
}
for _, h := range horas {
fmt.Println(h.Hora.UTC().Format(time.RFC3339), h.Media, h.Maximo, h.N)
}
}import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
public class Leituras {
record Leitura(int sensorId, Instant ts, double valor) {}
// JDBC batch; com reWriteBatchedInserts=true na URL, o driver junta tudo em poucos comandos
static void gravarLote(Connection db, List<Leitura> leituras) throws SQLException {
var sql = "INSERT INTO leituras (sensor_id, ts, valor) VALUES (?, ?, ?)";
try (var comando = db.prepareStatement(sql)) {
for (var l : leituras) {
comando.setInt(1, l.sensorId());
comando.setObject(2, OffsetDateTime.ofInstant(l.ts(), ZoneOffset.UTC));
comando.setDouble(3, l.valor());
comando.addBatch();
}
comando.executeBatch();
}
}
// Média, máximo e quantidade por hora de um sensor, num intervalo
static void porHora(Connection db, int sensorId, Instant inicio, Instant fim)
throws SQLException {
var sql = """
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 = ? AND ts >= ? AND ts < ?
GROUP BY hora ORDER BY hora""";
try (var comando = db.prepareStatement(sql)) {
comando.setInt(1, sensorId);
comando.setObject(2, OffsetDateTime.ofInstant(inicio, ZoneOffset.UTC));
comando.setObject(3, OffsetDateTime.ofInstant(fim, ZoneOffset.UTC));
try (var linhas = comando.executeQuery()) {
while (linhas.next()) {
var hora = linhas.getObject("hora", OffsetDateTime.class).toInstant();
System.out.println(hora + " " + linhas.getBigDecimal("media") + " "
+ linhas.getDouble("maximo") + " " + linhas.getLong("n"));
}
}
}
}
public static void main(String[] args) throws Exception {
try (var db = DriverManager.getConnection(System.getenv("JDBC_URL"))) {
// Seis horas de leituras de um sensor, uma por minuto
var inicio = Instant.parse("2026-11-15T00:00:00Z");
var lote = new ArrayList<Leitura>();
for (int m = 0; m < 360; m++) {
lote.add(new Leitura(42, inicio.plusSeconds(60L * m), 20 + (m % 60) / 10.0));
}
db.createStatement().execute("DELETE FROM leituras WHERE sensor_id = 42");
gravarLote(db, lote);
porHora(db, 42, inicio, inicio.plusSeconds(6 * 3600));
}
}
}using Npgsql;
using NpgsqlTypes;
await using var db = NpgsqlDataSource.Create(Environment.GetEnvironmentVariable("DATABASE_URL")!);
// COPY em modo binário: a forma mais rápida de gravar muitas linhas no PostgreSQL
async Task GravarLote(IEnumerable<(int SensorId, DateTime Ts, double Valor)> leituras)
{
await using var conexao = await db.OpenConnectionAsync();
await using var copia = await conexao.BeginBinaryImportAsync(
"COPY leituras (sensor_id, ts, valor) FROM STDIN (FORMAT BINARY)");
foreach (var (sensorId, ts, valor) in leituras)
{
await copia.StartRowAsync();
await copia.WriteAsync(sensorId, NpgsqlDbType.Integer);
await copia.WriteAsync(ts, NpgsqlDbType.TimestampTz);
await copia.WriteAsync(valor, NpgsqlDbType.Double);
}
await copia.CompleteAsync();
}
// Média, máximo e quantidade por hora de um sensor, num intervalo
async Task PorHora(int sensorId, DateTime inicio, DateTime fim)
{
await using var comando = db.CreateCommand("""
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
""");
comando.Parameters.Add(new NpgsqlParameter { Value = sensorId });
comando.Parameters.Add(new NpgsqlParameter { Value = inicio });
comando.Parameters.Add(new NpgsqlParameter { Value = fim });
await using var linhas = await comando.ExecuteReaderAsync();
while (await linhas.ReadAsync())
Console.WriteLine(
$"{linhas.GetDateTime(0):O} {linhas.GetDecimal(1)} {linhas.GetDouble(2)} {linhas.GetInt64(3)}");
}
// Seis horas de leituras de um sensor, uma por minuto
var inicio = new DateTime(2026, 11, 15, 0, 0, 0, DateTimeKind.Utc);
var lote = Enumerable.Range(0, 360).Select(m => (42, inicio.AddMinutes(m), 20 + (m % 60) / 10.0));
await using (var apagar = db.CreateCommand("DELETE FROM leituras WHERE sensor_id = 42"))
await apagar.ExecuteNonQueryAsync();
await GravarLote(lote);
await PorHora(42, inicio, inicio.AddHours(6));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 60Cada 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 lote | Datas | |
|---|---|---|
| TypeScript | INSERT ... SELECT * FROM unnest($1::int[], $2::timestamptz[], $3::float8[]) | Date, sempre em UTC por dentro |
| Python | cursor.copy("COPY ... FROM STDIN") e write_row | datetime com tzinfo |
| Go | CopyFrom com pgx.CopyFromSlice | time.Time |
| Java | addBatch e executeBatch, com reWriteBatchedInserts=true na URL | OffsetDateTime |
| 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
DELETEem 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]