Bancos de dados industriais: eventos de produção em escala
Modelagem de fábrica em PostgreSQL, particionamento, BRIN, retenção, séries temporais e historiadores, com SQL para paradas, produtividade e OEE.

Uma única máquina que reporta o estado a cada segundo gera 86.400 registros por dia. Dez máquinas, cada uma com vinte variáveis de processo amostradas a cada segundo, geram mais de 17 milhões de pontos por dia. Esses números são aritmética simples, não benchmark, e mostram por que dados industriais exigem decisões de engenharia de dados que um CRUD não exige.
Este é o oitavo artigo da série. Depois de ver quem produz e consome dados e como eles chegam, vamos ver como armazená-los e consultá-los.
Tipos de dado numa fábrica
Misturar tudo numa única tabela é o erro mais comum. Há pelo menos cinco naturezas de dado, com requisitos diferentes:
- Dados mestres: máquinas, linhas, produtos, receitas. Poucos, mudam devagar, precisam de integridade.
- Dados transacionais de produção: ordens, lotes, apontamentos, genealogia. Volume moderado, consistência forte, auditoria.
- Eventos de máquina: mudanças de estado, paradas, alarmes. Volume médio a alto, ordenados no tempo.
- Telemetria / séries temporais: valores de sensores em alta frequência. Volume muito alto, escrita contínua apenas de acréscimo, consultas por intervalo.
- Dados analíticos derivados: agregados por hora, turno, dia, usados em painéis e relatórios.
Essa classificação é uma ferramenta de raciocínio (opinião), não uma norma. Cada classe sugere uma estratégia de armazenamento diferente.
Onde cada tecnologia se encaixa
Banco relacional (PostgreSQL, SQL Server, Oracle). O lugar natural de dados mestres, ordens, lotes e rastreabilidade: integridade referencial, transações, consultas ad hoc. O PostgreSQL também aguenta eventos e telemetria até certa escala, desde que se usem particionamento e índices adequados.
Historiadores industriais. Produtos desenhados para tags de processo, com compressão, interpolação e conectores para controladores e SCADA. Já vimos no artigo 05 que, no AVEVA PI, o dado passa por exception reporting na interface e por compressão swinging door no arquivo, o que significa que o que se consulta pode ser uma reconstrução do sinal. São produtos proprietários, com ecossistema próprio.
Bancos de séries temporais.
- TimescaleDB é uma extensão do PostgreSQL. Segundo a documentação, uma hypertable é uma tabela PostgreSQL particionada automaticamente por tempo (e opcionalmente por outras dimensões), dividida em chunks que cobrem faixas de tempo configuráveis; as consultas rodam apenas nos chunks relevantes. Mantém SQL e o ecossistema PostgreSQL. A sintaxe de criação e os recursos (compressão, agregados contínuos, políticas de retenção) têm mudado entre versões e há aspectos de licenciamento por edição; verifique a documentação da versão que for usar, pois não os detalho aqui.
- InfluxDB adota um modelo próprio. Na documentação da versão 3, a antiga measurement corresponde a uma tabela, as tags guardam metadados de identificação (como o
sensor_id) e os fields guardam os valores medidos; as colunas de tags de uma tabela são imutáveis depois de definidas. Também existe a linguagem InfluxQL, projetada para o modelo da v1. Há diferenças significativas entre versões e edições do produto, então confirme na documentação o que cada uma suporta.
Data lake / lakehouse. Arquivos em formato colunar em armazenamento de objetos, consultados por motores analíticos. Faz sentido para histórico longo, análises entre plantas e treinamento de modelos, e menos para operação do dia a dia. Na minha leitura, é um destino para onde dados derivados ou arquivados fluem, não o primeiro lugar onde a produção grava.
Não há vencedor universal. A pergunta útil é: qual é a taxa de escrita, por quanto tempo guardo, que consultas preciso e quanto tempo de queda tolero?
Modelando uma fábrica
Um modelo relacional mínimo para máquinas, linhas, produtos, lotes, ordens e eventos. Todo o SQL deste artigo foi executado em PostgreSQL 17 antes de publicar.
CREATE TABLE line (id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, code text UNIQUE NOT NULL);
CREATE TABLE machine (id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
line_id int NOT NULL REFERENCES line, code text UNIQUE NOT NULL,
ideal_cycle_s numeric NOT NULL CHECK (ideal_cycle_s > 0));
CREATE TABLE product (id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, sku text UNIQUE NOT NULL);
CREATE TABLE work_order (id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, erp_no text UNIQUE NOT NULL,
product_id int NOT NULL REFERENCES product, qty_planned int NOT NULL);
CREATE TABLE lot (id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, code text UNIQUE NOT NULL,
work_order_id bigint NOT NULL REFERENCES work_order);
-- eventos de estado de máquina, particionados por mês
CREATE TABLE machine_event (
machine_id int NOT NULL REFERENCES machine,
ts timestamptz NOT NULL,
state text NOT NULL CHECK (state IN ('RUNNING','STOPPED','FAULT')),
reason text,
PRIMARY KEY (machine_id, ts) -- inclui a chave de partição
) PARTITION BY RANGE (ts);
CREATE TABLE machine_event_2026_03 PARTITION OF machine_event
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
-- contagens de produção, com BRIN no tempo
CREATE TABLE production_count (
machine_id int NOT NULL REFERENCES machine, lot_id bigint NOT NULL REFERENCES lot,
ts timestamptz NOT NULL, qty_good int NOT NULL, qty_scrap int NOT NULL DEFAULT 0,
PRIMARY KEY (machine_id, ts)
) PARTITION BY RANGE (ts);
CREATE TABLE production_count_2026_03 PARTITION OF production_count
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE INDEX ON production_count USING brin (ts);
Algumas decisões que valem explicação:
- Chave natural
(machine_id, ts)como chave primária: evita duplicatas quando a mesma leitura é reenviada (idempotência, discutida nos artigos 06 e 07). ComINSERT ... ON CONFLICT DO NOTHING, o reenvio é inofensivo. - A chave primária inclui a coluna de partição. É uma limitação documentada do PostgreSQL: restrições de unicidade em tabelas particionadas precisam conter todas as colunas da chave de partição.
- Eventos de estado, não estado atual. Gravamos transições (do tipo “passou para STOPPED às 10:00”) e derivamos intervalos. É mais compacto que amostrar o estado a cada segundo e preserva o histórico.
- Granularidade da contagem. Aqui as contagens são por hora, uma simplificação. Em produção, a granularidade (por ciclo, por minuto, por lote) é decisão que afeta volume e qualidade das análises.
- Uma tabela de eventos e outra de contagens, em vez de uma tabela genérica de “leituras”, para que os tipos, restrições e índices reflitam o significado de cada dado.
Particionamento
A documentação do PostgreSQL descreve três métodos declarativos: por faixa (range), por lista e por hash. Para dados de tempo, o particionamento por faixa é o natural. Benefícios documentados:
- Poda de partições: consultas com filtro de tempo ignoram partições que não podem conter linhas relevantes.
- Retenção barata: descartar um mês com
DROP TABLEouALTER TABLE ... DETACH PARTITIONevita oDELETEem massa e o custo deVACUUMassociado. - Movimentação para armazenamento mais barato de partições antigas.
E um cuidado, também da documentação: partições demais aumentam o tempo de planejamento e o uso de memória; de menos, deixam índices grandes e pior localidade. A recomendação oficial é testar com carga simulada, e não presumir.
Índices
Para séries temporais com inserção em ordem cronológica, o índice BRIN é uma opção interessante: ele guarda resumos por faixa de páginas e é muito pequeno, mas só é eficiente quando a coluna indexada tem correlação com a ordem física das linhas, como um timestamp de inserção. Não serve para busca pontual nem para garantir unicidade (para isso, B-tree). Em tabelas com carga fora de ordem ou com muitas atualizações, a correlação se perde. O parâmetro pages_per_range (padrão 128) controla o compromisso entre tamanho e precisão.
Retenção
ALTER TABLE machine_event DETACH PARTITION machine_event_2026_03;
DROP TABLE machine_event_2026_03;
Com particionamento por mês, a política “guardar 24 meses de eventos detalhados e agregar o restante” vira rotina: criar a partição do mês seguinte e descartar ou arquivar a mais antiga. Uma advertência de negócio: em setores regulados, o prazo de retenção de registros de rastreabilidade é definido por regulação e contrato, não por conveniência técnica. A tabela de eventos de máquina e a de rastreabilidade de lotes podem ter regras diferentes.
Consultas: paradas, disponibilidade, OEE
Os dados de exemplo (sintéticos) descrevem um turno de 8h: a máquina roda de 08:00 às 10:00, para por falta de rótulo até 10:20, roda até 13:00, entra em falha até 13:30, roda até as 16:00.
Identificar paradas e suas durações
SELECT machine_id, state, reason, ts AS inicio, next_ts - ts AS duracao
FROM (SELECT machine_id, ts, state, reason,
lead(ts) OVER (PARTITION BY machine_id ORDER BY ts) AS next_ts
FROM machine_event
WHERE ts >= '2026-03-02' AND ts < '2026-03-03') ev
WHERE state <> 'RUNNING' AND next_ts IS NOT NULL
ORDER BY ts;
Resultado:
machine_id | state | reason | inicio | duracao
------------+---------+-----------------+------------------------+----------
1 | STOPPED | falta de rótulo | 2026-03-02 10:00:00+00 | 00:20:00
1 | FAULT | sensor de nível | 2026-03-02 13:00:00+00 | 00:30:00
A função de janela lead() pareia cada evento com o seguinte, transformando transições em intervalos. A duração é o fim do intervalo menos o início.
Disponibilidade, desempenho, qualidade e OEE do turno
WITH turno AS (SELECT '2026-03-02 08:00+00'::timestamptz AS ini,
'2026-03-02 16:00+00'::timestamptz AS fim),
ev AS (
SELECT machine_id, state, ts,
lead(ts) OVER (PARTITION BY machine_id ORDER BY ts) AS next_ts
FROM machine_event, turno
WHERE ts >= turno.ini - interval '1 day'
),
run AS (
SELECT machine_id, sum(least(next_ts, fim) - greatest(ts, ini)) AS run_t
FROM ev, turno
WHERE state = 'RUNNING' AND next_ts > ini AND ts < fim
GROUP BY machine_id
),
prod AS (
SELECT machine_id, sum(qty_good) AS good, sum(qty_scrap) AS scrap
FROM production_count, turno
WHERE ts >= ini AND ts < fim GROUP BY machine_id
)
SELECT m.code,
round(extract(epoch FROM r.run_t) / extract(epoch FROM (fim - ini)), 3) AS disponibilidade,
round((p.good + p.scrap) * m.ideal_cycle_s / extract(epoch FROM r.run_t), 3) AS desempenho,
round(p.good::numeric / (p.good + p.scrap), 3) AS qualidade,
round(p.good * m.ideal_cycle_s / extract(epoch FROM (fim - ini)), 3) AS oee
FROM run r JOIN prod p USING (machine_id) JOIN machine m ON m.id = r.machine_id, turno;
Com tempo de ciclo ideal de 30 s, o resultado foi disponibilidade 0,896, desempenho 0,945, qualidade 0,966 e OEE 0,818, valores que conferem com a conta manual (430 min de operação em 480; 785 peças boas e 28 refugadas). Dois pontos de armadilha, que aparecem facilmente numa primeira versão da consulta e só são percebidos ao conferir com um cálculo manual (foi o que aconteceu ao preparar este exemplo):
- Intervalos que atravessam a fronteira do turno precisam ser recortados (
least/greatest). Filtrar eventos pelo timestamp antes de calcular olead()descarta o evento que fecha o último intervalo e subestima a disponibilidade. - OEE é uma definição, não uma consulta única. Como discutido no artigo 03, o que conta como tempo planejado, parada programada e tempo de ciclo ideal muda o número. Documente a definição junto com a consulta.
Poda de partições
EXPLAIN (COSTS OFF)
SELECT * FROM production_count WHERE ts >= '2026-03-02' AND ts < '2026-03-03';
O plano mostra apenas production_count_2026_03 sendo lido: as demais partições nem entram. Com uma tabela de poucas linhas, o planejador usa varredura sequencial; o benefício dos índices BRIN e da poda aparece em volumes reais, que este exemplo não reproduz.
Volume, escrita e confiabilidade
Escrita em lote. Inserir linha a linha com uma transação por linha é caro. O padrão é acumular eventos (na borda ou numa fila) e gravar em lotes, com INSERT multi-linha ou COPY. A latência extra de poucos segundos raramente importa para análise, mas importa para alarmes, que devem seguir outro caminho.
Ordem e duplicidade. Eventos chegam atrasados, repetidos ou fora de ordem, especialmente após uma queda de rede e o esvaziamento de um buffer na borda (artigo 07). A chave natural e o ON CONFLICT resolvem duplicatas; a ordem é resolvida pelo timestamp de origem, e não pelo de chegada.
Qualidade do dado. Guardar o indicador de qualidade de cada leitura (bom, suspeito, falho) e não confundir “sem leitura” com “valor zero”.
Consultas históricas. Cuidado com consultas pesadas sobre a base operacional: relatórios longos competem com a escrita. Alternativas: réplica de leitura, tabelas agregadas (por hora, turno, dia) mantidas por rotina, ou envio para uma plataforma analítica.
Consistência. O que precisa ser consistente (a relação entre ordem, lote e produção) vive no banco transacional com chaves estrangeiras. O que pode ser aproximado (telemetria de alta frequência) aceita compressão e perdas controladas. Misturar as duas exigências na mesma tabela costuma custar caro.
Backup e recuperação. Defina RPO e RTO por classe de dado. Backup do banco transacional é obrigatório; para telemetria bruta, às vezes basta preservar os agregados.
Relação com engenharia de software
Na minha leitura, a decisão mais valiosa é modelar eventos de forma explícita e derivar o resto. Estados atuais, indicadores e relatórios são consultas sobre eventos imutáveis, o que facilita auditoria, reprocessamento e correção retroativa. A segunda é separar cedo o que é transacional do que é série temporal, mesmo que ambos morem inicialmente no PostgreSQL. E a terceira é tratar as definições de indicadores (OEE, paradas, tempo planejado) como código versionado e testado, não como fórmulas espalhadas em planilhas e telas.
Conclusão
Bancos de dados industriais não são uma categoria única: são uma combinação de relacional para negócio e rastreabilidade, séries temporais ou historiadores para processo, e plataformas analíticas para histórico longo. O PostgreSQL, com particionamento por tempo, chave natural idempotente e índices adequados, resolve uma faixa grande de casos; a partir de certo volume ou de certas exigências industriais, TimescaleDB, InfluxDB ou um historiador passam a fazer sentido. O que não muda é a disciplina: eventos com timestamp de origem, qualidade explícita, retenção planejada e definições de indicadores documentadas.
O artigo seguinte sobe para a IIoT e a Indústria 4.0, onde esses dados passam a viajar entre planta, borda e nuvem.
Referências
Este artigo é um estudo baseado nas fontes abaixo. Trechos marcados como opinião refletem a minha leitura. Os dados de exemplo são sintéticos e todo o SQL foi executado em PostgreSQL 17.
- PostgreSQL Global Development Group. Table Partitioning, PostgreSQL 17 Documentation.
- PostgreSQL Global Development Group. BRIN Indexes, PostgreSQL 17 Documentation.
- Tiger Data (Timescale). Hypertables, documentação do TimescaleDB. Recursos como compressão, agregados contínuos e retenção não foram verificados nesta pesquisa.
- InfluxData. Explore your schema with InfluxQL e InfluxDB 3 Core, SHOW, documentação do InfluxDB 3.
- AVEVA. Exception reporting attributes e Data Archive, documentação do PI System (exemplo de historiador proprietário).
- ISO. ISO 22400-2:2014, indicadores de desempenho para gestão de operações de manufatura (consulta limitada ao catálogo).
Precisa de apoio técnico no seu projeto ou sistema?
Atuo no desenvolvimento de sistemas web sob medida, integrações de APIs e diagnóstico de performance. Atendimento direto com o desenvolvedor, sem intermediários.
Artigos Relacionados
WAL, checkpoints e replication slots: como o PostgreSQL protege os dados (e como um slot esquecido enche o disco)
Write-Ahead Logging, full-page writes, checkpoints, crash recovery, streaming e replicação lógica, lag e retenção de WAL: como cada peça funciona e como um replication slot abandonado vira um incidente. Com experimentos.
CTEs, materialização e consultas recursivas no PostgreSQL: o que o planner faz com o WITH
Como o PostgreSQL trata CTEs desde a versão 12: inlining, MATERIALIZED e NOT MATERIALIZED, efeitos colaterais, WITH RECURSIVE, SEARCH e CYCLE, ciclos em grafos e o impacto no plano de execução.

