Curso de TimescaleDB – PostgreSQL para dados de séries temporais
Autoria: freeCodeCamp.org · Assistir no YouTube
Vídeo de autoria do canal indicado, não da IANIA; todos os direitos pertencem ao criador. Transcrição, resumo e tradução gerados automaticamente; o conteúdo no idioma original é preservado na versão em idioma original.
Idioma original: inglês
Vídeo original: TimescaleDB Course – PostgreSQL for Time-Series Data · Canal: freeCodeCamp.org · 4:19:46 · Assistir no YouTube
Tópicos: Agentes de IA, Dados, bancos de dados e embeddings
O curso apresenta o TimescaleDB como uma extensão do PostgreSQL para trabalhar com dados de séries temporais, explicando hypertables, índices, armazenamento colunar, agregados contínuos e retenção. Com medições e planos de consulta, mostra como reduzir leituras e custos de armazenamento sem abandonar SQL nem as ferramentas habituais do PostgreSQL. Dois projetos práticos aplicam esses recursos: um gravador de eventos de agentes de IA e a telemetria de uma frota de carregadores de veículos elétricos. O curso também explora o Tiger Cloud, incluindo armazenamento em camadas, monitoramento e busca vetorial, e encerra com recomendações operacionais para produção.
Pontos-chave
Seção intitulada “Pontos-chave”- TimescaleDB é uma extensão do PostgreSQL, não um fork nem um banco de dados separado; tabelas comuns podem continuar no mesmo banco que as hypertables.
- Filtros de tempo permitem excluir chunks irrelevantes; sem eles, muitas consultas não aproveitam a principal vantagem das hypertables.
- O intervalo dos chunks deve considerar volume de dados, memória disponível, quantidade de chunks e precisão necessária para a retenção.
- O armazenamento colunar pode reduzir o espaço ocupado e acelerar consultas analíticas, mas a configuração não converte automaticamente os chunks existentes.
- Agregados contínuos atualizam resumos incrementalmente; combiná-los em uma hierarquia reduz o trabalho repetido em consultas de dashboard.
- Para combinar agregados corretamente, armazene estados que possam ser mesclados, em vez de tentar agregar médias ou contagens distintas já finalizadas.
- A retenção de chunks evita o custo de apagar milhões de linhas individualmente; o período de atualização dos agregados deve respeitar a retenção dos dados de origem.
- O projeto do gravador de agentes combina ingestão em lotes, UUIDv7, armazenamento colunar e agregados para investigar latência, erros, custos e execuções específicas.
- No projeto de telemetria, hyperfunctions identificam reinicializações de contadores, duração em estados, lacunas de comunicação e médias ponderadas pelo tempo.
- Tiger Cloud oferece recursos gerenciados, incluindo armazenamento em camadas, ferramentas operacionais e integração com MCP; os dados em camadas exigem habilitação explícita para leitura.
Capítulos
Seção intitulada “Capítulos”| Início | Capítulo | Resumo |
|---|---|---|
| 00:00 | Demonstração e objetivos do curso | Uma comparação de consultas introduz os agregados contínuos, os projetos práticos e o uso de ambientes locais e gerenciados. |
| 10:00 | SQL essencial para séries temporais | O instrutor revisa filtros por intervalo, agrupamentos, FILTER, percentis e tipos de dados usados nos exemplos. |
| 19:37 | EXPLAIN, páginas e particionamento nativo | O curso ensina a ler planos de consulta e explica MVCC, limpeza de linhas, particionamento nativo e as limitações de views materializadas. |
| 29:06 | O problema de analytics e a introdução às hypertables | Um cenário de analytics estabelece a linha de base e apresenta os chunks como forma de excluir dados fora do intervalo consultado. |
| 37:01 | Conversão para hypertables e escolha do intervalo | O curso mostra a criação de hypertables, a exclusão de chunks, o dimensionamento dos intervalos e a medição dos resultados. |
| 59:05 | Índices, UUIDv7 e organização física | O instrutor aborda índices por chunk, restrições de unicidade, UUIDv7 e reordenação para melhorar a localidade dos dados. |
| 1:15:19 | Armazenamento colunar e compressão | O curso explica o armazenamento por colunas, segmentação, ordenação, conversão de chunks, políticas e estruturas para ignorar lotes. |
| 1:32:09 | Agregados contínuos e hierarquias de resumos | Agregados incrementais, dados em tempo real e hierarquias de granularidade reduzem o custo de consultas repetidas. |
| 1:59:01 | Hyperfunctions, retenção e armazenamento em camadas | O curso cobre preenchimento de lacunas, estados agregáveis, aproximações, políticas de retenção e dados frios em armazenamento de objetos. |
| 2:16:16 | Ambiente local e esquema do gravador de agentes | O instrutor configura Docker e PostgreSQL e projeta o esquema do gravador de execuções de agentes de IA. |
| 2:42:36 | Agregados, consultas e diagnóstico de incidentes | O projeto implementa armazenamento colunar e agregados, mede custos, reconstrói execuções e investiga uma falha simulada. |
| 3:16:01 | Tiger Cloud, CLI e integração com MCP | O curso migra o esquema para Tiger Cloud, apresenta a CLI, o modo somente leitura e a conexão de ferramentas de IA por MCP. |
| 3:29:05 | Modelo de dados e escolha da segmentação da frota | O projeto de carregadores elétricos define o esquema de telemetria e compara estratégias de segmentação por dispositivo e local. |
| 3:56:15 | Busca vetorial, armazenamento em camadas e console | O instrutor combina busca vetorial com filtros temporais, configura dados em camadas e apresenta recursos de monitoramento e operações. |
| 4:09:17 | Verificações antes da produção | Uma lista de verificação aborda conversão, segmentação, índices, retenção, agregados em tempo real e monitoramento de jobs. |
| 4:16:06 | Consultas de monitoramento e encerramento | O curso reúne consultas para acompanhar compressão, chunks, jobs e atualidade dos agregados, além de resumir os princípios principais. |
Citações
Seção intitulada “Citações”A diferença está na quantidade de trabalho. — 00:32
Sempre filtre pelo tempo. — 48:34
Uma consulta de média lê os dados de apenas uma coluna. — 1:17:55
Os agregados definem o problema; os dados brutos o explicam. — 2:53:34
Conceitos e entidades
Seção intitulada “Conceitos e entidades”- TimescaleDB (ferramenta): Extensão do PostgreSQL que automatiza o particionamento em chunks e oferece recursos para dados de séries temporais.
- PostgreSQL (ferramenta): Banco de dados relacional usado como base para os exemplos e projetos do curso.
- Tiger Data (empresa): Empresa por trás do TimescaleDB e do serviço gerenciado Tiger Cloud.
- Tiger Cloud (ferramenta): Serviço gerenciado apresentado no curso para hospedar bancos PostgreSQL com TimescaleDB e recursos de nuvem.
- hypertable (conceito): Tabela particionada em chunks pelo TimescaleDB, que continua sendo consultada como uma única tabela.
- continuous aggregate (conceito): Agregado materializado que atualiza incrementalmente os buckets afetados por novas inserções, atualizações ou exclusões.
- columnstore (conceito): Formato de armazenamento por colunas usado para reduzir o espaço e acelerar consultas analíticas em chunks.
- hyperfunctions (conceito): Funções do TimescaleDB para operações como preenchimento de lacunas, análise de contadores, estados, percentis aproximados e médias ponderadas.
- UUIDv7 (conceito): Formato de UUID que inclui um timestamp e permite gerar identificadores ordenáveis temporalmente.
- pgvector (ferramenta): Extensão do PostgreSQL mencionada no curso para armazenar embeddings e realizar buscas vetoriais.
Tradução completa (pt-BR)
Seção intitulada “Tradução completa (pt-BR)”00:00 dois bancos de dados no mesmo laptop, cada um com 10 milhões de registros de requisições. Estamos fazendo a mesma pergunta para os dois: quantas requisições chegaram a cada hora e qual foi o tempo médio de resposta? À esquerda, o PostgreSQL calcula a resposta a partir das requisições individuais. À direita, o TimescaleDB lê um resumo por hora que preparamos com antecedência. Então, vamos executá-los. Podemos ver que a consulta bruta levou 1,66 segundo, ou 1.682
00:32 milissegundos. E a consulta à direita levou apenas 10 milissegundos. E, se verificarmos, as duas retornam as mesmas respostas. A diferença está na quantidade de trabalho. Uma consulta lê eventos individuais. A outra lê resultados que já calculamos. Construir esse resumo também levou tempo. E o PostgreSQL comum pode armazenar resumos. O que o TimescaleDB acrescenta é uma maneira de manter resumos baseados em tempo atualizados sem reconstruir todo o histórico a cada vez. Essa é uma das coisas
01:04 que vamos construir neste curso. Olá, sou Bo KS e, neste curso, vou ensinar você a usar o TimescaleDB para trabalhar com dados de séries temporais no PostgreSQL. Vamos ver como organizar tabelas que crescem, reduzir o armazenamento e manter as consultas dos dashboards responsivas. Também vamos abordar as restrições e configurações que você precisa entender antes de usar isso em produção. A Tiger Data forneceu uma bolsa para viabilizar este curso. É a empresa por trás do TimescaleDB. Ela tem uma plataforma
01:36 em nuvem totalmente gerenciada chamada TigerCloud. Essencialmente, é PostgreSQL de nível empresarial com o TimescaleDB integrado, além de recursos nativos da nuvem, como o armazenamento automático de dados em camadas no armazenamento S3 de baixo custo, a criação de uma bifurcação do banco de dados com um clique e um dashboard visual desenvolvido especificamente para operações com séries temporais. Vamos usar o TigerCloud mais adiante no curso, no segundo projeto. Mas, na primeira parte deste curso, vamos começar totalmente local, dentro do Docker, do
02:08 zero. Quero que você entenda exatamente como esse mecanismo funciona nos bastidores antes de passarmos para a nuvem. Você pode continuar usando as ferramentas e o SQL com que já está familiarizado. Alguns designs de tabela e consultas precisam de mudanças, e vamos trabalhar nisso ao longo do caminho. Então, o que são dados de séries temporais? São dados que você analisa em relação ao tempo. Você pode perguntar o que aconteceu na última hora, comparar isso com a semana passada ou calcular os tempos de resposta ao longo do dia. Isso
02:40 inclui requisições HTTP, preços financeiros, leituras de sensores, logs de aplicações e chamadas de modelos e ferramentas feitas por um agent de IA. Essas cargas de trabalho costumam adicionar novos registros continuamente. Grande parte dos dados permanece inalterada, e as perguntas geralmente dizem respeito a um intervalo de tempo específico. O PostgreSQL consegue lidar com grandes conjuntos de dados, mas, à medida que uma tabela de eventos cresce, alguns custos ficam mais perceptíveis. Índices ativos grandes podem exigir mais
03:11 operações de disco. Excluir linhas antigas gera trabalho de limpeza. E os dashboards podem percorrer repetidamente os mesmos eventos históricos para calcular os mesmos totais. O PostgreSQL já nos oferece ferramentas para parte disso, incluindo particionamento. O TimescaleDB acrescenta gerenciamento automático de chunks, armazenamento orientado a colunas e agregados contínuos. Vamos ver em que situações cada recurso ajuda e qual é o custo de mantê-lo. Naquela primeira demonstração, nosso foco foram os agregados contínuos, armazenando resultados por hora para que o dashboard não precise calculá-los do zero. O cálculo inicial acontece antes de iniciarmos o cronômetro.
03:43 Mais adiante, vamos ver como atualizar esses resumos quando novos dados chegam ou outros registros mudam. Também vamos examinar o armazenamento e os planos de consulta para poder explicar os resultados. Os resultados de tempo de execução que você viu são os obtidos neste laptop e com este conjunto de dados. Eles não são uma garantia para todas as consultas. Seu esquema, hardware, filtros e distribuição dos dados são importantes. Vamos medir à medida que avançamos, e você
04:14 poderá repetir os exemplos na sua própria máquina. Então, vamos aplicar isso a dois projetos. O primeiro é um gravador de voo de agent de IA. Ele registra chamadas de modelos e ferramentas, com a duração, o custo e o resultado de cada uma. Vamos usá-lo para responder a perguntas como: qual agent está girando mais, qual ferramenta tem as chamadas mais lentas, quando a taxa de erros mudou e, quando algo dá errado, podemos inspecionar as etapas registradas em uma execução específica. Esse projeto roda localmente no Docker, e um simulador
04:44 fornece dados sem exigir uma API paga de modelo. O segundo projeto é a telemetria de uma frota de carregadores de veículos elétricos. Vamos modelar 2.000 carregadores, cada um reportando a cada 10 segundos, com uma linha por relatório. Isso representa 200 linhas por segundo e cerca de 17 milhões de linhas por dia. Vamos usar o TigerCloud neste projeto para explorar operações gerenciadas e a transferência de dados mais antigos para o armazenamento de objetos. Essa taxa de eventos, por si só, não exige hospedagem na nuvem. O objetivo é ver como funcionam as
05:16 opções operacionais e de armazenamento. Os capítulos estão na descrição, caso você queira ir direto a um tópico específico. Vamos começar pelos conceitos do PostgreSQL de que precisaremos. A coisa mais importante a entender sobre o TimescaleDB é que ele é simplesmente Postgres. Não é um fork do PostgreSQL. Não é um banco de dados de leitura e gravação. É apenas uma extensão. O mesmo mecanismo que fornece o PostGIS para dados geoespaciais ou o pgvector para embeddings. Isso significa
05:47 que tudo o que você já sabe sobre Postgres continua valendo, e tudo na sua stack continua funcionando. Então, deixe-me demonstrar. Tenho o Postgres rodando neste terminal e vou executar este comando, que vai mostrar que o TimescaleDB está na mesma lista de extensões que o PostgreSQL, que vem com toda instalação do Postgres no mundo. Então, é muito fácil instalar.
06:19 Basta executar CREATE EXTENSION IF NOT EXISTS timescaledb. Ela já está instalada nesta máquina, mas essa única linha é toda a instalação. Depois que o binário está na máquina, é preciso instalá-la em cada banco de dados. Essa é a parte que as pessoas não percebem. Se você criar um banco de dados novo, execute o comando novamente. A consequência prática é que suas migrações do Django, Rails ou Prisma funcionam. Seu pooler de conexões funciona, o pg_dump funciona, seu monitoramento funciona e sua equipe não precisa aprender uma nova linguagem de consulta,
06:49 porque não existe uma. É apenas SQL. Certo, vamos usar alguns dados de exemplo. Quero falar sobre o que há nesses dados de exemplo para que você entenda. Isso é mais importante do que parece. Todas as consultas deste curso são executadas em uma tabela. E, se você não souber o que há nela, o SQL será apenas ruído. Então, este é o cenário. Imagine que você administra um serviço que fica diante de muitos sites, como uma CDN, uma plataforma de hospedagem ou um gateway de API. Toda vez que o navegador de alguém solicita uma página, essa requisição passa pelo seu serviço, e você registra uma linha sobre ela. Quem solicitou, o que solicitou, quanto tempo levou e se funcionou. É só isso. Esses são os dados. Apenas uma linha por requisição HTTP. Milhões delas. Se você já viu um arquivo de log de servidor web, é isso, mas em uma tabela de data center em vez de um arquivo de texto. Quer dizer, em uma tabela de banco de dados em vez de um arquivo de texto. Então, deixe-me mostrar o formato. Esta é a tabela, e executei apenas \d requests. O \d é um
07:21 comando do PostgreSQL, não do SQL. É um atalho fornecido pelo cliente psql e significa descrever esta tabela. Você obtém cada coluna e seu tipo. Há muitas colunas aqui. Então, vou agrupá-las, porque elas se dividem em cinco categorias. O horário indica quando aconteceu: o timestamp da requisição. Essa é a coluna mais importante de todo o curso. Vamos ordenar, filtrar e agrupar muito por essa
07:54 coluna. Depois, temos algumas informações sobre quem solicita o quê, como o ID do servidor, o domínio da URL e o ID da sessão. E temos dados sobre o que aconteceu: o status. Esse é o código de status HTTP. Depois, temos a duração, que indica quanto tempo levou. E, por fim, todo o resto, como de onde veio a requisição, qual navegador e sistema operacional foram usados e quantos bytes foram transferidos. Isso é bem abstrato. Deixe-me mostrar uma linha real. Primeiro, vou executar \x on, que vai alternar
08:24 o SQL para o modo expandido, que imprime uma coluna por linha, em vez de uma linha por tabela, numa tabela tão larga. É a diferença entre algo legível e algo ilegível. Depois, vamos executar SELECT * FROM requests LIMIT 1. Vou diminuir o zoom para conseguirmos ver tudo em uma tela. Como você pode ver, temos o horário, a URL, o domínio, o status, a duração e o total. Esses serão alguns dos campos mais importantes.
08:58 SQL no modo expandido, que imprime uma coluna por linha em vez de uma linha por tabela, que é tão larga. Essa é a diferença entre legível e ilegível. E então vamos fazer select everything from request limit one. Deixe-me reduzir o zoom para podermos ver tudo em uma única tela. Então, você pode ver que temos o horário, a URL, o domínio, o status, a duração e o total. E esses vão ser alguns dos mais importantes.
09:29 Então, há uma requisição. Alguém em alguma cidade solicitou alguma página, e ela retornou um status e levou alguns milissegundos. Cada linha representa isso, e há milhões delas. Então, vou usar /x off para desativar a exibição estendida. Depois, vou executar uma consulta que nos dirá quantas linhas há e qual período elas abrangem. Este é o número de linhas, e aqui está o período. Esse é o seu conjunto de dados, e este é o timestamp mais recente. Podemos ver que os dados terminam em
10:00 aproximadamente no momento em que carregamos os dados. Isso importa para as próximas consultas porque, quando eu digo “as últimas 24 horas”, quero dizer as últimas 24 horas desses dados, ou seja, o dia que antecede o momento em que executamos o gerador. Agora, vamos começar a ver SQL. Vou falar sobre os 20% de SQL de que você precisa, que basicamente são tudo de que vai precisar neste curso. Vou mostrar quatro coisas. Se as quatro já forem familiares, a parte um será uma revisão e você poderá avançar rápido. Se alguma for novidade, este é o básico.
10:32 Basicamente, tudo o que vem depois no curso é construído a partir dessas coisas. Aqui está um código SQL básico que vamos abordar, e vou executá-lo no PostgreSQL, bem aqui. Toda consulta deste curso começa restringindo os resultados a um intervalo de tempo. Então, vamos começar por isso. Vou selecionar max time de request. Este é o horário máximo e o intervalo de tempo. Vou salvar esse horário, esse valor, e reutilizá-lo, porque quero que todas as consultas desta seção apontem para a parte mais recente dos
11:04 dados, em vez de apontarem para o horário do relógio. Então, vou executar este comando aqui. Selecione max time, e podemos ver aqui o , com no final. Este é um truque do PostgreSQL. Ele pega o resultado dessa consulta e o armazena em uma variável chamada newest. Assim, posso escrever :newest nas consultas seguintes, em vez de digitar um timestamp. Este é o filtro que vamos usar.
11:38 Então, primeiro, SELECT COUNT. Vamos contar as linhas em que o horário é igual ou posterior a 24 horas antes da linha mais recente. Então, vamos usar FROM request, WHERE o horário é igual ou posterior a, e temos INTERVAL 24 hours. Ou seja, 24 horas antes da linha mais recente. O que vale notar aqui é que o PostgreSQL tem operações aritméticas reais com datas. Então, esse intervalo de 24
12:10 horas — ou você pode ver o colorido aqui — é um valor genuíno, com um tipo, e você pode subtraí-lo de um timestamp e obter um timestamp. Você pode usar INTERVAL 7 days, INTERVAL 15 minutes, INTERVAL 1 month. Tudo isso funciona. Você vai ver isso constantemente em todas as partes deste curso. Então, vou executar este comando e podemos ver quantas linhas, quantas linhas, foram incluídas nas últimas 24 horas. Agora, vamos falar
12:43 sobre agregação e agrupamento. Basicamente, vou usar este código aqui. Vou copiá-lo e colá-lo aqui no nosso PostgreSQL. Aqui está o código completo. Vamos ler uma cláusula de cada vez, porque a ordem em que o PostgreSQL executa o trabalho não é a ordem em que você lê o código. Aqui temos FROM requests. Vamos começar com esta tabela, a tabela request. Depois temos WHERE, que basicamente significa
13:15 descartar tudo o que estiver fora das últimas 24 horas. Depois vamos agrupar por status, o que é bem importante. Pegamos as linhas restantes e as separamos em grupos, um para cada código de status distinto. Por exemplo, todos os 200 em um grupo, todos os 404 em outro. Depois, aqui em cima, vamos obter o status e a contagem. Então, a contagem com o asterisco e a duração média total
13:48 são calculadas uma vez por grupo, não uma vez por linha. Essa é a mudança de perspectiva. Então, essa contagem de tudo indica quantas linhas há neste grupo, e a duração média total é a duração média das linhas deste grupo. Depois, ORDER BY request DESC, ou em ordem decrescente, ordena os resultados com o maior grupo primeiro. E AS requests, aqui em cima, simplesmente renomeia a coluna para que a saída fique legível. É só isso que AS faz.
14:19 Certo, vou enviar este comando e podemos ver o resultado. Temos principalmente 200. Temos os 200 e todos estes códigos de status diferentes aqui. E podemos ver que são principalmente 200, como eu disse. É isso que você quer. Há alguns 404 e alguns 500. Esses são os que indicam que algo está quebrado, e encontrá-los rapidamente é basicamente o enredo
14:50 de todo este curso. Agora vamos falar desta seção de filtro aqui. FILTER serve para contar coisas diferentes ao mesmo tempo. Já tenho o código aqui no PostgreSQL. Aqui está uma pergunta de verdade: qual é a minha taxa de erros? Para responder, você precisa de dois números da mesma linha: o total e quantos deles falharam. Você poderia executar duas consultas, mas não faça isso. O PostgreSQL tem FILTER. Então, temos este
15:21 FILTER WHERE. Isso associa uma condição a uma única agregação. O WHERE na parte de baixo, bem aqui, aplica-se à consulta inteira. Mas este FILTER aqui em cima só se aplica a uma contagem, aquela à qual está associado. COUNT FILTER WHERE status é maior ou igual a 500 significa contar as linhas, mas apenas aquelas que foram
15:54 erros do servidor. Quatro contagens diferentes, uma única passagem pelos dados. Se você vem escrevendo CASE WHEN … THEN … ELSE … END, como muita gente — eu inclusive —, isso faz a mesma coisa, mas é mais rápido de ler e de executar. Se executarmos, podemos ver os erros do servidor, as páginas não encontradas e as respostas corretas. Também podemos transformar isso facilmente em uma porcentagem e obter uma taxa de erros. Então, aqui estamos basicamente fazendo um
16:26 cálculo para transformar isso em uma porcentagem e chamando o resultado de error rate percentage. E vemos uma taxa de erros de 25%. Agora, a última coisa de que vamos falar são os percentis. Aqui você pode ver que estamos usando percentile_cont, com percentile_cont(0.5), percentile_cont(0.95) e percentile_cont(0.99). Vou executar isso. Assim, podemos comparar os milissegundos médios com a
16:58 média, o P99, o P95 e os percentis. Esses são percentis: percentil 50. O P99 deve ser várias vezes maior, e é. Observe a diferença entre a média e o P99. A mesma requisição, uma história completamente diferente. A média é o número que faz você se sentir bem, mas o P99 é o número que representa a experiência dos seus usuários mais irritados. A sintaxe é, reconheço, estranha, com percentile_cont(0.95).
17:29 Isso indica qual percentil você quer. Depois, WITHIN GROUP, e então vamos ordenar por duration total. Isso indica em qual coluna calcular o percentil. O PostgreSQL precisa colocar os valores em ordem para encontrar a marca dos 95%. É isso que você está dizendo para ele ordenar. E lembre-se: o resultado é exato, o que significa que ele ordena cada valor para encontrar essa marca. Com alguns milhares de linhas, tudo bem. Mas com 100 milhões, isso é um problema. E, na próxima parte, vamos substituí-lo por algo
18:01 que fornece uma resposta quase idêntica sem a ordenação. Outra coisa são os tipos. Há três que importam aqui. O tipo TIMESTAMP TZ. Use-o sempre. Nunca use simplesmente TIMESTAMP. TZ significa fuso horário. Então, TIMESTAMP TZ armazena um instante absoluto e o converte para o fuso horário do leitor na saída. Já TIMESTAMP simples armazena uma leitura do relógio, sem saber onde ela foi registrada. Nossa coluna time é TIMESTAMP TZ, e toda
18:33 hypertable na próxima parte é particionada por uma coluna desse tipo. JSONB, que é JSON binário, pode ser indexado para os atributos menos comuns que não merecem sua própria coluna. E UUID: é um identificador de 128 bits. As longas strings com hífens em request ID são UUIDs. Parece uma escolha sem graça agora, mas, na seção de indexação que vem a seguir, vai
19:05 ser bastante importante. E há uma armadilha importante, eu acho que podemos dizer, à espera ali, sobre a qual vamos falar mais tarde. Então, essas coisas que acabamos de abordar são basicamente os 20% de SQL de que você vai precisar. Tudo é construído, de certa forma, em torno de interval, group by, filter e percentile count. Vamos precisar conhecer todas essas coisas à medida que o curso avança. Certo, quero falar sobre outro comando chamado explain. Então, se você levar uma habilidade desta seção, que seja esta.
19:37 Quase todas as afirmações que o TimescaleDB faz podem ser verificadas com explain. E quase todo mistério de desempenho é resolvido lendo-o. Então, vamos aprender a lê-lo de verdade, não apenas dar uma olhada. Esta é a versão simples, que não executa a consulta; apenas mostra o plano. Vou colocar explain e depois select count from request where e, em seguida, basicamente, where o horário está no último dia, aqui no fim dos resultados. Agora podemos ver o
20:11 plano de consulta. Este é o plano do que ele vai fazer. O comando ainda não foi executado. É apenas o que o comando select vai fazer. Primeiro, ele vai filtrar. Depois, vai fazer uma varredura paralela de busca em request, agregação parcial e coleta. Basicamente, vai começar de baixo para cima. E é isso que vai fazer aqui. Há dois números por linha. Podemos
20:44 ver esta linha aqui, cost. Cost é uma unidade arbitrária. Não são milissegundos nem nada físico. Só é útil para comparar dois planos da mesma consulta. E rows, aqui, é a estimativa do planejador, onde está o problema interessante. Agora vou executar um comando parecido, mas vamos usar explain analyze buffers. Analyze realmente executa a consulta e informa o que de fato
21:15 aconteceu. Buffers informa a quantidade de dados que ele precisou acessar. Esse segundo comando é o que ninguém usa, mas todo mundo deveria usar. Você pode ver que ele atingiu quatro páginas. shared hit é igual a quatro. E podemos ver as informações reais: workers plan e workers launched. Basicamente, ele informa o que aconteceu de fato quando executou o comando. Então, quando aparece “seq scan” aqui, isso significa uma
21:47 varredura sequencial. Então, deixe-me falar sobre uma varredura sequencial em comparação com uma varredura de índice. Uma varredura sequencial lê a tabela inteira. Isso não é automaticamente ruim. Se você está agregando a maior parte da tabela, é a escolha certa e é mais rápida do que uma varredura de índice seria. O problema é quando você queria algumas poucas linhas e acabou lendo tudo. E aqui podemos ver, em cada linha, que ele mostra
22:18 o tempo real e as linhas reais. Então, se o plano estimou 100 linhas e encontrou um milhão, todas as decisões seguintes são baseadas numa mentira, e você terá um plano ruim. Isso geralmente significa que as estatísticas estão desatualizadas. Então, você deve executar analyze na tabela. E aqui estão os buffers. Vemos shared hit. Isso significa que os dados já estavam na memória. shared read significa que vieram do disco. As leituras de disco são o que realmente consome tempo de relógio. E esse é o
22:50 número que veremos cair drasticamente quando chegarmos à reordenação e ao column store. Às vezes, você também vai querer comparar o tempo de planejamento com o tempo de execução. Se forem muito diferentes, o planejador está confuso com alguma coisa. Deixe-me mostrar outra coisa. Basicamente, o caso patológico, porque é mais instrutivo do que um plano ideal. Aqui, estamos selecionando tudo de request onde o caminho da URL é igual a SLC checkout. Não há índice no caminho da URL.
23:21 Então, temos uma varredura sequencial e uma quantidade enorme de shared read, e o tempo de execução é dominado pela leitura de páginas do disco, que depois descartamos. Vou comparar isso com outro comando. Vamos criar um índice em requests. Quer dizer,
23:51 um índice no caminho da URL e depois vamos executar o mesmo comando novamente, em que o caminho da URL é igual a /checkout. Agora podemos ver que temos uma varredura de índice. Se observarmos o shared read, subindo aqui, veremos 106.533 nos buffers; aqui, temos 3.001. Então, dá para ver que isso é muito menor, basicamente uma ordem de grandeza. É isso que
24:24 queremos. Queremos acessar menos páginas, e isso fará as coisas rodarem muito mais rápido. Mas agora vou executar o comando drop index, porque quero deixar a versão lenta de volta para mais tarde. Agora, um hábito que vale a pena desenvolver: vou executar \timing on. Isso ativa a medição de tempo. Agora, cada solicitação informa seu tempo de relógio. Isso está no arquivo psqlrc do repo, junto com algumas outras configurações que uso. Quer dizer, está
24:56 nesse arquivo, se você conferir o repo no GitHub. Esta é a seção que explica a maior parte do design do TimescaleDB. Então, acompanhe, mesmo que pareça algo de baixo nível. O PostgreSQL não lê linhas do disco. Ele lê páginas. E uma página tem 8 quilobytes. Toda leitura, toda gravação e toda busca em índice acontece em unidades de 8 quilobytes. Então, se suas linhas tiverem, digamos, 100 bytes, cabem aproximadamente de 50 a 100 linhas em uma
25:29 página, dependendo da sobrecarga. E, quando você pede uma linha, o banco de dados lê os 8 quilobytes inteiros e entrega a linha que você queria. Isso tem uma consequência que se torna todo o argumento a favor do column store mais adiante. Se as linhas de que você precisa estiverem espalhadas por muitas páginas, você paga por cada página. Dez linhas em uma página são uma leitura. Dez linhas em dez páginas diferentes são dez leituras. As mesmas dez linhas. Guarde isso
25:59 na cabeça. Isso vai voltar daqui a pouco no curso e é a chave para dois recursos diferentes. Agora, a segunda metade disso é sobre gravações. O PostgreSQL usa MVCC, controle de concorrência multiversão. Resumindo: quando você atualiza uma linha, o PostgreSQL não a sobrescreve. Ele grava uma nova versão da linha e marca a antiga como morta. Quando você exclui uma linha, não remove
26:30 nada. Apenas marca a linha como morta. Por quê? Para que outras transações que já estejam lendo aquela linha não tenham o chão arrancado debaixo dos pés. É um design genuinamente bom e é por isso que leitores no PostgreSQL nunca bloqueiam escritores. Mas isso significa que as linhas mortas se acumulam. Elas continuam ocupando páginas. Sua tabela fica maior mesmo quando você exclui dados dela. Então, o comando vacuum precisa entrar em ação e fazer a limpeza.
27:02 Vamos concretizar isso, porque fica mais fácil de entender quando você vê. Vou criar uma nova tabela chamada dead row demo e gerar alguns itens para as linhas. Vamos conferir o tamanho. São 72 megabytes, porque geramos os dados e depois verificamos o tamanho da tabela. Certo. Agora vou
27:33 excluir muitos dados da tabela e depois vamos verificar o tamanho de novo. Você pode ver que o tamanho é o mesmo. Excluímos 80% das linhas, e a tabela não diminuiu nem um byte. Agora vou executar outro comando: select n_live_tup e n_dead_tup da tabela dead row demo. Agora vou executar vacuum full e
28:04 podemos ver que o tamanho diminuiu. Então, vacuum full adquiriu um bloqueio exclusivo na tabela. Nada podia ler nem gravar nela enquanto o comando era executado. Em uma tabela de teste com 500 mil linhas, isso é instantâneo. Em uma tabela de produção com 200 milhões de linhas, pode levar muito tempo. Então, aqui está uma armadilha em que todo mundo cai. Você tem uma tabela enorme de eventos. Decide manter 90 dias de dados. Então, escreve um
28:36 cron job noturno, como delete from events where time nos últimos 90 dias. Basicamente, esse job cria milhões de linhas mortas todas as noites. O vacuum precisa ir atrás delas. Sua tabela não diminui. Seus índices ficam inchados e fragmentados. O próprio delete demora mais a cada noite, à medida que a tabela cresce, e compete por I/O com o tráfego real. A resposta do TimescaleDB para isso, e eu
29:06 O motivo de eu estar antecipando isso é que, como é uma vitória tão clara, descartar dados antigos passa a ser como excluir um arquivo. Não é excluir linhas, mas remover um chunk inteiro da tabela como uma unidade. Milissegundos, nenhuma linha morta, nenhuma pressão de vacuum, nenhum inchaço. Essa é a seção sobre retenção, que vamos discutir mais adiante. Agora, uma objeção: o PostgreSQL já tem particionamento integrado. Desde a versão 10, você pode dividir uma tabela em partes por intervalo. Então, por que precisamos de uma extensão?
29:39 Bem, vamos criar isso manualmente e descobrir. Estes são os cinco minutos mais úteis daqui, porque, depois de fazer isso manualmente, você entende exatamente o que o TimescaleDB está automatizando. Certo, vou criar esta tabela events_native. E a tabela ainda não pode armazenar nada. Ela é um roteador sem destinos. E temos estes três campos: time, device e value. Mas, se tentarmos inserir algo,
30:12 não vai funcionar. Podemos ver que não foi encontrada nenhuma partição da relação necessária. Então, primeiro tenho que criar a partição. É assim que vou criá-la. Vou criar uma partição. Vou executar CREATE TABLE e, em seguida, dar o nome, PARTITION OF events_native para estes valores e, depois, uma PARTITION OF events_native para estes outros valores.
30:46 Certo, criamos essas tabelas. Agora a inserção deve funcionar. Isso, inserimos na tabela. E isso realmente traz um benefício concreto. O planner pode ignorar partições por completo. Então, vou executar EXPLAIN e conferir o plano. Podemos ver o plano que ele tem aqui. Uma partição no plano. E dá para ver
31:17 que temos esta partição aqui. A tabela de agosto nem sequer é considerada, assim como a outra partição. Isso é eliminação de partições, e é a mesma ideia central que o TimescaleDB usa. Então, o que está faltando? Bem, há quatro coisas que o particionamento nativo não faz. Primeiro, você precisa criar cada partição por conta própria antes que os dados cheguem. Esqueça uma e suas inserções começarão a falhar em produção à meia-noite do primeiro dia do mês. As pessoas resolvem isso com tarefas cron ou
31:48 pg_partman, e é só mais uma coisa que pode dar errado. Segundo, não há dimensionamento automático. Você escolhe os limites. Erre e terá 4.000 partições minúsculas, o que torna o planejamento lento porque o planner considera todas elas, ou três partições enormes, o que anula o propósito. Terceiro, não há gerenciamento do ciclo de vida. Não existe um recurso integrado para comprimir partições com mais de uma semana ou descartar partições com mais de um ano. Você precisa escrever isso por conta própria. Quarto, e este
32:19 é o mais importante: não há recursos de séries temporais. O particionamento organiza o armazenamento. Ele não oferece agregados atualizados incrementalmente, compressão colunar, preenchimento de lacunas ou esboços de percentis. Esses são os recursos que realmente tornam a análise rápida e compõem a maior parte da próxima seção. Então, uma hypertable é uma tabela particionada. O TimescaleDB não substitui
32:49 esse mecanismo. Ele se apoia nele. O que você está pagando pela extensão é a automação e a camada de análise por cima. Certo, aquela tabela era só um exemplo por enquanto. Então, vamos apagar essa tabela. A última peça da base prepara o terreno para o melhor recurso do TimescaleDB. A consulta do seu dashboard está lenta. Então, a solução óbvia é não calcular tudo sempre. Calcule
33:20 uma vez, armazene o resultado e leia o resultado armazenado. Esse é o primeiro impulso de todo mundo, e é um bom impulso. O PostgreSQL oferece views materializadas exatamente para isso. Então, é assim que eu criaria uma view materializada. A view materializada request_hourly_mv e, em seguida, os diferentes campos que estamos adicionando a ela. Enquanto isso é executado, observe o que está acontecendo. Ela está lendo a tabela inteira para criar o resumo de cada
33:51 linha desde o início dos tempos. E então vamos consultar a view, o que é instantâneo. Obviamente, é uma tabela pequena, pré-computada. Mas o problema aparece quando, digamos, queremos atualizar os dados. Vamos executar REFRESH MATERIALIZED VIEW. E isso pode levar algum tempo. Essa atualização recalculou tudo do zero, todo o histórico, para incluir os últimos cinco minutos ou algo assim de
34:21 dados novos. Então, as views materializadas têm dois problemas. Basicamente, dois obstáculos intransponíveis. O custo da atualização cresce para sempre porque é proporcional à quantidade total de dados, não ao que mudou. E a atualização simples usa um lock que bloqueia as leituras. Existe REFRESH CONCURRENTLY, que não bloqueia, mas exige um índice único e é ainda mais lenta. Então, as pessoas descem um nível e
34:53 fazem isso manualmente, basicamente criam a solução por conta própria: uma tabela de resumo de verdade, atualizada por um trigger ao inserir. Na verdade, isso é pior, mas seria algo assim. Criamos esta tabela request_summary. Criamos esta função que vai atualizá-la. Parece razoável, mas vamos pensar no que acontece sob carga. Cada inserção em requests precisa atualizar uma linha em request_summary. E, para qualquer hora e domínio, é sempre a mesma linha. Cada
35:24 requisição para example.com nesta hora disputa uma única linha. Os locks em nível de linha fazem com que inserções concorrentes entrem em fila umas atrás das outras. E aqui está a questão das filas: a degradação não é gradual. Está tudo bem, está tudo bem, está tudo bem, e então você cruza um limite e tudo desmorona, porque cada transação em espera mantém recursos enquanto aguarda. A cada nova consulta, o tempo de espera aumenta, até que o banco de dados fica sobrecarregado por uma confusão de
35:55 consultas bloqueadas esperando para serem executadas. Esse é o modo de falha. Não fica lento; fica travado. E, mesmo que você resolva a contenção, agora tem um trigger para manter a lógica de backfill de dados históricos, a lógica de correção para atualizações e exclusões na tabela de origem, e toda uma nova categoria de bugs em que o resumo e os dados brutos não correspondem. Mas há outra opção. Os agregados contínuos do TimescaleDB resolvem exatamente isso. Eles mantêm um resumo materializado,
36:28 mas a atualização é incremental. Eles acompanham quais buckets de tempo realmente mudaram e recalculam apenas esses. Então, não há triggers, contenção de locks nas gravações nem recálculo completo, e eles lidam com atualizações e exclusões na origem. Vamos falar mais sobre isso a seguir. É um ótimo recurso da extensão, mas você precisava ver por que as abordagens óbvias falham; caso contrário, a abordagem incremental só parece uma view materializada um pouco melhor. Não é. É um mecanismo diferente. Então, vamos apagar todas essas
37:01 tabelas e funções que acabamos de criar. Certo, vamos para a próxima parte e falar sobre o problema desta seção. Esta seção foi inspirada no livro Make Analytics Fast, de Tobias Petri. Antes de falar de recursos, precisamos de um problema que valha a pena resolver e de dados para resolvê-lo. Vamos usar um cenário de análise da web em toda esta parte, porque é algo que todo mundo entende e tem exatamente o formato certo. Então, imagine o seguinte; e este é
37:32 também o cenário que usamos em alguns dos exemplos de código anteriores. Digamos que você trabalhe em um serviço que atende muitos sites: uma CDN, uma plataforma de hospedagem, um gateway de API, tanto faz. Toda requisição HTTP que passa por ele é registrada. Seus clientes acessam um dashboard e querem ver o tráfego: requisições ao longo do tempo, tempos de resposta, taxas de erro, quais caminhos estão lentos, com os dados divididos por domínio e serviço. Funciona muito bem por um
38:02 ano, e então deixa de funcionar. O dashboard que levava 200 milissegundos agora leva 40 segundos, e os tickets de suporte estão se acumulando. Imagine que o colega que configurou tudo desapareceu sem deixar vestígios. Você sabe que alguém criou isso. Funcionava. Essa pessoa foi embora, e agora o sistema é seu, mas você não sabe por que nenhuma das decisões foi tomada. Então, vou mostrar os dados de verdade. Isto é o que mostrei antes: apenas as requisições para /, e vamos ver todos esses dados. Esta é a tabela. Ela é larga de propósito, com uma linha por
38:34 requisição HTTP e tudo sobre ela em uma coluna: timestamp, domínio, serviço, servidor que a atendeu, caminho, status HTTP, duração da resposta dividida em componentes, informações geográficas e detalhes do user agent. Então, podemos ver mais algumas coisas aqui. Agora vou mostrar a contagem. Vamos executar SELECT COUNT(*) FROM requests e obter o tamanho total da relação. Podemos ver que há
39:06 mais de 5 milhões de linhas, e são 16 kilobytes. Então, foi isso que coloquei neste computador. Mas o repo na descrição tem um script gerador com opções. Assim, você pode ajustar a escala ao que sua máquina suporta e testar com tamanhos diferentes da tabela. Agora, a consulta do dashboard é a que vamos tornar rápida. Vamos executá-la e… esta é a consulta que vamos tornar
39:36 rápida. Vamos voltar a ela depois de cada recurso. Então, vamos executá-la várias vezes e ver como ela muda. Basicamente, estamos selecionando e obtendo todas essas informações diferentes da nossa tabela, verificando onde o domínio da URL é igual a example.com e se o intervalo está nos últimos 7 dias. Depois, vamos agrupar por hora e ordenar por hora. Podemos ver todos os resultados aqui e ver que isso
40:09 levou 2.341 milissegundos. Agora vou executá-la três vezes. Vou executá-la de novo agora. Quero explicar por que estou fazendo isso, porque é um bom hábito. Como você pode ver, o número já está diferente, e pode variar bastante. A primeira execução paga o custo de ler os dados frios do disco.
40:41 A segunda e a terceira — aqui está a terceira — vão encontrar os dados na memória. Isso não é nenhum truque. É a diferença entre um cache frio e um aquecido. Se você fizer um benchmark sem saber qual dos dois está observando, vai tirar conclusões confiantes a partir de ruído. Então, este terceiro número é o que vamos usar de fato. Ele será nossa referência para esta consulta: 2.211
41:11 milissegundos. É assim que, executando a consulta algumas vezes, conseguimos um número mais preciso. Essa é nossa referência, mas o tempo de relógio de parede é a medida menos confiável. Então, vamos também obter a medida mais sólida, aquela que não muda entre as execuções. Aqui, estamos executando EXPLAIN com ANALYZE e BUFFERS e obtendo a hora e a contagem das requisições, agrupadas por hora. Como você pode ver, isso nos dá
41:44 muitas informações. Também podemos ver o tempo de planejamento e o tempo de execução aqui embaixo. Mas o que quero observar são estes buffers: o número de shared hits, que é 235.761. Esse é o número de páginas de 8 kilobytes que a consulta acessou. Estamos lendo uma quantidade enorme de dados para produzir cerca de 168 linhas de saída. E é por isso que
42:15 buffers são melhores do que milissegundos como medida. A contagem de páginas quase não muda entre as execuções. Ela não depende de o cache estar aquecido, de o Docker estar limitando o desempenho ou de alguma outra coisa ter sido ativada no seu laptop. É uma medida direta de quanto trabalho o banco de dados fez. E toda otimização deste curso trata, fundamentalmente, de fazer menos trabalho. Milissegundos são o que seus usuários sentem. Buffers são aquilo sobre o que você consegue realmente raciocinar. Vamos acompanhar os dois. E, quando eles divergirem, confie nos buffers. E
42:46 há três problemas distintos misturados nesse número, por isso não existe uma solução única. Estamos lendo linhas de que não precisamos: todas as linhas de todos os outros domínios, porque as linhas de example.com estão espalhadas por toda parte. Hypertables e o columnstore resolvem isso. Estamos lendo colunas de que não precisamos. Esta consulta acessa quatro colunas entre dezenas, mas o armazenamento baseado em páginas traz todas elas junto. O columnstore resolve isso. E estamos recalculando uma resposta que não pode ter
43:18 mudado. As contagens por hora da terça-feira passada estão fixas para sempre, mas as recalculamos a cada carregamento do dashboard. Continuous aggregates resolvem isso. E mais uma coisa: isto é uma migração, não uma reescrita. Antes de começarmos, é importante esclarecer o escopo, porque essa é uma pergunta que as pessoas realmente fazem: você não migra o banco de dados inteiro para o TimescaleDB. Suas tabelas de usuários, pedidos e produtos continuam exatamente como estão, como tabelas normais do PostgreSQL. Elas são pequenas.
43:49 Elas recebem atualizações o tempo todo. Precisam de chaves estrangeiras reais e restrições de unicidade. E não são dados de séries temporais. Só as tabelas de fluxo contínuo viram hypertables: requisições, eventos, métricas, traces; todo o resto permanece intocado. E o melhor é que tudo isso fica no mesmo banco de dados. Então, você ainda pode fazer junções entre elas, como faremos em um dos nossos projetos. Certo, primeiro recurso: hypertables. Vamos falar sobre elas. Uma
44:20 hypertable parece uma tabela, mas fisicamente é composta por muitas tabelas. Sua aplicação faz requisições e insere dados em requests. Para sua aplicação, seu ORM e seu SQL, há apenas uma tabela chamada requests. Por baixo, o TimescaleDB divide os dados por tempo em partes chamadas chunks e encaminha automaticamente cada linha para o chunk correto. Então, por que usar chunks? Há três vantagens, e elas correspondem diretamente aos três problemas da primeira parte. As consultas
44:52 ignoram completamente os chunks irrelevantes. Você pede os últimos sete dias, e o planejador exclui todos os chunks fora desse intervalo antes de ler uma única página. Ele não os examina de forma eficiente; nem sequer os abre. E os índices permanecem pequenos. Este ponto é sutil e é o mais importante para o throughput de gravação. Cada chunk tem seu próprio índice. Assim, o índice no qual você está gravando ativamente cobre apenas o chunk atual, o que significa que cabe na memória e que as inserções não precisam acessar o
45:24 disco para manter o índice. Compare isso com um índice gigantesco que cobre dois anos de dados e não cabe na memória. Nesse caso, cada inserção vira uma gravação aleatória em disco. Além disso, excluir dados antigos é como excluir um arquivo. O problema da retenção da parte anterior fica resolvido por construção. Você descarta um chunk, não linhas. Vamos ver a conversão completa. Tenho uma cópia do esquema da tabela sem a hypertable aplicada. Podemos fazer isso ao vivo. Aqui está a
45:58 cópia. Para converter, basta executar CREATE HYPERTABLE na tabela de requisições, chamada request_ht, e separá-la por intervalo de tempo, com intervalos de um dia. É só isso. Esse é o recurso: é assim que se cria uma hypertable. Três coisas sobre essa chamada. O by_range aqui é a sintaxe moderna para definir dimensões. Se você ler tutoriais mais antigos,
46:29 talvez veja outro método para criar dimensões. Ele ainda funciona, mas é a forma legada. A coluna precisa ser NOT NULL, porque cada linha tem que ser encaminhada para algum lugar. Normalmente, você faz isso em uma tabela vazia, embora haja uma opção migrate_data para converter uma tabela que já tenha linhas. Funciona, mas é lento e bloqueia a tabela. Então, reserve um período de manutenção. Agora, vamos ver o que
47:00 acontece quando os dados chegam. Neste momento, não há nada aqui. É uma tabela vazia, sem chunks. Diferentemente do particionamento nativo, não precisamos criar nada com antecedência. Agora, vamos inserir na nossa nova hypertable informações da tabela original, considerando os dados dos últimos 3 dias. Inserimos vários registros. Isso levou 21 segundos. Agora, vamos mostrar os chunks. E agora
47:32 podemos ver quatro chunks diferentes. Eles apareceram sob demanda, um por dia, criados conforme as linhas chegavam. É essa automação que você está obtendo. Não há tarefas cron, nem pg_partman, nem alertas de pager à meia-noite no primeiro dia do mês. Vou mostrar as tabelas reais. Vamos fazer uma consulta diretamente nelas. Esta consulta vem direto dessas tabelas de chunks. Como você pode ver, conseguimos consultá-las. Elas são tabelas reais, com nomes, e
48:03 têm um esquema. Se precisar, você pode consultar uma delas diretamente, mas normalmente nunca precisa fazer isso. Vou mostrar o pruning. Vou executar EXPLAIN em request_ht para o último dia. Como você pode ver, estamos acessando apenas um dos chunks. Os outros nem aparecem no plano. Agora, vou mostrar o caso que dá errado. Vou executar EXPLAIN SELECT COUNT em request_ht
48:34 WHERE o domínio da URL é igual a example.com. Como você pode ver, estamos acessando todos os chunks. Sem filtro de tempo, não é possível fazer pruning: a consulta precisa olhar em todos os lugares. Isso nos leva ao próximo ponto: sempre filtre por tempo. Essa é a regra número um para usar o TimescaleDB e também o principal motivo pelo qual as pessoas dizem: “Instalei, mas não ficou mais rápido”. Se sua consulta não filtrar pela coluna de tempo, você
49:04 você não obtém nenhum benefício. A exclusão de chunks é o fundamento sobre o qual todo o resto se apoia. Ao escrever consultas, código ORM ou um endpoint de API, o intervalo de tempo não é opcional. É o preço de entrada. Então, anote isso — ou, melhor, lembre-se desta regra, porque vamos voltar a ela. Então, qual deve ser o tamanho de um chunk? Essa é a única decisão realmente importante que você precisa tomar, e errar para qualquer um dos lados traz problemas diferentes.
49:35 Os chunks podem ser pequenos demais, e aí você acaba com milhares deles. O tempo de planejamento de cada consulta aumenta, porque o planejador precisa considerar e excluir cada um. A sobrecarga de metadados cresce, e você consome o equivalente à memória de uma conexão só para planejar uma consulta que retorna quatro linhas. Ou os chunks podem ser grandes demais, e aí você perde as duas coisas para as quais eles servem. A poda fica grosseira: uma consulta por uma hora lê um mês, e o índice dos chunks ativos deixa de caber na
50:06 memória, o que prejudica sua taxa de inserção. Então, há duas diretrizes em que você deve pensar. Primeiro, mantenha menos de cerca de mil chunks por hypertable. Esse é um limite máximo flexível para a sobrecarga de planejamento. Se você estiver prestes a ultrapassá-lo, aumente o intervalo ou adicione uma política de retenção para descartar os chunks antigos e manter a contagem sob controle. Além disso, mantenha os índices dos chunks ativos abaixo de cerca de 25% da
50:37 sua RAM. Este é o ponto importante. Você só insere nos um ou dois chunks mais recentes, e esses índices precisam ficar na memória. O restante pode ficar no disco, porque você raramente o acessa. Então, vou fazer as contas, porque dizer “depende” não serve para nada sem um exemplo concreto. Vamos executar este comando aqui e obter o número total de linhas dos últimos três dias e também os bytes
51:07 por linha, que aqui são 406. Então, suponha que estejamos recebendo 100 milhões de solicitações por dia, o que é razoável para um serviço de médio porte. Isso dá cerca de 1.200 por segundo. Com 400 bytes por linha, são 40 gigabytes de tabela por dia. Digamos que os índices acrescentem 30%, e chegamos a 52 gigabytes por dia no total. Se eu tiver 32 gigabytes de
51:39 RAM, 25% disso são 8 gigabytes para os índices dos chunks ativos. Meu volume diário de índices é de 3 gigabytes. Então, um chunk de um dia consome 3 dos meus 8 gigabytes disponíveis. É confortável, mas um chunk de uma semana teria 21 gigabytes de índices. Muito acima do limite. Então, chunks diários. Depois, confira a contagem: chunks diários e retenção de 90 dias dão 90 chunks, bem abaixo de mil. Se eu mantivesse cinco anos sem retenção, seriam 1.800 chunks, e eu teria que reconsiderar: usar chunks semanais ou uma
52:10 política de retenção. E a política de retenção é a melhor resposta. Então, se você não quiser fazer as contas, aqui vai um resumo: alto volume, dezenas de milhões de linhas por dia ou mais, um dia; volume moderado, milhões por dia, uma semana; baixo volume, milhares por dia, um mês. E, se realmente não tiver certeza, comece com um dia: essa é a opção mais comum na prática. É o que os dois projetos que vamos fazer neste curso usam. Você pode alterar isso com um comando como este: defina o intervalo de tempo entre chunks, e aqui você pode defini-lo como sete
52:42 dias. Uma ressalva importante: isso só afeta os novos chunks. Os chunks existentes mantêm para sempre o intervalo com que foram criados. Não há reescrita. Então, você pode acabar com uma hypertable que tem chunks diários no primeiro ano e semanais depois disso. E tudo bem. Funciona. Mas isso significa que você deve pensar um pouco nisso desde o início, em vez de planejar corrigir depois. E também é possível particionar por mais do que o tempo. Esta é outra possibilidade, e estou
53:15 mencionando-a principalmente para que você saiba que existe e quando não usá-la. Você pode adicionar uma segunda dimensão de particionamento, geralmente um identificador de tenant ou dispositivo. Assim, cada chunk é dividido ainda mais por hash, mas eu diria que provavelmente você não precisa disso. Isso era muito mais importante nas implantações antigas com vários nós, em que os dados eram distribuídos entre servidores. Em um único nó, seu principal uso é paralelizar a E/S entre vários discos físicos ou dividir uma contagem enorme de linhas por chunk.
53:46 E isso tem um custo real. Multiplica a contagem de chunks pelo número de partições de hash. Quatro partições de hash transformam 90 chunks em 360. Assim, você fica mais perto do limite de mil chunks, sem necessariamente obter algum benefício. A configuração de segmentação das columnstores, que será abordada mais adiante no curso, oferece a maior parte do que as pessoas querem da partição por espaço: dados agrupados por tenant. Assim, as consultas podem ignorar os outros
54:16 tenants sem multiplicar a quantidade de chunks. Então, saiba que essa opção existe e prefira segmentar por primeiro. Certo, vamos apagar essa cópia, porque daqui em diante vamos trabalhar na tabela real de solicitações, que vou converter agora. Vamos criar uma hypertable a partir da tabela requests — na verdade, eu já fiz isso. Ela já é uma hypertable, mas a primeira conversão pode levar um tempo. Vamos mostrar os chunks. Temos quatro chunks. E, antes de medir
54:49 qualquer coisa, vamos falar de algo que às vezes as pessoas esquecem: executar ANALYZE. Vou executar ANALYZE requests. Converter uma tabela em hypertable move os dados para novos chunks físicos, e esses novos chunks ainda não têm estatísticas para o planejador. O PostgreSQL planeja com base em estimativas até você executar ANALYZE. Então, faça isso depois de cada conversão. Se você pular essa etapa, vai
55:19 obter planos ruins e pode culpar a coisa errada. Agora vamos executar novamente a consulta de referência do painel. Esta é a consulta que executamos antes, mas agora temos uma hypertable e vamos ver quanto tempo ela leva. Vamos executá-la algumas vezes, porque a primeira provavelmente será muito mais lenta. A migração de dados acabou de reescrever todas as linhas em novos chunks. Então, nada está no cache. Se a executarmos de novo, podemos basicamente
55:53 ver um número estabilizado. Às vezes, é bom executá-la três vezes. Podemos ver que este número não representa uma melhora clara. É parecido, mas talvez um pouco mais lento. A primeira vez que a executamos, o cache era o antigo, porque acabamos de reescrever todas as linhas no disco para os novos chunks. Então, como eu disse, o resultado aqui é basicamente um pouco pior.
56:24 E esse é o resultado honesto, e é a coisa mais útil que vai acontecer nesta seção. Veja o que pedimos: todas as linhas dos últimos sete dias. Se eu mostrar isso, estamos tentando obter tudo dos últimos sete dias, e temos sete dias de dados. Então, pedimos a tabela inteira. A exclusão de chunks só pode ignorar chunks que estejam fora do seu filtro de tempo. Nosso filtro abrange todos eles. Então, não ignoramos nenhum. Lemos
56:57 exatamente a mesma quantidade de dados que antes e acrescentamos um pouco de trabalho, porque agora o planejador planeja a consulta em oito chunks e acrescenta oito varreduras, em vez de executar uma única varredura sequencial simples. Vamos conferir também os buffers. Lembram que verificamos os buffers antes? Agora podemos ver que eles praticamente não mudaram. O número é quase igual. Basicamente, são as mesmas páginas. Não economizamos nem uma unidade de trabalho no banco de dados.
57:29 E a contagem de buffers confirma isso, independentemente do que o cronômetro indicou. Então, o particionamento não é propriamente um recurso de velocidade. Não é realmente um recurso de desempenho. Os chunks possibilitam ignorar dados. Eles não ignoram nada por conta própria. Se sua consulta pede todos os dados, você recebe tudo, mais um pouco de sobrecarga. Essa é a decepção mais comum com o TimescaleDB. Alguém converte uma tabela, executa novamente a consulta do painel, não vê nenhuma melhora e conclui que a extensão não funciona. Mas a extensão funciona perfeitamente. A
58:01 consulta pede todos os dados. Agora, vamos fazer uma pergunta que um painel realmente faria. Aqui, vamos procurar os dados das últimas seis horas, em vez dos últimos sete dias. Agora vemos que há apenas um chunk no plano. E, se olharmos a contagem de buffers, ela é muito pequena. Agora são só três. Ou seja, uma pequena fração do valor de referência. Aí está: um chunk em
58:34 o plano, em vez de oito e uma fração das páginas lidas. A mesma tabela, os mesmos dados, um filtro de tempo que permite ao planejador descartar sete, quer dizer, muitas páginas da tabela antes de ler uma única linha. E note que estou falando de páginas, não de milissegundos. Em um conjunto de dados tão pequeno, tudo pode caber na memória, e os tempos medidos podem ficar próximos. A contagem de buffers mostra a diferença real. E, em uma tabela de produção cujos dados não cabem na RAM, essa diferença em páginas é a diferença em segundos. Então, essa é a
59:05 forma de cada ganho neste curso. Não é que fique magicamente mais rápido; é que demos ao banco de dados um jeito de não fazer o trabalho. A seguir, vamos garantir que encontremos linhas com eficiência dentro de um chunk. Então, vamos falar sobre índices e descarte de chunks. A exclusão de chunks nos levou da tabela inteira até os chunks certos. Agora, vamos localizar linhas com eficiência dentro de um chunk. E a indexação em uma hypertable tem uma diferença estrutural em relação à indexação em uma tabela normal. Quando você cria um índice
59:38 em uma hypertable, você não recebe um índice: recebe automaticamente um índice por chunk, inclusive nos chunks criados no futuro. Então, aqui vou criar um índice em requests, com URL domain e time em ordem decrescente. Isso vai criar um índice por chunk, e agora terminou. Então, agora vamos basicamente contar os índices. E agora temos oito aqui, um por chunk. E é por isso que as hypertables
1:00:10 conseguem sustentar taxas de gravação que uma tabela normal não consegue. Pense no caminho de inserção. Em uma tabela normal com dois anos de dados, esse índice é enorme, maior que a RAM. Cada inserção precisa encontrar seu lugar nessa B-tree gigantesca, e as páginas necessárias provavelmente não estão na memória. Então, ocorre uma leitura aleatória do disco, seguida de uma gravação aleatória no disco. A E/S aleatória é a operação mais lenta que um banco de dados realiza. Em uma hypertable, as inserções
1:00:42 vão apenas para o chunk atual. O índice desse chunk contém um dia de dados. É pequeno. Está ativo. Está na memória. A inserção é uma operação de memória, mais uma gravação sequencial no WAL. Essa é a diferença entre milhares de inserções por segundo e centenas de milhares. Então, na própria definição do índice, coloquei URL domain primeiro e time em segundo. Essa ordem é intencional. A
1:01:13 regra geral é colocar primeiro as colunas com igualdade e depois a coluna de tempo. Você filtra URL domain igual a example.com. Isso é uma igualdade. Ela restringe a busca a um ponto específico da B-tree. Depois, time é maior ou igual a algum valor. Isso é uma varredura por intervalo a partir daquele ponto. Se você inverter a ordem, vai percorrer um intervalo de tempo enorme e filtrar os domínios depois, o que dá muito mais trabalho. E time em ordem decrescente é porque as consultas de séries temporais quase
1:01:43 sempre querem os dados mais recentes primeiro. Isso torna quase gratuita a consulta order by time em ordem decrescente limit 100, para mostrar os resultados mais recentes. Agora, aqui está o erro que vai pegar você, e ele decorre diretamente daquela regra de sempre filtrar por tempo que mencionei antes. Vou obter um ID para este próximo exemplo. Digamos que eu queira uma solicitação específica pelo ID. Então, estou selecionando a solicitação em que o request
1:02:14 ID é igual ao ID que acabei de obter. E veja só: ele verificou todos os chunks. Podemos ver este chunk, este chunk, este chunk. Cada um tem um índice em request ID. Então, cada busca é rápida, mas ele fez uma busca por chunk porque nada informou em qual chunk a linha está. Se tivéssemos 90 chunks, seriam 90 consultas ao índice para buscar uma linha. Ainda retornaria em milissegundos com um conjunto de dados pequeno. Então, isso
1:02:46 parece funcionar no desenvolvimento, mas, com dois anos de chunks diários, são 730 consultas e já não parece tão bom. Então, aqui está a solução. Primeiro, vou obter o request ID e também o horário dessa solicitação. Depois, vou fazer a consulta com o request ID e inserir o request ID, além do horário. E agora podemos ver que há apenas um chunk e uma consulta ao índice. Então, a regra de projeto é: sempre que buscar uma linha pelo ID, passe também o timestamp.
1:03:17 Isso parece inconveniente, mas pense de onde veio esse ID. Veio de uma visualização em lista, de um resultado de busca, de um payload de webhook, de uma linha de log; e tudo isso já tinha o timestamp ao lado do ID. Basta carregá-lo pela sua API e pelas URLs. Se você realmente não puder fazer isso — digamos que o ID venha de terceiros que só fornecem o ID —, há uma saída. Agora, a limitação real das
1:03:48 hypertables, aquilo que você deve saber antes de se comprometer com essa abordagem: todo o resto deste curso são vantagens, mas há uma restrição real. Basicamente, não existe um índice único global. Então, se eu tentar criar um índice único em requests, recebemos um erro. Ele rejeita a operação. O motivo é estrutural, não é exatamente uma falha de implementação. Um índice único precisa conseguir responder se esse valor existe em algum lugar da tabela. Mas não há um único índice que abranja a hypertable. Há 90 índices separados, um por
1:04:21 chunk. Para garantir a unicidade global na inserção, o PostgreSQL teria que verificar todos os 90 em cada inserção. Isso destruiria justamente o desempenho que as hypertables existem para proporcionar. Então, a regra é: qualquer índice único — ou chave primária — em uma hypertable precisa incluir a coluna de particionamento. Então, vamos executar ALTER TABLE request ADD PRIMARY KEY (time, request ID). E isso funciona porque agora a unicidade só é garantida dentro de um chunk. O valor de time
1:04:52 determina o chunk. Portanto, o índice de um único chunk basta para verificar. E é isto que você está basicamente abrindo mão: duas linhas com o mesmo request ID em timestamps diferentes agora são válidas. Geralmente, isso não tem problema, mas às vezes tem. Se o ID for uma referência de pagamento, você vai querer unicidade global garantida. Há uma solução realmente elegante para isso, e é meu truque favorito. A ideia é usar um ID que contenha o próprio timestamp. Assim,
1:05:25 só o ID basta para encontrar o chunk, e você obtém unicidade global de graça. Isso é UUID v7. O mesmo formato UUID de 128 bits que todo mundo já usa, mas os primeiros 48 bits são um timestamp em milissegundos Unix, seguido por bits aleatórios. Então, UUIDs v7 são ordenados cronologicamente, e você pode extrair o horário de criação do próprio ID. Acabei de executar este SELECT UUID v7 algumas vezes; veja os caracteres iniciais.
1:05:58 Todos vão começar com os mesmos caracteres. Veja, na verdade, eles vão até 722. Então, os caracteres iniciais são quase idênticos. Esse é o timestamp, gerado em um segundo. Eles foram gerados com um segundo de diferença e, então, ficam ordenados. Então, aqui estamos gerando isso de novo, mas chamando esta função UUID timestamp
1:06:28 e podemos recuperar o timestamp. Essa função é fornecida pelo TimescaleDB. O PostgreSQL 18 também inclui UUID v7 nativo. Então, dependendo da sua versão, talvez você tenha duas opções disponíveis, e ambas servem. Basta manter a consistência. Certo, vamos falar sobre esta restrição CHECK. Isso vai amarrar tudo. Estou criando esta tabela events_v7 com ID, time e JSON. Depois, vou criar uma hypertable. Mas veja o que estamos fazendo: estou executando uma verificação. Ela faz algo inteligente. Basicamente, garante que o timestamp embutido no ID seja igual à coluna de particionamento, o que significa que, tendo apenas um ID, você pode calcular o horário; isso significa que sempre pode encontrar o chunk; e isso significa que o ID sozinho agora é uma chave
1:07:00 única global na prática. Um ID duplicado teria que ter o mesmo timestamp embutido. Então, ele ficaria no mesmo chunk, onde o índice único do chunk o detectaria. Você obtém unicidade global e buscas em um único chunk com uma só restrição. Então, vou inserir na tabela events_v7. Estamos passando o timestamp e, depois,
1:07:32 “hello world”. Esses são os dados. Isso gera dois IDs UUID diferentes. Então, vou fazer isso corretamente, com NEW ID AS e, depois, SELECT UUID v7 AS id, INSERT INTO events, com as colunas id, time e data, e SELECT id e, depois, FROM new_id. Se executarmos isso no código da aplicação, fica mais limpo. Você gera o UUID no seu
1:08:03 Olá, mundo. Esses são os dados. Então, isso gera dois IDs UUID diferentes. Deixe-me fazer isso corretamente, com new_id AS e depois SELECT uidv AS id INSERT INTO events (id, time, data) SELECT id e então de new_id. Se executarmos isso no código da aplicação, fica mais limpo. Você gera o UUID no seu
1:08:34 app, derive o timestamp a partir dele e passe ambos. É exatamente isso que um dos nossos projetos vai fazer mais adiante, e você verá o código Python para isso. Então, vou executar isto para obter o ID recente que inserimos na tabela. Vamos pegar um ID dessa tabela e, então, consultá-lo, passando o ID aqui. E o timestamp será exatamente o mesmo ID aqui. Então
1:09:04 basicamente, estamos inserindo o mesmo ID UYU duas vezes. E então podemos ver este plano de consulta aqui. Um chunk usando apenas um ID. Mas há uma coisa importante a ter em mente, e isso pode custar uma tarde inteira se você simplesmente não entender. Os timestamps de UUID V7 têm precisão de milissegundos. Timestamp TZ tem precisão de microssegundos. Então, se você gerar um timestamp de outra forma agora
1:09:36 por exemplo, e compará-lo com o que está embutido no UUID v7, eles não serão iguais, e sua restrição de verificação rejeitará a linha. Portanto, sempre derive o timestamp do UUID usando o timestamp do UUID; nunca os gere de forma independente esperando que coincidam. Uma coisa que você deve saber é que índices não são gratuitos e, em uma tabela com alta taxa de ingestão, são caros de uma forma que não ocorre em uma tabela normal. Todo índice precisa ser atualizado a cada inserção em uma tabela
1:10:07 que recebe 100.000 linhas por segundo. Um índice extra significa 100.000 inserções extras na B-tree por segundo. É possível adicionar seis índices só por precaução e se perguntar por que tudo despencou de repente. Então, encontre aqueles que você não está usando. Com este código, selecionamos os índices cujo index scan é igual a zero. Isso significa que o índice nunca foi usado desde a última reinicialização das estatísticas. Em um
1:10:38 sistema que está em funcionamento há algum tempo, esses são candidatos à exclusão. Primeiro, confirme que não se trata de uma restrição de unicidade que esteja garantindo a integridade. E há uma coisa que você deve saber, que quero garantir que entenda: quando chegarmos ao column store, você precisará de muito menos índices do que imagina. Isso porque os lotes colunares têm seu próprio mecanismo integrado de descarte de dados. Alguns dos índices que você adicionaria a uma tabela de analytics normal
1:11:10 se tornam pura sobrecarga. Então, vamos medir com o índice aplicado e medir a consulta de intervalo curto, a de seis horas, porque é nela que um índice pode realmente ajudar. O shared hit é 3. Agora podemos ver o intervalo de 7 dias, a referência de 7 dias, e vemos shared hit de 2.198 e read de 1.100 ou 11.655.
1:11:42 Então, esses dois números contam histórias diferentes. E essa diferença é justamente o ponto. A consulta de intervalo curto ficou mais rápida. A consulta de sete dias não. E um índice nunca resolveria isso. Quando você está lendo a maior parte da tabela, fazer uma varredura pelo índice dá mais trabalho do que simplesmente fazer uma varredura. O planner sabe disso, e é por isso que provavelmente ignorou completamente nosso novo índice. Essa consulta precisa de outro tipo de solução, que veremos daqui a pouco. Então, algo interessante aconteceu. Temos exclusão de chunks. Temos
1:12:12 um bom índice. E a consulta está mais rápida, mas ainda não está rápida. E o motivo já não é não conseguirmos encontrar as linhas. O gargalo já não é identificar as linhas para a análise, mas carregá-las. Qualquer cálculo de analytics em grande escala enfrenta o mesmo problema. Sabemos exatamente onde estão as linhas. O problema é carregá-las. Então, aqui está o motivo, e é aqui que a explicação anterior sobre páginas de 8 kilobytes
1:12:44 se mostra útil. As linhas são armazenadas na ordem de inserção. As solicitações chegam intercaladas de todos os domínios. Uma de example.com, três de other.com, outra de example.com. Então, em qualquer página, as linhas de um único domínio são uma pequena fração dispersa. Digamos que uma página contenha 80 linhas e example.com represente 1% do seu tráfego. Então, cada página contém aproximadamente uma linha que você quer. Para
1:13:14 ler um milhão de linhas desse domínio, você lê um milhão de páginas, ou 8 gigabytes, para extrair cerca de 100 megabytes dos dados que realmente pediu. E o problema é ainda pior do que o volume sugere, porque essas leituras de página são aleatórias. Leituras sequenciais são de 10 a 100 vezes mais rápidas do que as aleatórias. Mesmo em SSDs, você está fazendo o tipo mais lento de E/S para dados que, em sua maioria, vai descartar. A solução é a localidade dos dados.
1:13:45 Reorganize fisicamente as linhas para que os dados de um domínio fiquem juntos. Então, ler um milhão de linhas de um domínio significa algumas milhares de leituras sequenciais de páginas, em vez de um milhão de leituras aleatórias. Vamos ver isso na prática. Crie o índice de ordenação de solicitações em request, usando o domínio da URL e o tempo. Esse índice define a ordem física desejada, agrupada por domínio e, dentro de cada domínio, ordenada por tempo. Depois, reescrevemos os chunks nessa
1:14:16 ordem. Este comando reescreve todos os chunks, o que definitivamente levará algum tempo enquanto estiver em execução. Uma propriedade interessante: ele reescreve cada chunk em uma cópia nova e a substitui, um chunk de cada vez. Assim, não precisa de um bloqueio enorme em toda a hyper table e reconstrói completamente os índices como efeito colateral, removendo o bloat. É como uma espécie de parente útil do vacuum full.
1:14:48 Certo, terminou. Levou 38 segundos. Agora vamos executar novamente a consulta de seis horas, sem alterações. Esta é a terceira vez que a executamos. Esses são os pontos de controle. E o shared read é 3 de novo. Quer dizer, não há tantas coisas para ler aqui. Então, a reordenação vai ajudar na consulta de intervalo curto guiada pelo índice, não na varredura de 7 dias. E isso aparece com muito mais clareza no número de páginas lidas do que no tempo decorrido. Mas vamos fazer a
1:15:19 varredura de 7 dias aqui. E podemos ver os valores de hit e read aqui em cima: 163.000 e 73.000. Então, são as mesmas linhas, o mesmo índice. Mas não tornamos a busca mais inteligente; apenas deixamos os dados mais compactos. Certo, vamos falar do column store. Esta é a parte principal. Se você levar uma única funcionalidade deste curso, leve esta. Então, vou começar com uma pergunta.
1:15:51 Nossa tabela de solicitações tem, o quê, talvez 15 colunas. Mas tabelas de analytics reais em produção costumam ter 50, 100 ou mais. Agora veja esta consulta: Select average duration total from request ht where time now minus interval 3 days. Então, veja esta consulta: selecionar a média de duration total de request, onde time está entre agora e os últimos três dias. A consulta precisa de uma coluna
1:16:21 exatamente uma coluna: duration total. Quantas colunas o PostgreSQL realmente leu do disco? Todas elas, por causa de como funciona o armazenamento por linha. Mas lembre-se da parte anterior: a menor unidade que o PostgreSQL consegue ler é uma página de 8 kilobytes, e uma página contém linhas completas. Todas as colunas de todas as linhas da página vão para a memória, quer você as tenha solicitado ou não. Você queria um número por
1:16:52 linha e pagou por 100. Esse é o primeiro problema. Aqui está o segundo. O PostgreSQL processa esses valores um de cada vez. Busca uma linha, extrai a coluna, adiciona o valor a um total acumulado, passa para a próxima linha. 10 milhões de linhas, 10 milhões de iterações desse loop. CPUs modernas conseguem fazer operações aritméticas com oito ou 16 números em uma única instrução, mas só se os números estiverem lado a lado na memória. No armazenamento por linha, eles ficam a 100 bytes de distância. Então
1:17:24 você lê dados de que não precisa e processa os dados de que precisa da maneira mais lenta possível. O armazenamento por linha é projetado para encontrar uma linha. Isso é exatamente o certo para agregar 10 milhões de linhas. Está errado. Todo banco de dados dedicado a analytics no mundo resolve isso da mesma maneira: armazena os dados por coluna, em vez de por linha. O column store do TimescaleDB pega um chunk e o reescreve. Em vez de mil linhas lado a lado, você tem os mil valores de duration total lado a lado em um único lote comprimido. O mesmo vale para todas as outras colunas. Agora, a consulta de média lê os dados de uma única coluna. E, como esses números são contíguos, a CPU consegue processá-los 16 de cada vez. Isso se chama execução vetorizada e explica por que o ganho de velocidade é muito maior do que a economia de E/S, por si só, levaria a crer. Mas há uma coisa que
1:17:55 outro, você obtém todos os mil valores de duração total lado a lado em um único lote compactado. O mesmo vale para todas as outras colunas. Agora, a consulta da média lê os dados de uma coluna. E, como esses números são contíguos, a CPU consegue processá-los 16 de cada vez. Isso se chama execução vetorizada e é por isso que o aumento de velocidade é muito maior do que a economia de E/S, por si só, faria supor. Mas aqui está o ponto:
1:18:28 algo que as pessoas às vezes não esperam. O armazenamento colunar não apenas torna as consultas mais rápidas; ele também reduz o tamanho dos dados, drasticamente. Isso acontece porque uma coluna de valores semelhantes comprime muito melhor do que uma linha com valores muito diferentes. Pense nisso. Na nossa tabela, o domínio da URL em mil linhas consecutivas pode ser um mesmo pequeno conjunto de valores. O horário em mil linhas consecutivas corresponde a mil timestamps que estão todos a poucos segundos uns dos outros. O código de status é
1:19:00 basicamente sempre 200, com um 500 ocasional. Isso é extremamente compressível, mas só se você colocar os valores lado a lado primeiro. Há quatro técnicas de compressão, e vale a pena conhecê-las porque elas explicam o que comprime bem e o que não comprime. Um valor repetido é armazenado uma vez. Codificação por comprimento de sequência. Mil linhas com service igual a shop viram shop, tempo, 1.000. Strings frequentes são encurtadas usando um
1:19:32 dicionário interno. Em vez de armazenar a string literal checkout service mil vezes, armazene-a uma vez em um dicionário e guarde uma referência de inteiro pequeno. Os números são armazenados como diferenças, usando o menor tipo de dado que comporta os valores. Timestamps consecutivos com diferenças de algumas centenas de milissegundos não precisam de oito bytes cada. Então, armazena-se o primeiro e, depois, diferenças de dois bytes. Booleanos viram um único bit, não um byte, mas um bit. Então, ao
1:20:03 aplicar essas otimizações, o TimescaleDB costuma reduzir o tamanho de uma tabela em mais de 90%. Não é erro de digitação, e vamos verificar isso nós mesmos mais adiante neste curso. Em um exemplo, uma tabela passou de 72,5 GB para 33. Mas veja os índices: de 10,6 GB para 400 megabytes. Voltaremos ao motivo de o número do índice ser tão extremo. Agora, se o armazenamento colunar é
1:20:35 tão bom, por que nem tudo é colunar? Porque o armazenamento colunar é ruim naquilo em que o armazenamento em linhas é bom: gravar uma linha, alterar um valor, excluir um registro. Então, o TimescaleDB faz o que é obviamente inteligente. Os novos chunks usam armazenamento em linhas: inserções rápidas, atualizações fáceis, PostgreSQL normal. Quando o chunk se estabiliza, quando você não está mais gravando ativamente nele, ele é convertido para o armazenamento colunar: leituras rápidas e tamanho reduzido. Essa é a parte
1:21:07 em que um banco de dados de analytics de verdade diria que os dados agora são somente leitura. Mas o TimescaleDB não faz isso. Com o armazenamento colunar do TimescaleDB, você ainda pode inserir novas linhas, modificar as existentes e excluí-las, o que não é possível em outros bancos de dados analíticos. Você ainda tem uma tabela SQL normal. UPDATE funciona, DELETE funciona, INSERT funciona. É mais lento do que em um chunk com armazenamento em linhas, e você não deve projetar uma carga de trabalho para ficar martelando atualizações em
1:21:39 dados comprimidos. Mas eventos que chegam atrasados, correções, exclusões para cumprir o GDPR: tudo isso simplesmente funciona. E há uma vantagem contraintuitiva. Inserir em um chunk com armazenamento colunar também é a maneira mais rápida de inserir muitas linhas. Com a opção de inserção direta no armazenamento colunar, você pode inserir mais de cinco milhões de linhas por segundo. O PostgreSQL não consegue fazer isso. Cinco milhões de linhas por segundo. Se você estiver fazendo uma
1:22:11 carga retroativa em lote, gravar diretamente no formato de armazenamento colunar é mais rápido do que gravar linhas e converter depois. Então, vamos fazer isso. Três configurações. Eu executo isso, e aparece o aviso de que a coluna request ID deve ser usada para segmentação ou ordenação. Agora, o PostgreSQL está nos dizendo: “Ei, request ID é uma chave única e, como não está em segment by nem em order by, procurar uma linha comprimida pelo ID não terá os benefícios de poda em consultas analíticas, como agregar por domínio e serviço ao longo do tempo”. Essa é exatamente a compensação que
1:22:42 queremos. Então, enable column store ativa o recurso. As outras duas opções são as que importam, e quero dedicar algum tempo a elas porque é aqui que as pessoas obtêm resultados ruins e concluem que o recurso não funciona. A opção segment by é a mais importante. segment by determina como as linhas são agrupadas em lotes. Linhas com os mesmos valores em segment by vão para o mesmo lote. E este é o ponto: uma consulta que filtra por essa coluna pode ignorar todos os
1:23:14 lotes que não correspondem, sem descomprimir nada. Ela não os examina para depois rejeitá-los; simplesmente nunca os acessa. Então, escolha segment by perguntando: “Em que coluna eu sempre filtro?”. Para nós, todas as consultas dos dashboards filtram pelo domínio da URL e, geralmente, por service. Essa é uma coluna de tenant, e o tenant é a resposta certa mais comum em sistemas reais. ID do cliente, ID do projeto, ID do dispositivo,
1:23:44 ID da conta. Agora, a restrição que pega todo mundo: um lote comporta até mil valores. Você quer lotes cheios. Um lote com três valores desperdiça boa parte do benefício, porque a compressão funciona em volume e você tem o custo fixo de um lote para quase nenhum ganho. Então, a regra é: para cada valor de segment by, você quer pelo menos mil linhas em um chunk, de preferência muitos milhares. Isso significa que colunas de alta
1:24:16 cardinalidade são veneno aqui. Em vez de simplesmente afirmar isso, vou mostrar. Vou executar SELECT COUNT(DISTINCT URL domain) AS domains, e depois contar os IDs de request e, por fim, contar as linhas. Temos apenas nove domínios e milhões de IDs de request distintos. Se eu definir segment by como request ID, cada lote terá exatamente uma linha. Vou ter
1:24:48 todo o custo fixo e nenhuma compressão. E vamos medir isso em breve. order by controla a ordem de classificação dentro de cada lote. Isso traz dois benefícios. Primeiro, o ganho de localidade dos dados da seção sobre reordenação vem de graça como parte da compressão. É por isso que a reordenação se torna redundante. Segundo, e menos óbvio, o TimescaleDB armazena o mínimo e o máximo de cada coluna de order by por lote. Então, uma consulta com WHERE time BETWEEN X AND Y verifica os timestamps mínimo e máximo de um lote e
1:25:20 ignora o lote inteiro se não houver sobreposição. É por isso que você coloca o timestamp primeiro em order by: você sempre filtra por intervalo de tempo. Assim, você sempre quer ter essa possibilidade de ignorar lotes. Usamos time em ordem decrescente e URL path. time vem primeiro porque sempre está no filtro; URL path vem em segundo para que os caminhos se agrupem dentro de um mesmo segundo, o que ajuda nas consultas por caminho.
1:25:52 Então, neste momento, na verdade, ainda não temos nenhuma compressão; ainda não economizamos espaço. Portanto, não pense simplesmente: “Ah, não houve melhora”, e desista. Ativar o armazenamento colunar para uma hypertable não converte automaticamente os chunks dela para chunks com armazenamento colunar. Esse equívoco costuma levar a resultados decepcionantes nos testes iniciais de desempenho. Ativar o armazenamento colunar configura o recurso. Não converte
1:26:23 nada. Os chunks existentes permanecem exatamente como estão até você convertê-los manualmente ou adicionar uma política que faça isso por você. Veja como convertê-los manualmente. Primeiro, vamos conferir os chunks. Depois, vamos executar CALL convert_to_columnstore. Vou escolher um desses chunks e colocá-lo aqui. Pronto, foi convertido. E o processo é reversível, o que será importante mais adiante. Basicamente, executo o mesmo comando,
1:26:54 mas, em vez de convert_to_columnstore, posso usar convert_to_rowstore. Se você estiver usando uma versão mais antiga do TimescaleDB, talvez encontre compressed chunk e decompressed chunk na documentação. É a mesma operação, mas com nomes antigos. Os procedimentos convert_to_columnstore e convert_to_rowstore são a API atual. Também podemos adicionar uma política de armazenamento colunar: add_columnstore_policy na tabela request, com um intervalo da política de uma hora ou
1:27:25 mais. Certo. Então, qualquer chunk cujos dados tenham mais de uma hora é convertido automaticamente por um job em segundo plano. Por que o atraso? Por que esperar uma hora, já que chunks com armazenamento colunar aceitam inserções? Bem, por dois motivos. Inserções em um chunk com armazenamento em linhas são mais baratas, e pipelines reais têm latência. Eventos enfileirados, webhooks repetidos, clientes móveis que ficaram offline. Dar uma hora de tolerância aos dados mais recentes significa que a maior parte das gravações segue pelo caminho mais barato.
1:27:57 Isso não é uma exigência de correção; é uma escolha de throughput para nosso conjunto de dados de três dias. Uma hora significa que quase tudo é convertido imediatamente. Vou forçar a conversão da tabela inteira para podermos medir. Então, vou forçar a conversão da tabela inteira para podermos medir. Tudo isso já estava no armazenamento colunar; agora, vamos conferir. Vou verificar o total de bytes antes da compressão e depois
1:28:28 bytes totais após a compactação, e basicamente economizamos 88% do espaço. Isso é muito bom. Agora vou ativar a medição de tempo para esta seção. E agora vamos executar aquilo que já executamos antes, em que calculamos a duração média dos últimos três dias, e chegamos a 60,8. Então, lembre-se daquele número de benchmark que nós
1:28:58 tínhamos: os índices passaram de 10,6 gigabytes para 400 megabytes. Eis por que a diferença é tão extrema. Os blocos do armazenamento colunar não têm índices. E talvez sua primeira reação seja se perguntar sobre isso. Então, deixe-me explicar por que não tem problema. Um índice existe para evitar a leitura de linhas de que você não precisa. O armazenamento colunar alcança o mesmo objetivo de outra forma, em duas etapas. Na primeira etapa, o filtro segment by elimina lotes inteiros instantaneamente, usando metadados. Um
1:29:30 milhar de linhas desaparece por lote, sem descompactação. Na segunda etapa, os intervalos de min e max do order by eliminam mais lotes, sem sobreposição com seu intervalo de tempo. Você pode ignorá-los. Assim, entre essas duas etapas, uma consulta típica de dashboard já descartou a maioria dos dados antes de ler um único valor. Um índice seria redundante e custaria a você a economia de 90% no tamanho, além de ter de mantê-lo. Mas e quanto a uma busca pontual, como por uma linha específica pelo
1:30:02 ID? Para isso, existe um terceiro mecanismo. O TimescaleDB mantém pequenas estruturas semelhantes a índices por lote, como intervalos de min e max para números e timestamps, e filtros de Bloom para strings e UUIDs. Um filtro de Bloom é uma pequena estrutura probabilística que responde, com baixo custo: este valor definitivamente não está aqui? Se a resposta for definitivamente não, ignore o lote; se for talvez, descompacte e verifique.
1:30:32 Então, aqui estamos usando um filtro de Bloom em geo_country, porque fazemos buscas por igualdade nessa coluna, e min e max em duration_total, porque filtramos por intervalo uma solicitação mais lenta que 5 segundos. Há uma ressalva importante, e ela se parece com o equívoco que vimos antes. Essas estruturas são criadas quando um bloco é convertido. Definir um índice de compactação em um bloco que já foi convertido não faz nada
1:31:03 com ele. Se precisar adicionar um retroativamente, converta para armazenamento em linhas e depois para armazenamento colunar; em uma tabela grande, isso é caro. Então pense nisso antes de converter um ano de dados. E há limitações, porque não quero que você apague seu índice e depois abra um issue. Esses recursos só ajudam com verificações de igualdade. Então, WHERE status = 'open' é ótimo. É isso que um filtro de Bloom responde. Eles não ajudam com listas. WHERE mode IN (2, 4, 5) não tem
1:31:37 benefício. Então: igualdade, sim; intervalo em uma coluna com min e max, sim; vários valores em uma lista, não. Há três coisas para lembrar desta seção. Primeiro: ativar o armazenamento colunar não converte nada. Você precisa adicionar uma policy. Segundo: segment by é sua coluna de filtro, geralmente tenant, e precisa ter mais de mil linhas por valor. Terceiro: defina o índice de compactação antes de converter e não espere que ele ajude com listas. Esse é o armazenamento colunar: 90% menor,
1:32:09 muito mais rápido, e torna obsoleta a seção sobre reordenação, exatamente como eu estava dizendo antes, quando passamos por isso no fim daquela seção. Então, a seguir, vamos pré-computar os resultados para que a consulta não precise agregar nada. Já reduzimos o tamanho e aceleramos as varreduras. Há mais uma medida disponível, e esta próxima é a maior de todas.
1:32:41 Todas as consultas de dashboard que executamos têm uma coisa em comum: calculam a mesma resposta repetidas vezes. Alguém carrega um dashboard. Agregamos os dados de ontem. A pessoa atualiza a página. Agregamos os dados de ontem de novo. Os dados de ontem não mudaram. Estamos repetindo o mesmo trabalho sem parar. Então você pode ir além. Pode acelerar ainda mais as consultas acessando resultados pré-computados, em vez de agregar linhas sempre que uma consulta é executada. Você já sabe por que
1:33:13 as implementações óbvias falham, porque fizemos as duas na parte anterior. Uma view materializada precisa recalcular tudo. Então, atualizá-la fica mais lento a cada dia. Uma tabela de resumo mantida por triggers causa contenção de bloqueios de baixo nível. Assim, a cada nova consulta, o tempo de espera aumenta até o banco de dados ficar sobrecarregado com uma massa de consultas bloqueadas esperando para serem executadas. Essa é a espiral da morte que vimos antes. O que queremos é
1:33:44 o conceito de uma view materializada, mas mantida incrementalmente. Recalcule apenas o que realmente mudou. Isso é um agregado contínuo. Então, aqui está o que acabei de executar, e vou explicar passo a passo, porque a maior parte é SQL comum e exatamente duas coisas são especiais. Temos CREATE MATERIALIZED VIEW, que é sintaxe padrão do PostgreSQL, e depois WITH (timescaledb.continuous), que é especial. Essa é a coisa
1:34:16 especial número um, e é o que transforma uma view materializada comum em uma mantida incrementalmente. Depois, temos uma consulta de agregação normal. Usamos time_bucket, com intervalo de 15 minutos. time_bucket é outra coisa especial, de certo modo: é uma função do TimescaleDB que arredonda um timestamp para baixo, até um intervalo fixo. Todo evento entre meio-dia e 12h15 recebe o intervalo das 12h. É isso que torna o
1:34:48 agrupamento estável e permite reagregá-lo. Tudo o que agrupei por dimensão é algo que quero poder filtrar ou segmentar depois. Tudo o que agrego é um número que quero mostrar em um gráfico. Observe que escolhi count, min e max, e não average. Isso é intencional, e vou explicar daqui a pouco. É algo bem interessante. Agora, a policy de atualização: SELECT add_continuous_aggregate_policy, com intervalo de 15 minutos. O intervalo de início e o deslocamento final são nulos. A cada cinco minutos, um job em segundo plano atualiza o agregado. O deslocamento inicial e o deslocamento final são nulos, o que significa considerar todo o período. E vamos mudar os dois mais adiante. Consultá-lo é como consultar uma tabela. Então, podemos consultá-lo como faríamos com uma tabela comum. E agora contamos todas as solicitações em que o domínio da URL é igual a example.com e o serviço é igual a shop.
1:35:20 Agora, vamos garantir que a medição de tempo esteja ativada para a próxima consulta. Vamos fazer um SELECT e usar time_bucket com esse intervalo. Estamos somando as solicitações de cada intervalo de 15 minutos e, de novo, procurando por example.com. Vou executar isso, e agora podemos ler o tempo e compará-lo com o tempo da mesma consulta feita na hypertable. Pedi valores por hora a partir de um
1:35:53 agregado de 15 minutos, reagregando quatro intervalos por vez. Isso funciona porque os limites de time_bucket se encaixam perfeitamente. Você sempre pode agrupar em intervalos maiores, mas nunca em intervalos menores. Então, este é o mecanismo e é por isso que esse recurso funciona. O TimescaleDB acompanha quais intervalos foram invalidados desde a última atualização. Você insere uma linha, e o intervalo correspondente é marcado como alterado. Você atualiza ou exclui uma linha: acontece a mesma coisa. Quando o job de atualização é executado,
1:36:24 ele consulta a lista de intervalos alterados e recalcula apenas esses. Mesmo com anos de dados, o processo de atualização continua rápido, porque, a cada execução, apenas alguns intervalos podem ter mudado desde a execução anterior. Essa é a diferença. O custo de atualização de uma view materializada cresce com o total de dados. O custo de atualização de um agregado contínuo cresce com a taxa de alterações, que, para dados de séries temporais, é basicamente constante, porque você só grava dados no presente. Então, eu
1:36:57 quero destacar este ponto em particular. Estes são jobs em segundo plano. Se começarem a falhar, por exemplo, por causa de permissões incorretas, uma alteração de schema ou pressão no disco, falham silenciosamente. O dashboard não apresenta um erro. Ele simplesmente exibe números desatualizados, e você só descobre quando alguém pergunta por que os dados de ontem parecem vazios. Então, basicamente, você deve executar isso e verificar se está vazio. Há um problema com o que acabamos de criar. Esse job de atualização é executado a cada 5 minutos. Então, o agregado pode ficar até 5 minutos defasado em relação à realidade. Para um relatório diário, tudo bem. Mas, para um dashboard operacional em que alguém acompanha um deploy, não está bom. A solução é combinar o histórico pré-computado com dados em tempo real, na hora. Então, deixe-me recriar isso. Primeiro, vamos simplesmente apagar essa tabela. Já criamos a view materializada. E então vou criá-la de novo. Depois, vamos criá-la novamente. E então adicionar a policy.
1:37:29 Então, desta vez, há três mudanças na forma como fizemos isso. Deixe-me explicar as diferenças na maneira como a criamos. Primeiro, materialized_only = false. Isso ativa a união. Quando você consulta a view, o TimescaleDB lê os intervalos pré-computados do período mais antigo e lê a hypertable bruta do período mais recente; depois, junta tudo. Você escreve uma consulta e obtém os dois. Depois, WITH NO DATA. Foi assim que terminamos nossa
1:38:00 tudo bem. Mas, para um painel de operações em que alguém está acompanhando um deploy, não está tudo bem. A solução é combinar o histórico pré-calculado com os dados ao vivo, na hora. Então, deixe-me reconstruir isso. Primeiro, vamos simplesmente remover essa tabela. Já criamos a view materializada. E então vou… e então vamos criá-la novamente. E então vamos criar isto de novo. E depois adicionar a policy.
1:38:31 Então, há três mudanças na forma como fizemos isso desta vez. Deixe-me explicar as diferentes mudanças dessa forma de criação. Primeiro, materialized_only é igual a false. Isso ativa a união. Quando você consulta a view, o TimescaleDB lê os buckets pré-computados do intervalo mais antigo e lê a hyper table bruta do intervalo recente, depois junta tudo. Você escreve uma consulta e obtém ambos, mesmo sem dados. Foi assim que encerramos nosso
1:39:04 linha de criação. Sem isso, a view calcula todo o histórico de uma vez em uma tabela grande. Essa é uma operação longa e bloqueante no pior momento possível. Com isso, a criação é instantânea e a política preenche os dados progressivamente. Então, em end_offset, definimos um valor maior ou igual a 30 minutos. Isso define o limite. Tudo o que tiver mais de 30 minutos é pré-calculado, e tudo o que for mais recente é lido em tempo real, o que significa que os números no seu dashboard estão atualizados até o segundo.
1:39:35 e a parte cara continua pré-calculada. Então, qual deve ser o tamanho do start_offset? Bem, a regra geral é usar pelo menos o dobro do intervalo do bucket. Temos buckets de 15 minutos, então são 30 minutos. O motivo é que você não quer pré-calcular um bucket que ainda está sendo preenchido. Você armazenaria um resultado parcial e teria que invalidá-lo imediatamente e atualizá-lo se os dados chegassem atrasados. Se os eventos passarem por uma fila que pode ficar temporariamente indisponível ou vierem de clientes móveis
1:40:05 que sincronizam quando se reconectam, a janela de 30 minutos pode se fechar antes de os dados chegarem. Então esses dados vão para um bucket que já foi materializado, o que ainda funciona. Ele é invalidado e recalculado, mas você está refazendo trabalho que um offset maior teria evitado. Agora, vou mostrar uma técnica que permite escalar para anos de dados. Também quero números por hora. A abordagem ingênua é criar um segundo agregado contínuo que leia a hypertable. Mas eu
1:40:38 já tenho buckets de 15 minutos. Quatro deles formam uma hora. Então, vou criar o agregado por hora a partir do agregado de 15 minutos. Posso executar este código. É muito parecido, mas você pode ver que estamos obtendo o intervalo de 1 hora a partir do agregado de 15 minutos, usando o agregado de 15 minutos como origem. Criei uma view materializada e, em seguida, vou adicionar a política de agregação. Então, veja a cláusula FROM: request_15_minutes; não é request.
1:41:09 E veja os agregados: temos a soma das somas, o mínimo dos mínimos e o máximo dos máximos. Estou combinando resultados que já foram calculados. Basicamente, há dois motivos para isso ser melhor. O primeiro é a pressão de atualização: para atualizar uma hora, isso lê quatro linhas pré-calculadas, em vez de examinar potencialmente milhões de linhas brutas. Cada nível da escada lê um pequeno nível abaixo dele. O segundo motivo é mais sutil: a velocidade da consulta. As duas views são em tempo real. Quando você consulta
1:41:41 a view por hora para o momento atual, a parte em tempo real não examina a hypertable. Ela lê o agregado em tempo real de 15 minutos, que por sua vez é, em grande parte, pré-calculado. Até a parte em tempo real da consulta é barata. E o end_offset é de duas horas, não de 30 minutos. A mesma regra: o dobro do intervalo do bucket, que agora é de uma hora. Então, vamos criar o restante da escada. Isto cria a view materializada request_daily. E
1:42:12 observe que removi o ID do servidor e o caminho da URL no nível diário. Ninguém pergunta qual caminho foi o mais lento em 4 de março ao consultar o gráfico anual. E remover dimensões reduz enormemente a quantidade de linhas. Tempo mais granular, dimensões menos granulares. Esse é o padrão. Então, podemos criar isso e adicionar essa política. Aqui você pode ver a escada entre o agregado básico de requisições — o meu se chama request, não request_ht —, o de 15 minutos, o horário e o diário. E cada um deles tem dimensões diferentes. Então, vamos ver o
1:42:45 resultado: 12 meses de tráfego, consultados no topo da escada. Usando um intervalo de bucket de um mês a partir de request_daily. Podemos ver que, no momento, não há muita informação na nossa tabela, mas dá para ver o mês atual aqui. Agora, vemos o tempo: 148 milissegundos. Vamos fazer a mesma consulta usando os dados brutos de requests. Fazendo isso, agora vemos 1.646 milissegundos. Então, o resultado é o mesmo. Bem, nós
1:43:17 vemos que a soma é a mesma, mas os custos são muito diferentes, e a diferença aumenta a cada dia que o sistema funciona, porque uma consulta lê uma quantidade fixa e pequena de linhas, enquanto a outra lê tudo o que você já coletou. Então, mais uma coisa: eles são apenas hypertables. Portanto, embora os agregados contínuos e os agregados em tempo real possam parecer mágicos, eles são apenas hypertables gerenciadas pelo TimescaleDB. Por consequência, você também pode adicionar índices e ativar o columnstore para acelerar a busca e a agregação de linhas. Isso abre muitas possibilidades, e
1:43:47 tudo o que discutimos recentemente também se aplica aos seus agregados. Por exemplo, podemos criar um índice. Podemos comprimi-los e montar uma escada de agregados com o columnstore em cada nível. Manter resumos antigos custa quase nada, o que torna possível exatamente a estratégia de retenção sobre a qual falaremos mais tarde. Também vale a pena saber algumas outras coisas. Podemos fazer uma atualização
1:44:19 manual, assim, para preencher dados retroativamente, e você pode alterar as configurações depois com ALTER MATERIALIZED VIEW, inclusive alternar para somente materializada sem reconstruir. Os agregados contínuos são a principal ferramenta de desempenho do TimescaleDB. Pré-calcule incrementalmente, adicione materialized_only = false para obter números em tempo real. Organize-os em uma escada e trate cada nível como a hypertable que ele realmente é. Agora, lembre-se de que eu disse que escolhi deliberadamente count, min e max e
1:44:50 evitei a média. Bem, vamos descobrir por quê. Hyperfunctions são funções de agregação adicionais do TimescaleDB. Há dezenas delas, e não vou ler um catálogo para vocês. Vou explicar as três ideias mais importantes e depois apontar as demais. A primeira, e a mais útil na prática nesta seção, é preencher as lacunas. Temos este código aqui, em que usamos time_bucket_gapfill com um intervalo de 5
1:45:21 minutos, a partir de request, filtrando example.com, o país geográfico JP (Japão) e consultando as últimas 30 horas. Observe os timestamps: há lacunas nos buckets de cinco minutos com filtros restritos; alguns não tiveram nenhuma correspondência, por exemplo. Então, olhe para estes buckets. Veja os timestamps: 35, 40, 45, 50. Podemos ver
1:45:53 esses timestamps e imaginar que poderia haver algumas lacunas. Temos buckets de cinco minutos e um filtro restrito. É possível que algumas delas não tenham nenhuma requisição correspondente. Então, o agrupamento pode não produzir nenhuma linha. Isso é um problema de verdade, porque sua biblioteca de gráficos vai ligar o ponto antes da lacuna diretamente ao ponto depois dela. Uma interrupção de cinco minutos pode aparecer como uma linha contínua, e isso não
1:46:24 seria realmente preciso. Então, aqui está a solução. Ela usa time_bucket_gapfill. Vemos time_bucket_gapfill bem aqui. É preciso especificar explicitamente um início e um fim. A função não pode inventar um intervalo do nada. É preciso informar qual janela preencher. Vou executar isso. E então haverá três colunas, porque existem três respostas legítimas para a pergunta:
1:46:55 qual era o valor durante a lacuna? Neste exemplo, sempre há uma contagem. Mas, se houvesse um período em que não existisse nada — na verdade, aqui está um exemplo: temos NULL, basicamente nenhum dado, o que é verdadeiro e correto quando a lacuna significa que realmente não sabemos. E locf vai propagar o último valor
1:47:29 para a frente: o último valor observado, propagado adiante. Isso é correto para coisas que mantêm o valor entre leituras, como a configuração de um termostato, o estado de um dispositivo ou uma configuração, quando o sensor não enviou dados porque nada mudou. Já interpolate desenha uma linha reta entre os pontos vizinhos, o que é correto para medições que variam continuamente, como temperatura, nível de combustível ou um valor que aumenta lentamente. Então, escolha com base no que
1:48:00 os dados ausentes significam, não no gráfico que fica mais bonito. Para a contagem de requisições, uma lacuna significa que não houve nenhuma requisição. Então, honestamente, COALESCE(count, 0) é a resposta certa para nós. Agora, quero explicar uma coisa que mencionei antes. Escolhi count, min e max e, deliberadamente, não usei a média. E aqui está o motivo. Imagine que eu tivesse armazenado a média da duração total como duration_average. Agora, quero calcular a média por hora. Então, agrego novamente quatro buckets. O que escrevo? Por que não armazenei a média? Bem, calcular a soma das médias de duração não faz sentido, e calcular a média das médias de duração também não, porque essa abordagem não leva em conta que cada média representa uma quantidade diferente de valores. Pense nisso, porque esse é o tipo de bug que você poderia acabar colocando em produção por não entender. A soma das médias não tem significado. A média das médias é
1:48:32 média. Agora quero a média por hora. Então, agrego novamente quatro buckets. O que devo escrever? Então, por que não armazenei a média? Bem, calcular a média das somas das durações não faz sentido, e calcular a média das médias das durações também não, porque essa abordagem não leva em conta que cada média representa um número diferente de valores. Pense bem nisso, porque é o tipo de bug que você poderia acabar lançando por não entender. A soma das médias não faz sentido. A média das médias é
1:49:04 com aparência plausível, mas errados. Ele trata um intervalo com três requisições como igualmente importante que um intervalo com 30.000. Então, seu painel mostra um número, e esse número está incorreto, mas não há erros. Portanto, mínimo, máximo, contagem e soma podem ser agregados novamente sem problemas. A média, não; nem a variância, o desvio padrão, a mediana ou qualquer percentil. A solução alternativa é armazenar isso em partes. Então, basicamente, vou criar uma visão materializada com um intervalo de tempo, soma e contagem, agrupando pelo intervalo de 15 minutos, e então vou obter a soma das somas e a soma das contagens da duração como duração média. Mas vou…
1:49:37 Agora, neste caso, não aparece nada aqui por causa do «sem dados» aqui, mas a soma das somas dividida pela soma das contagens é uma média ponderada corretamente. Mas é manual e…
1:50:07 é fácil errar, e só funciona para a média. Não há um par de colunas que você possa armazenar e que permita reconstruir uma mediana. Então, vamos falar sobre o que resolve tudo isso de uma vez. Agregar valores é um processo de duas etapas em qualquer banco de dados, embora isso fique oculto para você. Primeiro, todos os valores são processados para calcular um estado interno. Depois, o restante é calculado a partir desse estado. Todo agregado funciona assim. A média não sabe magicamente qual é a média.
1:50:37 Internamente, ela mantém uma soma acumulada e uma contagem acumulada, e faz a divisão no final. Essa coisa intermediária é o estado. Normalmente, o banco de dados descarta esse estado e entrega o número final. As hyperfunctions entregam o próprio estado. E um estado, ao contrário de um resultado, pode ser armazenado em uma linha e combinado com outros estados mais tarde. Então, aqui está o código que mostra que obtivemos o estado, o stats_agg; ele não vai retornar um número. Ele retorna um…
1:51:08 objeto de estado que contém tudo o que é necessário para calcular estatísticas descritivas. Então, vou fazer isso e, em seguida, chamar refresh_continuous_aggregate; depois, podemos usar este código aqui. Assim, para a média e a variância, temos o rollup da duração. Portanto, rollup combina os estados, e average, variance e standard deviation são funções de acesso.
1:51:39 Elas extraem respostas diferentes do estado combinado. Uma coluna armazenada, três estatísticas calculadas corretamente e, basicamente, qualquer granularidade que eu quiser. Então, podemos ver todos os resultados aqui quando executamos isso. Isso muda tudo para agregados contínuos, porque agora você pode armazenar estados agregados em linhas para reutilizá-los mais tarde. Os agregados hierárquicos ficam corretos de forma trivial.
1:52:09 Cada nível armazena estados, e cada nível agrega os estados do nível abaixo. Sem controle manual de soma e contagem, sem médias incorretas, e você obtém variância e desvio padrão de graça, porque eles já estavam no estado que você armazenou. Portanto, em um agregado, armazene estados, não resultados. Essa é a regra. Agora, algumas respostas não podem ser armazenadas em um estado pequeno. Alguns agregados precisam, fundamentalmente, de cada valor individual. Então, imagine uma…
1:52:40 função que recebe uma coluna e um número e retorna o sétimo maior valor. Não existe um resumo pequeno que possa responder a isso. Você teria que guardar tudo. Os percentis têm o mesmo problema. Uma mediana precisa saber qual é o valor do meio, o que significa conhecer a distribuição. A contagem de valores distintos é ainda pior. Para saber quantos IDs de sessão únicos você viu, é preciso se lembrar de todos os IDs de sessão que você viu. Então, há uma segunda família de hyperfunctions: os agregados aproximados. Eles mantêm um…
1:53:12 estado pequeno e limitado e fornecem uma resposta com uma margem de erro conhecida. Em vez de guardar 10 milhões de valores, eles guardam alguns quilobytes de um resumo estruturado de forma inteligente. Então, o histograma é exato. Há quatro argumentos: a coluna, o mínimo, o máximo e o número de intervalos. Assim, de 2 segundos a 10 segundos, em…
1:53:45 quatro intervalos — de 2 a 4, de 4 a 6, de 6 a 8 e de 8 a 10 —, mas você recebe seis números, não quatro. Então, você pode ver o resultado bem aqui. Mas vamos imaginar que o resultado tivesse estes números aqui, em azul. Basicamente, o array está anotado indicando o que cada elemento representa. Você pode ver o que cada elemento representa aqui embaixo. O primeiro elemento é tudo que está abaixo do mínimo: 121 requisições mais rápidas que 2 segundos. O…
1:54:17 último elemento é tudo que está no máximo ou acima dele: 43 requisições mais lentas que 10 segundos. Os quatro do meio são os intervalos que você pediu. Então, não ignore os dois de fora. Essas caudas costumam ser a parte interessante, e, neste exemplo, o problema são aquelas 43 ali. A próxima coisa que quero explicar é o percentil aproximado. Então, este é a mediana, o valor que separa a metade mais rápida da metade mais lenta.
1:54:48 O mesmo padrão de duas partes de antes. percentile_agg, aqui, cria o estado; approx_percentile extrai uma resposta dele. E, depois de ter o estado, qualquer percentil sai de graça. Por exemplo, agora estou obtendo estes percentis: o percentil 50, o percentil 95 e o percentil 99. Então, P95, o percentil 95…
1:55:20 é o número que importa. É esse que você quer acompanhar ativamente para a latência, e vale a pena dedicar um pouco de tempo para explicar o motivo. Uma média esconde suas piores experiências. Algumas requisições muito lentas mal alteram a média. O P95 diz que 95% das requisições foram mais rápidas que esse valor, o que significa que 5% dos seus usuários tiveram uma experiência pior do que esse número. É esse número que deve gerar um alerta. As médias tornam as interrupções invisíveis. Nos bastidores, há dois algoritmos: o UD sketch, ou UD sketch…
1:55:51 e o t-digest. percentile_agg escolhe um padrão sensato, mas você pode escolher explicitamente se tiver um motivo. Certo, vou mostrar outra coisa: a contagem aproximada de valores distintos. Primeiro, vou me certificar de que a medição do tempo está ativada; depois, vamos selecionar count distinct session ID de requests. O resultado é 250.000. E o tempo, podemos ver, é 2.134…
1:56:23 milissegundos. Agora, em vez de fazer select count distinct, vamos fazer select distinct count e depois usar a contagem aproximada de valores distintos com o ID de sessão. Então, você pode ver que este número está bem próximo, e foi muito mais rápido de executar. Isso é HyperLogLog. É um número um pouco diferente, com um estado pequeno e, muito importante, pode ser armazenado em um…
1:56:55 agregado contínuo. A contagem exata de valores distintos não pode ser pré-computada, porque os estados não podem ser combinados. O mesmo visitante que aparece em dois intervalos seria contado duas vezes. Os estados do HyperLogLog são combinados corretamente. Então, aqui estou fazendo select distinct count de hyperloglog com precisão 1024 para o ID de sessão. O primeiro argumento faz uma troca entre precisão e tamanho. Mais intervalos significam mais precisão e mais bytes. Os estados do HyperLogLog podem ser combinados corretamente; os exatos, não. Então, o que mais…
1:57:28 tem no toolkit? Há várias coisas no toolkit, agrupadas pelo que você está medindo. Quero apenas passar por elas rapidinho. Você também pode pesquisá-las depois para saber mais. Mas, para contadores monotônicos, como counter, use rate e delta por cima, para qualquer coisa que só aumenta, como energia fornecida ou requisições. Isso lida com reinicializações do contador, que é exatamente o motivo para não fazer isso manualmente. Para métricas do tipo gauge, como gauge_agg…
1:57:59 para valores que sobem e descem. Médias ponderadas pelo tempo: time_weight. Se as leituras não forem feitas em intervalos regulares, uma média simples com AVG dá peso excessivo aos picos. Isso pondera pela duração. Máquinas de estados: state_agg, duration_in_state e state_timeline. Quanto tempo essa coisa passou em cada estado? Vivacidade: heartbeat_agg, para calcular o tempo de atividade e o tempo de inatividade. num_gaps: essa coisa parou de reportar? Finanças: candlestick_agg e VWAP. Reamostragem para gráficos: lttb e ASAP suavizam um mês de dados em 500 pontos sem distorcer o formato. E depois há os mais robustos e o top K, como freq, topn, min e max. Vale a pena considerar se todas as suas estatísticas precisam ser exatas ou se uma pequena margem de erro é tolerável. À medida que seus dados crescem, os benefícios de desempenho da pré-computação de resultados aproximados se tornarão cada vez mais importantes.
1:58:30 Então, quero encerrar esta seção com isso, porque é mais uma mudança de mentalidade do que uma técnica. Engenheiros costumam querer valores exatos, mas ninguém toma uma decisão diferente porque o número de visitantes únicos foi 48.214 em vez de 48.27. Então, se um erro de meio por cento permite um aumento de velocidade de mil vezes e possibilita pré-computar algo que, de outra forma, você não conseguiria pré-computar de jeito nenhum, aceite essa troca. Apenas faça isso deliberadamente e saiba quais dos seus números são estimativas. Agora, vamos falar sobre retenção de dados.
1:59:01 Então, quero encerrar a seção com isso, porque é mais uma mudança de mentalidade do que uma técnica. Engenheiros geralmente querem valores exatos, mas ninguém toma uma decisão diferente porque o número de visitantes únicos foi 48.214 em vez de 48.27. Então, se um erro de meio por cento proporciona um aumento de velocidade de mil vezes e permite pré-calcular algo que, de outra forma, você não conseguiria pré-calcular, aceite a troca. Apenas faça isso deliberadamente e saiba quais dos seus números são estimativas. Agora, vamos falar sobre retenção de dados.
1:59:32 Até aqui, tudo foi sobre tornar os dados mais rápidos. Esta seção é sobre não guardar tudo, e isso é mais importante do que parece. Então, primeiro, vamos falar sobre o custo real do crescimento dos dados. É isso que acontece quando uma tabela cresce sem limite, e raramente é o que as pessoas esperam. O disco é barato, então esse não é o problema. Seus backups ficam lentos e enormes todas as noites, e você mantém vários deles. As restaurações também levam uma eternidade, o que
2:00:05 só importa uma vez, no pior momento possível. Seu objetivo de tempo de recuperação — seu objetivo de tempo de recuperação — aos poucos vira uma mentira, e você chega ao limite do disco, basicamente no pior momento possível. Então, cada backup armazenado será grande e, somados, podem representar uma parte significativa dos seus custos mensais, um aspecto frequentemente ignorado nos cálculos de custo. É isso que as pessoas não percebem. Você não está pagando por uma única cópia dos dados. Está
2:00:35 pagando pela cópia ativa, mais uma réplica, além de quantos snapshots noturnos você mantiver. Um gigabyte de tabela pode gerar um gasto de cinco ou dez gigabytes. E os dados antigos se tornam menos importantes. Aí vem a observação que torna tudo administrável: a verdade é que os dados antigos ficam menos importantes com o tempo. Ninguém consulta requisições HTTP individuais de 14 meses atrás. As pessoas consultam resumos dos dados de 14 meses atrás, o que significa que as linhas brutas são puro custo, com
2:01:06 praticamente nenhum valor. E nós já criamos os resumos. Então, você pode considerar adicionar um job em segundo plano para descartar qualquer chunk cujos dados sejam inteiramente anteriores a três meses. Vale entender por que isso é muito melhor do que fazer uma exclusão todas as noites. Para excluir 100 milhões de linhas, é preciso encontrar cada linha, marcá-la como morta, gravar isso no write-ahead log e deixar o espaço para ser recuperado depois pelo vacuum. Isso exige muito IO. Aumenta o inchaço da tabela. E interfere
2:01:37 no tráfego ativo. Depois, muitas vezes você precisa de um vacuum full, que exige um bloqueio exclusivo. Descartar um chunk apaga um arquivo. O chunk é uma tabela. A tabela inteira desaparece. Não há varredura de linhas, tuplas mortas, vacuum nem inchaço. É praticamente instantâneo, independentemente de quantas linhas havia nele. Então, os chunks são tudo ou nada. Uma consequência importante: um chunk só pode ser descartado quando todas as linhas nele se qualificam. Com chunks diários e uma política de três meses, você descarta aproximadamente um dia de dados por dia. Certo. Com
2:02:10 chunks mensais, um chunk só é removido quando sua linha mais recente ultrapassa o limite. Então, você pode acabar mantendo até um mês extra de dados. Portanto, o intervalo dos chunks e o intervalo de retenção interagem. Se a retenção precisa ser precisa, por exemplo, por causa de um prazo de conformidade, seus chunks precisam ser pequenos em relação à janela de retenção. Então, aqui vai um padrão útil no TimescaleDB. Vamos combinar a retenção com a escada de agregações, porque essa combinação é o padrão arquitetural mais útil no TimescaleDB.
2:02:42 Cada degrau da escada recebe sua própria política de retenção. Os detalhes mais finos expiram rapidamente. Os resumos mais grosseiros duram bastante e são pequenos o suficiente para que mantê-los para sempre seja realmente acessível. Então, basicamente, você pode adicionar sua política de retenção aos dados de 15 minutos, horários e diários. Para os dados diários, talvez você queira simplesmente mantê-los para sempre. Então, vamos falar sobre o que você ainda consegue responder depois de cinco anos.
2:03:14 Tendências mensais de tráfego? Sim, com dados diários. Qual serviço teve desempenho degradado no terceiro trimestre de três anos atrás? Sim, com dados diários. O que aconteceu minuto a minuto durante o incidente da última terça-feira? Sim, com os dados brutos. O que aconteceu minuto a minuto durante um incidente de quatro anos atrás? Não, mas ninguém pergunta isso. Você obtém as respostas de que as pessoas realmente precisam usando uma fração minúscula do armazenamento. Mas há um aviso: a ordem importa. Isso vai causar problemas se você inverter a ordem. A janela de atualização das suas agregações
2:03:45 não pode se estender além da janela de retenção dos dados brutos. Se uma agregação contínua estiver configurada para atualizar material com mais de três meses e os chunks brutos desse período já tiverem sido descartados, a atualização recalcula esses buckets como se não houvesse dados e grava zeros no histórico. É para isso que serve o start offset. Você o define como um intervalo menor que o de retenção. É também por isso que nossas políticas usavam null anteriormente. Isso não tem problema em um conjunto de dados de demonstração com apenas alguns dias,
2:04:16 mas pode ser perigoso em produção quando há retenção. Então, o último recurso desta seção é o armazenamento em camadas. E ele responde à pergunta que você deveria estar fazendo depois da seção sobre retenção: e se eu não puder excluir os dados? Isso pode acontecer. Registros financeiros com exigência de sete anos, dados médicos, qualquer coisa sobre a qual um auditor possa fazer uma pergunta a respeito de 2019. Você não pode descartar esses chunks. Mas mantê-los em armazenamento local rápido, como se você fosse replicá-los e fazer backup deles todas as noites, não faz sentido para dados que ninguém consulta. Então, há uma terceira camada. Já vimos duas: armazenamento em linhas para dados recentes e armazenamento em colunas para dados mais antigos. O armazenamento em camadas adiciona o armazenamento de objetos da classe S3, muito mais barato para os dados realmente frios que você não vai usar.
2:04:46 Então, o aspecto mais interessante do armazenamento em camadas é que você ainda pode consultar todos os seus dados. Os chunks continuam fazendo parte da hypertable. Portanto, você não precisa fazer nada diferente. Essa é a proposta. O chunk sai do armazenamento local do seu
2:05:18 banco de dados e vai para um bucket. E continua fazendo parte da hypertable. Não há tabela de arquivo, caminho de consulta separado nem processo de restauração. O mesmo SELECT lê os dados. É mais lento. A consulta vai pela rede até o armazenamento de objetos, mas isso é transparente e não afeta a correção. Então, o que acabei de explicar é um recurso exclusivo do TigerCloud. Ele não está disponível na extensão que você instala pelo Docker em uma instalação própria, porque exige que a infraestrutura de armazenamento de objetos
2:05:49 e a gestão dos metadados sejam administradas para você. Então, vou descrever a API agora e, mais tarde, vamos usá-la de verdade em um serviço do TigerCloud com dados reais e ver um chunk ser movido. Então, aqui está a API de armazenamento em camadas. Primeiro, podemos habilitar esse armazenamento e, depois, mover para essa camada os chunks mais antigos que um determinado limite. Aqui, os dados têm mais de um ano. Também podemos mover um chunk específico imediatamente ou
2:06:20 inspecionar o que foi movido para essa camada. Selecione tudo de timescaledb_osm_tiered_chunks. É o mesmo modelo mental da política de armazenamento em colunas: um limite e um job em segundo plano. Então, o que você deve usar: retenção, armazenamento em colunas ou armazenamento em camadas? Basicamente, é simples quando você pensa em quem precisa desses dados e com que rapidez. Se os dados são consultados regularmente e precisam estar disponíveis rapidamente, use armazenamento local em colunas
2:06:51 se forem mais antigos que um dia ou algo assim. Se forem raramente consultados, mas precisarem ser retidos, mova-os para o armazenamento em camadas. É barato, lento, ainda permite consultas e continua fazendo parte da tabela. E, se ninguém jamais precisar dos dados e nenhuma regra exigir que sejam mantidos, descarte-os. Essa é a sua política de retenção. E essa seria a configuração de produção: dados brutos no armazenamento local por três meses, movidos para armazenamento em camadas dos três meses aos sete anos e descartados depois de sete anos. As médias ficam no armazenamento local o tempo todo, porque são pequenas.
2:07:21 Então, esse é o ciclo de vida completo. Essa é a visão completa, e eu queria deixá-la na tela por um instante porque é o que diferencia o TimescaleDB de simplesmente acoplar um banco de dados analítico ao PostgreSQL. Uma tabela, uma interface de consulta, dados que percorrem automaticamente uma escala de custos à medida que envelhecem e, por fim, expiram. Tudo isso declarado em cerca de seis linhas de política.
2:07:52 Certo, vamos falar de todos os recursos. Apenas um resumo dos recursos, basicamente. Então, se você se lembra da estrutura, do básico, sempre pode consultar a sintaxe. Hypertables particionam por tempo automaticamente, e o intervalo dos chunks é a única decisão real de dimensionamento. Os índices funcionam como sempre funcionaram, com um truque de UUID v7 para mantê-los ordenados por tempo. A reorganização dos chunks
2:08:22 existia para oferecer localidade dos dados, mas o armazenamento em colunas tornou isso obsoleto. O armazenamento em colunas é o principal recurso: ocupa 90% menos espaço e é vetorizado; segment by é a configuração que determina se ele funciona. As agregações contínuas pré-calculam os dados de forma incremental, podem ser organizadas em escadas e permanecem atualizadas com materialize_only igual a false. As hyperfunctions oferecem preenchimento de lacunas, estados de agregação armazenáveis e aproximações que você pode pré-calcular. A retenção descarta chunks
2:08:55 em vez de excluir linhas. O armazenamento em camadas coloca os dados frios no armazenamento de objetos sem tirá-los da tabela. Agora você sabe mais sobre o TimescaleDB do que a maioria das pessoas que o executam em produção. O que você ainda não tem é memória muscular: um esquema que você projetou, um pipeline que escreveu, consultas que ajustou. Por isso, a próxima parte deste curso é focada em construir. Certo, hora de ver como configurar nosso ambiente local. Muitos cursos colocam a configuração no início, mas colocá-la aqui fornece o contexto necessário. Então
2:09:27 você já entende a finalidade de configurações como max_worker_processes para tarefas em segundo plano. Agora vamos transformar aquele contêiner básico em um ambiente adequado, com uma estrutura real de projeto, configuração ajustada, dados carregados e ferramentas essenciais. Tudo daqui em diante usará o que construirmos nesta seção. Na outra seção, usei apenas um docker run básico para começar, mas vamos configurar tudo do jeito que você realmente manteria em um projeto. Primeiro, precisamos estar em um terminal e
2:09:57 criar um diretório dedicado para este curso. Depois, entre nele. Eu já entrei. Agora vamos baixar a imagem de alta disponibilidade. Vou usar docker pull timescale/timescaledb-ha e depois :pg18. Basicamente, esta imagem inclui o TimescaleDB, o Toolkit, as hyperfunctions e o pgvector. Você pode especificar a versão do PostgreSQL que quiser, e isso pode levar algum
2:10:30 tempo para terminar de instalar. Certo, em vez de memorizar flags do terminal, vamos criar uma configuração em um arquivo docker-compose. Vou usar touch docker-compose.yml para criar o arquivo. Agora vou abrir e editar o arquivo docker-compose, e já acrescentei estas configurações. Quatro coisas merecem destaque. São todas as configurações de
2:11:02 preparação. Então, TS_TUNE_MEMORY e TS_TUNE_NUM_CPUS: a imagem executa timescaledb-tune na inicialização, que define shared_buffers, work_mem, effective_cache_size e as configurações de parallel workers com base no que você informar. Vamos fornecer os números reais do que alocamos ao Docker, e você ganha de graça um PostgreSQL configurado de forma razoável. Se deixar isso de fora, receberá os valores padrão, pensados para uma estação de trabalho bem pequena
2:11:32 basicamente. Depois, temos um volume nomeado, timescaledb-a. Na verdade, o volume nomeado está aqui embaixo. Sem isso, os dados ficam na camada gravável do contêiner e desaparecem na primeira vez que você o recria. Temos o caminho, que é onde a imagem -ha mantém este diretório de dados. É esta imagem aqui em cima. Isso é diferente da imagem simples do PostgreSQL, e montar o caminho errado faz com que você tenha, sem perceber, um banco de dados não persistente.
2:12:05 Também temos shm_size de 1 GB. A memória compartilhada padrão do Docker é de 64 megabytes. As consultas paralelas usam memória compartilhada para coordenar os workers, e uma agregação paralela grande — que corresponde à maior parte do que fazemos — falhará com a mensagem “could not resize shared memory segment”. Por isso, precisamos aumentá-la. Também temos restart unless-stopped. Assim,
2:12:37 o contêiner volta a funcionar depois de uma reinicialização, e você para de se perguntar por que seu laptop perdeu o banco de dados. Posso salvar isso e sair. Depois, basta executar docker compose up -d. Ele já está iniciando — na verdade, já iniciou. Também podemos conferir os logs com docker compose logs.
2:13:07 Quando virmos isso, poderemos confirmar que o sistema de banco de dados está pronto para aceitar uma conexão. Vou pressionar Ctrl + C. Agora podemos definir as configurações importantes para o Timescale. Vou executar docker compose exec, que executa um comando diretamente dentro do contêiner do banco de dados em execução. Vamos abrir o psql como usuário postgres. Então, temos o
2:13:40 docker compose exec, que executa um comando diretamente dentro do contêiner do banco de dados em execução. Vamos abrir o psql como usuário postgres, dentro do banco de dados course_db. Agora estamos dentro do PostgreSQL e podemos começar a digitar comandos. Com o prompt do SQL aberto, podemos habilitar o TimescaleDB e o Toolkit. Vamos executar CREATE EXTENSION IF NOT EXISTS timescaledb e, depois, timescaledb_toolkit.
2:14:11 Depois, podemos executar \dx para ver o que foi habilitado ou o que temos aqui. Agora podemos revisar as configurações específicas do TimescaleDB. Vou verificar quatro configurações críticas que controlam o desempenho da extensão. Temos max_background_workers, que queremos que tenha pelo menos um por policy, para que as tarefas não fiquem na fila nem atrasem.
2:14:42 Max worker processes: queremos que esse seja o limite global, abrangendo workers em segundo plano, paralelos e de replicação. Max parallel workers determina quantos núcleos uma única consulta grande pode usar. E shared_buffers deve corresponder a aproximadamente 25% da RAM para manter os índices dos chunks ativos em cache. Dependendo do valor de max_background_workers, o nosso já está correto: 24. Mas você pode alterá-lo com ALTER SYSTEM
2:15:13 SET e, em seguida, definir max_background_workers com o valor que quiser — no nosso caso, queremos 24. Agora podemos sair com \q. Como alteramos configurações de workers no nível do sistema, o banco de dados precisa ser reiniciado. Vamos reiniciar o contêiner com docker compose restart db. Agora queremos configurar nosso arquivo psqlrc. Então, vamos usar cat e
2:15:44 ir direto ao nosso arquivo, que fica no diretório home local. O psql vai lê-lo sempre que for iniciado. Vamos garantir que o timing esteja ativado aqui, para que cada consulta mostre sua duração e você possa acompanhar o desempenho. Com \x auto, linhas largas mudam automaticamente para um formato vertical de chave e valor, em vez de se quebrarem em blocos ilegíveis em telas pequenas. Vamos definir o pager como less -SFX. Isso impede a quebra de linhas, permitindo rolar para os lados, e sai imediatamente se a saída couber em uma tela. Também temos esta linha: \pset linestyle unicode e border 2. Ela adiciona bordas adequadas, desenhadas com caracteres de caixa, para uma aparência mais limpa.
2:16:16 Certo, este é o projeto que vamos construir. Este é o problema que o projeto aborda. Imagine que você criou um agent de IA. Ele chama um modelo. O modelo solicita uma ferramenta. Você executa a ferramenta, envia o resultado de volta e repete o processo até terminar. E tudo funciona muito bem no desenvolvimento. Então, o sistema entra em produção e alguém diz algo como: “Nossa conta da OpenAI triplicou” ou “isso continua falhando para este cliente”. E aí você só tem logs. Toneladas de logs, logs de texto, milhares de linhas por execução. Não há como fazer uma pergunta que abranja todos eles. Então, vamos construir uma caixa-preta de voo para agents. Basicamente, é como o equipamento que as aeronaves têm. Cada etapa executada por um agent emite um span, um registro com timestamp
2:16:48 de uma operação, sua duração, seu resultado, a contagem de tokens e o custo — milhões de registros. Além disso, teremos dashboards ao vivo, latência P95 por ferramenta, atribuição de custos por cliente, alertas de taxa de erros em tempo real e a capacidade de reproduzir uma execução individual para ver exatamente o que aconteceu. Este vai ser um projeto muito bom para o TimescaleDB. Escolhi esse projeto porque a telemetria de agents tem todas as propriedades que fazem do TimescaleDB a escolha certa. Tem alto volume: uma execução de agent
2:17:18 tem dezenas de spans. Os dados são, em sua maioria, acrescentados, e os spans são fatos que não são atualizados. Todas as perguntas envolvem intervalos de tempo, como a última hora ou esta semana em comparação com a semana passada. É multi-tenant, o que nos dá segmentação natural por cliente. Além disso, os detalhes antigos deixam de importar, mas o resumo nunca expira — exatamente o que a política de retenção em camadas oferece. E todas as consultas são agregações em janelas. Vamos usar hypertables, chaves primárias UUID v7, columnstore com duas dimensões de segmentação e uma estrutura de agregações em tempo real de três níveis, armazenando estágios como stats, approx_count_distinct e state_agg. Também vamos usar preenchimento de lacunas e a política de retenção em camadas.
2:17:50 Basicamente, teremos tudo sobre TimescaleDB em um único sistema, e você também não precisa de uma chave de API. Uma observação prática: há um agent instrumentado de verdade no repo, e vou mostrar o código dele. Mas também há um simulador que gera 72 horas de tráfego realista, sem chave de API e sem custo. Então, basicamente, você
2:18:20 escada de agregações em tempo real, armazenando etapas como stat_approx_count_distinct_state. Também usaremos preenchimento de lacunas, escada de retenção e… Então, basicamente, tudo sobre o TimescaleDB em um único sistema, e você também não precisa de uma chave de API. Então, aqui vai uma observação prática. Há um agent instrumentado de verdade no repo, e vou mostrar o código dele, mas também há um simulador que gera 72 horas de tráfego realista sem chave de API e sem custo. Então, basicamente, você…
2:18:54 Você pode acompanhar tudo de graça. Então, vamos falar sobre o dashboard: que dados precisamos e a quais perguntas ele precisa responder? Basicamente, o que o dashboard precisa mostrar determina tudo o que vem depois. Vou listar os itens, porque eles aparecem literalmente em SQL mais adiante: custo por agent por hora; latência P95 e P99 por ferramenta; taxa de erro ao longo do tempo; quais ferramentas falham mais; sessões únicas por dia; a
2:19:25 distribuição da duração das execuções; quanto tempo uma execução passa pensando versus chamando ferramentas; e reproduzir uma execução específica. A segunda pergunta é: o que é uma linha? Isso diz respeito apenas a como vamos construir nosso banco de dados. Então, o que é uma linha? A granularidade útil mais fina é um span: uma chamada de LLM, uma chamada de ferramenta, uma recuperação. Poderíamos ir mais a fundo, por exemplo, uma linha por token, mas isso seria basicamente inútil. Ninguém faz perguntas no nível do token, e isso multiplicaria o volume por
2:19:55 mil. No sentido contrário, uma linha por execução perderia a capacidade de atribuir a latência a uma ferramenta específica, o que responde à segunda pergunta da lista. Então, uma linha por span, que é basicamente a granularidade mais fina que alguém realmente consulta. A próxima pergunta que queremos fazer sobre nossos dados é: vamos usar uma tabela ou várias? Bem, é aqui que vou me afastar do que se costuma fazer. Basicamente, quero mostrar o raciocínio, e não a conclusão. Uma chamada de
2:20:28 LLM tem tokens e o nome do modelo. Uma chamada de ferramenta tem o nome da ferramenta e os argumentos. Uma recuperação tem uma consulta e uma contagem de resultados. São formatos diferentes, então alguém poderia dizer que deveríamos usar tabelas separadas. Mas, se olharmos mais de perto, elas compartilham 80% das colunas: horário, execução, projeto, nome do agent, status e duração. As diferenças são dois ou três campos em cada uma. E, o que é importante, todas as consultas importantes — como taxa de erro, latência e o diagrama em cascata da execução — precisam olhar para todos
2:20:58 os tipos de span ao mesmo tempo, o que significaria fazer um UNION ALL de três tabelas em cada consulta. Com licença. Então, vamos usar uma tabela com um discriminador. Por que isso funciona? Porque teremos uma tabela com uma coluna de tipo de span e os campos específicos de cada tipo em JSONB. Essa é a exceção à regra que mencionei antes. E o teste para saber quando abrir uma exceção é: suas consultas tratam essas coisas como uma só ou como coisas
2:21:30 diferentes? Aqui, elas são uma só: um span que pode ter diferentes variantes. Três tabelas significariam normalizar por uma dimensão que ninguém consulta. As colunas de tokens de entrada, tokens de saída e custo que vamos criar terão valor zero para spans de ferramenta. Tudo bem: são oito bytes de dados quase nulos, que são codificados por sequências. Então, não ocupam nada após a compressão. Agora, a tabela. Vamos vê-la no
2:22:02 arquivo. Certo, ela ficará na nossa pasta de migrações, em 002 tables, e podemos ver o que temos nela. Cada coluna aqui é uma decisão. Vou justificar as que importam. Temos timestamp TZ como primeira coluna. Essa é a chave de particionamento, aqui. E os agents executam em todos os fusos horários. Não há vantagem em deixar isso ambíguo. O ID do span é um UUID v7.
2:22:34 Isso aparece aqui. Os agents geram os IDs dos spans no lado do cliente. Precisam fazer isso porque o span existe antes de ser gravado, e os spans filhos fazem referência ao pai. Então, precisamos de IDs globalmente únicos, gerados sem uma ida e volta ao banco de dados. E o v7 significa que eles são ordenados por horário. Assim, o índice permanece compacto e os dados são adicionados à extremidade direita, em vez de espalhar gravações por toda a B-tree. UUIDs v4 aleatórios seriam um problema real de desempenho em grande escala. Também temos a chave primária
2:23:06 span ID. A coluna de particionamento precisa estar na chave primária, e o horário vem primeiro. Assim, o índice é útil por si só para varreduras por intervalo. Depois temos o parent ID. Essa é uma coluna de dependência. Quer dizer, não: o parent ID organiza os spans em uma árvore, o que nos permite renderizar um diagrama em cascata. A coluna de dependência é project. Portanto, nosso agrupamento por
2:23:38 segmento vai usar essa coluna, que tem baixa cardinalidade: dezenas, talvez centenas de projetos. Toda consulta do dashboard faz filtro por ela. Depois temos o status, que será texto em vez de booleano, porque há mais de dois resultados. Certo: erro, tempo limite, recusado. Essa distinção importa, e uma recusa não é uma falha do seu sistema. Depois temos os atributos, que são um JSONB. É onde ficam os dados menos comuns: a temperatura do modelo, o formato dos argumentos da ferramenta, a contagem de resultados da recuperação. Basicamente, coisas que talvez queiramos consultar, mas pelas quais não vamos filtrar diariamente.
2:24:10 Agora, vamos fazer as contas para definir esse intervalo de chunks. Digamos que uma plataforma de agents movimentada tenha 200 agents executando uma vez por minuto, com 30 spans cada. Isso dá 6.000 spans por minuto. Arredondando, são 8,5 milhões de spans por dia. Nossa linha tem cerca de 160 bytes, e o JSON fica quase todo vazio. Então, são cerca de 1,44 gigabytes
2:24:41 de tabela por dia, mais os índices: cerca de 1,8 gigabyte. O volume dos índices é o número que mais importa: aproximadamente 400 megabytes por dia. Em uma instância de 16 GB, 25% são 4 GB para os índices dos chunks ativos. Os índices de um dia ficam bem abaixo disso. Uma semana ficaria abaixo de 3 gigabytes. Também funciona, na verdade. Então, por que usar chunks diários em vez de semanais? Bem, por causa da granularidade da retenção. Vamos manter as linhas, os spans brutos, por 14 dias.
2:25:12 Com chunks semanais, não é possível excluir um chunk até que sua linha mais recente tenha 14 dias. Então, poderíamos acabar mantendo até 21 dias de dados. Com chunks diários, a retenção é precisa e a quantidade de chunks é limitada. Quatorze dias de dados brutos correspondem a 14 chunks, bem longe de mil. Então, usamos chunks diários quando a retenção é rigorosa: o intervalo dos chunks acompanha a retenção. Vou criar um novo arquivo, hyper tables. Vamos criar essa hypertable, agent span, no nosso arquivo de hyper tables. E então
2:25:46 quero falar das tabelas de metadados. São basicamente duas tabelas relacionais pequenas, porque vamos precisar delas para o que estamos fazendo. Elas já estão no arquivo de tabelas. Então, criamos project e model price. Temos monthly budget USD, que é numérico porque representa dinheiro, e temos a duração em milissegundos. Ah, e também temos o
2:26:17 preço do modelo. Isso aqui é só um exemplo, porque vai mudar, mas podemos armazenar a tabela de preços como referência. Ainda assim, precisamos calcular o custo no momento certo e armazená-lo no span. Também vamos ter alguns exemplos de outra função, para inserir esses itens no projeto.
2:26:48 Vamos ter apenas três índices. Então, vamos adicioná-los ao nosso arquivo de índices. Vou criar esse arquivo, e temos agent span: criamos um índice em agent span para project. Basicamente, ele serve para o principal fluxo de consultas do dashboard. Depois, temos o horário em ordem decrescente. Em seguida, run ID e end time, para a reprodução da execução, que é uma consulta pontual por execução. E então
2:27:20 um índice parcial apenas para falhas, porque os erros representam uma pequena fração das linhas. Assim, um índice que cobre apenas essas linhas é minúsculo, e “mostrar erros recentes” é a consulta mais executada em qualquer ferramenta de observabilidade. Não vou criar um índice para span kind nem para status em geral. Eles têm baixa cardinalidade e, após a conversão para armazenamento em colunas, esses valores se tornam metadados de eliminação de lotes. Falta mais uma tabela, que será a de runs. Vou criar esse arquivo, runs.sql. Então, vamos
2:27:53 acompanhar as execuções dos agents. A contagem de spans e o custo em USD podem ser derivados dos spans, mas vou armazená-los mesmo assim. Isso porque a lista de execuções é a tela mais carregada de todo o app, e não quero agregar um milhão de spans para exibi-la. Então, gravamos uma vez, quando a execução termina, e podemos ler esses dados para sempre. Certo, o esquema está pronto. Agora, algo precisa gravar nele. Então, quero
2:28:26 Deixe claro o que estamos construindo, porque o padrão de instrumentação aqui é a parte reutilizável. Ele funciona com qualquer framework de agent e, na verdade, com qualquer fluxo de eventos. Um agent realiza trabalho. Cada unidade de trabalho é um intervalo: uma chamada ao modelo de linguagem, uma chamada de ferramenta, uma recuperação. Envolvemos cada unidade, medimos seu tempo, registramos o que aconteceu e a enviamos para um buffer. O buffer grava os dados no PostgreSQL em lotes. O agent nunca espera pelo banco de dados. Então, acabei de criar este arquivo recorder.py.
2:28:59 E lembre-se: todo esse código está disponível. Você pode acessar o link na descrição para obtê-lo. Então, esta é uma fila de processos, e a lista de colunas é definida uma única vez, para que o writer e o recorder não divirjam. Se descermos, podemos ver esta função span. Eis o que quero que você observe. Usamos UUIDv7 e, em seguida, vamos pegar o
2:29:30 carimbo de data e hora aqui. E lembre-se da restrição de verificação na tabela: o ID da sequência de tempo, o carimbo de data e hora e o ID do intervalo precisam corresponder. Então, lembre-se dessa restrição de verificação na tabela: os IDs da sequência de tempo precisam corresponder ao horário. É por isso que estou fazendo isso. Se eu gerasse o UUID com um valor de agora e o carimbo de data e hora com outro valor de agora, eles seriam diferentes por microssegundos, e cada inserção falharia na verificação. Então, simplesmente geramos o
2:30:00 carimbo de data e hora uma única vez e, em seguida, derivamos ambos a partir dele. Isso ajuda. Depois, temos este bloco finally, e o intervalo é registrado quer o trabalho tenha sido concluído com sucesso, falhado ou dado errado. Uma observabilidade que registra apenas sucessos é pior do que nenhuma, porque pode deixar você confiante demais. E podemos ver que os tratadores de exceção propagam o erro: classificamos a falha e então
2:30:30 saímos do caminho. Um recorder que engole suas exceções não é bom. Temos uma função para copiar um lote e, aqui embaixo, a função do loop de gravação. Basicamente, vamos gravar um lote usando COPY. Você também pode usar COPY ou INSERT. Basicamente, os dados serão gravados diretamente no nosso banco de dados. Estas são todas funções auxiliares que vamos usar mais tarde. Mas uma coisa
2:31:00 em que vale a pena prestar atenção são batch_rows e batch_seconds. Eles definem por quanto tempo vamos acumular tudo antes de descarregar. A condição de descarregamento é a linha do do, que está bem aqui no loop de gravação. Estamos usando batch_rows ou batch_seconds, como acabamos de falar. Então, o limite será 500 linhas ou 1 segundo, o que ocorrer primeiro. Em um sistema
2:31:32 movimentado, você obtém lotes completos e o máximo throughput. Mas, em um sistema tranquilo, seus dados ainda chegam em até um segundo. Sem o limite de tempo, os intervalos dos agents com pouco tráfego ficariam na memória indefinidamente, e seu dashboard pareceria estar quebrado. E observe a reconexão em caso de falha, aqui embaixo. Ele vai se reconectar em caso de falha, porque writers em segundo plano precisam sobreviver à reinicialização do banco de dados, pois vão
2:32:02 passar por uma. Basicamente, juntas, as funções copy_batch e writer_loop desacoplam a velocidade de execução de quem faz a chamada da latência do banco de dados, o que pode transformar milhares de eventos minúsculos em gravações em lote enormes. Para dar um pouco mais de informação sobre o loop de gravação, ele funciona como um worker assíncrono em segundo plano que desacopla a thread principal das gravações no banco de dados e armazena eventos em buffer na memória
2:32:34 até que atinjam um limite específico de tamanho ou de tempo. Então, você pode ver que a arquitetura não bloqueia. Já falamos sobre o envio em lotes com dois gatilhos, por tamanho ou por tempo. E, para falar um pouco mais sobre copy_batch: ela transmite um lote inteiro de linhas armazenadas na memória diretamente para o TimescaleDB usando o protocolo nativo de transmissão do PostgreSQL. Esse é o COPY de que falamos,
2:33:07 e isso ignora a sobrecarga padrão de análise de SQL. Agora, falando um pouco mais sobre COPY, que está bem aqui, o comando COPY é muito importante para o TimescaleDB porque as hypertables do TimescaleDB encaminham os dados para tabelas de blocos subjacentes com base em partições de tempo. Então, usar COPY alimenta o mecanismo de armazenamento diretamente com linhas em lote, permitindo que o TimescaleDB processe de milhares a centenas de milhares de
2:33:38 linhas por segundo com contenção mínima de bloqueios. Agora, vamos voltar a este arquivo, mas vou criar outro arquivo, pricing, e simplesmente colar o código nele. Já vimos os preços nas nossas tabelas, onde estamos adicionando alguns valores de preço, mas aqui estamos apenas definindo esses valores diretamente no código outra vez. Esses números são ilustrativos e, basicamente, estarão desatualizados quando você assistir a este vídeo. Então, você vai precisar consultar a página do fornecedor para ver os valores reais. Mas o que importa é
2:34:08 o formato. A entrada e a saída têm preços diferentes. A saída costuma custar várias vezes mais. Essa assimetria é o motivo pelo qual tornar o modelo mais conciso é uma maneira real de reduzir custos. Também vamos ter um arquivo chamado agent.py. Este é um agent pequeno que usa ferramentas, com três ferramentas. Não vamos passar por todas elas porque nosso foco principal é a seção do banco de dados, mas temos uma ferramenta de busca na web, uma calculadora
2:34:39 e uma consulta ao banco de dados. E ela falha de propósito em cerca de 8% das vezes. Essa ferramenta instável é basicamente uma linha de código valiosa neste projeto, porque gera os erros que fazem o dashboard da taxa de erros exibir alguma coisa. Isso vai exigir uma chave de API da OpenAI, mas você pode testar sem uma usando apenas o arquivo simulate.py, que
2:35:10 estará no repo. Se formos até a execução, podemos ver que esta é toda a superfície de integração: um bloco with em torno da chamada que já estamos fazendo, com três atribuições da resposta. As chamadas de ferramentas têm o mesmo formato, com kind igual a tool. Aqui está: kind igual a tool. Então, basicamente, estamos adicionando ao nosso banco de dados as chamadas ao modelo de linguagem ou as chamadas de ferramentas. E aqui está
2:35:43 o nosso simulate.py, que vai fazer uma simulação caso não tenhamos uma chave de API. Vou executar este arquivo de simulação para inserir alguns dados aqui. Mas primeiro precisamos nos certificar de que o banco de dados está configurado corretamente. Meu contêiner do Docker já está em execução. Em seguida, vou criar o banco de dados com docker compose exec, depois vamos executar psql e criar o
2:36:15 banco de dados flight rack. É esse que nosso código usa. E já criamos todos aqueles arquivos de migração. Então, vou criar este loop para executar cada um dos arquivos de migração. Certo, criei tudo. Agora posso executar nosso comando de simulação. Isso vai levar um tempinho. E não se trata apenas de criar ruído aleatório,
2:36:49 o que não nos ensinaria nada. Há quatro coisas incorporadas. Basicamente, ele cria três projetos, oito agents e 72 horas de histórico, a 40 intervalos por segundo. Temos esses dados simulados, que vão apresentar este padrão diário. Basicamente, o tráfego atinge o pico ao meio-dia e cai à noite. Qualquer dashboard real mostraria isso. Se o seu teste for plano, você não vai perceber quando o preenchimento de lacunas estiver errado. Ele também vai usar uma distribuição realista de latência. A
2:37:22 distribuição é log-normal, não uniforme. A maioria das chamadas é rápida, mas há uma longa cauda à direita, e é por isso que existe o P95. Também há picos de erros, em vez de falhas distribuídas uniformemente. Sistemas reais falham em agrupamentos, e há um incidente inserido de propósito. Uma ferramenta específica fica drasticamente mais lenta durante uma janela de 40 minutos, em um horário conhecido. E, na seção de consultas, vamos encontrá-lo usando apenas consultas de agregação. Depois, podemos explicá-lo a partir dos intervalos brutos. Então, eu
2:37:52 recomendo usar os dados simulados se você estiver acompanhando. Certo, criei os dados e agora vou executar o comando para acessar o banco de dados, que se chama flight wreck. Certo, vamos contar tudo aqui. Agora vemos que há mais de 9 milhões de intervalos e que o tamanho é de 48 kilobytes. São 3 dias de histórico, mas ainda não ajustamos nada. Então, vamos corrigir isso. Certo, nós
2:38:27 vão deixá-la mais rápida. Então, primeiro, vamos estabelecer uma linha de base. Nunca otimize antes de medir aquilo que você está otimizando. Vou ativar a medição de tempo. Quer dizer, ela já deveria estar ativada, mas vamos confirmar. Agora, vamos fazer um SELECT de project e agent. Vamos contar tudo como spans, calcular a duração média e obter o custo de tudo nas últimas 24 horas. Certo. Aqui está o nosso resultado
2:38:58 aqui. Os spans de project e agent; podemos ver quantos spans há para cada projeto e agente. Isso levou 665 milissegundos. Vamos comparar esse resultado depois de fazer algumas alterações. Agora vou criar um novo arquivo de migração e configurar o armazenamento colunar. Vamos direto para a etapa nove e preencher o restante depois. Certo. Aqui está o nosso arquivo de armazenamento colunar
2:39:29 e vou mostrar como estamos configurando isso. Temos segmentação por projeto e agente. Projeto é a coluna de locação. Toda consulta filtra por ela. Agente é o segundo filtro mais comum, e há oito deles. Assim, os lotes ficam confortavelmente acima de mil linhas. Não usei name, embora filtremos por ela com frequência. Há uma dúzia de nomes de ferramentas e modelos e, combinados com
2:39:59 projeto e agente, eles fragmentariam os lotes. Também temos a ordenação aqui, e sempre usamos time primeiro, porque toda consulta tem um intervalo de tempo, o que permite ignorar lotes com base nos valores mínimo e máximo. Depois, temos span kind. Assim, spans de LLM e spans de ferramentas ficam agrupados separadamente. A maioria dos painéis consulta um tipo de cada vez. E temos os índices de compressão com bloom e minmax. Um filtro bloom em name, porque “mostre-me os spans da ferramenta DB lookup” é uma verificação de igualdade, e name não está em segment by; minmax em duration_ms, porque “spans com duração superior a 5 segundos” é um filtro por intervalo, e é exatamente assim que vamos procurar o incidente plantado que usaremos mais tarde.
2:40:32 E lembre-se desta regra: há tempos de conversão integrados. Então, defina-os agora, antes de converter qualquer coisa. Temos esta política de armazenamento colunar. O intervalo
2:41:04 é de duas horas, não uma. Spans de agentes podem chegar atrasados, e uma execução que fica travada por 10 minutos grava o span pai 10 minutos depois de começar. Então, quero uma janela de tolerância maior no armazenamento em linhas. Também crio alguns índices, uma view materializada e uma política de armazenamento colunar. Vou configurar isso e fazer um teste. Primeiro, vou executar o arquivo de migração; vamos criar estes itens depois. Então,
2:41:36 não vou me preocupar com isso. Depois, volto ao psql e, como os dados são históricos, vamos forçar o processamento do backlog. Converter para armazenamento colunar tudo o que for mais antigo que duas horas, usando show_chunks. Primeiro, vou tentar encontrar o ID do job e depois podemos executá-lo. Fizemos isso porque tanto convert_to_columnstore quanto run_job são procedimentos do PostgreSQL,
2:42:06 e não funções comuns. Então, podemos chamá-los com o comando CALL. Isso inicia a conversão do backlog. Depois, vamos executar o mesmo comando que usamos antes de configurar o armazenamento colunar. Certo, agora são apenas 215 milissegundos. Ganhamos tempo. A consulta e a resposta são as mesmas, mas ela ficou muito mais rápida e ocupa uma fração do espaço em disco. Isso com um ALTER TABLE e uma política. Mas podemos
2:42:36 fazer melhor do que uma leitura mais rápida, porque não deveríamos fazer nenhuma leitura. Certo. Acabei de criar este novo arquivo, 06 span 5 minutes, e vamos falar sobre hyperfunctions. Basicamente, estamos criando uma view materializada. Ela vai cobrir os últimos cinco minutos. Se olharmos a lista de agregados, cada item foi escolhido com muito cuidado.
2:43:08 Temos a contagem e uma contagem filtrada. Elas são contagens de spans. Ou seja, dois números inteiros, a partir dos quais podemos obter a taxa de erros em qualquer granularidade, porque as contagens podem ser agregadas novamente. Depois, temos estas somas: a soma do custo e as somas dos tokens, que também podem ser agregadas novamente. Em seguida, temos os estados. Basicamente, temos três estados. Temos
2:43:40 as estatísticas. A primeira é o estado para a média e o desvio padrão. Depois, o agregado de percentil para P95 e P99 e o agregado aproximado de contagem distinta para sessões únicas. Nenhum deles poderia ser armazenado como um número finalizado. Uma média de médias está errada. Percentis de percentis não fazem sentido, e a contagem distinta conta duas vezes entre buckets. Armazenados como estados, os três serão
2:44:11 combinados corretamente. Não há nenhuma coluna de média nesta view. Depois, mais abaixo, vamos adicionar uma política de agregado contínuo, mas o intervalo de agendamento é de 1 minuto. Isso alimenta um painel de operações; o deslocamento final é de 10 minutos, o dobro do bucket de cinco minutos, conforme a regra. O deslocamento inicial é de sete dias, e esse valor não é
2:44:43 arbitrário. Os spans brutos são mantidos por 14 dias. Então, uma janela de atualização de 7 dias fica seguramente dentro desse período. Basicamente, é uma sequência de etapas: cinco minutos, por hora e por dia. Cada linha acima é criada a partir das linhas abaixo. Agora vamos criar as outras. Aqui está meu agregado de spans por hora. Vamos ter estas somas
2:45:16 e agregar novamente os estados. É basicamente isso. O arquivo não é muito longo, porque é construído a partir do arquivo anterior. Agora tenho meu arquivo de spans por dia. Você pode obter todo esse código no repositório do GitHub. Então, removemos name no nível diário.
2:45:46 Ninguém vai perguntar qual ferramenta específica foi lenta em um gráfico de custos anual. Remover name também vai reduzir a contagem de linhas em mais de uma ordem de grandeza. Assim, teremos dimensões mais grosseiras. Agora podemos usar isso nos arquivos que já criamos, como o arquivo de armazenamento colunar. É isso que temos aqui. Estamos usando
2:46:18 estes arquivos. Estamos criando um índice usando esses itens que acabamos de criar e criando uma view materializada e, depois, o armazenamento colunar. Basicamente, tudo isso usa os arquivos que acabamos de criar. Agora vou fazer uma última migração para a retenção e criar as políticas de retenção. Vou executar este comando no terminal para executar todos os arquivos de migração. Alguns já existem, então não vamos
2:46:49 criá-los novamente. Agora, vamos ao psql. Vamos ver quanto a hierarquia de agregados que acabamos de configurar ocupa em disco. Para isso, precisamos escrever uma consulta um pouco estranha. Antes de executá-la, vou explicar por que não podemos simplesmente consultar diretamente o tamanho das views. Ao criar um agregado contínuo, o TimescaleDB cria, nos bastidores, uma hypertable interna oculta para armazenar esses buckets pré-calculados. Se você executar uma verificação de tamanho padrão do PostgreSQL
2:47:20 em span five min, ela não inclui os dados reais. Então, esta consulta usa uma união. A parte de cima obtém o tamanho da nossa hypertable de spans brutos de agentes. A parte de baixo consulta os metadados do TimescaleDB para encontrar os nomes gerados pelo sistema dessas tabelas de suporte ocultas. Calcula o tamanho real delas e reúne os resultados para que possamos compará-los lado a lado. Vou executar a consulta e, aqui, obtemos
2:47:51 os resultados. Observem a queda nos tamanhos: 494, 106, 49. O tamanho vai diminuindo aos poucos, e esperamos que o agregado diário seja o maior aqui. Essa saída mostra basicamente toda a arquitetura. Nossos dados brutos ocupam quase 500 megabytes; ao reduzi-los a buckets de cinco minutos, o tamanho cai para cerca de um quinto. O agregado por hora ocupa uma fração disso. E o resumo diário, que contém exatamente
2:48:22 o que precisamos para um gráfico de comparação ano a ano, ocupa meio megabyte. É praticamente um erro de arredondamento no disco. Os dados brutos são grandes e têm vida curta, mas os resumos são pequenos e duram para sempre. Agora, vamos fazer algumas consultas. Vamos encontrar as sessões únicas por dia. Podemos ver que há 900 em cada um desses dias. Isso levou 29 milissegundos. E aqui vamos contá-las de outra maneira. E
2:48:53 Desta vez, estamos obtendo basicamente o mesmo número de sessões únicas, mas levou muito mais tempo: 3.647 milissegundos. Isso porque este primeiro exemplo executou nossa hierarquia de agregações, que criamos para acelerar as consultas: as sessões de contagem distinta agregadas. Lembre-se de que, quando criamos a agregação de cinco minutos, não salvamos um número para as sessões; salvamos o estado usando
2:49:24 approx_count_distinct internamente. Isso cria um sketch HyperLogLog. A função de roll-up combina esses sketches de cinco minutos em sketches diários, e a contagem distinta extrai o número final. Então, esta foi a maneira mais tradicional de obter os dados com SQL: obtivemos os mesmos dados, mas levou muito mais tempo. No entanto, a velocidade não é realmente a diferença mais importante aqui. É o fato de que a consulta rápida é arquiteturalmente possível. Você
2:49:56 não pode pré-computar uma contagem distinta padrão em uma view materializada e depois agregá-la. Se um usuário fizer login no intervalo das 9h e também no das 10h, e você tentar somar esses intervalos depois, contará esse usuário duas vezes. A única maneira de obter uma contagem distinta exata em uma janela de tempo mais ampla é voltar até os dados brutos e contar de novo. Os estados HyperLogLog resolvem isso. Os sketches podem ser combinados matematicamente sem
2:50:28 contar duas vezes. Essa é a diferença entre uma métrica que você pode colocar em um dashboard ao vivo e rápido e uma que não pode. Se você precisa do número exato para uma fatura mensal, execute a consulta lenta uma vez por mês. Para um dashboard que ninguém fica encarando com seis casas decimais, porque não precisa ser tão exato, a estimativa HyperLogLog é uma boa decisão de engenharia. Até aqui, tudo foi sobre consultas de agregação. Mas, quando um agent se comporta mal,
2:51:00 você precisa ver a sequência exata do que ele fez. E é aí que entra a hypertable bruta, onde os dados completos ainda estão armazenados. Certo, estou procurando os casos em que o projeto é acme e o status é error, e quero obter um desses IDs de execução. O objetivo é encontrar a sequência exata do que um agent específico fez quando apresentou um erro e se comportou mal. Então precisamos consultar a hypertable bruta.
2:51:30 E aqui está uma das execuções que falharam. Vamos tentar reconstruir exatamente o fluxo de execução em cascata. Vou colar um pouco de código SQL para isso. Deixe-me explicar o que esta consulta faz. Para desenhar uma interface de fluxo em cascata, precisamos saber o que chamou o quê, e fazemos isso com UNION ALL. A metade de cima pega o span raiz, que é como o
2:52:01 ponto de entrada principal do agent, em que o ID do pai é nulo, e atribui a ele uma profundidade zero. A metade de baixo pega todas as operações filhas, em que o ID do pai não é nulo, e atribui a elas uma profundidade de um. Depois, no SELECT externo, usamos REPEAT com a profundidade. Quer dizer, no SELECT externo usamos REPEAT com COALESCE da profundidade para zero para indentar visualmente os spans filhos sob o pai.
2:52:32 O COALESCE serve apenas como uma proteção. Assim, se a profundidade, por algum motivo, resultar em nulo, o valor padrão será zero, em vez de apagar toda a string de texto. Vou executar a consulta. Aqui podemos ver a execução, perfeitamente ordenada por horário. O LLM pensa, decide fazer um tool call. A ferramenta falha, ele tenta de novo, pensa mais uma vez e
2:53:03 finalmente responde. Isso está indentado e mostra o tempo e o custo, e podemos ver todas essas informações. O custo é o gravador de voo fazendo exatamente o que o nome promete. E repare na velocidade: acabamos de encontrar uma agulha num palheiro de milhões de linhas, em milissegundos. Isso acontece porque, na seção do schema, criamos um índice no ID de execução, e o TimescaleDB usa esse índice para ignorar completamente as partições e ir direto para
2:53:34 esta execução específica. Este é, portanto, um workflow em dois níveis. É o padrão operacional de todo o projeto e da maioria dos sistemas de observabilidade: as agregações definem o problema; os dados brutos o explicam. Assim, temos rapidez e baixo custo para a pergunta ampla e todos os detalhes para a pergunta específica. Nenhum dos dois, sozinho, é suficiente. Acabamos de analisar um fluxo em cascata de spans individuais, mas muitas vezes você só quer uma resposta mais simples. Uma pergunta mais simples:
2:54:04 qual é o verdadeiro gargalo desta execução? O LLM está lento ou são as ferramentas? Aqui temos mais um pouco de SQL. Certo, vou inserir um novo trecho de SQL que já executei. Repare na última linha do SQL. Na verdade, estou falando desta linha aqui. Estamos fazendo uma soma. Estamos somando uma soma usando uma função de janela. A soma interna obtém o tempo total daquele
2:54:34 tipo específico de span. A soma externa, com OVER, calcula o tempo total da execução inteira, considerando todos os tipos. Convertemos o valor para numeric para fazer o cálculo corretamente, e a divisão nos dá uma porcentagem precisa sem precisar escrever uma subconsulta. Na maioria das execuções de agents, a resposta surpreende as pessoas: o modelo não é o gargalo; as ferramentas são. Isso muda fundamentalmente o que você deveria otimizar. Nenhuma quantidade de engenharia de prompt resolve uma consulta lenta ao banco de dados. Agora, se você quiser acompanhar isso
2:55:06 ao longo do tempo, em milhares de execuções, fazer esse cálculo na hora fica pesado. Por isso usamos a hyperfunction state_agg, que modela exatamente quanto tempo um sistema passou em estados discretos. Deixe-me mostrar: estamos agrupando por ID de execução nesta subconsulta. state_agg modela uma única máquina de estados, isto é, um sistema que só pode estar em um estado por vez. Se tentássemos agregar
2:55:37 o projeto inteiro na nossa linha do tempo, o banco de dados geraria um erro no momento em que o agent A iniciasse uma chamada ao LLM exatamente no mesmo microssegundo em que o agent B iniciasse um tool call. Ao agrupar por ID de execução, tratamos corretamente cada execução de agent como uma linha do tempo independente. Depois que essas linhas do tempo individuais são criadas, usamos duration_in para extrair os microssegundos gastos em cada estado e, em seguida, somá-los ao longo da hora inteira. Criamos o estado e lemos as respostas. Certo, agora vamos testar
2:56:08 tudo. Plantei um incidente nos dados simulados. Uma ferramenta ficou muito mais lenta por cerca de 40 minutos. Sei quando isso aconteceu, mas agora vamos ver como encontrar o problema da mesma forma que encontraríamos um problema real, sem saber o que estamos procurando: começando de forma ampla e aproximando aos poucos. Então, o primeiro passo é descobrir se há algum problema. Vamos começar com a visão mais ampla possível, usando apenas agregações horárias. Isso vai
2:56:40 me dar algumas informações. Estamos selecionando, basicamente, esses intervalos de uma hora e procurando o projeto acme e a ferramenta de verificação. Podemos ver que alguns desses valores são um pouco diferentes dos outros. Especificamente, muitos deles são 1.843. Este é 1.729. Este também é 1.729. Então, vou olhar para todos esses
2:57:12 valores de P95 em milissegundos, e podemos ver — na verdade, demorei um pouco para encontrar — que este está bem acima de todos os outros. Mas, se olharmos para a coluna de tráfego dos spans, ela parece normal. Então, não é um pico de carga. Algo específico ficou mais lento. O segundo passo é tentar descobrir qual ferramenta. Então, vamos
2:57:42 manter a janela horária, mas agora vamos agrupar por nome. Certo, vou executar este código para analisar as diferentes ferramentas. Vemos que a busca na web está levando mais de 10 segundos, enquanto todo o resto funciona normalmente. Agora, o próximo passo, o terceiro, é descobrir exatamente quando começou e quando terminou. Acabei de executar este código, e vamos descer um nível na nossa hierarquia de agregações, para os intervalos de cinco minutos, e obter mais precisão. Então,
2:58:16 Estamos usando um intervalo de 5 minutos e podemos ver que a maioria desses números está abaixo de 3.000 milissegundos. Mas, bem aqui, vemos que o valor dispara. Então, agora temos uma janela estreita. E observe o formato da recuperação, porque esse formato é diagnóstico. Uma queda abrupta significa que um deploy ou uma alteração de configuração foi revertido. Uma subida gradual significa que a saturação está se recuperando. Uma fila drenando, um pool de conexões travando.
2:58:48 Então, agora, a etapa quatro: o próximo passo é explicar. Só agora vamos aos spans brutos. Mas, como anotamos a janela de tempo e o nome da ferramenta, esta consulta será instantânea. Então fazemos esta consulta com o nome da ferramenta e o horário que descobrimos na consulta anterior. E veja o que encontramos aqui. Agora podemos ver a causa raiz exata, presente
2:59:18 no payload dos atributos JSON. O tempo de leitura do upstream foi de 10 segundos sem resposta. A API web caiu. O agent em Python esperou até o limite de timeout de 10 segundos antes de desistir, o que fez cada execução naquela janela parar por 10 segundos. Então, deixa eu explicar o que aconteceu, porque essa é a tese de grande parte deste curso. Tivemos três dias, milhões de spans, quatro consultas, cada uma retornando em milissegundos. Fomos de algo
2:59:49 que parecia errado até encontrar a ferramenta exata, a janela exata de 35 minutos e a mensagem de erro exata do cliente HTCP. As três primeiras consultas leram agregados pré-computados. Só a última tocou nos dados brutos, e tocou em uma fatia minúscula deles. É para isso que servem muitos desses recursos: a capacidade de fazer uma sequência de perguntas com rapidez suficiente para continuar perguntando, em vez de desistir. Certo, hora de dar uma cara a isso. Temos as consultas. Agora vamos juntá-las em uma API. Assim, podemos realmente ver um
3:00:19 dashboard. É aqui que os agregados contínuos deixam de ser um conceito de banco de dados e passam a ser visivelmente úteis. A regra de design para todo o backend é rígida: um endpoint por painel, uma consulta SQL por endpoint, e essa consulta acessa um agregado. Nada de RMS, nada de construtores de consultas, nada de buscar milhares de linhas para juntá-las em Python. O banco de dados faz os cálculos, e a API apenas entrega o JSON. Então, temos isto para configurar nosso arquivo da API, e é aqui que começamos nossa
3:00:54 conexão, basicamente, para obter todos os dados. Em produção, usamos um pool do asyncpg, não uma conexão por requisição, mas não vamos nos desviar para falar de pooling agora. E então temos nossos endpoints da API. Temos nossos endpoints de integridade, que são esta sonda de vivacidade. Aqui está um dos nossos principais endpoints de painel. O custo. Então, essa é exatamente a consulta que
3:01:25 escrevemos no psql, parametrizada de forma limpa usando os parâmetros nomeados do asyncpg. Todos os outros endpoints têm exatamente o mesmo formato. E esses endpoints não acessam dados brutos. Então, antes de olharmos o dashboard, vamos verificar essa afirmação. Mas deixa eu mostrar os outros endpoints. Temos o endpoint de latência.
3:01:57 E podemos ver que muitos deles são comandos que já executamos e dos quais mostrei um exemplo, mas agora estamos apenas colocando-os na API. Esse é o endpoint de erros. Depois temos as sessões com mais falhas, quantas sessões por dia, e você pode ver que tudo isso é apenas SQL bruto usando tudo o que aprendemos. Depois temos o histograma. E lembre-se de que você pode ver todo esse código no link para o código, na descrição. Também obtemos as informações de uma
3:02:29 execução. Então, voltei ao psql e vou executar esta consulta para ver que ela não acessa os dados. Certo, deixa eu diminuir um pouco o zoom para não ocupar tanto espaço. Basicamente, neste plano de consulta, ela busca alguns chunks de span_hourly. Examina algumas centenas de linhas. Não há nenhuma referência a agent_span. A tabela bruta tem milhões de linhas, e esta consulta do dashboard literalmente nem
3:03:00 sabe que ela existe. Um dashboard cujo custo computacional é proporcional ao que ele exibe, e não à quantidade de dados que você já coletou. É melhor. Seu dashboard é tão rápido no primeiro dia quanto no dia 1. Certo. Agora estou em staticindex.html e vamos dar uma olhada no frontend. O frontend é apenas um arquivo HTML. Se descermos, podemos ver
3:03:30 que, no nosso código, buscamos os endpoints. Desenhamos os gráficos e configuramos um loop para atualizar a cada 5 segundos. Não vou explicar isso em detalhes. Este não é um curso de HTML e JavaScript, mas você pode examinar o código no repo, se quiser. Certo, hora de testar isso e executar esta demonstração.
3:04:00 Então, esta é a coisa que estamos construindo há algum tempo. Vou executar o uvicorn e ele iniciará a aplicação. Certo. Temos a aplicação rodando aqui embaixo, no terminal. Agora podemos ver tudo isso. Temos todas essas informações sobre os spans: taxa de erros, P95, taxa de erros no pior caso, atraso. E veja estes gráficos. Podemos descer e ver a latência das ferramentas, a porcentagem de erros, a distribuição de latência
3:04:32 e as ferramentas com mais falhas. E olha só: posso mudar a janela. Passamos de 24 horas; vamos mudar para 72 horas e podemos ver os gráficos mudando em tempo real. Ah, e quando mudamos para 72 horas, vemos uma grande taxa de erros bem aqui. Também podemos aproximar ou afastar usando a roda do mouse aqui. E é muito bom ter esta seção com as ferramentas com mais falhas
3:05:03 aqui. Também podemos mudar de projeto. No momento, não estamos recebendo dados novos, mas vamos simular a chegada de dados ao vivo. Vou abrir uma nova janela do terminal e executar Python simulate live rate 60. Vou mudar a janela para mostrar apenas as últimas seis horas. Agora vamos observar a borda direita destes gráficos.
3:05:33 Agora podemos ver os dados chegando aqui. Não tínhamos dados neste intervalo, mas agora os dados ao vivo estão chegando, e podemos ver o gráfico se atualizando em tempo real. O processo de atualização é executado a cada minuto. Portanto, os dados do último minuto ainda não foram materializados. Eles vêm da parte ao vivo do agregado em tempo real. O TimescaleDB está lendo as
3:06:03 linhas mais recentes diretamente da tabela hyper bruta e as unindo ao histórico pré-computado. Os dados antigos são pré-computados e instantâneos. Os novos dados são ao vivo. Basicamente, ele combina os dados novos com os antigos sem interrupções. Certo, deixa eu limpar ou parar o simulador. Não estão chegando mais dados novos. Então, os dados de tráfego agora vão
3:06:33 parar. O bucket atual terminará de ser preenchido e, em até um minuto, o processo de atualização o materializará. Depois, basicamente, haverá uma lacuna como a que temos aqui. Esse é um stack de observabilidade com formato de produção: instrumentação, ingestão em lotes, armazenamento colunar, uma hierarquia de agregados e um dashboard ao vivo que lê apenas resumos. Agora vamos falar mais sobre a política de retenção aqui. Isso faz com que o disco não fique cheio. Ajuda o sistema a não crescer para sempre. Temos uma hierarquia de retenção, um padrão de retenção em camadas, e agora temos um motivo concreto para cada número. Deixa eu falar de cada um, porque por quanto tempo manter os dados é uma questão de produto disfarçada de questão operacional. Mantemos os spans brutos do agent por 14 dias.
3:07:07 Spans individuais só são úteis para depurar algo recente. Ninguém vai procurar uma execução específica de um agent de três meses atrás. E, se precisar, é para isso que serve o arquivo de logs. Quatorze dias cobrem o que aconteceu na última sprint. Mantemos as execuções por 90 dias; elas ocupam muito menos espaço que os spans. E a lista de execuções é o que as pessoas consultam. É mais barato mantê-la por mais tempo. Depois, mantemos os buckets de cinco minutos por 90 dias. Cinco minutos são granulares o suficiente para investigar um incidente, e um trimestre é o período para o qual alguém realmente amplia a visualização
3:07:38 Spans individuais só são úteis para depurar algo recente. Ninguém responde a uma execução específica de um agent de três meses atrás. E, se precisarem, é para isso que serve o arquivo de logs. 14 dias cobrem o que aconteceu no sprint passado. Depois, mantemos as execuções por 90 dias, ocupando muito menos espaço do que os spans. E a lista de execuções é o que as pessoas consultam. É mais barato mantê-la por mais tempo. Depois, buckets de cinco minutos por 90 dias. Intervalos de cinco minutos são granulares o suficiente para investigar um incidente, e um trimestre é o período máximo para o qual alguém realmente volta ao consultar os dados em
3:08:09 essa resolução. Uma resolução horária por um ano, para comparações ano a ano e planejamento de capacidade. Depois, mantemos os dados diários para sempre. Eles ocupam muito pouco espaço. Resumos diários comprimidos de um ano inteiro representam um erro de arredondamento no disco, e os executivos perguntam sobre o ano passado o tempo todo. Portanto, você deve garantir que a política de retenção não exclua dados que o aggregate contínuo ainda possa atualizar; caso contrário, seus dados agregados também serão excluídos. Nosso aggregate de cinco minutos tem um start offset de um intervalo de
3:08:41 sete dias, o que significa que ele pode recalcular qualquer bucket dos últimos sete dias. Os dados brutos são mantidos por 14 dias. Sete é menos que 14. Portanto, todo bucket que o aggregate talvez atualize ainda tem dados de origem disponíveis. Então, deixe-me ajudar você a entender como isso pode dar errado, com um exemplo. Voltei ao psql e acabei de criar uma nova hypertable chamada foot gun raw, que terá 30 dias de histórico.
3:09:13 Agora vou criar um aggregate diário sobre ela e atualizá-lo. Assim, podemos ver que temos cerca de 24 linhas por dia. Perfeito: são 24 linhas por dia e o resumo está correto. Agora vamos assumir o papel de uma política de retenção e excluir os dados brutos com mais de 25 dias. Assim, veremos o que acontece quando você destrói acidentalmente dados que não deveria destruir. Então
3:09:44 vou excluir de foot gun os dados em que o horário seja, basicamente, anterior a 25 dias. Certo. Agora podemos ver que, ao consultar os últimos 25 dias, todos esses dados foram excluídos. Só que a linha mais recente é do dia 20 — na verdade, as linhas mais antigas também são do dia 20, em vez
3:10:15 do dia 19 ou 18. Todas essas outras datas foram simplesmente excluídas. Quero que você entenda como isso aconteceu. Como não limitamos a janela de atualização dos aggregates com um start offset, o job de atualização acordou e perguntou ao banco de dados: “Ei, vamos recalcular a contagem de 28 dias atrás”. A tabela bruta respondeu: “Não tenho nenhuma linha desse dia”. E o aggregate registrou isso fielmente. Ele substituiu seu resumo histórico correto por um nada com aparência correta. Não há erro. Não há aviso. Nem sequer há
3:10:46 uma linha no log. Então, seu gráfico anual simplesmente ganha um buraco onde costumava estar o passado. Você só descobre meses depois, quando finalmente tenta consultar os dados. E observe a ordem dos eventos: o intervalo entre cometer o erro e conseguir percebê-lo pode ser de dias, o que dificulta a depuração. Então, vamos simplesmente excluir essa tabela da visualização agora. Basicamente, o start offset de cada aggregate precisa ser menor que o período de retenção da fonte. Essa é, de longe, a forma mais comum de as pessoas destruírem dados acidentalmente com TimescaleDB, e isso pode ser totalmente evitado com uma simples comparação matemática. Acabei de executar este comando, que reúne todas as políticas automatizadas do seu sistema em uma única saída. Ele mostra as políticas de retenção e compressão, e também as atualizações do columnstore com seus intervalos. E então podemos
3:11:18 comparar visualmente os offsets de atualização com os períodos de retenção. Isso também nos ajuda a verificar quais dados temos e quais configurações estão armazenadas. Agora executei este comando para obter os bytes por span. Assim, podemos ter uma ideia do tamanho que tudo vai ter. Vemos que o valor fica perto de 200. Vamos arredondar para 200, cerca de 200 bytes por span. Então, vamos analisar três cenários com base nesse tamanho. O valor indicado é 25
3:11:48 depois do columnstore, ou seja, depois da compressão do columnstore. Para um projeto de hobby, digamos que você processe 10.000 spans por dia. Isso dá cerca de 2 megabytes de dados brutos. Com uma retenção de 14 dias, o banco de dados bruto inteiro fica abaixo de 30 megabytes. Suas tabelas de aggregates ocupam kilobytes. Você nem vai pensar nos custos de armazenamento. Mas, se você for uma startup, talvez tenha 10 milhões de spans por dia. É bastante movimentado. É um projeto de agent lucrativo. Isso dá cerca de 2 gigabytes por dia sem compressão, reduzidos a 250 megabytes por dia com compressão. Manter 14 dias de traces brutos
3:12:21 leva cerca de três gigabytes e meio, mais alguns gigabytes para seus aggregates. Isso cabe confortavelmente na menor e mais barata instância de banco de dados paga disponível. Mas, se você tiver um projeto muito grande e intenso, com 500 milhões de spans por dia, estará gerando 100 gigabytes de telemetria bruta por dia. A compressão reduz esse volume para 12 gigabytes por dia. Manter 14 dias exige menos de 200 gigabytes. Nessa escala, é aí que o armazenamento em camadas se mostra vantajoso. Você mantém
3:12:54 os chunks dos três dias mais recentes em SSDs locais rápidos e caros para depuração em tempo real e envia automaticamente os chunks mais antigos para um armazenamento externo barato. Essa é a comparação que importa: sem retenção e sem a escada de aggregates, o caso da startup gera 2 gigabytes por dia para sempre, 700 gigabytes no primeiro ano, e cada consulta do dashboard fica mais lenta a cada dia. Com a escada, o armazenamento se estabiliza em alguns gigabytes e o tempo de consulta permanece constante
3:13:26 para sempre. São os mesmos dados e as mesmas perguntas, mas um desses sistemas pode ser deixado de lado por três anos, porque não vai ficar grande demais. Então, eu acrescentaria quatro coisas para levar isso à produção. Primeiro, amostragem para spans baratos. Não é preciso armazenar toda chamada bem-sucedida de calculadora que dura 12 milissegundos. Você pode manter todos os erros, todos os spans lentos e um em cada
3:13:57 dez dos mais comuns. Seu volume cai quase todo, sem que você perca nada. Também seria necessário considerar a remoção de informações de identificação pessoal antes das gravações: os prompts contêm dados dos usuários. Faça essa remoção no recorder, não no banco de dados, porque, depois que os dados são gravados, eles também ficam nos backups. Crie alertas para a consulta de regressão. Já a escrevemos. Coloque-a em um cron com um limite e um webhook. Adicione busca semântica sobre as falhas com pgvector. Gere embeddings dos prompts. Depois, pergunte: “Mostre execuções
3:14:28 parecidas com esta falha”. Isso é muito útil quando um cliente relata algo vago. Então, vamos recapitular o que construímos. Construímos um schema com chave primária composta e identificadores UUID v7, ingestão em lote com copy que não bloqueia o agent, uma configuração de columnstore ajustada à forma como os dados são realmente consultados, três níveis de aggregates que armazenam estados em vez dos resultados, oito consultas operacionais reais, uma das quais encontrou um incidente inserido propositalmente
3:15:00 em quatro etapas, um dashboard em tempo real que lê apenas resumos e uma escada de retenção que mantém tudo limitado para sempre. Isso é uma stack de observabilidade pronta para produção. São algumas centenas de linhas de código, uma extensão do PostgreSQL e nenhuma outra infraestrutura. Então, não preciso usar Kafka, nem um data warehouse de analytics separado, nem um pipeline de dados: só um banco de dados. Certo, terminamos o primeiro projeto e é hora de criar outro. Tudo o que construímos até agora está sendo executado localmente no
3:15:30 Docker. É o lugar perfeito para aprender e desenvolver, e funciona muito bem para executar cargas de trabalho pequenas em produção. Mas o Timescale também tem uma versão gerenciada na nuvem, criada pela mesma equipe responsável pela extensão. Ela tem alguns recursos arquiteturais que só existem na nuvem. Então, vamos migrar para ela. Também vamos criar um projeto totalmente novo para que você veja esses padrões aplicados a um problema com características diferentes. Estamos no site da Tiger Data.
3:16:01 Você pode ver que diz “dos criadores do TimescaleDB”. Se você se inscrever, terá uma avaliação de 30 dias do plano Performance. Você nem precisa de cartão de crédito. É tempo suficiente para experimentar e até testar a migração de uma carga de trabalho de teste. Eles também têm um serviço gratuito com CPU compartilhada, ótimo para aprender dentro do projeto. Então, faça login ou crie uma conta no serviço. Depois de se inscrever ou entrar,
3:16:32 você pode começar a criar seu primeiro banco de dados. Você pode escolher qualquer tipo de CPU. Vou escolher o nível gratuito e continuar. Depois, pode alterar o nome. Vou chamá-lo de fleet. Você pode chamá-lo do que quiser. Em seguida, crie o serviço. Vou simplesmente usar as opções padrão para tudo. Meu serviço está pronto. Agora que criamos tudo, temos todas as informações de conexão: host, porta, nome do banco de dados, usuário e uma
3:17:02 você pode começar a criar seu primeiro banco de dados. Então, vou… Você pode escolher qualquer tipo de CPU. Vou escolher o plano gratuito e continuar. Depois, você pode alterar o nome. Vou chamá-lo de fleet. Você pode chamá-lo do que quiser. E então, criar o serviço. Vou simplesmente usar os valores padrão para tudo. Meu serviço está pronto. Agora que criamos tudo, temos todas as informações de conexão. Então, temos um host, uma porta, um nome de banco de dados, um usuário e uma
3:17:33 senha. E basicamente tudo vai para cá: nossa string de conexão principal. É isso. É só uma string de conexão padrão do PostgreSQL. Certo. Vou me conectar usando o psql pela linha de comando aqui. E então vou passar aquela URL que obtivemos do Tiger Data. Você vai notar que este SSL mode equals require aparece na string de conexão. Isso é obrigatório quando você se conecta pela internet pública. E o console fornece isso
3:18:06 por padrão. E vai pedir minha senha. Então volto ao painel aqui. Se eu acessar mais detalhes, posso selecionar “esqueceu sua senha?” e criar uma nova senha, caso não saiba qual é. Depois, posso digitá-la aqui. Certo, vamos conferir nossas extensões. Vou executar select X name X version from PG extension or by X name, e podemos ver
3:18:36 aqui que o TimescaleDB e o TimescaleDB Toolkit já estão instalados e ativos. Sem etapas de compilação, sem modificar nada. Simplesmente está lá. Há outras extensões instaladas na máquina que você mesmo pode habilitar. Vou ativar as extensões PG vector e PG vector scale, que tornam a busca vetorial mais rápida em escala. Vamos usá-las bem no final desta seção. E quero testar algo agora. Basicamente,
3:19:09 a hypertable, o armazenamento colunar, as camadas de agregados contínuos, as políticas de retenção, todas as coisas que discutimos anteriormente no curso, devem funcionar neste serviço gerenciado sem alterar uma única linha de código. Então, vamos descobrir. Voltei à linha de comando e vou tentar executar todas as minhas migrations e inserir aqui a URL do Tiger Data. E terminou. Ele executou todas as migrations
3:19:39 e agora está tudo na nuvem. Então, se eu voltar ao psql, ao banco de dados na nuvem, veremos que executamos esta consulta e vemos que numchucks zero, num num chunks é zero. Isso é exatamente o que queremos e esperamos. O TimescaleDB cria chunks dinamicamente quando os dados chegam de fato. Acabamos de criar o esquema em um banco de dados novo, mas, até inserirmos linhas, ele não desperdiça espaço em disco
3:20:10 pré-alocando chunks vazios. Mas as tabelas, as views e as políticas estão todas perfeitamente configuradas. E agora que executamos todas as migrations, posso acessar nosso painel web e entrar em explorar. Podemos ver os diferentes objetos. Essas hypertables foram criadas, assim como as tabelas. Podemos ver quantos itens de cada tipo temos. Temos alguns agregados contínuos. E, se entrarmos na tabela, podemos ver
3:20:42 algumas informações. No momento, não há dados na tabela, mas, se houvesse, veríamos os dados aqui. E podemos ver todos os campos que criamos diretamente nos nossos arquivos de migration. Então, basicamente, agora temos cada tabela, cada view materializada e cada política executada perfeitamente. Nenhuma linha de código foi alterada. Quero enfatizar isso porque é o argumento prático mais forte para usar esta stack. Seu banco de dados de desenvolvimento local
3:21:14 em execução no Docker, seu ambiente de CI e seu banco de dados de produção gerenciado podem todos usar exatamente o mesmo esquema. Você pode desenvolver localmente e fazer deploy na nuvem, ou baixar um snapshot da nuvem para depurar no seu laptop. E, se algum dia decidir que quer sair do serviço gerenciado, você sai com um dump padrão do Postgres que pode ser restaurado em qualquer lugar onde a extensão esteja instalada. Essa é uma posição significativamente diferente da de adotar um banco de dados proprietário disponível apenas na nuvem.
3:21:44 Agora, navegar por um console web serve para o primeiro dia, mas, se você gerencia isso todos os dias, vai querer uma CLI. Então, vou executar brew install time scale tap tiger CLI. O arm64 é específico do meu computador. Certo, ele informa o que fazer em seguida. Vou executar tiger o login. E então ele abrirá meu navegador para a autenticação. Depois que estiver autenticado no
3:22:15 navegador, ele continuará o login na CLI. E então mostra algumas opções do que fazer em seguida. Podemos obter um servidor MCP para uma ferramenta de programação com IA, listar serviços existentes ou criar um serviço novo. Vou listar os serviços existentes. Então, tiger service list. E aqui está o serviço que criamos, aquele que acabamos de configurar no navegador. Você também pode provisionar infraestrutura diretamente
3:22:46 por aqui. Então, vou executar tiger service create-c shared e depois memory shared. Agora ele está criando este serviço. Esse comando único inicia um serviço compartilhado gratuito. Sem precisar do navegador. Certo, agora ele vai me dar todas as informações sobre o serviço que acabamos de criar. Agora posso simplesmente executar tiger db connect.
3:23:16 Esse comando nos leva diretamente a uma sessão psql conectada ao nosso banco de dados, sem que eu precise copiar e colar uma string de conexão. Assim, podemos começar a fazer coisas no nosso banco de dados. Vou sair daqui com /Q. Vou limpar a tela. E, antes de continuar, quero configurar uma proteção. Quero que você me veja fazendo isso de propósito. Posso executar tiger config set read only true. Agora, se você
3:23:51 se lembra, depois que fizemos login, ele perguntou sobre isso, mas é assim que podemos configurar manualmente pelo terminal. Então, set readonly equals all. Isso configura a CLI para recusar comandos que modificam dados e força qualquer sessão de banco de dados que ela abrir a ficar em modo somente leitura. Então, se eu executar tiger service delete e inserir o ID daquele que acabamos de criar,
3:24:21 vai falhar. Erro: esta operação não é permitida no modo somente leitura. Então, ele não pergunta simplesmente: “Tem certeza?”. Ele impede a ação. Esta é minha opinião sobre conectar agents de programação com IA ao seu banco de dados. Modelos de linguagem são ótimos para explorar um esquema e sugerir otimizações, e também são perfeitamente capazes de executar um comando drop table porque seu prompt era um pouco ambíguo. Então, a resposta não é nunca usar IA com seu banco de dados.
3:24:53 A resposta é garantir que o caminho destrutivo seja impossível. Ativar o modo somente leitura exige apenas um comando e transforma um experimento arriscado em um seguro. MCP, ou Model Context Protocol, é um padrão para disponibilizar ferramentas externas a um modelo de linguagem. E, como já vimos, o Tiger inclui um servidor MCP diretamente na CLI. Assim, seu agent de programação com IA pode se comunicar com seu banco de dados. Então, podemos instalá-lo com tiger
3:25:25 mcp install e escolher qual queremos configurar. Dependendo do que você usa, vou escolher o Cursor. E pronto: a configuração foi feita e o servidor MCP do Tiger foi instalado com sucesso para o Cursor. Ele indica que o próximo passo é reiniciar o Cursor para carregar a nova configuração. Depois, estará disponível como tiger e ele nos dá algumas coisas que podemos tentar testar com uma.
3:25:55 Vamos experimentar. Estou com o Cursor aberto. Vou aqui no Cursor e pedir: “Liste meus serviços Tiger e me diga em que região cada um está”. E então só preciso dizer para ele sempre executar nosso servidor MCP para o Tiger Cloud. Certo, recebemos as informações. Estes são os dois: o que criamos no site e o que criamos na CLI. E então temos as
3:26:26 regiões. Temos duas regiões. Temos dois serviços de nuvem, ambos em US East1, que por acaso é a região mais próxima de onde moro. Então, em vez de consultar as opções da CLI, ele chama a ferramenta e obtém os dados. Certo, vamos tentar outra coisa: “Descreva a tabela agent span, incluindo as configurações da hypertable e a configuração do armazenamento colunar”. Isso é ainda mais interessante.
3:26:57 Ele não está apenas lendo as tabelas do Postgres. Está lendo os metadados do Timescale. Certo. E parece que preciso salvar minha senha localmente para a tabela fleet. Então, vou executar tiger tiger db save password e inserir a senha da tabela que desenvolvemos. Depois, vou informar ao agent de IA que a senha está salva lá. Certo. Agora ele fez o que pedimos. Então, ele
3:27:27 descreveu a tabela de spans do agent e, em seguida, incluindo o column store, a hipertabela, e ela sabe de tudo, inclusive do intervalo dos chunks e coisas assim. E mais uma pergunta: considerando como consulto os spans do agent, minha configuração de segmentação é razoável? O que você mudaria? Agora, ela vai usar informações que está obtendo dos dados do tagger e vai raciocinar sobre minha
3:28:00 cardinalidade e os padrões de consulta, que são os conceitos que discutimos anteriormente. Ela consegue fazer isso porque o servidor MCP da Tiger tem skills e documentação do Timescale integradas. Não está apenas fazendo suposições com base em dados genéricos de treinamento do Postgres. Certo, está me dando algumas sugestões. Eu segmentaria apenas por projeto. E então está dizendo basicamente o que
3:28:33 fiz de errado, por que o par atual é menos eficiente do que parece e o que eu configuraria. Então, está dando sugestões bem específicas com base nessas skills do Timescale. Mas aqui vai minha regra para IA: basicamente, verifique você mesmo cada sugestão. Certo, vamos começar este segundo projeto. Escolhi intencionalmente um problema com uma estrutura completamente diferente. Então,
3:29:05 antes, o projeto anterior era sobre eventos irregulares: os spans chegavam aleatoriamente sempre que um agent de IA fazia algo. As durações variavam muito, e agregávamos esses eventos específicos. Este projeto trata de dados de sensores em intervalos regulares. Estamos monitorando uma frota de 2.000 carregadores de veículos elétricos. Cada dispositivo envia um relatório exatamente a cada 10 segundos, para sempre. Isso exige ferramentas completamente diferentes. Temos contadores monotônicos
3:29:35 que precisam ter suas diferenças calculadas, máquinas de estado dos dispositivos e o problema de monitoramento mais difícil de todos: detectar quando um dispositivo para de enviar relatórios. Vamos olhar para este esquema. Primeiro, temos a tabela site e a tabela device. São duas tabelas relacionais padrão. Não há colunas de tempo nelas, e elas não são hipertabelas. Essas são nossas dimensões. São fatos sobre os dispositivos que não
3:30:06 mudam a cada 10 segundos. Em seguida, criamos o índice em device. E então temos esta hipertabela, que é reading. É aqui que criamos a hipertabela. Esta é nossa tabela de fatos, que transformamos em uma hipertabela. Vamos analisar os quatro tipos diferentes de colunas que temos aqui, porque o tipo de dado determina completamente como você pode agregá-lo. Primeiro, temos a potência
3:30:37 em W, a tensão e a temperatura em °C. São medidas contínuas. Sobem e descem. Você pode calcular a média, mas, como veremos mais adiante, a forma de calcular a média realmente importa. Segundo, temos o total de energia em Wh. É um contador monotônico. Representa a energia total fornecida ao longo da vida útil. Só aumenta, a menos que o dispositivo reinicie; nesse caso, volta a zero. Calcular a média desse número não faz sentido algum. Você quer encontrar a
3:31:07 diferença ao longo de um intervalo de tempo. Terceiro, temos o estado. É um estado discreto, como carregando, ocioso ou em falha. Não se calcula a média de uma palavra. Você simplesmente pergunta quanto tempo o dispositivo passou em cada estado. Observe também que as medidas contínuas são armazenadas como real, ou seja, números de ponto flutuante de quatro bytes, em vez de números de ponto flutuante de precisão dupla de oito bytes. A leitura de temperatura de um carregador não tem 15 dígitos significativos. Quando você gera 17 milhões de linhas por dia,
3:31:39 reduzir esses tamanhos de coluna pela metade faz uma enorme diferença. Vamos analisar a chave primária, que é o ID do dispositivo e o horário. No projeto anterior, usamos o horário e o ID do span, mas aqui colocamos primeiro a lista de dispositivos. E talvez você esteja se perguntando por quê. É porque a consulta mais comum, de longe, em um app de gerenciamento de frotas é: “Mostre o histórico de um dispositivo nos últimos 24 horas”. Colocar o dispositivo primeiro significa que todas essas linhas ficam fisicamente adjacentes
3:32:10 no disco, tornando isso uma única varredura de intervalo incrivelmente rápida. A chave primária deve sempre seguir seu padrão de consulta dominante. Limpei meu projeto ou serviço da frota, então vou começar do zero e usar as informações de conexão. Agora vou inserir este comando para executar meu arquivo de migration, confirmar que estou no mesmo diretório do arquivo de migration e informar a URL do banco de dados.
3:32:40 Certo, executei o comando e criei tudo no meu banco de dados. Podemos verificar isso no nosso painel web. Agora conseguimos ver os objetos aqui, a hipertabela e tudo que acabamos de criar. Aqui estão os dispositivos. Há um mito de que, em analytics, nunca se pode usar joins e, por isso, é preciso desnormalizar tudo em uma única tabela de fatos enorme, o que resulta em esquemas terríveis. A realidade é que
3:33:11 fazer join de uma pequena tabela de dimensão com uma enorme hipertabela é perfeitamente aceitável, e o Postgres é profundamente otimizado para isso. Vamos provar. Antes de começar a consultar esses dados, precisamos de dados. Tenho este script em Python para gerar a telemetria da frota. Basicamente, ele simula os dados de que precisamos. Usa exatamente o mesmo padrão de cópia em lotes que usamos no primeiro projeto. E pode gerar 2.000
3:33:42 dispositivos enviando relatórios a cada 10 segundos. Para não ficarmos aqui vendo uma barra de progresso, já apliquei nosso esquema e executei o simulador em segundo plano. Ele preencheu retroativamente 24 horas de dados, o que corresponde a muitas linhas. Também inseri três anomalias específicas nesses dados, que vamos procurar mais tarde. Um comentário à parte: usei um script em Python aqui para que você veja como os dados são estruturados. Mas, no Tiger Cloud,
3:34:13 você não escreveria esse código de ingestão por conta própria. Usaria o conector Kafka totalmente gerenciado deles para consumir telemetria diretamente de um tópico, ou o conector S3 para carregar em massa arquivos CSV históricos. Também temos aqui este arquivo de consultas, que contém algumas consultas que vamos usar como exemplos. Então, temos muitas linhas de dados, e quero abordar um grande mito de analytics. O mito é que
3:34:44 nunca se pode usar joins e, por isso, é preciso desnormalizar tudo em uma única tabela de fatos enorme, o que resulta em esquemas terríveis. A realidade é que fazer join de uma pequena tabela de dimensão com uma enorme hipertabela é perfeitamente aceitável, e o Postgres é profundamente otimizado para isso. Vamos começar com este primeiro join. Primeiro, vou acessar o psql no nosso banco de dados hospedado.
3:35:14 Depois, vou colar este código SQL. Fizemos join de milhões de leituras, e o resultado foi retornado em milissegundos. Então, basicamente, levou apenas 11 segundos para executar a consulta inteira. Agora vamos executar esta consulta EXPLAIN. Mais uma vez, você encontra tudo isso no link do código na descrição. Se olharmos para o plano de execução, veremos
3:35:47 exatamente por que é tão rápido. Vou diminuir o zoom para conseguirmos ver mais coisas na tela ao mesmo tempo. O Postgres pega as tabelas device e site, que somam talvez alguns milhares de linhas e ocupam alguns quilobytes, e cria uma tabela hash na memória. Depois, simplesmente transmite as milhões de leituras, passando por essa tabela hash no cache da CPU. O custo do join é praticamente nulo. E aqui está a regra prática para modelagem de dados.
3:36:19 Desnormalize os fatos sobre a própria medição, mas normalize os atributos que descrevem a entidade. O estado atual do carregador é um fato sobre aquele estado naquele segundo específico. Então, ele fica na hipertabela. Mas a versão do firmware pertence à tabela device. Se um carregador receber uma atualização de firmware, você atualiza uma linha na tabela device. Você não reescreve 400 milhões de linhas na tabela de leituras.
3:36:50 Vamos responder à pergunta de configuração mais importante. Qual deve ser nossa coluna de segmentação ao compactar esta hipertabela? Temos duas opções óbvias. Podemos segmentar pelo ID do dispositivo, e há 2.000 dispositivos, ou pelo ID do site, e há 40 sites. Antes, eu disse que a regra prática era buscar pelo menos mil linhas por lote compactado. Vamos fazer um experimento e medir de fato os dois eixos: o tamanho do armazenamento e a latência da consulta.
3:37:23 Já executei todo este código e rodamos estes comandos ALTER TABLE. Eles vão criar duas cópias idênticas dos dados de um dia, mas compactá-las de formas diferentes. Uma é segmentada pelo ID do dispositivo. A outra é segmentada pelo ID do site. Observe que, na versão por site, precisei colocar o ID do dispositivo na cláusula ORDER BY para garantir que os dados permaneçam agrupados localmente dentro do segmento.
3:37:54 Executei isso antes de começarmos a gravação, porque copiar e comprimir um dia inteiro de dados pode levar algum tempo. Então, vamos primeiro comparar os tamanhos em disco usando as estatísticas de compressão de hypertables. Basicamente, tenho este código SQL que faz a comparação aqui. Vamos conferir as porcentagens de economia. Podemos ver que agrupar por dispositivo, ou ID do dispositivo, é um pouco melhor: pouco mais de 89%.
3:38:25 Esse resultado é exatamente o motivo de fazermos este experimento em vez de adivinhar. Se você apenas lesse a documentação, poderia supor que segmentar por ID do site comprimiria melhor, porque os lotes são maiores. Com um intervalo de 10 segundos, cada dispositivo emite cerca de 8.600 leituras por dia. Segmentar por site agrupa 50 dispositivos, criando lotes enormes com mais de 400.000 linhas. Então, por que o ID do dispositivo ficou só
3:38:55 um pouco à frente? Bem, pense no formato físico dos dados com segmentação por ID do dispositivo. Cada lote representa a linha do tempo contínua de um único carregador. A temperatura varia suavemente. A potência de saída permanece estável, e a codificação delta consegue comprimir esses padrões perfeitamente. Quando você segmenta por ID do site, intercala 50 carregadores diferentes no mesmo lote. Os valores ficam saltando entre máquinas diferentes, o que prejudica a
3:39:25 codificação e, na verdade, piora sua taxa de compressão. Então, o ID do dispositivo vence em espaço em disco. Mas o tamanho do armazenamento é apenas metade do benchmark. Vamos olhar para o segundo eixo: a latência das consultas. Acabei de executar outra consulta para medir a velocidade. Aqui estão os resultados. A primeira, para ler por dispositivo, levou 251 milissegundos; a leitura por
3:39:57 site levou 176 milissegundos. Certo, acabei de executar este código para verificar a latência das consultas. Vamos conferir os tempos. Ao ler por dispositivo, são 253 milissegundos; aqui, são 166 milissegundos. Se você estiver prestando bastante atenção, vai notar algo inesperado. A leitura por site foi mais rápida aqui: 166 milissegundos, em comparação com 253.
3:40:27 Então, por que isso aconteceu? Por causa de como os bancos de dados realmente funcionam nos bastidores. Primeiro, um aviso sobre o cache. A primeira consulta precisou buscar dados frios no disco e carregá-los na memória. A segunda consulta foi executada logo depois, encontrou o que precisava no cache quente e terminou mais rápido. Segundo, estamos consultando apenas uma pequena parte dos dados. Neste momento, temos apenas alguns megabytes de dados comprimidos na RAM. A varredura é muito rápida, independentemente de como os dados estejam segmentados. Mas é preciso projetar essa escolha de arquitetura para uma escala de produção.
3:40:57 Imagine que você tenha três anos de dados da frota, totalizando bilhões de linhas. Se usar segmentação por ID do site, o banco de dados será obrigado a descomprimir e percorrer o histórico de outros 49 dispositivos só para encontrar o carregador que você solicitou. Essa sobrecarga de memória vai sobrecarregar seu banco de dados. Se segmentar por ID do dispositivo, o banco de dados lê os metadados do lote, ignora completamente o restante do site e descomprime apenas o dispositivo exato de que você precisa. Portanto, apesar das peculiaridades do microbenchmark causadas pelo
3:41:29 cache, o ID do dispositivo é definitivamente a escolha certa para nossa carga de trabalho de produção, porque a consulta de um único dispositivo é o que nossa API atende em cada página de detalhes do serviço, centenas de vezes por minuto. Então, vamos escolher o ID do dispositivo. O armazenamento é barato. A latência que seus usuários sentem ao abrir a página de detalhes do dispositivo 50 vezes por hora não é a regra geral. A conclusão não é que se deva sempre usar o ID do dispositivo. A regra é: a coluna de segmentação deve ser aquela pela qual você mais filtra os dados brutos. Então,
3:42:01 agora podemos excluir nossas tabelas de teste temporárias e aplicar essa política de configuração de compressão diretamente à nossa hypertable principal de leituras. Isso ocorre porque eu já tinha configurado para comprimir após 4 horas, pois os chunks não comprimidos são os mais caros. E adicionamos índices sparse minmax em temp C e power W. Assim, as consultas que filtram por picos de temperatura ou limites de potência podem ignorar lotes inteiros sem descomprimi-los. Certo. Agora
3:42:32 vamos começar a trabalhar mais com os dados. E projetei deliberadamente três anomalias no conjunto de dados. Basicamente, são problemas do mundo real que são incrivelmente difíceis de detectar com SQL simples. Uma interrupção em um site: todos os dispositivos de um site específico ficam completamente silenciosos por algum tempo. Sem registros de alertas, sem estado de falha, apenas silêncio. Uma reinicialização do contador: um dispositivo reinicia, e o total de energia vitalícia em WH cai para zero. E também um estado travado: um dispositivo fica preso em um
3:43:03 estado de falha e informa fielmente que está quebrado a cada 10 segundos durante um dia inteiro, mas nunca fica offline. Acabei de executar isso para ver quanto temos, basicamente, para você entender como está o banco de dados inteiro agora. Estas são minhas leituras, a quantidade de dispositivos e o intervalo de datas. Isso representa apenas um dia de operação da frota. Poderíamos fazer algo maior, usar mais dados, mas isso só criaria um banco de dados ainda maior. E estou usando um script Python aqui para
3:43:34 você ver como os dados estão estruturados. Mas, no Tiger Cloud, você não escreveria esse código de ingestão por conta própria. Usaria o conector Kafka totalmente gerenciado deles para consumir telemetria diretamente de um tópico, ou o conector S3 para carregar em lote arquivos CSV históricos. Agora vamos encontrar as anomalias. Vamos usar quatro hiperfunções específicas, criadas para resolver problemas que são penosos de escrever em SQL puro. Primeiro, a reinicialização do contador. Nossa coluna de energia total em Wh é uma leitura de um medidor vitalício. Ela só aumenta, até que o dispositivo reinicie e volte a zero. Se escrevermos uma consulta SQL ingênua para encontrar a energia total fornecida nas últimas 24 horas, ela fica assim. Para o dispositivo saudável 1338, funciona perfeitamente. Mas, para o dispositivo 1547, que reiniciou, obtemos uma resposta completamente errada. O valor máximo
3:44:07 era de pouco antes da reinicialização, e o valor mínimo era zero. E não estamos vendo nenhum erro. Observe estes números para o dispositivo 1338. Ele informa cerca de 2 milhões de watt-hora, aproximadamente 2 megawatt-hora, o que faz todo o sentido para um dia normal de operação. Mas, se olharmos para o 1547, ele afirma ter fornecido mais de 44 milhões de watt-hora, 20 vezes mais do que o normal. Então, por que
3:44:40 isso acontece? Porque o dispositivo reiniciou, zerando o medidor. O valor mínimo era zero logo após a reinicialização, e o valor máximo era 44 milhões logo antes dela. Então, a matemática ingênua contabilizou, em um único dia, o total acumulado durante toda a vida útil de vários anos do carregador. E observe o pior: não há erro. O Postgres executou a consulta sem problemas e entregou a você uma fatura aparentemente plausível, mas com um valor catastrófico. Então, veja como fazer
3:45:10 isso corretamente usando as hiperfunções do Timescale. Isso representa a energia realmente fornecida. A counter_agg percorre as leituras em ordem cronológica. Quando detecta que o valor diminuiu, reconhece isso como uma reinicialização e soma com segurança o acumulado anterior à reinicialização ao total. Também vemos a quantidade de reinicializações nesse estado. Claramente, vemos que o dispositivo 1547 reiniciou exatamente uma vez. Essa é uma métrica incrivelmente útil por si só. Um carregador que reinicia cinco vezes por dia é um carregador que você precisa substituir. Isso usa o mesmo padrão em duas partes que aprendemos no projeto anterior. Você cria um estado usando uma função de agregação e obtém a resposta usando uma função acessora. E, por ser um estado, podemos colocá-lo em uma agregação contínua.
3:45:41 Rate fornece a taxa média de aumento da energia, convertida para watts. Como é derivada diretamente do contador do medidor, e não de amostras do indicador de potência, ela representa o que foi realmente fornecido, segundo o medidor de hardware. Agora, vamos ver como isso se encaixa em nossa estrutura de agregações contínuas. Para deixar nossos dashboards extremamente rápidos, vamos usar agregações contínuas. São views materializadas que resumem automaticamente seus dados
3:46:12 em segundo plano. Mas observe atentamente o que estamos armazenando de fato em nossa view horária. Não estamos armazenando números finais. Cada coluna agregada armazena um estado intermediário. Estamos olhando para esta view aqui. Então, por que isso importa? Se você calcular a média padrão do consumo de energia de segunda-feira e a média padrão do consumo de energia de terça-feira, não poderá calcular com segurança a média desses dois números para obter a média semanal. A matemática fica errada. Ao
3:46:45 Rate fornece a taxa média de aumento de energia, convertida em watts. Como é derivada diretamente do contador do medidor, em vez de amostras do medidor de potência, ela representa o que foi realmente fornecido, de acordo com o medidor de hardware. Agora, vamos ver como isso se encaixa na nossa escada de agregações contínuas. Para deixar nossos painéis extremamente rápidos, vamos usar agregações contínuas. São views materializadas que resumem automaticamente seus dados
3:47:16 em segundo plano. Mas observe com atenção o que estamos realmente armazenando na nossa view horária. Não estamos armazenando valores finais. Cada coluna agregada armazena um estado intermediário. Estamos olhando para esta view aqui. Então, por que isso importa? Se você calcular uma média padrão do consumo de energia de segunda-feira e uma média padrão do consumo de energia de terça-feira, não poderá calcular com segurança a média desses dois valores para obter a média semanal. A matemática está errada. Ao
3:47:49 Usando hyperfunctions como state_agg e counter_agg, armazenamos o estado matemático bruto. Pense nisso como ter os ingredientes crus em vez de um bolo assado. Como salvamos o estado, podemos facilmente agregar esses blocos horários em blocos diários ou semanais depois, com precisão perfeita. E veja a política de atualização logo abaixo. Dizemos ao Timescale para atualizar
3:48:22 isso a cada 5 minutos. Mas observe o deslocamento final de 2 horas. De propósito, não fechamos a janela até o minuto atual. No mundo real, dispositivos de IoT perdem a conexão celular e acabam enviando os dados com atraso. Portanto, deixar uma margem de duas horas garante que os dados que chegam atrasados sejam incorporados perfeitamente aos resumos antes de o bloco ser finalizado. Agora, vamos ver o degrau
3:48:55 dois da nossa escada: a visualização diária. Observe que a consulta diária dos dispositivos consulta a tabela horária dos dispositivos, não a tabela de leituras brutas. Como armazenamos os estados matemáticos no primeiro degrau, o segundo pode simplesmente chamar rollup nesses blocos horários; ele nunca precisa examinar as 100 milhões de linhas de dados brutos. É assim que você mantém as consultas rápidas em escala infinita. E então temos esta taxa de atualização
3:49:28 aqui. Certo, vamos aplicar este arquivo. Vou executar este comando. Já armazenei a URL na variável de ambiente. Certo. Agora, vamos voltar ao nosso banco de dados no PSQL. E lembre-se: os agregados contínuos ficam vazios até serem atualizados. E, como device_daily depende de device_hourly, precisamos atualizar primeiro o nível mais baixo.
3:49:58 Então, vou executar estes comandos para atualizá-los e inserir nossos dados. Certo, agora vamos consultar a energia total entregue hoje a partir do nosso agregado contínuo. É isso que esta linha vai fazer. Agregamos os estados dos contadores horários ao longo de um dia inteiro e calculamos a diferença entre eles. Essa matemática leva em conta as reinicializações entre os limites dos blocos. Se um
3:50:30 carregador reiniciar às 14:59:55, bem no limite de uma hora, o cálculo continua completamente preciso. Agora vamos encontrar nossa segunda anomalia plantada usando state_agg no gerenciamento da frota. A reunião matinal de operações sempre começa com a pergunta: quanto tempo nossos carregadores passaram carregando, ociosos ou com defeito? Vou executar este SQL aqui.
3:51:01 Observe a sintaxe de duration_in aqui. Com as hyperfunctions do Timescale, você sempre passa o estado agregado como primeiro argumento e o valor específico que está procurando como segundo. Então, se olharmos esta primeira linha, um carregador ficou no estado de falha por quase todo o período. Essa é nossa segunda anomalia plantada.
3:51:32 O carregador nunca ficou offline. Ele reportou fielmente, a cada 10 segundos, que seu estado interno estava com defeito. Um alerta simples de tempo de atividade nunca detectaria isso, mas duration_in revela o problema imediatamente, sem precisar reconstruir o estado. Escrever essa consulta no PostgreSQL exige uma função de janela enorme com lag ou lead para detectar mudanças de estado, diferenças de timestamps para calcular durações e uma lógica complexa de limites para sessões que estavam ativas quando
3:52:03 a janela abriu ou fechou. Com o Timescale, isso se resolve com uma única chamada de função. Se você precisar desenhar um gráfico de linha do tempo no frontend, poderá extrair os intervalos exatos. Aqui está o SQL para extrair esses intervalos. Você pode ver que state_timeline expande o estado em timestamps distintos de início e fim. É um gráfico de Gantt completo, pronto para uma interface de frontend e gerado diretamente pelo SQL.
3:52:35 Agora, a terceira anomalia: a interrupção no local. Tenho este outro código SQL aqui, que acabei de executar. Ele representa o problema mais difícil da telemetria: o silêncio. Não há linhas de erro. Não há status de falha. O hardware simplesmente parou de se comunicar. Não é possível escrever uma cláusula WHERE para encontrar registros que não existem. Então, se olharmos os resultados aqui, nós
3:53:06 vemos vários dispositivos com exatamente o mesmo intervalo de inatividade. Vamos confirmar a abrangência dessa interrupção juntando nossos dados à tabela de dimensão dos locais. Como precisamos calcular os batimentos cardíacos ausentes por dispositivo antes de encontrar o maior período de inatividade por local, usaremos uma expressão de tabela comum, uma cláusula WITH, para estruturar a consulta de forma clara. Então, aqui está o SQL completo. Vamos apenas
3:53:39 olhar o resultado. Todos os dispositivos que mostram esse período de inatividade pertencem exatamente ao mesmo local. E podemos ver onde ocorreu a maior interrupção. Isso mostra imediatamente que não estamos lidando com falhas individuais no hardware dos carregadores. A conexão física com a internet ou o gateway celular daquela instalação caiu. Esse é um alerta de integridade muito melhor do que limites de valores. Um dispositivo que reporta números estranhos é um problema que você pode investigar depois. Um
3:54:11 dispositivo que fica completamente em silêncio está quebrado, desconectado ou sem energia. Vamos analisar outra armadilha sutil das leituras contínuas de sensores: as médias padrão. Aqui, estamos calculando as médias de maneiras diferentes: primeiro, a média ingênua e, depois, a média ponderada pelo tempo. E, se olharmos aqui, vemos que os números são apenas ligeiramente diferentes.
3:54:42 A média padrão presume que cada linha representa um intervalo de tempo igual. Mas as leituras em redes reais se agrupam. Se um dispositivo fica ocioso, com zero watts, por 10 minutos, faz uma leitura e depois envia rapidamente 60 leituras em um minuto enquanto consome 50 quilowatts, a média padrão dá 60 vezes mais peso a essas leituras altas do que ao período ocioso. E uma coisa é que não vemos a função time_weight nesta consulta, porque fizemos
3:55:12 o trabalho pesado na nossa visualização de agregado contínuo device_hourly, quando criamos essa visualização. Passamos linear para a função time_weight, o que diz ao Timescale para interpolar linearmente entre leituras ausentes — o modelo matemático correto para valores contínuos, como consumo de energia ou temperatura. Agora, no nosso SELECT, basta usar rollup para combinar perfeitamente os estados horários pré-calculados e calcular a média, extraindo o valor final correto
3:55:45 Você também pode usar integral nesse mesmo estado para calcular a energia total diretamente a partir da medição de potência e compará-la com o contador. Os valores não vão coincidir até a última casa decimal, porque um é amostrado e o outro é integrado a partir dos pulsos do hardware, mas uma grande discrepância indica imediatamente um sensor defeituoso. A última hyperfunction trata da renderização de gráficos. Um único carregador
3:56:15 gera cerca de 260.000 leituras ao longo de um período de 30 dias. Se você enviar 260.000 coordenadas JSON para um gráfico de navegador com 800 pixels, vai congelar a thread da interface e desperdiçar largura de banda. A função LTTB significa “largest triangle three buckets” e é um algoritmo de redução de amostras integrado diretamente ao mecanismo do banco de dados. Ela pode reduzir esses 260.000 pontos a exatamente 500, preservando matematicamente os picos, as depressões e os contornos visuais.
3:56:47 Então, se você usar médias horárias, um pico de potência breve de 3 minutos desaparece na suavização. O LTTB fornece ao frontend uma carga leve de dados que preserva o formato real dos dados. Certo, agora vamos falar sobre o armazenamento em camadas. Já falei um pouco disso antes no curso, mas quero entrar em mais detalhes. Ao lidar com dados de séries temporais,
3:57:18 você está lidando com uma escala enorme. Uma frota de IoT de tamanho médio pode gerar terabytes de dados por mês. Se você mantiver tudo isso em unidades SSD NVMe ultrarrápidas e premium, a conta do banco de dados vai levar seu projeto à falência. Mas os dados de séries temporais têm um ciclo de vida muito específico. Começam quentes e esfriam rapidamente. As leituras das últimas 24 horas são consultadas constantemente para dashboards em tempo real e alertas. Mas
3:57:50 as leituras de seis meses atrás são consultadas apenas uma vez por mês, para um relatório histórico. O armazenamento em camadas resolve o problema dos custos dividindo o banco de dados entre dois tipos diferentes de armazenamento. Os dados quentes mais recentes ficam em SSDs rápidos e caros. Os dados frios mais antigos são movidos automaticamente para o armazenamento de objetos barato Amazon S3. A vantagem do armazenamento em camadas do Timescale é que ele
3:58:21 é completamente invisível para a sua aplicação. Você não precisa escrever código personalizado para consultar um bucket do S3 e juntar os dados com o seu banco de dados. Tudo parece apenas uma única tabela PostgreSQL. Vou mostrar como configurar isso. Antes de escrever qualquer SQL, precisamos provisionar a infraestrutura de nuvem. Por isso, estou agora no console do Tiger Cloud. Se você tentar executar o
3:58:51 Se você executar o comando SQL para transferir seus dados agora, o PostgreSQL vai retornar um erro dizendo que a função não existe. Isso acontece porque a extensão do gerenciador de armazenamento de objetos ainda não está instalada no nosso banco de dados. Para instalá-la, estamos no nosso projeto e vamos à aba Explore. Depois, clico em Data Tiering e, em seguida, em Enable Tiered Storage.
3:59:23 E pronto. Dependendo do plano que você tem na Tiger Data, talvez seja necessário atualizar sua conta antes de habilitar isso. Nos bastidores, o Tiger Cloud está provisionando um bucket S3 dedicado, configurando as funções de segurança do IAM e instalando a extensão Timescale OSM diretamente na nossa instância do PostgreSQL. Como nossa sessão do terminal já estava
3:59:54 aberta, talvez ela ainda não veja a nova extensão. Então vou sair dela e depois voltar. Vou me conectar novamente ao banco de dados. Agora que a infraestrutura de nuvem está configurada, criar nossa política é só uma linha de SQL: add_tiering_policy. E ela foi adicionada. Se eu tivesse feito isso antes de realizarmos aquelas etapas no painel, teria ocorrido um erro, mas desta vez deu certo. A partir de agora, qualquer chunk que contenha dados com mais de 3
4:00:25 dias será transferido automaticamente do disco local caro para o armazenamento S3 barato. Agora, vamos ver a armadilha mais importante do armazenamento em camadas. As leituras em camadas ficam desabilitadas por padrão. Então, se você tentar consultar dados com mais de 4 dias, a consulta simplesmente retornará zero resultados. Não porque os dados estejam faltando. Eles foram transferidos com sucesso para o armazenamento de objetos, mas nossa conexão atual está
4:00:55 ignorando intencionalmente os chunks em camadas. Por que isso fica desativado por padrão? Porque ler dados no armazenamento de objetos pela rede tem mais latência do que ler de um SSD local. Se as leituras em camadas fossem ativadas globalmente, uma consulta mal elaborada em um painel poderia, sem querer, examinar três anos de dados no S3, paralisar a conexão e consumir recursos. Então, para consultar seus dados frios, você precisa ativar explicitamente essa opção com set timescaledb
4:01:26 enable_tiered_reads = true. Aí eu poderia consultar dados com mais de 4 dias. Na verdade, agora não tenho dados com mais de 4 dias porque acabei de configurar isso com apenas 24 horas de dados. Mas, se tivéssemos esses dados, poderíamos consultar todas as linhas. Portanto, a prática recomendada em produção é deixar as leituras em camadas desativadas globalmente para a aplicação principal e ativá-las apenas de forma seletiva para as funções analíticas que realmente precisam acessar o histórico.
4:01:57 Por exemplo, alter role reporting set, e podemos ativar as leituras em camadas para a função reporting. Não temos essa função agora, mas esse é apenas um exemplo de como seria se a tivéssemos. Certo, esta é a seção final desta parte. Vamos falar sobre busca semântica em falhas de agents. Vamos combinar telemetria de séries temporais com busca vetorial em um único banco de dados PostgreSQL. Quando um agent de IA falha, as consultas padrão por palavras-chave muitas vezes não encontram a causa raiz por causa das diferenças de formulação.
4:02:30 Queremos executar uma busca vetorial semântica restrita aos erros dos últimos sete dias. Então, vejam este SQL aqui e, na verdade, o arquivo inteiro. Separamos os embeddings em uma tabela própria. Ela se chama span_embeddings, porque um vetor de 1.536 dimensões em ponto flutuante ocupa cerca de 6 kilobytes. Só geramos embeddings para
4:03:00 spans que contêm erros, evitando uma sobrecarga enorme nos spans sem problemas. Como essa nova tabela também é uma hypertable, o Timescale usa a exclusão de chunks para ignorar por completo janelas de tempo mais antigas durante a busca vetorial. Certo, acabei de aplicar isso; agora vamos ver como fica uma consulta híbrida. Vamos inserir um registro de erro fictício na nossa nova tabela, para termos algo para
4:03:31 pesquisar, e depois vamos executar nossa consulta híbrida. Isso vai inserir o erro fictício. No código da sua aplicação, você usaria aqui uma variável de parâmetro para passar o embedding real gerado pelo OpenAI. Como estamos no terminal, vou usar a função array_fill do PostgreSQL para gerar na hora um vetor fictício de 1.536 dimensões. Assim, podemos ver a execução. E, se vocês notarem este detalhe
4:04:03 aqui, o operador de distância calcula a distância cosseno, que compara o ângulo dos vetores, e não a magnitude. Esse é o padrão para embeddings de texto. Lembrem-se desta regra fundamental de indexação para bancos de dados vetoriais: a classe de operadores do índice precisa corresponder ao operador da consulta. Criamos nosso índice HNSW usando vector_cosine_ops. Então, vejam o que acontece quando escrevemos uma
4:04:34 consulta usando este outro símbolo aqui, que é o operador de distância euclidiana. Se olharmos o plano da consulta, veremos que ela volta a fazer uma varredura sequencial. O PostgreSQL não pode usar o índice HNSW porque os operadores matemáticos não correspondem. Sempre verifique se o operador da consulta corresponde à definição do índice. Esse padrão elimina completamente a necessidade de um banco de dados vetorial separado e dedicado. Você evita executar tarefas de sincronização
4:05:07 entre esses sistemas distintos. Elimina o atraso de consistência eventual e permite combinar junções relacionais, partições do Timescale e similaridade vetorial, tudo em um único plano de consulta SQL unificado. Antes de encerrarmos esta seção, vamos sair do terminal. Escrever SQL é apenas metade do trabalho de administrar um banco de dados. A outra metade é mantê-lo saudável em produção. Então, vamos falar um pouco mais sobre o console do
4:05:37 Tiger Cloud, porque este painel oferece uma visibilidade que você simplesmente não consegue obter pela linha de comando. Quando você abre seu serviço, chega ao painel de visão geral. Ele mostra as principais métricas de telemetria: carga da CPU, uso de memória, armazenamento utilizado e muito mais. Quando você está ingerindo milhares de leituras de IoT por segundo, é aqui que monitora os gargalos. Se a CPU fica constantemente em 99% ou a memória
4:06:10 começa a usar o disco como memória virtual, este painel informa que está na hora de aumentar os recursos de computação. Agora, vamos à aba Explore. Este é o centro de controle visual criado especificamente para dados de séries temporais. Se você abrir nossa tabela de leituras, não verá apenas o esquema padrão de uma tabela do PostgreSQL. O Explorer entende hypertables. Ele mostra exatamente quantos chunks existem e quanto dos dados está compactado. Agora, vamos à aba
4:06:41 Explore. Este é o centro de controle visual criado especificamente para dados de séries temporais. Se clicarmos na tabela de leituras aqui, não veremos apenas o esquema padrão de uma tabela do PostgreSQL. O Explorer entende hypertables. Ele mostra exatamente quantos chunks existem, quanto dos dados está compactado e quanto armazenamento você está economizando. Você também pode ver os agregados contínuos que criamos antes
4:07:13 como os agregados por dispositivo e por dia, e por dispositivo e por hora. A interface acompanha visualmente os jobs em segundo plano que atualizam essas views. Se um job de atualização falha por causa de um tipo de dado inválido ou de um tempo limite excedido, isso é sinalizado bem aqui. Você não precisa vasculhar as tabelas de sistema do PostgreSQL para descobrir por que seus painéis ficaram desatualizados de repente. Agora, vá à aba Monitoring. Este é um sistema contínuo e sem agents para monitorar o desempenho das consultas
4:07:44 Ele basicamente observa todas as consultas executadas pela sua aplicação, normaliza-as e as classifica. Temos os logs, os jobs e os insights. Certo, os insights nos dão muitas informações, talvez sejam os que oferecem mais informações. Temos conectores, operações e várias coisas que você pode fazer nas abas de operações. Mas uma coisa que quero destacar são as opções de backup e restauração e de criação de um fork de recuperação. Basicamente, em uma configuração tradicional do PostgreSQL,
4:08:15 é preciso testar uma migração de esquema enorme ou uma consulta destrutiva. Se você precisar fazer isso, terá que passar horas restaurando um backup diretamente em um servidor de staging. Mas, na Tiger Data, basta criar um fork, porque a plataforma usa armazenamento copy-on-write. Em minutos, ela cria uma cópia exata, byte por byte, do banco de dados de produção, executada em recursos de computação isolados. Você pode fazer seus experimentos destrutivos no fork,
4:08:47 verificar os resultados e depois excluí-lo com segurança. Assim, a produção permanece completamente intocada. É justamente essa visibilidade e segurança operacional que nos levou a passar de um contêiner local do Docker para um ambiente de nuvem gerenciado. Certo, isso encerra esta seção. Passamos de um Docker local para a nuvem gerenciada. Validamos a portabilidade completa do esquema. Configuramos uma frota de dispositivos IoT com hyperfunctions para lidar com casos extremos do mundo real, como reinicializações de contadores e problemas de rede
4:09:17 … indisponibilidades. Armazenamento em camadas configurado e busca vetorial unificada com dados de séries temporais em tempo real. Antes de terminar, aqui estão 12 coisas para verificar antes de executar isso em produção. Você pode usar o slide de resumo no final como referência. Um: habilitar o armazenamento colunar e converter os chunks são etapas separadas. A configuração de ALTER TABLE configura o armazenamento colunar. Ela não converte imediatamente os chunks existentes. Verifique se já existe uma política de conversão. Se existir, ela pode
4:09:49 processar chunks antigos elegíveis, assim como os chunks que se tornarem elegíveis depois. Você também pode converter um chunk específico manualmente quando quiser que isso aconteça imediatamente. Dois: verifique quantas linhas cada segmento conterá. Uma coluna de segmentação com valores distintos demais pode resultar em lotes pequenos e compressão ruim. Procure ter linhas suficientes por segmento em cada chunk para preencher lotes com cerca de mil linhas. Por exemplo, 8.000 leituras de um
4:10:20 dispositivo preencheriam vários lotes. Isso não significa que há um único lote contendo todas as 8.000. A consulta na tela calcula uma média para você verificar se há dispositivos ou usuários com pouquíssimos registros. Se segmentar por várias colunas, verifique os valores combinados. Três: escolha as configurações de índice esparso antes de converter os chunks. Alterar as configurações não atualiza retroativamente os chunks que já estão no armazenamento colunar. Para reconstruir um chunk existente com as novas configurações,
4:10:53 converta-o de volta para o armazenamento por linha e, depois, novamente para o armazenamento colunar. Isso reescreve os dados. Portanto, planeje o tempo e o espaço em disco necessários. Quatro: inclua um intervalo de tempo e buscas pontuais quando tiver um. Uma busca por ID pode exigir a pesquisa em muitos chunks. Uma condição de tempo permite que o planejador exclua chunks fora do intervalo. Ela não reduz automaticamente a consulta a um único chunk. Isso depende da largura do intervalo e de como seus chunks são
4:11:24 particionados. Use o intervalo correto mais estreito e verifique o plano. Cinco: as restrições de unicidade precisam incluir todas as colunas de particionamento. Para uma tabela particionada apenas por tempo, isso pode significar uma chave primária composta por tempo e ID. Se você adicionou outra dimensão de particionamento, inclua essa coluna também. UUIDv7 pode ajudar porque o ID contém um timestamp, mas UUIDv7, por si só, não contorna a regra da restrição. Você ainda precisa da
4:11:56 chave única apropriada e de uma restrição CHECK que vincule a coluna de tempo ao timestamp no ID. Seis: mantenha as atualizações dos agregados dentro do período cujos dados ainda são retidos. Se uma atualização cobrir um período cujas linhas de origem foram excluídas, as linhas correspondentes do agregado também poderão ser removidas. Elas não são necessariamente substituídas por zero. Um gráfico pode mostrar uma lacuna ou exibir zero se a aplicação preencher valores ausentes dessa forma. Mantenha o intervalo entre atualizações
4:12:28 bem mais curto que o período de retenção dos dados de origem. Por exemplo, atualizar os últimos 7 dias e reter 14 dias dá uma margem. Verifique isso sempre que qualquer uma das políticas mudar e tenha cuidado com atualizações manuais de períodos mais antigos. Sete: a política de reordenação processa cada chunk uma vez e ignora os dois mais recentes. Uma carga retroativa grande pode adicionar linhas que deixam de seguir a ordem física do chunk. Se isso afetar suas consultas, execute
4:12:58 a reordenação novamente nos chunks afetados. E, se estiver testando com dados novos, lembre-se de que a política deixa intencionalmente os dois chunks mais recentes de lado. Oito: separe a agregação em tempo real das configurações da política de atualização. Definir materialize_only como false habilita a agregação em tempo real. Assim, uma consulta combina resultados materializados com dados novos ou brutos posteriores ao watermark de materialização. Usar WITH NO DATA
4:13:28 evita preencher a view inteira quando ela é criada. Isso é útil para controlar a atualização inicial, mas não é necessário para habilitar a agregação em tempo real. O offset controla o quanto a atualização se aproxima do presente. Trinta minutos para intervalos de 15 minutos é a opção mostrada aqui, não um mínimo universal. As atualizações abrangem apenas intervalos completos dentro da janela. Escolha o offset de acordo com sua carga de trabalho e com o atraso dos dados
4:14:00 na chegada. Lembre-se também de que correções em intervalos mais antigos que já foram materializados ainda precisam de uma atualização. O modo em tempo real não recalcula automaticamente todo o histórico. Nove: monitore a quantidade de chunks e o tamanho dos índices ativos. Cerca de mil chunks é um ponto útil para revisar a sobrecarga do planejamento, não um limite rígido. Um intervalo de uma hora mantido por um ano cria 8.760
4:14:31 chunks. Portanto, vale a pena analisar isso. Manter os índices dos chunks que estão recebendo gravações dentro de aproximadamente um quarto da memória disponível é outra diretriz inicial. Considere todas as hypertables ativas em conjunto. Meça sua carga de trabalho antes de mudar o intervalo e lembre-se de que um novo intervalo normalmente se aplica apenas aos novos chunks. Dez: verifique se as consultas incluem dados em camadas. As leituras de dados em camadas vêm desabilitadas por padrão. Sem habilitá-las, sua consulta pode omitir chunks armazenados em armazenamento de objetos
4:15:02 e ainda assim retornar um resultado aparentemente válido. Habilite a configuração para as sessões ou funções que precisam desse histórico. Espere que as leituras do armazenamento de objetos tenham desempenho diferente das leituras do armazenamento local. Onze: monitore falhas dos jobs em segundo plano. Uma atualização que falha pode deixar o dashboard exibindo resultados antigos, mesmo que a consulta do dashboard seja bem-sucedida. Verifique os erros dos jobs e configure alertas para falhas. Daqui a pouco, veremos a consulta de erros.
4:15:34 Doze: revise as configurações do PostgreSQL antes de fazer benchmarking. Limites de memória, capacidade de workers e paralelismo podem afetar seus resultados. Eles não substituem um bom design de esquema nem configurações adequadas do TimescaleDB para um banco de dados autogerenciado. O TimescaleDB Tune ou o PG Tune podem fornecer uma configuração inicial. Revise as recomendações, aplique-as e reinicie se as configurações alteradas exigirem isso. Depois, meça novamente. Os serviços gerenciados oferecem uma configuração inicial pronta, mas
4:16:06 você ainda precisa monitorar sua carga de trabalho. Aqui estão as 12 verificações reunidas. Pause aqui se quiser salvar a lista. Ela também está no repositório do curso. Agora, cinco consultas de monitoramento que você pode manter. Um: quanto espaço a compressão economiza? Esta consulta mostra os tamanhos antes e depois, e a porcentagem economizada para cada hypertable. Observe as mudanças ao longo do tempo. Uma economia menor pode ser causada por lotes menores, por uma mudança na
4:16:38 cardinalidade dos segmentos ou por uma mudança nos próprios dados. Considere isso um motivo para investigar, não um diagnóstico por si só. Dois: quantos chunks temos? Isso mostra a quantidade total de chunks, quantos estão comprimidos e o intervalo de tempo mais antigo. Se a quantidade continuar crescendo, verifique o tempo de planejamento e se o intervalo e a retenção ainda fazem sentido. Mudar o intervalo não redimensiona os chunks que já existem. Três: os jobs estão sendo executados com sucesso? Isso combina as definições dos jobs com as estatísticas de execução
4:17:10 deles. Verifique o status da última execução, o término da última execução bem-sucedida e a quantidade de falhas. Um sucesso recente não apaga falhas anteriores, e um job que ainda não foi executado pode não ter estatísticas de execução. Quatro: o que falhou? Isso mostra as mensagens de erro recentes, com o ID do job e o horário de término. Use para investigar políticas com falha e criar alertas. Um resultado vazio significa que esta consulta não encontrou erros registrados no período selecionado.
4:17:42 Isso não prova que todos os jobs esperados foram executados. Portanto, continue verificando também o status dos jobs. Cinco: quão recentes são os dados retornados por cada agregado? Isso compara o fim do intervalo mais recente com o horário atual. É um indicador útil de atualidade, mas não mede diretamente o atraso da atualização. Se o tráfego parar, o intervalo mais recente ficará mais antigo, mesmo que todas as atualizações sejam bem-sucedidas. Se a agregação em tempo real estiver habilitada, os dados brutos recentes podem fazer a
4:18:12 view parecer atual, mesmo enquanto as atualizações materializadas estão falhando. Uma view vazia não retorna uma idade que possa ser medida. Use isso junto com a taxa de chegada esperada, o status dos jobs e os erros dos jobs. Não gere alertas apenas com base na idade do intervalo, sem considerar essas condições. Vale a pena manter outras duas verificações. Primeiro, a capacidade dos workers em segundo plano. Reserve espaço para os agendadores do TimescaleDB e para os jobs que precisam ser executados ao mesmo tempo. Você não precisa de um
4:18:44 worker dedicado para cada política que criou. Além disso, verifique o limite geral de workers do PostgreSQL e se os jobs estão sendo atrasados. Segundo, use as verificações de integridade habituais do PostgreSQL: conexões, acertos de cache, inchaço e transações de longa duração. Continue usando-as junto com as consultas específicas do TimescaleDB. O TigerCloud também oferece monitoramento no console. E quatro ideias para lembrar. Primeiro, as hypertables organizam os dados em chunks para que as consultas e as políticas de retenção possam trabalhar nos
4:19:15 intervalos relevantes. Segundo, escolha o armazenamento de acordo com a forma como você usa os dados. As gravações recentes geralmente são adequadas ao armazenamento por linha, enquanto os dados analíticos mais antigos podem se beneficiar do armazenamento colunar. Terceiro, mantenha estados combináveis quando precisar combinar resumos. As médias precisam de seus pesos, e os estados compatíveis de percentis e contagens distintas permitem combinar estimativas corretamente. Os agregados reduzem o trabalho repetido, mas não garantem tempo de consulta constante à medida que todas as partes da sua carga de trabalho crescem.
4:19:46 Quarto, você ainda está usando PostgreSQL. Pode continuar usando seus drivers, joins e ferramentas de monitoramento, levando em conta as restrições e o comportamento das hypertables. Chegamos ao fim. Obrigado por assistir e boa programação.
Transcrição original
[00:00] two databases on the same laptop, each with 10 million request records. We’re asking both the same question. How many requests arrived each hour and what was their average response time? On the left, PostgreSQL calculates the answer from the individual requests. On the right, time scale DB reads an hourly summary we’ve prepared ahead of time. So, let’s run them. We can see the raw query took 1.6 6 seconds or 1,682
[00:32] milliseconds. And the right query only took 10 milliseconds. And if we check, they both return the same answers. The difference is the amount of work. One query reads individual events. The other reads results we already calculated. Building that summary took time, too. And ordinary PostgreSQL can store summaries. What time scale DB adds is a way to keep timebased summaries updated without rebuilding the entire history every time. That’s one of the things
[01:04] we’re going to build in this course. Hello, I’m Bo KS and in this course I’ll teach you how to use time scale DB to work with time series data in PostgreSQL. We’ll look at how to organize growing tables, reduce storage, and keep dashboard queries responsive. We’ll also cover the constraints and settings you need to understand before using this in production. Tiger Data provided a grant to make this course possible. They’re the company behind Timecale DB. They have a fully managed
[01:36] cloud platform called Tigercloud. It’s essentially enterprisegrade PostgreSQL with Timecale DB built in plus cloudnative features like automatic data tiering to cheap S3 storage. one-click database forking and a visual dashboard built specifically for time series operations. We’re going to use Tigercloud later in the course for the second project. But for the first part of this course, we’re going to start completely local inside Docker from
[02:08] scratch. I want you to understand exactly how this engine works under the hood before we move to the cloud. You can keep using familiar Postgregql tools and SQL. Some table designs and queries need changes and we’ll work through those as we go. So what is time series data? Time series data is data you analyze in relation to time. You might ask what happened in the last hour, compare this with last week or calculate response times throughout the day. That
[02:40] includes HTTP requests, financial prices, sensor readings, application logs, and the model and tool calls made to an AI agent. These workloads often add new records continuously. Much of the data stays unchanged and the questions usually concern a particular time range. PostgreSQL can handle large data sets, but as an event table grows, a few costs become more noticeable. Large active indexes can create more
[03:11] disk work. Deleting old rows creates cleanup work. And dashboards can repeatedly scan the same historical events to calculate the same totals. PostgreSQL already gives us tools for some of this including partitioning. Time scale DB adds automatic chunk management, column oriented storage, and continuous aggregates. We’ll see where each helps and what it costs to maintain. In that first demo, we focused on continuous aggregates, storing hourly
[03:43] results so the dashboard doesn’t calculate them from scratch. The initial calculation happen before we start the timer. Later, we’ll look at refreshing those summaries when new data arrives or other records change. We’ll also examine storage and query plans so we can explain the results. The runtime results you saw are the results from this laptop and this data set. They’re not a promise for every query. Your schema, hardware, filters, and data distribution all matter. We’ll measure as we go, and you
[04:14] can repeat the examples on your own machine. So, we’ll apply this to two projects. The first is is an AI agent flight recorder. It records model and tool calls with their duration, cost, and outcome. We’ll use it to answer questions like which agent is spinning the most, which tool has the slowest calls, when did the error rate change, and when something goes wrong, we can inspect the recorded steps in a particular run. This project runs locally in Docker and a a simulator
[04:44] supplies data without requiring a paid model API. The second project is EV charger fleet telemetry. We’ll model 2,000 chargers, each reporting every 10 seconds with one row per report. That’s 200 rows per second and about 17 million rows per day. We’ll use Tigercloud for this project to explore managed operations and moving older data into into object storage. That event rate doesn’t require cloud hosting it by itself. The point is to see how the
[05:16] operational and storage choices work. Chapters are in the description if you want to jump to a particular topic. Let’s start with the PostgresQL concepts we’ll need. So the single most important thing to understand about time scale DB is that it’s just Postgress. It’s not it’s it’s not a fork of PostgresQL. It’s not a read what write. It’s just an extension. The same mechanism that gives you postJIS for geospatial data or PG vector for embeddings. So that means
[05:47] that you already everything you know about Postgress still applies and everything in your stack still works. So let me demonstrate. I have Postgress running in the terminal here and I’m going to run this command that will show that uh we’re seeing that time scale DB is sitting in the same extension list next to PostgresQL which ships with every Postgress install on Earth. So it’s super easy to install
[06:19] just create extension if not exist time scale DB. I already have it existing on this machine, but this one line is the entire installation. Once the binary is on the machine uh per database, that’s the part people miss. If you create a new database, you run that again. The practical consequence is that your Django or Rails or Prisma migrations work. Your connection pooler works, PG dump works, your monitoring works, your team doesn’t have to learn a new query
[06:49] language because there isn’t one. It’s just SQL. Okay, we’re going to use some example data. So, I just want to talk about what’s in the example data data so you can understand. This matters more than it sounds like it does. Every query in this course runs against one table. And if you don’t know what’s in it, the SQL is just noise. So, here’s the scenario. Imagine you run a service that sits in front of a lot of websites like a CDN, a hosting platform, an API gateway. Every time somebody’s browser asks for a page, that request passes
[07:21] through your service and you write down a line about it. Who asked, what they asked for, how long it took, whether it worked. That’s it. That’s the data. Just one row per per HTTP request. Millions of them. If you’ve ever looked at a web server log file, this is that in a data center table instead of a text file. I mean, a data database table instead of a text file. So, let me show you the shape of it. So here’s the table and I did just slashd requests. Uh slashd is a
[07:54] postgrql command not SQL. It’s a shortcut the postsql client gives you and it means describe this table. You get every type column and its type. So there’s a lot of columns here. So let me just group them because they fall into five buckets. Uh the time is when it happened, the time stamp of the request and this is the most important column in the whole course. We’re going to do a lot of sorts, filters and groups by this
[08:24] column. And then we have some about who asks for what like the server ID, the URL domain, session ID, and then we have stuff about what happened, the status. This is the HTTP status code. Then how slow it was. It’s all about the duration here. and then everything else like where the request came from, what browser, what operating system, how many bytes moved. So this is pretty abstract. Let me show you one actual row. So first I’ll do slash x on that’s going to flip
[08:58] SQL into expanded mode which prints one column per line instead of one row per line for a table this wide. That’s the difference between readable and unreadable. And then we’ll do select everything from request limit one. Let me just zoom out so we can see everything on one screen here. So you can see we have the time, URL, domain, status, duration, total. And those are going to be some of the most important ones.
[09:29] So there’s one request. Somebody in some city asked for some page and it returned a status and it took some numbers some number of milliseconds. Every row is that there are millions of them. So, let me just do /x off to turn off extended display. And then I’m going to do a query that’s going to tell us how many rows and what time period do they cover. So, this is the number of rows and here’s the time period here. So, that’s your data set and the the newest time stamp. We can see the data ends at
[10:00] roughly the moment um uh we loaded the data. that matters for the next few queries because when I say the last 24 hours, I mean the last 24 hours of this data, which is the day leading up to when we ran the generator. Now, let’s start going over SQL. I’m going to talk to you about the 20% of SQL you need, which is basically all you’ll need for this course. I’m going to show you four things. If all four are familiar, part one is a refresher and you can move fast. If any are new, this is the floor.
[10:32] Basically everything later in the course is built out of these. So here’s some basic SQL code that we’re going to cover and I’m going to be running it on PostgreSQL right over here. Every query in this course starts by narrowing to a time range. So let’s get that out of the way first. So I’m going to select max time from request. This is the max time and the time range. I’m going to save that time, that value, and reuse it because I want every query in this section to point at the busy end of the
[11:04] data rather than at wall clock time. So, I’m going to run this command right here. Select max time and we can see right here slashg set at the end. This is a postql trick. It takes the result of that query and stores it in a variable called newest. So I can write colon newest in later queries instead of typing a timestamp. So here’s the filter we’re going to use.
[11:38] So first select count. We’re going to count the rows where the time is at or after 24 hours before the nearest row. So we’re going to from the request where the time is at or after and we have interval 24 hours. So 24 hours after the newest row. The thing to notice here is that postgrql has real date arithmetic. So this interval 24
[12:10] hours or you can look at the colorful one right over here. This is a genuine value with a type and you can subtract it from a timestamp and get a time stamp like interval. You could use interval 7 days, interval 15 minutes, interval 1 month. These all work. You’ll see this constantly in every part of this course. So I’m just going to run this command and we can see how many how many uh how many rows were within the last 24 hours. Now let’s talk
[12:43] about aggregation and grouping. So basically I’m going to be using this code here. I’m going to copy this and paste it right over in our post P post SQL PostgresQL there. There’s the full thing here. So let’s read this one clause at a time because the order Postgress does the work is not the order you read it. So here we have from requests. We’re going to start with this table, the request table. And then we have the where where then we have basically this
[13:15] means to throw everything outside, throw away everything outside the last 24 hours. And then we’re going to group by status, which is pretty important. Take the surviving rows and sort them into piles. One pile per distinct status code. Like all the 200s in one pile, all the 404s in another. Then we have up here we’re going to get the status and we’re going to get the count. So the count with the asterric and the average duration total
[13:48] they run once per pile not once per row. That’s the mental shift. So this count everything is how many rows in this pile and then the average duration total is the average duration of the rows in this pile. Then order by request de or descending sorts output biggest pile first and this as requests up here just renames the column to the out to so the output is readable that’s all as ever
[14:19] does okay I will submit this command here and we can see what we get here so mostly 200s so we have the 200 we have all these different status codes here and we can see it’s you can see it’s mostly 200s like I said um that’s what you want. There are some 404s and there’s some 500s. Those are the ones that mean something is broken and finding them fast is basically the plot
[14:50] of this entire course. Now we’re going to talk about this filter section here. So filter is for counting different things at once. I have the code already over here in the in the PostgreSQL. So here’s a here’s a real question. What’s my error rate? To answer it, you need two numbers from the same row. The total and how many of them failed? You could run two queries, but don’t. Postcrest has filter. So we have this
[15:21] filter wear. So this attaches a condition conditions to a single aggregate. The wear at the bottom which is right here applies to the entire query. But this filter up here only applies to one count it’s attached to. So count filter where status is more than or equal to 500 means count the rows but only the ones that that were
[15:54] server errors. So four different counts one pass over the data. If you’ve been writing some case when then one l0 end and a lot of people have myself included this is the same thing faster to read and faster to run. So if we run that we can see server errors not found and okay here we can also easily turn it into a percentage and get an error rate. So here we’re um basically running a
[16:26] calculation to turn it into a percentage and calling it air rate percentage. And then we see we have a 25% air rate percentage. So here we have this last thing we’re talking about is percentiles. So here what you can see we’re using percentile count and percentile count 0.5 n5.99 and I’ll just submit this. So we can compare the average milliseconds to the
[16:58] average P99, P95 and percent. These are percentiles percentile 50. The P99 should be several times larger um which it is. So look at the gap between the average and P99. Same request, wildly different story. The average is the number that makes you feel good, but the P99 is the number your angriest users is living in. The syntax is admittedly odd where it says percentile count 0.95.
[17:29] This says which percentile you want. Then within group, then we’re going to order by duration total. This says which column to compute it over. Postgress has to put the values in order to find the 95% mark. That’s what you’re telling it to sort by. And remember this, it’s exact, which means it sorts every single value to find that mark. On a few thousand rows, fine. But on a 100 million, that’s a problem. And in the next part, we replace it with something
[18:01] that gives you a nearly identical answer without the sort. So another thing is types. There are three of them that matter here. The type of timestamp TZ. Always use this. Never plain time stamp. The TC TZ is time zone. So time stamp TZ stores an absolute moment and converts it to the reader time zone on the way out. Plain’s time stamp stores a wall clock reading with no idea where it was taken. Our time column is timestamp TZ and every
[18:33] hyper table in the next part is partitioned on a column like it. The JSON B which is binary JSON uh this can be indexed for the longtail attributes that don’t deserve their own column and UU ID. Uh it’s a 128 bit identifier. The long hyphenated strings and request ID are the are UU ID. It looks like a boring choice right now, but in the indexing section coming up, it’s going
[19:05] to be pretty important. And there’s uh important, I guess we could say, gotcha uh waiting there that we’ll talk about later. So these things that we just covered are basically the 20% of SQL that you’ll need. Everything is kind of built on these interval, group by, filter, percentile count. We’re going to need to know all these things as the course progresses. Okay, I want to tell you about another command called explain. So if you take one skill from
[19:37] this section, make it this one. Almost every claim time scale DB makes is verifiable with explain. And almost every performance mystery is solved by reading it. So let’s actually learn to read it, not just glance at it. So this is the the the plain version which doesn’t run the query. just so it shows the plan. So I’m going to put explain and then select count from request where and then just basically where the time is in the last day here from the the end of the results. So we can now see the
[20:11] query plan. So this is the plan of what it’s going to do. So it hasn’t actually run the command. It’s just what the what the command is going to do. What the select command is going to do. So So first it’s going to filter. Then it’s going to do the parallel seek scan on request partial aggregate and gather. Basically, it’s going to start from the bottom and go up. And that’s what it’s going to do here. And there are two numbers per line. Uh we have you can
[20:44] see this line here cost. Cost is an arbitrary unit. It’s not milliseconds. It’s not anything physical. It’s only meaningful for comparing two plans of the same query. And then rows here is the planner’s estimate, which is where the interesting failure lives. Now I’m going to run a similar command, but we’re going to do explain analyze buffers. So analyze actually executes the query and reports what really
[21:15] happened. Buffers reports how much data it had to touch. That second one is the one nobody uses and really everyone should use it. So you can see it hit four. The shared hit equals four. And we can see that the true information workers plan workers launched. It’s basically reporting what actually happened when it run ran the command. So when it says sik scan here that means a
[21:47] a sequential scan. So let me talk about a sequential scan versus an index scan. A se a sequential scan reads the entire table. That’s not automatically bad. If you’re aggregating most of the table, it’s the right choice and it’s faster than an index scan would be. It’s bad when you wanted a handful of rows and got a full read. And here we can see for every line you can see basically we it’s showing here
[22:18] the actual time and the actual rows. So if the plan get a 100 rows and it got a million every decision downstream is built on a lie and you’ll get a bad plan. That usually means stale statistics. So you should run analyze on the table. And then here’s buffers. We see that it got shared hit. This means the data was already in memory. Shared read means it came off the disk. Disk reads are the thing that actually cost you wall clock time. And this is the
[22:50] number we’ll watch drop dramatically when we get to reordering and column store. And then you sometimes you want to look at the planning time versus execution time. If they disagree wildly, the planner is confused about something. So let me show you something else. basically the pathology because it’s more instructive than the happy plan. So here we’re selecting everything from request where URL path equals SLC checkout. So there’s no index on URL
[23:21] path. So we have a sequential scan and a huge uh shared red count and the execution time is dominated by pulling pages off discs that we then pull away. So I’m going to just compare that to another command here. We are going to create we’re going to create an index on the requests. I mean
[23:51] an index on the URL path and then we’re going to run the same command again where the URL path equals/checkout. And now we can see we have an index scan. So if we see the the shared if we go up here and see the shared red we have 106,533 in the buffers here we have 3001. So you see that’s a very that’s basically an order of magnitude lower. So that’s what
[24:24] we want. We want to touch fewer pages and it’ll make things run a lot quicker. But now I’m going to run the drop index command because I I want the slow version back for later. Now one here’s a one habit worth building. I’m going to run slashtiming on. This will turn on timing. Now every request reports its wall time. It’s the uh psqlrc file in the repo along with a couple other settings I use. I mean it’s
[24:56] in in that file if if you check the repo on GitHub. This is the section that explains most of time scale DB’s design. So stay with me even if it feels low-level. Postgrql does not read rows from disk. It reads pages. And a page is 8 kilobytes. Always every read, every write, every index lookup in units of 8 kilobytes. So if your rows are say a 100 bytes, you get somewhere around 50 to 100 rows in a
[25:29] page depending on overhead. And when you ask for one row, the database reads all 8 kilobytes and hands you the one you wanted. That has a consequence that becomes the entire argument for the column store later. If the rows you need are spread across many pages, you pay for every page. 10 rows in one page is one read. 10 rows in 10 different pages is 10 reads. Same 10 rows. Hold on to
[25:59] that. It comes back in about uh in just later in this course and it’s the key to two different features. Now the second half of this which is about rights. Postgress uses MVCC multi- version concurrency control. The short version, when you update a row, Postgress does not overwrite it. It writes a new version of the row and marks the old one as dead. When you delete a row, it doesn’t remove
[26:30] anything. It just marks the row dead. Why? So that other transactions already reading that row don’t have the ground move under them. It’s a genuinely good design and it’s why Postgress readers never block writers. But it means dead rows accumulate. They still occupy pages. Your table gets bigger even as you delete from it. And then the command vacuum has to come along and clean up.
[27:02] So let’s make that concrete because it’s easier to understand once you see it. So, I’m just going to create a new table called dead row demo and just uh generate some items for the row. And let’s look at the size. It’s 72 megabytes because so we generated it and then we uh figure out the size of the the table. Okay. So now I’m going
[27:33] to delete a lot from the table and then we’ll get the size again. You can see the size is the same. We deleted 80% of the rows and the table did not get smaller by a single bite. So now I’m going to run another command. Select inlive tup and in dead tup from the table from the dead row demo. Now I’m going to run vacuum full and we
[28:04] can see now the size has gone down. So vacuum full took an exclusive lock on the table. Uh nothing could read or write it while that ran. On a 500,000 row toy table that’s instant. On a 200 million row production table it can take a long time. So here’s a trap that catches everyone. You’ve got a huge events table. You decide to keep 90 days of days of data. So you write a nightly
[28:36] cron job like delete from events where time uh in the last 90 days. Basically that job creates millions of dead rows every night. Vacuum has to chase them. Your table doesn’t shrink. Your indexes get bloated and fragmented. The delete itself takes longer every night as the table grows. and it competes for I/IO with your actual traffic. The time scale DB answer to this and I’m
[29:06] pre previewing it because it’s such a clean win is that dropping old data becomes deleting a file. Not deleting rows, removing an entire chunk of the table as a unit. Milliseconds, no dead rows, no vacuum pressure, no bloat. That’s the retention section that we’re going to talk about later. Now an object an object an objection Postgress already has partitioning built in. Since version 10, you can split a table into pieces by range. So why do we
[29:39] need an extension? Well, let’s build it by hand and find out. This is the most useful five minutes here because in this section because once you’ve done it manually, you understand exactly what time scale DB is automating. Okay, I’m going to create this table events native. And the table can’t hold anything yet. It’s a router with no destinations. And we have these three fields, time, device, and value. But if we try to
[30:12] insert into something, it it’s not going to work. We can see that there is no partition of relation needed found for now. So I have to create the partition first. So this is how I’ll create the partition. I’m going to create a partition. I’ll do create table and then this is going to be the name partition of events native for these values and then a partition of events native for these other values.
[30:46] Okay, we’ve created those tables. Now the insert should work. Yep, we insert it into the table. And this genuinely does buy you something real. The planner can skip partitions entirely. So I’m going to run explain and check out the plan. So we can see the plan that it’s that it has here. One partition in the plan. And you can see
[31:17] we got this partition here. Um, the August table isn’t even considered the other partition. That’s partition pruning and it’s the same core idea time scale DB uses. So, what’s missing? Well, there’s four things that native partitioning doesn’t do. One, you have to create every partition yourself before the data arrives. Forget one and your inserts start failing in production at midnight on the first of the month. People solve this with cron jobs or
[31:48] pgartman and it’s just one more thing that can break. Two, no automatic sizing. You pick the boundaries. Get them wrong and you either have 4,000 tiny partitions which makes planning slow because the planner considers all of them or three enormous ones which defeats the purpose. Three, no life cycle management. There’s no built-in compressed partitions older than a week or drop partitions older than a year. You write that yourself. Four, and this
[32:19] is the big one, no time series features. Partitioning is storage organization. It doesn’t give you the incrementally updating aggregates or column nar expression or compression or gap filling or percentile sketches. Those are the features that actually make analytics fast and they’re the bulk of the next section. So a hypert is a partitioned table. Time scale DB does not replace
[32:49] that machinery. It builds on it. What you’re paying what you’re paying the extension for is the automation and the analytics layer on top. Okay, that table is just an uh example for now. So we’re going to drop that table. The last piece of foundation and it sets up the single best feature in uh time scale DB. Your dashboard query is slow. So the obvious fix is don’t compute every time. Compute
[33:20] it once, store the answer, read the stored answer. Everybody’s first instinct and it’s a good instinct. Postgress gives you materialized views for exactly this. So here’s how I’d set up a materialized view. So the request hourly MV materialized view and then we have the different fields that we’re adding to it. So uh while that runs notice what it’s doing. It’s reading the entire table to build the summary every
[33:51] row from the beginning of time. And then we’re we’ll query it which is instant. Obviously it’s a small precomputed table. But the problem, let’s say we want to refresh the data. We’ll do refresh materialized view. And this can take some time. That refresh recmputed everything from scratch, all of history to pick up the last 5 minutes or so of
[34:21] new data. So materialized views have two problems. Basically, two deal deal breakers. The cost of refreshing grows forever because it’s proportional to your total data, not to what changed. And the plain refresh takes a lock that blocks reads. There is a refresh concurrently, which doesn’t block, but it needs a unique index and it’s even slower. So, people go one level down and
[34:53] hand roll basically make it themselves. a real summary table updated by a trigger on insert. That’s actually worse, but it’ll be like this. We create this request summary table. We create this function that’s going to update it. Uh, it looks reasonable, but let’s think about what happens under load. Every insert into requests has to update a row in the request summary. And for any given hour and domain, that’s the same row. every
[35:24] single request for example.com this hour contends for one row row level locks concurrent inserts queue up behind each other and the there here’s the thing about queuing it doesn’t degrade gracefully it’s fine it’s fine it’s fine and then you cross a threshold and it collapses because each waiting transaction is holding resources while it waits with each new query the waiting time increases until the database becomes comes overwhelmed by a mess of
[35:55] blocked queries waiting to run. That’s the failure mode. Not slow, but stuck. And even if you solve the contention, you’ve now got a trigger to maintain backfield logic for historical data, correction logic for updates and deletes on the source table, and a whole new class of bug where your summary and your raw data disagree. But there’s another option. Time scale DB’s continuous aggregates solve exactly this. They keep a materialized summary,
[36:28] but the refresh is incremental. It tracks which time buckets actually changed and recomputes only those. So no triggers, no lock contention on write, no full recomputee, and it handles updates and deletes to the source. We’ll be talking about that more uh next. and it’s a great feature in the extension, but you had to see why the obvious approaches fail or the incremental thing just sounds like a a nicer materialized view. It’s not. It’s a different mechanism. So, let’s just drop all those
[37:01] tables and functions that we just created. Okay, let’s go on to the next part and talk about the problem for this section. This section is inspired by the book Make Analytics Fast by Tobias Petri. Before any features, we need a problem worth solving and data to solve it on. We’re going to use a web analytics scenario for all of this part because it’s something everyone understands and it has exactly the right shape. So, picture this um and this is
[37:32] the scenario that we uh use for some of the sample code earlier also. But let’s say you work on a service that fronts a lot of websites, a CDN, a hosting platform, an API gateway, it doesn’t matter. Every HTTP request that passes through gets logged. Your customers log into a dashboard and want to see their traffic like requests over time, response times, error rates, which which paths are slow, broken down by domain and by service. It works great for a
[38:02] year, then it doesn’t. The dashboard that took 200 milliseconds now takes 40 seconds and support tickets are piling up. Just imagine your colleague who set everything up has vanished into thin air. So you know somebody built this. It worked. They left and now it’s yours and you don’t know why any of the decisions were made. So let me show you the actual data. This is what I showed you earlier. Just the slashd requests and it’s going to show all this data. This is the table. It’s wide on purpose, one row per
[38:34] HTTP request with everything about that request as a column. Timestamp, the domain, the service, the server that handled it, the path, the HTTP status, the response duration broken into components, geo info, user agent details. So we can just see a few more things here. And now I’m going to show you the count. So count from requests and let’s get the total relation size. So we can see that there are
[39:06] uh over 5 million rows and it’s 16 kilobytes. So that’s what I’ve seated on this computer. But the repo in the description has a generator script with flags. So you can scale this to whatever your machine tolerates and test on different uh different sizes of the table. Now the dashboard query, this is the one we’re going to make fast. So we’re going to run this and we’re we’re this is the one we’re going to make
[39:36] fast. We’re going to come back to it after every single feature. So we’re going to just run this many times and see how it changes. Basically, we’re selecting we’re getting all these different pieces of information from our our table and we’re checking where URL domain equals example.com and the interval was in the last 7 days. And then we’re going to group by hour and then order by hour here. So, we can see all the results here and we can see this
[40:09] was 2 thou uh 2341 milliseconds. Now I’m going to run that three times. So I’m just going to run it right now again. And I want to explain before why I’m doing this because this is a good habit. Uh so you can see it’s already showing a different number and they’re possibly they’re going to possibly differ a lot. So the first run pays for the reading cold data off disk.
[40:41] the second and third, here’s the third one here, are going to find RD in memory. That’s not a trick. It’s the difference between a cold cache and a warm one. And if you benchmark without knowing which one you’re looking at, you will draw confident conclusions from noise. So this third one is the the number we’re actually going to use. This is going to be our baseline for this query. 2,211
[41:11] milliseconds. That’s how we’re if we run the query a few times, we can get a more accurate number. So this is our baseline, but the wall clock time is the soft number. So let’s also grab the hard one, the one that doesn’t move between runs. So here we’re running explain with analyze buffers and then we are getting the hour and the count of the requests and we’re grouping by hour. So we can see this is giving us
[41:44] a lot of information here. We can see um also the planning time the execution time down here. But what I want to look at is the this buffers this shared hit which is 230 235,761. So this is how many 8 kilobyte pages this query touched. We’re reading an enormous amount of data to produce about a 168 rows of output. And here’s why
[42:15] buffers beat milliseconds as a measure. That page count barely moves between runs. It doesn’t care whether your cache is warm, whether Docker is throttling or whether something else on your laptop laptop woke up. It’s a direct measure of how much work the database did. And every optimization in this course is fundamentally about doing less work. Milliseconds are what your users feel. Buffers are what you can actually reason about. We’ll track both. And when they dis disagree, believe the buffers. And
[42:46] there are three separate problems tangled together in that number, which is why there’s no single fix. We’re reading rows we don’t need. Every row for every other domain because the rows for hexample.com are scattered everywhere. Hyper tables and the column store fix that. We’re reading columns we don’t need. This query touches four columns out of dozens, but pagebased storage drags all of them along. The column store fixes that. And we’re recomputing an answer that cannot have
[43:18] changed. Last Tuesday’s hourly counts are fixed forever and we recomputee them on every dashboard load. Continuous aggregates fix that. And one more thing, this is a migration, not a rewrite. The important note on scope before we start because it’s a real question people have, you do not move your whole database to time scale DB. Your users table, your orders table, your products table, those stay exactly as they are normal Postgress tables. They’re small.
[43:49] They get updates up update updated constantly. They need real foreign keys and unique constraints. And they are not time series data. Only the fire hose tables become hypert. The requests, the events, the metrics, the traces, everything else is untouched. And this is the good part, they all live in the same database. So you can still join them which we’ll do in uh one of our projects. All right, first feature hypert. So let’s talk about hyper tables here. A
[44:20] hypert looks like one table and is physically many tables. Your query requests you insert into requests. As far as your application, your OM, your SQL are concerned, there’s only one table called requests underneath. Time scale DB is splitting it by time into pieces called chunks and routing every row to the right chunk automatically. So why chunks? Well, there are three payoffs and they map directly onto the three problems from part one. Queries
[44:52] skip irrelevant chunks entirely. You ask for the last seven days, the planner excludes every chunk outside that window before reading a single page. Not scans them efficiently. It doesn’t even open them. And indexes stay small. This is the subtle one and it’s the biggest deal for write throughput. Every chunk has its own index. So the index you’re actively writing into only covers the current chunk, which means it fits in memory, which means inserts don’t hit
[45:24] disk to maintain the index. Combat. Compare that to one giant index covering two years of data, which cannot fit in memory. So every insert becomes a random disk. Right. Also dropping old data is a file deletion. The retention problem from the previous part solved by construction. You drop a chunk not rows. So let’s see the whole uh conversion. I’ve got a copy of the table schema without the hyper table applied. So we can do this live. So this is the the
[45:58] copy here. And to convert we just do select create hypert from the request ht request hyper table and we’re going to uh separate it by uh the range by intervals of one day. So that’s it. That’s the feature that’s how to create hypert. So three things about that call. This by range here is the modern dimension builder syntax. If you read older
[46:29] tutorials, you’ll may see a different method of uh creating different dimensions, which still works, but it’s the legacy form. The column has to be not null because every row must route somewhere. And you’ll typically do this on an empty table, though there is a migrate migrate data flag that you can do, an option for converting a table that already has rows in it. It works, but it’s slow and it locks. So plan for maintenance time. Now let’s watch what
[47:00] happens as data arrives. So right now there’s nothing here. It’s an empty table. No chunks. Unlike native partitioning, we did not have to create anything in advance. So now we’re going to insert into our new hypert uh information from our original table where time is in basically the last 3 days. So we inserted a bunch of records. It took us 21 seconds. And now we are going to show the chunks here. And now
[47:32] we can see that there are four different chunks. They appeared on demand. One per day created as as rows arrived. That’s the automation you’re paying for. No crown jobs, no PG part man, no pager alerts at midnight on the first of the month. So let me show you their real tables. We’re actually going to just do a query from them. And so this is a query right from these chunk tables. And we can see we’re actually able to query. They’re real tables with names and they
[48:03] have a schema. You can query one directly if you ever need to, but you normally never need to. And let me show you the pruning. I’m going to do an explain from request ht for the last day. And you can see here that we are only accessing just one of the chunks. So the others aren’t in the plan at all. And now I’m going to show the failure case. So I’m going to explain select count from request ht
[48:34] where your domain equals example.com. And you can see here that we’re actually accessing accessing from every chunk. So no time filter. So no pruning is possible. it has to look everywhere. So this brings us to the next point. Always filter by time. This is the number one rule of using time scale DB and it’s the number one reason people say I installed it and it wasn’t faster. If your query doesn’t filter by the time column, you
[49:04] get none of the benefit. Chunk exclusion is the found is the foundation everything else sits on. when you’re writing queries, when you’re writing OM code, when you’re designing an API endpoint, the time range is not optional. It’s the price of admission. So, write it down or I mean or remember this rule basically because we’re going to be coming back to this. So, how big should a chunk be? This is the one real decision you have to make and both
[49:35] directions are wrong in different ways. So basically they can be the chunks can be too small and then you get thousands of them. Every query’s planning time goes up because the planner has to consider and exclude each one. Metadata overhead grows and you burn a connections worth of memory on planning for a query that returns four rows or your chunks can be too large and then you lose the two things chunks are for. pruning get pruning gets coarse asking for one hour reads a month and the active chunks index stops fitting in
[50:06] memory which kills your insert throughput. So there’s two guidelines you should be thinking about. One, keep it under about a thousand chunks per hyper table. That’s a soft ceiling for planning overhead. uh if you’re projecting past it, widen the interval or add a retention policy so old chunks get dropped and the count stays bounded. Also, keep the active chunks indexes to under about 25% of
[50:37] your RAM. This is the important one. You’re only ever inserting inserting into the most recent chunk or two, and those indexes need to live in memory. The rest can be on desk because you touch them rarely. So let me do the arithmetic because it depends is useless without a worked example. So we’re going to run this command here and we’re going to get the rows uh the total rows for the last three days and also the bytes
[51:07] per row which is 406 here. So we’re we’re so suppose we’re taking a 100 million requests a day which is a reasonable midsize service. That’s about,00 a second. 100 or two 400 bytes a row is 40 gigabytes of table per day. Say indexes add 30% and now we’re at 52 gigabytes per day total. If I have 32 gigabytes of
[51:39] RAM, 25% of that is 8 gigabytes for the active chunks indexes. My daily index volume is three gigabytes. So a 1-day chunk uses three of my 8 g gigabytes budget. It’s comfortable, but a oneweek chunk would be 21 g gigabytes of index. Uh way over. So daily chunks. Then check the count daily chunk chunks and I keep 90 days 90 chunks well under a thousand. If I kept five years with no retention, that’d be 800 chunks and I’d have to reconsider either weekly chunks or a
[52:10] retention policy. And the retention policy is the the better answer. So, if you don’t want to do arithmetic, here’s the cheat sheet. High volume, tens of millions of rows a day or more, one day. Moderate, millions a day, one week. Low volume, thousands a day, one month. And when genuinely unsure start with a day, it’s the most common answer in practice. And it’s what both of our projects that we’re going to do in this course use. So, you can change it with a command like this, set chunk inter time interval, and you can set it to uh seven
[52:42] days here. So an important caveat that only affects new chunks. Existing chunks keep the interval they were created with forever. There’s no rewrite. So you can end up with a hyper table that has daily chunks for its first year and weekly after that. And that’s fine. It works. But it means you should think about this a little upfront rather than planning to fix it later. And you can partition by more than time. This is another capability and I’m
[53:15] mainly mentioning it so you know it exists and when not to use it. You can add a second partitioning dimension, usually a tenant or device identifier. So each chunk is split further by hash, but I would say that you probably don’t need this. It was much more important in the old multi-node deployments where it distributed data across servers on a single node with its main use is paralyzing IO. across multiple physical discs or splitting an enormous pre-chunk
[53:46] row count. And it has a real cost. It multiplies your chunk count by the number of hash partitions. Four hash partitions turns 90 chunks into 360. Now you’re closer to that thousand chunk ceiling for a benefit you may not be getting. The column stores segment by setting which is coming up later in the course gets you most of what people want uh from space partitioning which is data grouped by tenant. So query skip other
[54:16] tenants without the chunk multiplication. So note it exists and reach for segment by first. Okay, let’s just drop that copy because from here on out we’ll work on the real request table which I will convert now. So we’re going to create a hypert from the requests table and actually I already did it. It’s so it’s already a hyper table but it can take a while the first time you do it. And let’s show the chunks. We have four chunks. And before we measure
[54:49] anything, let’s talk about one thing that people sometimes forget, which is to um analyze. So I’m going to run analyze requests. Uh converting a table to a hyper table moves the data into new physical chunks and those new chunks have no planner statist statistics yet. Postgress is planning against guesses until you run analyze. So do this after every conversion. If you skip it, you
[55:19] get bad plans and you can blame the wrong thing. So now let’s rerun the baseline dashboard query. So here’s the query we ran before, but now we have a hyper table and we’re going to see how long it takes. And we’re going to run this a few times because the the first will likely be dramatically slower. The migrate data just rewrote every row into new chunks. So nothing is in cache. So if we run it again, we can basically
[55:53] see a settled number. Sometimes it’s good to run it three times. So we can see that this number is um it’s not a clear win. It’s similar, but maybe just somewhat slower. the so the the the first time we ran it um it’s was the old cache because we just rewrote every row on disk into the new chunks and so here is ba basically just a bit worse like I
[56:24] said and that’s the honest result and it’s the most useful thing that will happen in the section. So look at what we asked for which was that every row in the last seven days. If I kind of show this, we’re trying to get everything in the last seven days and how much and we have seven days of data. So we ask for the entire table. Chunk exclusion can only skip chunks that fall outside your time filter. Our filter covers all of them. So we skip nothing. We read
[56:57] exactly as much as before and we’ve added a little work because the planner now plans across eight chunks and appends eight scans instead of running one clean sequential scan. So let’s also check the the buffers. So remember we checked buffers before and we can see the buffers is essentially unchanged now. It’s almost the same number. Uh basically it’s the same pages. We not save the data database one single unit
[57:29] of work and the buffer count says so regardless of what the clock said. So partitioning is not specifically a speed feature. It’s not really a performance feature. Chunks make it possible to skip data. They don’t skip anything on their own. If your query asks for everything, you get everything plus a little overhead. This is the single most common disappointment with time scale DB. Somebody converts a table, reruns their dashboard query, sees no improvement, and concludes the extension doesn’t work. But the extension works fine. The
[58:01] query asks for all the data. So now, so let’s ask a question a dashboard would actually ask. Here we’re going to look for the data in the last six hours instead of the last seven days. Now we see that there is only one chunk in the plan. And if we point to the buffer count, it’s very small. It’s just three now. So it’s just a small fraction of the baseline. So there it is. One chunk in
[58:34] the plan instead of eight and a fraction of the pages read. Same table, same data, a time filter that lets the planner throw away seven, like tons of the table before it reads a single row. And notice I’m pointing at pages, not milliseconds. On a data set this small, the whole thing may fit in memory and the clock times will be could be close together. The buffer count shows the real difference. And on a production table where the data doesn’t fit in RAM, that difference in pages is the difference in seconds. So that’s the
[59:05] shape of every win in this course. It’s not it’s magically faster, but we gave the database a way to not do the work. So next up, making sure we find rows efficiently inside a chunk. So let’s talk about indexes and chunk skipping. Chunk exclusion got us from the whole table down to the right chunks. Now we define rows efficiently within a chunk. And indexing on a hyper table has one structural difference from indexing a normal table. When you create an index
[59:38] on a hypert, you don’t get one index. you get one index per chunk automatically including on chunks created in the future. So here I’m going to create an index on requests um URL domain and time descending. So this is going to build per chunk and now it’s done here. So now we’re going to basically count the indexes. And now we have eight here, one per chunk. And this is why hyper tables
[1:00:10] can sustain write rates that a normal table can’t. Think about the insert path. On a normal table with two years of data, that index is enormous, bigger than RAM. Every insert has to find its place in that giant B tree. and the pages it needs are probably not in memory. So, it’s a random disk read followed by a random disk write. Random IO is the slowest thing in a that a database does on a hyper table. Inserts
[1:00:42] only go to the current chunk. That chunk’s index is one day of data. It’s small. It’s hot. It’s in memory. Insert is a memory operation plus a sequential write to the w. That’s the difference between thousands of inserts a second and hundreds of thousands. So on the index def definition itself, I put URL domain first and time second. And that order is deliberate. Uh the
[1:01:13] rule of thumb is equality columns first then the time column. You filter URL domain equals example.com. That’s equality. It narrows to a specific point in the B tree. Then time is more than or equal to something. It’s a range scan from that point. If you flip the order, you scan a huge time range and filter domains out afterwards, which is much more work. And the time descending is because time series queries almost
[1:01:43] always want recent data first. It makes order by time uh descending limit 100 the show me the latest query. It makes it nearly free. Now here’s the mistake that will get you and it follows directly from that always filter by time rule that I talked about earlier. So I’m getting an ID for this next example here. So let’s say I want to one specific request by its ID. So I’m selecting request where the request
[1:02:14] ID equals um the ID that I just got. And look at that. It checked every chunk. We can see here this chunk, this chunk, this chunk. Um each one has an index on request ID. So each lookup is fast, but did that look once per chunk because nothing told it which chunk the row is in. with if we had 90 chunks that’s 90 index probes to fetch one row. It’ll still return in milliseconds on a small data set. So it
[1:02:46] looks fine in development but with two years of daily chunks it’s 730 probes and it does not look fine anymore. So here’s the fix. First I’m going to get the request ID and also a time for that request. And then I will call where the request ID and put in the request ID and also put in the time here. And now we can see only one chunk one probe. So the design rule whenever you look up a row by ID
[1:03:17] pass its timestamp also which sounds annoying but think about where that ID came from. It came from a list view, a search result, a web hook payload, a log line, and all those already had the timestamp right next to the ID. You just have to carry it through your API and your URLs. If you genuinely can’t say the ID comes from a third party who only gives you an ID, there’s an escape an escape hatch. Now the actual limitation on hyper
[1:03:48] tables the thing you should know before you commit to this everything else in this course is upside but there is one genuine constraint basically there’s no global unique index so if I try to create unique index on requests we got an error so reject it the reason is structural and not really an oversight a unique index has to be able to answer does this value exist anywhere in the But there’s no single index spanning the hyper table. There are 90 separate perch
[1:04:21] chunk indexes. To enforce global uniqueness on insert, Postgress would have to check all 90 on every single insert. That would destroy exactly the right performance that hyper tables exist to provide. So the rule any unique index on primary key or a hyper table must include the partitioning column. So we’re going to do alter table request add primary key time and request ID. And that works because now uniqueness is only enforced within a chunk. The time
[1:04:52] value determines the chunk. So a single chunks index is sufficient to check. And here’s what you’re basically giving up. Two rows with the same request ID at different timestamps are now legal. Usually that’s fine, but sometimes it isn’t. If the ID is a payment reference, you want hard global uniqueness. There’s a genuinely elegant solution to that and it’s my favorite trick. The idea is to use an ID that contains its own timestamp. Then
[1:05:25] the ID alone is enough to find the chunk and you get global uniqueness for free. That’s UU ID V7. same 128 bit UUID format everyone already uses, but the first 48 bits are a Unix millisecond timestamp followed by random bits. So, UUID V7s sort chronologically, and you can extract the creation time from the ID itself. So, I just ran this select UU ID V7 a few times and look at the leading
[1:05:58] characters. They’re all going to start with the same characters. You see, actually even goes up through 722. So the leading characters are nearly identical. That’s the timestamp and generated a sec. They it’s they were generated a second apart and then they sort in order. So then we can here we’re generating that again but then we’re calling this UUD timestamp
[1:06:28] and we can pull the timestamp back out. That function is provided by time scale DB. Postgress 18 also ships a native UUIDV7. So depending on your version you may have two available and either is fine. Just be consistent. Okay, let’s talk about this uh check the check constraint. So, this is going to tie everything together. And so, I’m creating the t this table events v7 with
[1:07:00] an ID time JSON. And then I’m going to create a hyper table. But, uh see what we’re doing is I’m running check. It’s doing something clever. So basically it guarantees that the time stamp embedded in the ID equals the partitioning column which means given only an ID you can compute the time which means you can always find the chunk which means the ID alone is now a
[1:07:32] global unique key in practice. A duplicate ID would have to have the same embedded timestamp. So it it would land in the same chunk where the perch chunk unique index catches it. You get global uniqueness and single chunk lookups from one constraint. So let me just uh insert into this events v7 table. Um we’re passing in so we’re having the time stamp and then
[1:08:03] hello world. That’s the data. So that generates two different UYU ids. So let me do it uh properly with new id as and then select uidv as id insert into events the id time data select the id and then uh from new id. So if we run that uh in the application code this is cleaner. You generate the UID in your
[1:08:34] app, derive the timestamp from it, and pass both. That’s exactly what one of our projects later is going to do, and you’ll see the Python for it. So, I’m going to run this to get the recent ID that we put into the table. So, let’s get an ID from that table, and then we can look it up where we’re going to pass in the ID here. And the timestamp is going to be the exact same ID here. So
[1:09:04] we’re basically putting in the same UYU ID twice. And then we can see this query plan here. One chunk from an ID alone. But one thing to keep in mind, and it’s a real one that will cost you an afternoon if you if you just don’t understand this. um UUID V7 timestamps are millisecond precision. Timestamp TZ is microssecond precision. So if you generate a time stamp any other way now
[1:09:36] for instance and compared to the one embedded in UU ID v7 that will not be equal and your check constraint will be reject the row. So always derive the time stamp from the UIU ID using UUID timestamp never generate them independently and hope. So, one thing you should know is that indexes are not free and on a high ingest table, they’re expensive in a way they aren’t on a normal table. Every index has to be updated on every insert on a table
[1:10:07] taking a 100,000 rows a second. One extra index is a 100,000 extra B tree insertions a second. I It’s possibly could add six indexes just in case and wonder why and just fell off a cliff. So find the ones you’re not using. So with this code, we’re selecting the indexes and we’re index scan equals zero. That means that the index has never been used since the stats were last reset. On a
[1:10:38] system that’s been up a while, the those are candidates for deletion. Check that it’s not a unique constraint doing integrity work first. And one thing you should know which I want to uh make sure you understand is that once we get to the column store, you’ll need dramatically fewer indexes than you think. Uh because columnar batches have their own built-in skipping. Some of the indexes you’d add to a normal analytics
[1:11:10] table become pure overhead. So let’s measure with the index in place and let’s measure the narrow query uh the six-hour one because that’s the one an index can actually help. So the shared hit is three. Now we can see the the 7-day interval, the 7-day baseline, and we can see the shared hit 2,198 and the read 1,100 or 11,655.
[1:11:42] So, two different stories in those two numbers. And the difference is the whole point. The narrow query got faster. The seven day query didn’t. And an index was never going to fix it. When you’re reading most of the table, the index scan is more work than just scanning. The planner knows that, which is why it likely ignored our new index entirely. That query needs a different kind of fix, and it’s coming up in a little bit here. So, something interesting has happened. We have chunk exclusion. We
[1:12:12] have a good index. And the query is faster, but it’s still not fast. And the reason it’s no the reason is no longer that we can’t find the rows. So the bottleneck is no longer identifying the rows for the analysis but loading them. Any big analytics calculation struggles with the same issue. We know exactly where the rows are. Loading them is the problem. So here’s why and this is where uh the previous
[1:12:44] parts 8 kilobyte page lesson pays off. Rows are stored in insertion order. requests arrive interle from every domain. One for example.com, three for other.com, one for example.com again. So within any page, the rows for a single domain are a small scattered fraction. Say a page holds 80 rows and example.com is 1% of your traffic. Then each page contains roughly one row you want. To
[1:13:14] read a million rows for that domain, you read a million pages, 8 gigabytes. to extract about a 100 megabytes of data you actually asked for. And it’s worse than the volume suggests because those page reads are random. Sequential reads are 10 to 100 times faster than random ones. Even on SSDs, you’re doing the slowest kind of IO for data you mostly throw away. The fix is data locality.
[1:13:45] Physically rearrange the rows so a domain’s data sits together. then reading a million rows for one domain is a few thousand sequential page reads instead of a million random ones. So let’s see it in action. Create index request ordering index on request with the domain URL domain and time. That index defines the desired physical order grouped by domain and within each domain ordered by time. Then we rewrite the chunks in that
[1:14:16] order. So this command here rewrites every chunk which will definitely take some time while it runs one nice property. It rewrites each chunk into a fresh copy and swaps it one chunk at a time. So it doesn’t need a giant lock on the whole hyper table and it fully rebuilds the indexes as a side effect which removes bloat. It’s the kind of it’s kind of like the useful cousin of vacuum full.
[1:14:48] Okay, so that finished. It took 38 seconds. Now we’ll run the six-hour query again unchanged. This is the third time we’ve want run it. That’s the points or control. And the shared read is three again. I mean there’s not that many to read here. the so reordering is going to pay off on the narrow index driven query, not the 7-day scan. And it shows up in pages read far more clearly than on the clock. But let’s do the
[1:15:19] 7-day scan here. And we can see the hit uh is and the read are up here. 163,000 73,000. So same rows, same index. Uh but what we didn’t make the search smarter, we just made the data denser. Okay, let’s talk about the column store. This is the big one. If you take one feature away from this course, take this one. So let me start with a question.
[1:15:51] Our request table has what maybe 15 columns. Well, real production analytics tables often have 50, 100, more. Now look at this query. Select average duration total from request ht where time now minus interval 3 days. So so look at this query time or select average duration total from request where time is between the last three days. One column that query needs
[1:16:21] exactly one column the duration total. How many columns did postresql actually read off disk? Well, all of them because of how how row storage works. So, but remember from the previous part, the smallest unit Postgress can read is an 8 kilobyte page and a page holds complete rows. Every column of every row on the page comes into memory whether you asked for it or not. You wanted one number per
[1:16:52] row and you paid for a 100. That’s the first problem. Here’s the second. Postgress processes those values one at a time. Fetch a row, extract the column, add it to a running total, next row. 10 million rows, 10 million trips through that loop. Modern CPUs can do arithmetic on eight or 16 numbers in a single instruction, but only if the numbers are sitting next to each other in memory. In row storage, they’re 100 bytes apart. So
[1:17:24] you read data you don’t need and you process the data you do need in the slowest possible way for finding one row which is what row storage is designed for. This is exactly right for aggregating 10 million rows. It’s wrong. Every dedicated analytics database on Earth solves this the same way. Store the data by column instead of by row. Time scale DB’s column store takes a chunk and rewrites it. Instead of a thousand rows sitting next to each
[1:17:55] other, you get all thousand duration total values sitting next to each other in one compressed batch. Same for every other column. Now the average query reads one column’s worth of data. And because those numbers are contiguous, the CPU can chew through them 16 at a time. That’s called vectorized execution, and it’s why the speed up is so much bigger than the IO savings alone would suggest. But here’s the thing that
[1:18:28] people sometimes don’t expect. Column NAR storage doesn’t just make queries faster, it makes the data smaller, dramatically smaller. And that’s because a column of similar values compresses far better than a row of wildly different ones. So think about it. In our table, URL domain for a thousand consecutive rows might be the same handful of values. Time for a thousand consecutive rows is a thousand timestamps that are all within a few seconds of each other. Status code is
[1:19:00] basically all 200s with uh the occasional 500. That’s enormously compressible, but only if you put them next to each other first. So there are four compression techniques and they’re worth knowing because they explain what compresses well and what doesn’t. A repeated value is stored once. Run length encoding. A thousand rows of service equals shop. It becomes shop time 1,00. Frequent strings get shortened using an
[1:19:32] internal dictionary. Instead of storing the literal string checkout service a thousand times, store it once in a dictionary and store a small integer reference. Numbers are stored as deltas in the smallest data type that fits. Consecutive timestamps differing by a few hundred milliseconds don’t need eight bytes each. So the first one then two byte deltas. Booleans become a single bit not a bite but a bit. So by
[1:20:03] applying these optimizations, Times scale DB often reduces a table size by more than 90%. That’s not a typo and we’re going to verify on our own in a in uh later in this course. So in one example, a table went from 72 1/2 GB to 33. But look at the indexes 10.6 GB down to 400 megabytes. So we’ll come back to why the index number is so extreme. Now if columnar is
[1:20:35] so great, why isn’t everything columnar? Because columnar storage is bad at the thing rows are good at writing one row, changing one value, deleting one record. So time scale DB does the obvious smart thing. New chunks are row store, fast inserts, easy updates, normal postgrql. Once the chunk has settled down, once you’re not actively writing to it, it gets converted to column store fast reads tiny footprint. This is the part
[1:21:07] where a real analytics database would tell you that tell you the data is now read only. But time scale DB doesn’t. With Timescale DB’s column store, you can still insert new rows or modify and delete existing ones, which is not the case with other analytical databases. You still have a normal SQL table. Update works, delete works, insert works. It’s slower than on a row store chunk, and you shouldn’t design a workload around hammering updates into
[1:21:39] compressed data. But late arriving events, corrections, GDPR deletions, all that just works. And there’s a uh counterintuitive bonus. Inserting into a column store chunk is also the fastest way to insert many rows. With the direct to column store option, you can insert more than five millions uh more than five million rows each second. Postgress can’t do that. Five million rows a second. If if you’re doing bulk
[1:22:11] backfill, writing straight into column store format is faster than writing rows and converting later. So let’s do it. Three settings. So I run that and it says warning column request ID should be used for segmenting or ordering. Now Postgress is telling us hey request ID is a unique key and because it isn’t in segment by or order by looking up a compressed row by ID won’t get pruning benefits for analytics queries. aggregating by domain and service over time. That’s exactly the trade-off we
[1:22:42] want. So the enable column store here turns the feature on. The other two are the ones that matter and I want to spend some time on them because this is where people get bad results and conclude the feature doesn’t work. So the segment by is the one that matters. Segment by decides how rows are grouped into batches. rows sharing the same segment by values go into the same batch. And this is the point. A query filtering on that column can skip every
[1:23:14] batch that doesn’t match without decompressing anything. It doesn’t scan and reject. It just never touches them. So you choose segment by by asking what do I always filter on? For us, every dashboard query filters on URL domain and usually service. That’s a tenency column and tenency is the mo is the most common right answer in real systems. Customer ID, project ID, device ID,
[1:23:44] account ID. Now the constraint that trips everyone up. A batch holds up to a thousand values. You want batches full. A batch with three values in it wastes more of the benefit because compression works on volume and you get one batch’s overhead for almost no payoff. So the rule for any given segment by value you want at least a thousand rows in a chunk ideally many thousands which means high
[1:24:16] cardel cardality columns are poison here. Let me show you rather than assert it. So I’m going to say select count distinct UR URL domain as domains and then we’re going to count the request ids and then count the rows. So we got just uh nine domains millions of distinct request ids. And if I set segment by equals request ID, every batch holds exactly one row. I get
[1:24:48] all of the overhead and none of the compression. and we’re going to measure that soon. So order by controls the sort order within each batch. Two things come from it. First, it’s the data locality win from the reordering section for free as part of compression. That is why reordering becomes redundant. Second, and less obvious, time scale DB stores the minimum and maximum of each order by column per batch. So a query with where time between X and Y checks a batch’s
[1:25:20] min and max timestamps and skips the whole batch if there’s no overlap. That’s why you put the timestamp first in order by you always filter by time range. So you always want that skip available. We use time uh deesc uh descending URL path. Uh so time first because it’s always in the filter URL path second so that within a given second paths group together which helps our per path queries.
[1:25:52] So right now actually uh we haven’t uh gotten any there’s no compression we haven’t saved any space yet. So you shouldn’t just think oh there’s no improvement and give up. Activating the column store for a hyper table doesn’t automatically convert its chunks to column store chunks. This misconception often leads to underwhelming performance during initial performance testing. Enabling the column store configures it. It doesn’t convert
[1:26:23] anything. Existing chunks stay exactly as they are until you either convert them by hand or add a policy that does it for you. So here’s how you could convert it by hand. So, we’re going to check out what the chunks are. And then we’re going to do call convert to column store. And I’m just going to take one of these chunks and then put it in here. Okay, it’s converted. And it is reversible, which is important later. So, basically, I run the same command,
[1:26:54] but instead of convert to column store, I can say convert to row store. If you’re on an older time scale DB, you may see compressed chunk and decompressed chunk in the docs. Uh it’s the same operation but older names. The convert to um column store row star store procedures are the or the current API. Now we can also add a policy add column store policy on the request table and the policy is interval 1 hour or
[1:27:25] after that. Okay. So any chunk whose data is more than an hour old gets converted automatically by a background job. So why the delay? Why wait an hour at all given that column store chunks accept inserts? Well, two reasons. Inserts into a row store chunk are cheaper and real pipelines have lag. Cute events, retried web hooks, mobile clients that were offline. Giving the newest hour a grace period means the bulk of your rights land on the cheap
[1:27:57] path. It’s not a correctness requirement. It’s a throughput choice for our 3-day data set. An hour means almost everything converts immediately. Let me force the whole table so we can measure. So let me force the whole table so we can measure. So everything this was already a column store and now let’s see it. So I’m going to see the before compression total bytes the after
[1:28:28] compression total bytes and it went it we basically saved 88% for uh how much uh space uh this is. So that is pretty good. Now I’m going to turn on timing for this section. And now we’re going to run this that we’ve run before where we’re getting the average duration from the last three days and we’re at 60.8. So remember that benchmark number we
[1:28:58] have indexes going from 10.6 gigabytes to 400 megabytes. Here’s why it’s so extreme. Column store chunks don’t have your indexes at all. And your first reaction uh that you may be wondering about that. So let me explain why it’s fine. An index exists to avoid reading rows you don’t need. The column store achieves the same goal in a different way in two steps. The step one, your segment by filter eliminates whole batches instantly from metadata. A
[1:29:30] thousand rows gone per batch and no decompression. Step two, the order by minmax ranges eliminate more batches, so no overlap with your time range. You can skip it. So between these two, a typical dashboard query has already discarded most eving a single value. An index would be redundant and it would cost you the 90% size win to maintain. But what about a point lookup like one specific row by
[1:30:02] ID? For that, there’s a third mech mechanism. Time scale DB keeps small index-like structures per batch like minmax ranges for numbers and timestamps. Bloom filters for strings and uyuids. A bloom filter is a tiny probabilistic structure that answers is this value definitely not in here very cheaply. If the answer is definitely not skip the batch if it’s main if it’s maybe decompress and check.
[1:30:32] So uh bloom we can here we’re doing a bloom filter on geo country because we do equality lookups on it then min max on duration total because we filter it by range a request slower than 5 seconds. One big caveat and it’s the same shape as the misconception from earlier. These structures are built when a chunk is converted. Setting compressed index on an already converted chunk does nothing
[1:31:03] to it. If you need to add one retroactively, convert to row store then convert to column store on a big table that’s expensive. So think about this before you convert a year of data. And the limitations uh because I don’t want you deleting your index and then filling a bug. So they only help equality checks. So where status equals open is great. That’s what a bloom filter answers. They do not help in lists. Where mode in 245 gets no
[1:31:37] benefit. So equality yes range on a min max column yes. Multialue in no. So three things to remember from this section. One enabling the column store converts nothing. You need to add a policy. Two, segment by is your filter column, usually tenency, and it needs over a thousand rows per value. And three, set compress index before you convert and don’t expect it to help in lists. That’s the column store. 90% smaller,
[1:32:09] dramatically faster, and it makes the reordering section obsolete, which is exactly um what I was telling you earlier when we were going through that at the end of that section. So next um premputing results so the query doesn’t have to aggregate anything at all. So we’ve made the status smaller and the scans faster. There’s one move available and it’s uh this next one is the biggest of all.
[1:32:41] Every dashboard query we we run has one thing in common. It computes the same answer over and over. Somebody loads a dashboard. We aggregate yesterday’s data. They refresh. We aggregate yesterday’s data again. Yesterday’s data has not changed. We are doing identical work repeatedly forever. So you could go further. You you could further speed up queries by by accessing precomputed results instead of aggregating rows each time a query is executed. You already know why
[1:33:13] the obvious implementations fail because we did both in the previous part. A materialized view has to recomputee everything. So refreshing it gets slower every day. A summary table maintained by triggers hits low-level lock contention. So with each new query, the waiting time increases until the database becomes overwhelmed by a mass of blocked queries waiting to run. That’s the death spiral we watched in earlier. What we want is
[1:33:44] the concept of a materialized view but incrementally maintained. Recomputee only what actually changed. That’s a continuous aggregate. So here’s what I just run here ran here and let me walk you through it because most of this is ordinary SQL and exactly two things are special. So we have create materialized view that standard postgressql syntax and then the with time scale db continuous that’s special the thing
[1:34:16] that’s the special thing number one and it’s what turns a regular materialized view into an incrementally maintained one then a normal aggregate query we have uh time bucket um interval 15 minutes time now this is Another special thing sort of it’s a time scale DB function that rounds a time stamp down to a fixed interval. Every event between noon and 12:15 gets bucket 12:00. That’s what makes the
[1:34:48] grouping stable and reagregatable. Everything I grouped by a dimension I want to filter or slice by later. Everything I aggregate is a number I want on a chart. Uh notice that I chose count min max and not average. That’s deliberate and I’ll explain in a little bit. It’s and it’s a pretty interesting thing. So now the refresh policy select add continuous aggregate policy request
[1:35:20] 15 minutes. The schedule interval start offset null. End offset null. Every five minutes, a background job updates the aggregate. Start offset and end offset are null, meaning consider all of time. And we’ll change both of those later. And querying it is just querying a table. So we can query it just like we would do a normal table. And now we’ve counted all the requests here where URL domain equals example.com and service equals shop.
[1:35:53] Now we’ll make sure timing is on for this next one and we’ll select and then we have the time bucket with this interval. We’re summing requests from request 15 minutes and we’re again looking for example.com. So I’ll do this and we can now read the the time number read we can read the timing and compare it to the same query then compared to the same query against the hyper table. So I asked for hourly numbers from a
[1:36:24] 15minute aggregate by reagggregating four buckets at a time. That works because uh time bucket boundaries nest cleanly. You can always go coarser. You can never go finer. So here’s the mechanism and it’s why this feature works. Time scale DB tracks which buckets have been invalidated since the last refresh. You insert a row, that row’s bucket is marked changed. You update or delete a row. Same thing. When the refresh job runs,
[1:36:57] it looks at the list of changed buckets and recomputes only those. Even with years of data, the update process remains quick because every time only a few buckets may have changed since the last run. That’s the difference. A materialized view refreshed co uh cost grows with your total data. A continuous aggregate refresh cost grows with your change rate, which for time series data is basically constant because you’re only ever writing to the present. So I
[1:37:29] want to flag this one specifically. These are background jobs. If they start failing uh like bad permission, a schema change, disc pressure, they fail quietly. Your dashboard doesn’t air out. It just serves sta numbers and you find out when someone asks why yesterday looks empty. So you want to basically run this and um check to see if it’s empty. There is a problem with what we just built. That refresh job runs every 5 minutes. So the aggregate lags reality up to 5 minutes. For a daily report,
[1:38:00] it’s fine. But for an operations dashboard where someone is watching a deploy go out, not fine. The fix is to combine precomputed history with live data on the fly. So, let me uh rebuild this. First, we’ll just drop that table. We’ve already created the materialized view. And then I’ll and then we’ll create it again. And then we’ll create this again. and then add the policy.
[1:38:31] So there’s three changes uh in the way we did it this time. So let me tell you about the different changes in this way we created it. First of all, materialized only is equals false. This turns on the union. When you query the view, time scale DB reads premputed buckets for the older range and reads the raw hyper table for the recent range, then stitches them together. You write one query and get both then with no data. That’s how we ended our
[1:39:04] creative creation line. Without this cring the view computes the entire history immediately on a big table. That’s a long blocking operation at the worst possible moment. With that creation is instant and the policy fills things in progressively. Then in offset equals more is more than equal to internal 30 minutes. This defines the boundary. Anything older than 30 minutes is precomputed and anything newer is read live which means the numbers on your dashboard are current to the second
[1:39:35] and the expensive part is still premputed. So um how big should in office offset be? Well the rule rule of thumb is at least twice your bucket interval. We have 15minute buckets so 30 minutes. The reason is that you don’t want to premp compute a bucket that’s still filling. You’d store a partial result and then have to invalidate it immediately and increase it if your data arrives late. If events come through a queue that can break back up or from mobile clients
[1:40:05] that sync when they reconnect, a 30 minute window might close before the data lands. Then that data goes into a bucket that’s already been materialized, which still which does still work. it gets invalidated and recomputed, but you’re paying rework for something for something a wider offset would have avoided. Now, let me show you a technique that makes a scale to years of data. I want hourly numbers, too. The naive approach is a second continuous aggregate reading the hyper table. But I
[1:40:38] already have 15-minute buckets. Four of them make an hour. So, let me build the hourly aggregate from the 15minute aggregate. So I can run this this code. It’s very similar but you can see we’re getting from the request 15 minutes uh a bucket 1 hour from bucket 15 minutes where I’ve created a materialized view and then I’m going to add the aggregate policy. So look at the from clause the uh request 15 minutes it’s not request
[1:41:09] and look at the aggregates we have sum it sum of the count min of the minimums max of the maximums I’m already I’m combining already computed results. So basically there are two reasons this is better. Um the reason one is refresh pressure to update an hour. This reads four precomputed rows instead of scanning potentially millions of raw ones. Each level of the ladder reads a small level below it. Then the reason two is the subtle one is query speed. Both views are real time. When you query
[1:41:41] the hourly view for right now, its live portion doesn’t scan the hyper table. It reads the 15minute real-time aggregate which is itself mostly precomputed. Even the live part of the query is cheap. and the end offset is two hours, not 30 minutes. Same rule, twice the bucket interval and the bucket is now an hour. So let’s build the rest of the ladder. So now this is basically creating the materialized view for request daily. And
[1:42:12] notice I dropped the server ID and URL path at the daily level. Nobody asks which path was slowest on March 4th from my yearly chart. And dropping dimensions collapses the row count enormously. Coarser time, coarser dimensions. So that’s the pattern. So we can create that and add that policy. So you can see here kind of the the ladder between the basic request. Now mine’s called request not request ht 15 minutes hourly and daily. And they’re each having different uh dimensions here. So let’s try the
[1:42:45] payoff the 12 months of traffic from the top of the ladder. So doing time bucket interval one month from request daily. And we can see we don’t have um too much information in our table right now but uh you can see it’s giving this month here. Now we see the time 148 milliseconds. Now let’s do it against the raw data from requests. If we do that same thing now we’re seeing uh 1,646 milliseconds. So, same answer. Well, we
[1:43:17] see the sum is the same but wildly different costs and the gap widens every single day the system runs because one of them reads a fixed handful of rows and the other reads everything you’ve ever collected. So, um one more thing they’re just hyperts. So, while continuous aggregates and real-time aggregates may appear magical, they are just hyperts managed by time scale DB. As a consequence, you can also add indexes and activate the column store to improve the speed of finding aggregating row and aggregating rows. This unlocks a
[1:43:47] lot and everything that we’ve been talking about recently applies to your aggregates too. For instance, we can create an index. We can compress them and an aggregate ladder with the column store on every rung. Old summaries cost almost nothing to keep which is exactly what makes the retention strategy uh that we’ll talk about later possible. Um also some other things worth knowing. We can do a manual
[1:44:19] refresh just like this for back fills and you can change the settings later with ultra materialized view including flip flipping materialized only without rebuilding. So continuous aggregates are the single biggest performance tool in time scale DB. Premputee incrementally, add materialize only equals false for live numbers. Stack them into a ladder and treat each rung as the hyper table it actually is. Now remember I said I chose count min and max deliberately and
[1:44:50] avoided average. Well, we’re going to find out why. Hyperfunctions are time scale DB’s extra aggregate functions. There are dozens and I’m not going to read you a catalog. I’m going to teach you the three ideas that matter and then point at the rest. First one and it’s the most immediately practical thing in this section which is filling the gaps. So we got this code here where we are using this time bucket of interval 5
[1:45:21] minutes from request example.com geoc country JP Japan and looking in the last 30 hours here so look at the timestamps there are holes five minute buckets narrow filters some buckets had zero matching for instance. So a look at these buckets. Look at the timestamps 35 40 45 50. So we can see
[1:45:53] these timestamps and we can imagine that there could be some holes. So we have five minute buckets and narrow filter. There could potentially be zero matching requests for some of them. So group group I could produce no row at all. So, uh, that’s a real problem because your charting library will connect the dots before the gap, uh, straight to the dot after it. A five minute outage could render as a smooth line and it won’t
[1:46:24] really be accurate. So, here’s the fix I have. It shows time bucket gap fill. So, here’s the fix. We see time bucket gap fill right here. This needs an explicit start and end. It can’t invent a range out of nothing. It has to be told what window to fill. And I will submit this. And then there are going to be three columns because there are three honest answers to what
[1:46:55] was the value during the gap. And if there is going to be in this example, there’s always a count. But if there was a period of time where there is nothing actually here’s an example right here we have um we have null basically no data which is truthful and correct when the gap means we genuinely don’t know and this locf this is going to carry the last value
[1:47:29] forward the last obs observation carried forward correct for things that hold their value between readings like a thermostat setting a device state a configuration the sensor didn’t report because nothing changed. Now interpolate this draws a straight line between the neighbors which is correct for continuously varying measurements like a temperature a fuel gauge or a slowly climbing value. So choose based on what
[1:48:00] the missing data means not to which chart looks nicer for request count. A gap means zero request happening. So honestly the the coales count asterk zero is the right answer for us. So now I want to kind of explain something I mentioned earlier. I sort count min and max and pointedly I did not use average. And here’s why. Imagine I had stored um average of total duration as duration
[1:48:32] average. Now I want the hourly average. So I reagregate four buckets. What do I write? So why didn’t I store average? Well, calculating sum duration average doesn’t make sense and neither does average duration average because the approach doesn’t take into account that each average represents a different number of values. Think that through because it’s the kind of bug that would you could potentially ship because you don’t understand. Sum of averages is meaningless. Average of averages is
[1:49:04] plausible looking and wrong. It treats a bucket with three requests as equally important as a bucket with 30,000. So your dashboard shows a number and the numbers incorrect, but there’s no errors. So min, max, count, and sum all reagregate cleanly. average does not neither does variance, standard deviation, median or any percentile. The workaround is to store this in pieces. So basically here’s I’m going to create a materialized view with the I’m with a
[1:49:37] time bucket sum count group by the bucket 15 minutes and then I’m going to get the sum divi the sum of the sum and the sum of the duration count as the average duration. Now, in this case, it’s showing nothing here because of the the with no data here, but sum of sums over sum of counts is a properly weighted average. But it’s manual and
[1:50:07] it’s easy to get wrong and only works for average. There’s no pair of columns you can store that lets you reconstruct a median. So, let’s talk about what fixes all that at once. Aggregating values is a two-step process for any database which is hidden from you. First all values are processed by calculating an internal state. After that the rest is calculated from the state. Every aggregate works this way. Average doesn’t magically know the average.
[1:50:37] Internally it keeps a running sum and a running count and divides at the end. That intermediate thing is the state. Normally the database throws it away and hands you the final number. Hyperfunctions hand you the state itself. And a state unlike a result can be stored in a row and merged with other states later. So here’s the code that shows that we got the state the stats a that’s not going to return a number. It returns a
[1:51:08] state object holding everything needed to complete descriptive statistics. So I will do that and then we’ll call refresh continuous aggregates and then we can use this code here and so we have for average and variance we have the rollup of the duration. So rollup merges the states then average variance and uh standard dev are accessors.
[1:51:39] They pull different answers out of the merge state. One stored column, three statistics correctly weighted and basically any granularity that I want. So we can see all the the results here when we run that. So this is a gamecher for continuous aggregates as you can now store aggregate states and rows to re you re reuse them later. uh hierarchial aggregates become trivially correct.
[1:52:09] Every rung stores states, every rung rolls up the rung below. No manual sum and count bookkeeping, no wrong averages, and you get variance and standard deviation for free because they were already in the state you stored. So in an aggregate, store states, not results. That’s the rule. Now, some answers can’t be stored in a small state. They some air grids fundamentally need every single value. So imagine a
[1:52:40] function int with a column and number that returns the seventh largest value. There’s no small summary that can answer that. You’d have to keep everything. Percentiles have the same problem. A media needs to know the the middle value which means knowing the distribution. Count distinct is worse. To know how many unique sessions ids you’ve seen, you have to remember every session ID you’ve seen. So there’s a second family of hyper functions, approximate aggregates. They keep a
[1:53:12] small bounded state and give you an answer with a known error margin. Instead of remembering 10 million values, they remember a few kilobytes of cleverly structured summary. So the histogram is exact. There are four arguments um the the column the minimum the maximum and the number of buckets. So from 2 seconds to 10 seconds in from see 2 seconds to 10 seconds in
[1:53:45] four buckets meaning 2 to 4, 4 to 6, 6 to 8, 8 to 10 but you get you get six numbers back not four. So uh you can see the result right here. But let’s imagine the result had these numbers here in in blue here. Basically the array annotated with which element is which. You can see which element is which down here. The first element is everything below the minimum. 121 requests faster than 2 seconds. The
[1:54:17] last element is everything at or above the maximum. 43 requests slower than 10 seconds. The four in the middle are your requested buckets. So don’t skip the outer two. Those tails are usually the interesting part that and that 43 over here in this example is the problem. The next thing I want to tell you is the approximate percentile. So this is median, the value separating the faster half from the slower half.
[1:54:48] Same two-part pattern as before. the percentile a right here builds the state the approximate percentile reads an answer out of it and once you have the state any percentile is free so for example now I’m getting these percentiles the 50th percentile 95th percentile and the 99th percentile so um P95 the 95th percentile
[1:55:20] is the number that matters. This is the one you actively want for latency, and it’s worth uh a little time to explain why. An average hides your worst experiences. A few very slow requests barely move in. The P95 says 95% of requests were faster than this, which means 5% of your users had a worse day than that number. That’s the number to alert on. Averages make outages invisible. Under the hood, there are two algorithms. the UD sketch or UD sketch
[1:55:51] and t digest the percentile a picks a sensible default and you can choose explicitly explicitly if you have a reason to okay I’m going to show you another thing which is the count distinct approximate first I’ll make sure timing is turned on and then we’ll select count distinct session ID from this is to be requests and then we have 250,000 And then the time we can see 2,13
[1:56:23] 34 milliseconds. Now instead of doing select count distinct, we’re going to do select distinct count and then do approximate count account count distinct with the session ID. So you can see this is a very close number and it was a way quicker it was way quicker to run. So that’s hyper log log. It’s a slightly different number tiny state and very importantly it’s storeable in a
[1:56:55] continuous aggregate. Exact count distinct can’t be premputed because the states can’t be merged. The same visitor appearing in two buckets would get counted twice. Hyperlog log states merge correctly. So here I’m doing select distinct count hyperlo log 1024 with a session ID here the first argument trades accuracy against size. So more buckets more precision more bytes hyper log state merge exact ones don’t. So what else is
[1:57:28] in the toolkit? So there’s a bunch in the toolkit uh group by what you’re measuring. I just want you to review them really quick. You can also search for them later to learn more. But monotonic monotonic counters like counter then rate and delta on top for anything that only goes up by serve energy delivered to requests. It handles counter resets which is the whole reason you don’t do this be by hand. Um gauges like gauge
[1:57:59] egg for values that go up and down. Time weighted averages. Time weight. If your readings are irregularly spaced, a plain AVG average overweights the bursts. This weights by dur This weights by duration instead. State machines. State egg. Duration end. State timeline. How long was this thing in each state? Livveness. Heartbeat. A then uptime and downtime. Num gaps. Did this thing stop reporting? Finance like candlestick a VWAP. Uh down
[1:58:30] sampling for charts. LTB and ASAP smooth draw a month of data in 500 points without distorting the shape. And then there’s the heavy hitters and top K like freak a top in, min, max in. It’s worth considering whether all your statistics require exact accuracy or if a small margin of error is tolerable. As your data grows, the performance benefits of precomputing approximate results will become increasingly important.
[1:59:01] So I want to close the section on that because it’s a mindset set shift more than a technique. Engineers often want exact but nobody makes a different decision because unique visitors was 48,214 instead of 48,27. So if a half percent error buys you a,000fold speed up and let you premputee something you otherwise couldn’t premputee at all, take the trade. Just make it deliberately and know which of your numbers are estimates. Now, let’s talk about data retention.
[1:59:32] Everything so far has been about making data faster. This section is about not keeping all of it, and it’s more important than it sounds. So, first, let’s talk about what growing data actually costs you. Here’s what happens when a t table grows without limit, and it’s rarely the thing people expect. Disk is cheap, so that’s not the problem. Your backups get slow and enormous every night and you keep several restores also take forever which
[2:00:05] matters exactly once at the worst possible moment. Your recovery time objective your recovery time objective quietly becomes a lie and you hit disc ceiling um basically at the worst possible time. So, every stored backup will be large and together can be a significant factor in your monthly costs, which is an often overlooked aspect in cost calculations. That’s the part people miss. You’re not paying for one copy of the data. You’re
[2:00:35] paying for the live copy plus a replica plus however many nightly snapshots you retain. One gigabyte of table can be five or 10 gigabytes of spend. and old data becomes less important. And then the observation that makes it all tractable, which is the tr the truth is that old data becomes less important over time. Nobody queries individual HTTP requests from 14 months ago. They query summaries of 14 months ago, which means the raw rows are pure cost with
[2:01:06] essentially no value. And we already built the summaries. So you may consider adding a background job that can drop any chunk whose data is entirely older than 3 months. And it’s worth understanding why this is so much better than a nightly delete. A delete of a 100 million rows has to find each row, mark it dead, write that to the right head, write ahead log, and leave the space to be reclaimed later by vacuum. It’s IO heavy. It bloats the table. It fights
[2:01:37] with your live traffic. And afterwards, you often need a vacuum full that takes an exclusive lock. Dropping a chunk deletes a file. The chunk is a table. The whole table goes away. No row scanning, no dead tpples, no vacuum, no blow. It’s essentially instant regardless of how many rows were in it. So chunks are all or nothing. One consequence to internalize, a chunk can only be dropped when every row in it qualifies. With daily chunks and a three-month policy, you drop roughly a day’s worth each day. Tight. With
[2:02:10] monthly chunks, a chunk only goes when its newest row crosses the line. So, you can carry up to an extra month of data. So, chunk interval and retention interval interact. If precise retention matters for like a compliance deadline, your chunks need to be small relative to the retention window. So, here’s a useful pattern in time scale DB. We’ll put the retention together with the aggregate ladder because this combination is the single most useful architectural pattern in time scale DB.
[2:02:42] Every rung of the ladder gets its own retention policy. Fine detail expires quickly. Course summaries live a long time and they’re small enough that forever is genuinely affordable. So basically you can add your retention policy every 15 minutes, hourly and daily. Uh if for daily you may just want to keep things forever. So what you can still let’s talk about what you can still answer after 5 years.
[2:03:14] Uh uh monthly traffic trends u yes from daily. Which service degraded in Q3 of three years ago? Yep. From daily. What happened minuteby minute during the last Tuesday’s incident? Yeah, from RAW. What happened minuteby minute during an incident four years ago? No, but nobody’s asking about that. You get the answers people actually need at a tiny fraction of the storage. But uh one warning is the order matters. Uh and the or this will bite you if you get backwards. Your aggregates refresh
[2:03:45] window must not extend past your raw retention window. If a continuous aggregate is configured to refresh material older than three months and the raw chunks for that period have been dropped, the refresh recomputes those buckets from nothing and writes zeros over the history. So that’s what the start offset is for. You set it narrower than your retention interval. It’s also why our policies use null earlier. uh it’s fine on a just a few day demo data
[2:04:16] set but it can be dangerous in production alongside retention retention. So, the last feature in this section is tiered storage. And it answers the question you should be asking after that retention section. Uh, what if I’m not allowed to delete? Because that can happen. Financial records with a 7-year requirement, medical data, anything where an auditor might ask a question about 2019. You can’t drop those chunks. But keeping them on fast local storage uh is like if when that you’re replicating that you’re
[2:04:46] backing up nightly is not it doesn’t make sense for data nobody reads. So there’s a third tier we’ve already seen two row store for recent column store for older tiered storage adds object storage s3 class dramatically cheaper for the truly like cold stuff that you’re not going to be using. So the most fascinating aspect of tiered storage is that you can still query all your data. The chunks are still part of your hypert. So you don’t have to do anything different. And that’s the whole pitch. The chunk moves out of your
[2:05:18] database’s local storage and into a bucket. And it is still part of the hypert. No archive table, no separate query path, no restore process. The same select reads it. It’s slower. It’s going to going over the network to object storage, but it’s transparent and correctness is unaffected. So, uh, what I just talked about is only a feature of Tigercloud. It isn’t in the self-hosted extension you install from Docker because it requires the object
[2:05:49] storage infrastructure and the metadata plumbing to be managed for you. So I’m going to describe the API now and then later we’ll run it for real on a tiger cloud service on real data and see a chunk move. So here is the taring API. So we can first we’re we can add tiering then tier chunks older than a threshold. Here it’s older than a year or we can move a specific chunk immediately or we
[2:06:20] can inspect what has been tiered. Select everything from time scale DB OSM tiered chunks. So same mental model as the column sort policy a threshold and a background job. So uh what should you use retention column stored or taring? Uh it’s basically simple once you frame it as a who needs this data and how fast. uh if it’s queried regularly and needs to be fast local storage column
[2:06:51] store if it’s older than a day or so. Um, if it’s rarely queried but must be retained, tar it. Uh, cheap, slow, still queryable, still part of the table. And if nobody will ever need it and no rule requires it, drop it. your retention policy. And and these compose our the our production shape would be raw data local for 3 months tiered from 3 months to 7 years dropped after seven average
[2:07:21] local the whole time because they’re small. So the full life cycle. So that’s the complete picture and I wanted to leave it on screen for a second because it’s the thing that makes time scale DB different from bolting an analytics database onto Postgress. One table, one query interface data automatically walking down a cost gradient as it ages and finally expiring all that declared in about six lines of policy.
[2:07:52] Okay, let’s talk about all the features. Uh just a summary of the features basically. So this will um if you remember the the shape just like the basics you can always look up the syntax. Hypert hyper tables partition by time automatically and chunk interval is your one real sizing decision. Indexes work as they always did with a uyuid v7 trick to keep them timeordered. Chunk
[2:08:22] reordering existed to give you data locality and the column store made it obsolete. The column store is the big one. 90% smaller vectorzed and segment by is the setting that decides whether it works. Continuous aggregates premputee incrementally, stack into ladders and stay live with materialize only equals false. Hyper functions give you gap filling storeable aggregate states and approximations you can premputee. Retention drops chunks
[2:08:55] instead of deleting rows. Tiered storage puts cold data in object storage without leaving the table. You now know more about timel than most people running it in production. What you don’t have yet is the muscle memory. A schema you designed, a pipeline you wrote, queries you you tuned. So the next part of this course is focused on building. Okay, time to see how to set up our local environment. Now a lot of courses put setup at the beginning, but putting it here provides the necessary context. So
[2:09:27] you already understand the purpose of settings like max worker pro processes for background jobs. So now we’re going to turn that basic container into a proper environment with a real project layout, tuned configuration, seated data, and essential to tooling. So everything from here on out uses what we build in this section. So in the other section, I just ran a bare docker run to get moving, but let’s set it up the way you’d actually keep it for a project. So uh first we need to be in a terminal and
[2:09:57] create a dedicated directory for this course. Then get inside it. I already got in there. So now we are going to pull the high availability image. So I’m going to use docker pole timecale timescale db and then h a uh uh colon pg18. Basically this image right here is going to include time scale DB the toolkit hyperfunctions and PG vector and then you can tag it with the Postgress version you want and it could take some
[2:10:30] time to finish installing okay instead of memorizing terminal flags let’s create a configuration in a docker composey file so I’ll just do touch docker composey yiml to create the file. Now I’ll go to I’ll just uh edit the docker compose file and I’ve already added this to it. Uh four things are worth calling out. These are all the the
[2:11:02] setup stuff. So the t-stune memory tsune numbum CPUs um the image runs time scale db tune at startup which sets shared buffer workmen effective cache size and the parallel worker settings based on what you tell it. So we’re going to give it real numbers what we actually allocated to docker and you you get a sanely configured postgress for free. leave it out and you get defaults meant for a just a really small workstation
[2:11:32] basically. And then we have a named volume um time scale db- a and then um oh actually the name volume is down here. So uh without this the data lives in the container’s writable layer and then vanishes the first time you recreate it. So we have the path which is where the um the dash ha image keeps this data directory. So that’s the image right up here. Um that’s different from
[2:12:05] the plain postgress image and mounting the wrong path silently gives you a non-persistent database. We also have the SHM size 1 GBTE. The Docker’s default shared memory is 64 megabytes. Parallel queries use shared memory for worker coordination and a big parallel aggregate which is most of what we do will fail with could not resize shared memory segment. So we need to raise it. We also have restart unless stopped. So
[2:12:37] it comes back after reboot and you stop wondering why your laptop lost its database. So I can save that and then we’ll exit. And then we just do docker compose up-d it. And this is already starting actually or it’s already started. And then we can actually um check the logs. Docker compose logs.
[2:13:07] Um and then once we see that uh we can see that the database system is ready to accept a connection. So I’ll do Ctrl + C. And now we can set the settings that matter for time scale. So here I’m going to run docker compose exec which is going to execute a command directly inside the running DB container. We’re telling it to open psql as the postgress use. So we have the
[2:13:40] docker compose exec which is going to execute a command directly inside the running db container. We’re telling it to open the the psql as the postgress user inside the course db database. So now we are inside the um postgrql we can start using uh typing commands. So now that we have the SQL prompt and we can enable time scale DB and the toolkit. So we’re going to do a create extensions if not exist timelb and then also times db
[2:14:11] toolkit and then we can do slashdx to see what has been enabled here or what we have here. Now we can review the time scale db specific configurations. So I’m going to check four critical settings that govern govern extension performance. We have the max background workers that we want at least one per policy so jobs don’t quue and run late.
[2:14:42] Max worker processes we want to the this is the global ceiling covering background parallel and replication workers. Max parallel workers determines how many cores a single big query can use. And the shared buffers should be roughly 25% of RAM to keep active chunk indexes cached. So, uh, depending on what your max background workers is, we already have ours to the correct one, 24, but you can change it with alter system
[2:15:13] set and then max background workers set to whatever thing you want, which we do want 24. Now, we can um quit this with / Q um because we altered system level workers, the database requires a restart. And now we’re going to restart the container with Docker Compose restart DB. So now we want to configure our psqlrc file. So we’re just going to use cat and
[2:15:44] go right to our file that’s going to be directly on the local home directory. And then psql will read this every time it launches. So we’re going to make sure timing is on here. So every query reports duration. So you can track performance /x auto. Uh wide rows automatically flip to a vertical key value layout instead of wrapping into unreadable mush on small screens. Uh we’re going to set the pager to less- sfxr. This stops line wrapping so you scroll
[2:16:16] sideways and exit immediately if output fits on one screen. Then we also have this um pet set uh line cell unic code and border 2. This adds proper box drawing borders for a cleaner look. Okay, here’s a project we’re going to build. So, here’s the problem for the project. So, let’s say you’ve built a an AI agent. It calls a model. The model asks for a tool. You run the tool, feed the result back, repeat until it’s done,
[2:16:48] and it works great in development. Then it’s in production and someone says something like our OpenAI build tripled or it keeps failing this one customer and then you just have logs. You have tons of logs, text logs, thousands of lines per run. No way to ask a question across all of them. So what we’re going to do is build a flight recorder for agents. Basically, it’s like the thing that aircrafts have. Every step an agent takes emits a span, a timestamped record
[2:17:18] of one operation with its duration, its outcome, its token count, its cost, millions of them. On top of that, live dashboards, P95 latency per tool, cost attribution per customer, erite error rate alerting, and the ability to replay an individual run to see exactly what happened. So, this is going to be a pretty good time scale DB project. Um the reason why I picked this is because uh agent telemetry has every property that makes time scale DB the right answer. It’s high volume one agent run
[2:17:50] is dozens of spans. It’s appended mostly. Uh spans are facts and you don’t update them. Every question is time ranged like last hour, this week versus last week. It’s multi-tenant which gives us natural segment by um also old detail stops mattering but the summary is never due which is the retention ladder exactly and every single query is an aggregate over window we’ll use hyper tables uu ID a v7 primary keys column store with two segmenting a three-level
[2:18:20] real-time aggregate ladder storing stages like stat approx count distinct state a we’ll also use gap filling retention ladder and So basically everything about times skill DB in one system and you also don’t need an API key. So here’s just a practical note. There’s a real instrumented agent in the repo and I’ll show you its code but there’s also a simulator that generates 72 hours of realistic traffic with no API key and no cost. So um basically you
[2:18:54] can follow along completely free. So let’s talk about what the dashboard like what data we need what what questions does this need to answer for a dashboard. So basically because that uh the like what the dashboard needs to say determines everything downstream. So let me list them because these come back as literal SQL later. Cost per agent per hour. P95 and P99 latency per tool. Air rate over time. Which tools fail most? Unique sessions per day. the
[2:19:25] distribution of runs of run durations, how long a run spends thinking versus calling tools and uh replay one specific run. So the question two is what is a row? This is just about how we’re going to build our database. So um what’s one row? The finest useful grain is one span, one LM call, one tool call, one retrieval. We could go finer like one row per token, but that would basically be useless. No one asked token level questions and it would multiply val
[2:19:55] volume by a thousand it. So corser one row per run that loses the ability to attribute latency to or to a specific tool which is uh question two on the list. So one row per span which is basically the finest grain that anyone actually queries. Next question we want to ask about our data is are we going to use one table or several? Well, here’s where I’m going to deviate from what uh basically I want to show you the reasoning rather than the conclusion. An
[2:20:28] LM call has tokens and a model name. A tool call is a tool name and arguments. A retrieval as a query and a query count, different shapes, but um that would sometimes say separate tables. But if we look closer, they share 80% of their columns. Time, run, project, agent name, status, duration. The differences are two or three fields each and importantly u all the important queries like air rate latency and the runwaterfall they need to look at all
[2:20:58] span kinds together which would mean a three-way union all on every query excuse me so we’re going to use one table with a discriminator so why it’s okay is because one table with a like a span kind column and this the kind specific speific extras in JSON B. This is the exception to um the rule I talked about earlier. And the test for when to make the exception is do your queries treat these as one thing or as different
[2:21:30] things. So here here they’re one thing a span that happens to come in flavors. Three tables will be normalizing along an axis. Nobody queries along. The tokens in, tokens out, and cost us columns that we’re going to create will be zero for tool spans. That’s okay. They’re eight bytes of nullish data that run length encodes. So nothing after compression. So now the table and so let’s actually see it in the
[2:22:02] file. Okay, this is going to be in our migrations folder 002 tables and we can see what we have in it. Every column here is a decision. Uh let me justify the ones that matter. So we have timestamp TZ. That’s the first column. That’s the um partitioning key right here. And agents run in every time zone. And there’s no upside to ambiguity. And we this the span ID is a UU ID v7.
[2:22:34] So we see that here. Um agents generate span ids client side. They have to because the span exists before it’s written and children reference their parent. So we need globally unique IDs generated without a round trip. And u v7 means they’re timeordered. So the index stays compact and appends to the right edge instead of scattering rights across the whole B tree. random UU ID V4s here would be a genuine performance problem at volume. We also have the primary key
[2:23:06] span ID. Um so the partitioning column must be in the primary key time first. So the index is useful for range scans on its own. Then we have the parent ID here. Um that’s a tenency column. I mean no the the parent ID makes the span makes the spans a tree which is what lets us render a waterfall. It’s the project that’s the um the dependency column. So therefore our segment by was
[2:23:38] we’re going to use this for our segment by low cardality dozens maybe hundreds of projects every dashboard query filters on it. And then we have the status um that is going to be a text rather than a boolean because there are more than two outcomes. Okay, air timeout refused. That distinction matters and refusal isn’t a failure of your system. And then we have the um attributes which is a JSON B. It’s the long tail, the model’s temperature, the tools argument
[2:24:10] shape, the retrieval result count. So basically things we might look at but never uh uh that we’re not going to filter on daily. So uh now the arithmetic about this chunk interval. So the a busy agent platform say 200 agents runs a minute 30 spans each. That’s 6,000 spans a minute. Call it 8 and a half million spans a day. Our row is roughly 160 bytes with the JSON being mostly empty. So about 1.4 4 gigabytes
[2:24:41] of a table a day plus ind indexes about 1.8. Index volume is the number that matters most. Roughly 400 megabytes a day. On a 16 GB instance, 25% is 4 GB for the active chunks indexes. A day of indexes is well inside that. A week would be under 3 gigabytes. Also fine actually. So why daily instead of weekly? Well, retention granularity. We’re keeping rows raw spans 14 days
[2:25:12] with weekly chunks. A chunk can’t be dropped until its newest row is 14 days old. So, we’d carry up to 21 days. With daily chunks, retention is precise and chunk count is bounded. Uh 14 days of raw data is 14 chunks and nowhere near a thousand. So, daily chunks when retention is tight, chunk interval follows retention. So I’m creating a new file uh hyper tables. So we’ll create this hyper table agent span in our hyper tables file. And then
[2:25:46] um I want to talk about the metadata tables. So these are just basically two small relational tables because um we’ll need these for what we’re doing and these are already in the um table file. So create project create model price. So we have monthly budget USD it’s numeric because it’s money and we have the duration milliseconds. Oh I mean and then we also have the the
[2:26:17] model price. Now this is just an example here because um this is going to change but we can store the price table for reference but we do have to we we’ll compute the the cost at right time and store in on the span and then we’re also going to have some examples for um an another uh function or to insert into these items into the pro into the
[2:26:48] project. So we’re going to have just three indexes. So we’ll add this to our indexes file. So I create this indexes file and we have agent span or we have create index on agent span uh the project. Uh basically this is for the dashboard’s main path. Then we have it time descending. Then we have the run id end time for the run replay which is the which is a point lookup by run and then
[2:27:20] a partial index on failures only because errors are a small fraction of rows. So an index covering only them is tiny and show me recent errors is the single mostrun query in any observability tool. So I’m not creating an index on spank kind or on status generally. Uh it’s low cardality and after the column store conversion those become batch skipping metadata anyway. So one more table which is going to be runs. So I’ll create this runs.sql file. So we’re
[2:27:53] going to keep track of the agent runs. So we have span count and cost USD are derable from the spans. But I’m storing them anyways. And because uh the run list is the most loaded screen in the whole app and I don’t want to aggregate a million spans to render it. So we write once at run close and then we can read that forever. Okay, the scheme is done. Now something has to write to it. So I do want to be
[2:28:26] clear about what we’re building because the instrumentation pattern here is the reusable part. It works for any agent framework and really for any event stream at all. An agent does work. Every unit of work is a span, an LM call, a tool call, a retrieval. We wrap each unit, time it, record that what happened, and hand it to a buffer. The buffer flushes to PostSQL in batches. The agent never waits on the database. So, I just made this recorder.py file.
[2:28:59] And remember all this code. You can check out the link in the description to get this code. So this is a process queue and the col the column list once it’s once so the writer and the recorder can’t drift apart. And if we go down we can see this span function. Here’s the things I want you to notice. We have this use UID7 and then we’re going to take the a
[2:29:30] timestamp here and remember the check constraint on the table the UID timestamp span ID equals time. So remember this check constraint on here’s the check constraint on the table uids matches time. So that’s why I’m doing this. If if I had generated UI ID from now and the time stamp from a different now and those two would differ micro by microsconds and every in insert would fail the check. So we just generate the
[2:30:00] time stamp once and then we derive both from it. So this helps and then we have this finally block and the span gets recorded whether the work succeeded, failed or blew up. observability that only records successes is worse than none because it can make you too confident. And then we can see that the exception handlers raise we classify the failure and then
[2:30:30] get out of the way. A recorder that that swallows your exceptions is uh not good. So we have a copy batch function and down here we have the writer loop function. So basically we’re going to write a batch with copy and this is you can just use either copy or insert. Basically it’s just going to write right into our database. And these are all helper functions or that we’ll use later. But one thing
[2:31:00] to kind of focus on is our batch roads and batch seconds. So this is how long we’re going to just flush everything. So the flush uh condition is the the do line which is just right here in the writer loop. So the length uh we’re we’re using the batch rows or the batch seconds as we just talked about. And so it’s either going to be 500 rows or 1 second whichever one comes first. A busy
[2:31:32] system in a busy system you get full batches and maximum throughput. But in quiet a quiet system, you still get your data within a second. Without the time limit, a low traffic agents spans would sit in memory indefinitely and your dashboard would look broken. And note the reconnect on failure down here. It’s going to reconnect on failure because background writers must survive a database restart because they will
[2:32:02] experience one. So basically together the copy batch and the writer loop functions here decouple the caller’s execution speed from database latency which can transform thousands of tiny events into massive bulk writes. And to kind of give a little more information about the the writer loop, this acts as an asynchronous background worker that decouples the main thread from database writes and it buffers events in memory
[2:32:34] until they hit a specific size or time threshold. So you can see it has a non-blocking architecture and uh we already talked about how the dual trigger batching with by size or time and the to kind of just talk a little bit more about the copy batch. This streams in an entire batch of in-memory rows directly into time scale DB using PostgresQL’s native streaming protocol. That’s the the copy that we talked about
[2:33:07] and it by bypasses the standard SQL parsing overhead. Now copy just to talk a little bit more about copy which is right here the copy command. Uh it’s pretty important for time scale DB because time scale DB hyper tables route data into underlying chunk tables based on time partitions. So using copy feeds rows directly into the storage engine in bulk allowing times table DB to handle thousands to hundreds of thousands of
[2:33:38] rows per second with minimal lock contention. Now we’ll come to back to this file but I’m going to create another file pricing and just paste in code. Now we already saw our pricing in our tables where we’re adding some pricing in there but um here we’re just kind of hard coding these in again. Now, these numbers are illustrative and they will basically be out of date by the time you watch this. So, you’re going to have to actually just check the vendor’s page for real ones. But what matters is
[2:34:08] the shape. Input and output are priced differently. Output usually costs several times more. And that the asymmetry is why make the model more concise is a real cost lever. Now, we’re also going to have a file called agent.py. Now, this is a small tool using agent uh with three tools. Now, we’re not going to go through all of these because we’re mainly focused on the database section, but we have a a web fetch, a calculator,
[2:34:39] and a database lookup. And it fails about 8% of the time on purpose. That flaky tool is basically a valuable line of code in this project because it manufactures the errors that make the air rate dashboard show something. Now, this is going to basically require an OpenAI API key, but you can uh test this out without one by just using the simulate.py file which is
[2:35:10] going to be in the repo. And if we go here on the run um we can see that this is the whole integration surface with one width block around the call we already making with three assignments from the response. Tool calls are the same shape with kind equals tool. Here’s the kind equals tool. So basically we are adding either the LM calls or the tool calls to our database. And here’s
[2:35:43] our simulate.py that’s just going to simulate if we don’t have an API key. I’m going to just run this simulate file to get in some data here. But first we actually have to make sure our database is set up correctly. So I already have my Docker container running and then I’m going to create my database docker compos exec and then we’re going to run psql and then we’re going to create the
[2:36:15] database flight rack. That’s the one that our code uses. And then we already created all those migration files. So, I’m just going to create this loop that’s going to run every one of the migration files. Okay, I got everything created. Now, I can run our command, our simulate command. So, this is going to take a little bit. And it’s not just creating random noise,
[2:36:49] which would teach us nothing. There are four things built in. It’s basically creating three projects, eight agents, 72 hours of history at 40 spans a second. So we have this simulated data and it’s going to have this dal pattern. Basically traffic peaks midday, troughs at night. Any real dashboard would show this. And if your test is flat, you won’t notice when your gap filling is wrong. It’s also going to use a realistic latency distribution. Um the
[2:37:22] it’s log normal not uniform. Most cells fast a long right tail and that tail is why the P95 exists. Also air bursts rather than evenly sprinkled failures. Real systems fail in clumps and one planted incident. The specific tool gets dramatically slower for a 40minute window at a known time stamp. And uh in the query section we’re going to find it using nothing just using aggregate queries. and then we can explain it from raw spans. So, um I
[2:37:52] would recommend using simulated the simulated data if you’re following along. Okay, I’ve created the data and now I’m going to go run go where we can access the database which is called flight wreck. Okay, let’s just count everything here. And so we now see that there are over 9 million spans and we have a size of 48 kilobytes. So 3 days of history but nothing is tuned yet. So let’s fix that. Okay, we
[2:38:27] are going to make it faster. So baseline first. Never optimize before you’ve measured the thing you’re optimizing. So let me turn on timing. I mean, I should already be on, but we’ll just make sure. Now, we’re going to do select project agent. We’re going to count uh everything as spans, then get the average duration, the we’re going to get the cost uh everything in the last 24 hours. Okay. So, here is our result
[2:38:58] here. The projects agent spans and we can see how many spans are for each project and agent. And this took 665 milliseconds. So we will compare that after we make some changes here. So now I’m going to set create a new migrations file and we’re going to set up the column store. We’re going to go straight to nine and we’re going to fill in the rest later. Okay. So here’s our column store file
[2:39:29] and let me show you what how we’re setting this up. So we have segment by project and agent. Project is the tenency column. Every single query filters on it. Agent is the second most common filter and there are eight of them. So batches stay comfortably over a thousand rows. I did not use name even though we filter on it a lot. There are a dozen of of there’s a dozen of tool and model names and combined with
[2:39:59] project and agent that would shatter the batches into fragments. We also have order by here and we are using time first always because every query has a time range and that gives us minmax batch skipping and then we have span kind. So lm span and tool spans cluster separately. Uh most panels ask about one kind at a time. And then we have the compress index with the bloom and minmax. A bloom filter on name because
[2:40:32] show me spans for the DB lookup tool is an equality check and name isn’t in segment by minmax on duration ms because spans slower than 5 seconds is a range filter and that’s literally how we we hunt the planted incident that we will use later. And remember this rule there are builtin conversion times. So set them now before we convert anything. So we have this column store policy. Um
[2:41:04] for interval is two hours not one agent spans can arrive late and a run that hangs for 10 minutes uh writes its parent span 10 minutes after it started. So I want a wider grace window on the row store side and because our and then I also create some indexes here materialized view and a column store policy. So I’m going to set this up and we’re going to do a test. So first I’m going to run the migration file and we’re going to create these later. So
[2:41:36] I’m not going to worry about that. And then I’m going to go back into psql and because the data is historical we’re going to force the backlog. So convert to column store anything older than uh from show chunks the agency older than two hours. So first I’m going to try to find the job ID and then we can run it. So we did this because both convert to column store and run job are postgress
[2:42:06] procedures rather than plain functions. So we can invoke them with the call command. And this basically kicks off the backlog conversion. And then we’ll run the same command we ran before we set up the column store. Okay, now it’s only 215 milliseconds. So we’ve saved some time. We got the same query, same answer, dramatically faster and a fraction of the disk. So that’s one alter table and one policy. But we can
[2:42:36] do better than a faster scan because we shouldn’t be scanning at all. Okay. Now I just created this new file 06 span 5 minutes and we are going to be talking about hyperfunctions. So we’re basically creating a materialized view. This is going to basically be the last five minutes. And if we look at the aggregates list, every entry is a very deliberate choice.
[2:43:08] We have the count and we have a filtered count here. And they are non spans. So two integers and from them the error rate of any granularity because counts will reagregate. And then we have these sums here. The sum of the cost and the tokens sums reagregate. Then we have the states. We have um basically three states. We have
[2:43:40] the stats. A is the first state for average and standard deviation. Then the percentile egg for P95 and P99 and approximate count district for unique sessions. None of those could be stored as finished numbers. Averages an average of averages is wrong. Percentiles percentiles is meaningless and count distinct double counts across buckets. stored as states all three will
[2:44:11] merge correctly. There’s no average column anywhere in this view. Then if you scroll down here we are going to do a add continuous aggregate policy but the schedule interviewable is 1 minute. This feeds an ops dashboard in offset the end offset is 10 minutes which is twice the five minute bucket per the rule. The start offset you can see is seven days and that one is not
[2:44:43] arbitrary. Uh raw spans are retired are retained 14 days. So a 7-day refresh window is safely inside that. So it’s basically a rung rungs on a ladder from 5 minutes hourly and daily. So we are each row above is built from the run rows below. And so we’re going to create the other ones now. So here’s my span hourly. So we’re going to have these sums
[2:45:16] and we are going to roll up the states. And that’s pretty much it. So the file is um not very long because it’s built from the previous file. And now I have my span daily file. And you can get all this code um from the uh GitHub repo. So we’ve dropped name at the daily level.
[2:45:46] So because nobody’s going to ask which individual tool was slow on a yearly cost chart. And then this is dropping name is going to collapse the row count by more than an order of magnitude. So that’s going to have coarser dimensions. And now we can actually use these in a file that we’ve already created, the column store file. So that’s what um we got here. So here we’re actually using
[2:46:18] these files. We’re creating an index using these uh things that we just created and we’re creating a materialized view and then the column. store. So the these are basically all using these files that we just created. And now I’m going to make one final migration for retent for retention and we’re going to create the retention policies. Now I’m going to run this command in my terminal to run all the migration files. Some of them already exist so won’t
[2:46:49] create them again. Now go into the psql. Now let’s look at what this aggregate ladder actually costs on disk. The one we just set up. But to do that we have to write a slightly weird query. So before I run this let me explain why we can’t just ask for the size of the views directly. When you write a continuous aggregate time scale DB creates a hidden internal hyper table behind the scenes to store those precomputed buckets. If you run a standard Postgress size check
[2:47:20] on span five min, it misses the actual data. So this query does a union. The top half gets the size of our raw agent span hyper table. The bottom half queries time scale with DB’s metadata to find the system generated names of those hidden hack hidden backing tables. It circulates it calculates their true sizes and stacks them together so we can compare them side by side. So I’m just going to run this and then here we get
[2:47:51] the results here. So look at the size gradient. So the it goes from 494 106 49. So it’s slowly going down and then we expect the daily to be the largest here. So this is basically the whole architecture in this one output. Our raw data is almost 500 megabytes, dropping down to five minute buckets, cuts down to roughly a fifth of the size. An hourly is a fraction of that. And the daily summary, which contains exactly
[2:48:22] what we need for a year-over-year chart, is half a megabyte. It’s kind of just like a rounding error on your disk. So, raw data is big and shortlived, but summaries are small and live forever. Now let’s ask it some questions. So let’s find the unique sessions per day. So we can see there’s 900 on each of these days. And it took us 29 milliseconds here. And we’re going to count it in a different way here. And
[2:48:53] this time we’re getting basically the same number of unique sessions, but it took a lot longer. Uh 300 3,647 milliseconds. That’s because this first example ran our aggregate ladder which is what we created to make things faster. The distinct count rollup sessions. Uh so remember when we built the five minute aggregate we didn’t save a number for sessions we saved the state using
[2:49:24] approx count distinct under the hood. That creates a hyperlo log sketch. The roll-up function merges those five minute sketches into daily sketches and distinct count extracts that final number. And so this was the more traditional SQL way of getting the data where we got the same data but it took a lot longer. And the the speed isn’t actually the most important difference here though. It’s that the fast query is architecturally possible at all. You
[2:49:56] cannot premputee a standard count distinct into a materialized view and then roll it up later. If a user logs in during the 9:00 a.m. bucket and and the 10 a.m. bucket and you try to sum those buckets later, you double count that user. The only way to get an exact distinct count across a wider time window is to go all the way back to the raw data and count again. Hyperlog log states solve this. The sketches can be merged together mathematically without
[2:50:28] double counting. This is the difference between a metric you can put on a fast live dashboard and one you can’t. If you need the exact number for a monthly invoice, run the slow query once a month. For a dashboard that uh nobody stares at to six digits because it doesn’t have to be so exact, the hyper log log estimate is going to be a good engineering call. So everything so far has been aggregate queries. But when an agent misbehaves,
[2:51:00] you need to see the actual sequences of what it did. And so that’s basically the raw hyper table where the full fidelity data still lives. Okay, I’m looking for when the project is acme and the status is error and I want to get one of these run IDs. So the point of this is we’re trying to find the actual sequence of what a specific agent did that had an error that misbehaved. So we need to go to the raw hyper table.
[2:51:30] And so this is one of the runs that failed here. So let’s try to reconstruct its exact execution waterfall. So I’m going to paste in some SQL code that we’re going to do. So let me break down what this query is doing. To draw a waterfall UI, we need to know what called what and we do that with the union all. So um the top half grabs the the root the root span which is like the
[2:52:01] main entry point for the agent where parent ID is null and it assigns it to a depth of zero. The bottom half grabs all the child operations where parent ID is not null and assigns them a depth of one. Then in the outer select we use repeat depth. I mean in the outer se select we use the repeat coales depth zero to visually indent the child spans under the parent.
[2:52:32] The coles is just as there’s a safeguard. So if the depth ever somehow evaluate to null, it would default to zero instead of wiping out the entire text string. So let me run that. And we can see here there’s the run perfectly ordered by time. The LM thinks, it decides to do a tool call. The tool fails, it retries, it thinks again, and
[2:53:03] it finally answers. This is in indented timed and cost cost wait where we can see all these things the costs that’s the the flight recorder doing exactly what the name promises and notice is notice the speed we just pulled a needle out of a haststack of millions of rows in milliseconds this is because back in the schema section we created an index on the run ID and time scale DB uses that index to bypass the partitions entirely and jump straight to
[2:53:34] this part specific run. So this is a two a two-level workflow. This is the operating pattern for the entire project and for uh most observability systems. Aggregates define the problem raw data to explain it. And so it’s fast and cheap on the wide question and full fidelity on the narrow one. Neither one alone is enough. So we just looked at a waterfall of individual spans, but often you just want a simpler answer. a simple
[2:54:04] you want to answer a simpler question. What is the actual bottleneck of this run? Is the LM slow or are the tools slow? So here’s some more SQL here. Okay, let me put in some new SQL here that I’ve already run. Notice that last line of SQL. Basically, actually I’m talking about this one right here. We are doing a sum. We’re doing a sum of a sum using a window function. The inner sum gets the total time for that
[2:54:34] specific span kind. The outer sum with over calculates the total time for the entire run across all kinds. We cast it to numeric to run it cleanly and dividing them gives us a perfect percentage without having to write a subquery. For most agent runs, the answer surprises people. The model isn’t the bottleneck. The tools are, which fundamentally changes what you’d optimize. No amount of prompt engineering fixes a slow database lookup. Now, if you want to track this
[2:55:06] over time across thousands of runs, doing that math on the fly gets heavy. So that’s why we used the state egg hyper function, which models exactly how long a system spent in discrete states. And let me show you that we are doing a group by in the run ID run. We’re doing group by run ID in this sub query state a models a single state machine meaning a system that can only be in one state at a time. If we tried to aggregate the
[2:55:37] entire project into our timeline, the database would throw an error the moment agent A started an LM call in the exact same microscond agent B started a tool call. So by grouping by run ID, we correctly treat each agent run as its own independent timeline. Once those individual timelines are built, we use duration n to extract the micros secondsonds spent in each state and clearly sum them up across the whole hour. Build the state and read the answers. Okay, now let’s uh test
[2:56:08] everything out. I planted an incident in the stimulated data. One tool got dramatically slower for about 40 minutes. So I know when but and but now we’re let’s see how we would find find it in the way you’d find a real one with no idea what we’re looking for. starting broad and zooming in. So the first step is is anything wrong at all? So we’re going to start with the widest possible view using only aggrits aggregate uh hourly aggregates. So this is going to um
[2:56:40] give me all some information. We are selecting um basically for every we’re getting these 1-hour buckets and we are looking for the project acme and the scan of tool. So we can see a few of these are a little different than the others. Specifically a lot of them are 1843. This one’s 1729. This one’s 1729. So, I’m going to look at all these
[2:57:12] uh in uh all this P95 milliseconds and we can see uh actually took me a little bit to find it, but it’s like you can see this one is well above all the other ones. But if we look at the spans column traffic, it looks pretty normal. So, this isn’t a load spike. something specifically got slower. So step two is trying to figure out what tool. So let’s
[2:57:42] keep the hourly window, but now we’re going to group by name. Okay, I’m running this code to like look at the different tools. And we see web search is taking over 10 seconds while everything else is operating normally. So now the next step, step three, is trying to find out exactly when did it start and stop. So, I just ran this code here and we’re going to drop down a rung on our aggregate ladder to the five minute buckets to get precision. So,
[2:58:16] we’re doing from span 5 minutes and we can see that most of these numbers are under 3,000 milliseconds. But then right around here, we can see it shoots up. So, now we have a tight window. And notice the shape of the recovery because that shape is diagnostic. A cliff edge means a deploy or a config change rolled back. A gradual ramp means the satur means saturation recovering. A Q draining a
[2:58:48] connection pool freezing up. So now then step four the next step is to explain it. So now and only now we drop down to the raw spans. But because we marked down the time window and the tool name, this query will be instant. So we do this query with the tool name and then the time that we found out from the last query. And look what we see here. So now we can see the exact root cause sitting
[2:59:18] in the JSON uh attributes payload. Read time of upstream did not respond in 10 seconds. uh the web API went down. The Python agent waited the full 10-second timeout limit before giving up, which caused every single run in that window to stall for 10 seconds. So, let me just name what happened because it’s the thesis for a lot of this course. So, we had three days, millions of spans, four queries, each returning in milliseconds. We walked from something
[2:59:49] feels wrong to here is the exact tool, the exact 35m minute window, the exact error message from the HTCP client. The first three queries read premputed aggrids. Only the last one touched the raw data and it touched a tiny slice of it. That is what a lot of these features are for. The ability to ask a chain of questions fast enough that you keep asking them instead of giving up. Okay, time to put a face on it. We have the queries. Now let’s them wrap them up in an API. So we can actually look at a
[3:00:19] dashboard. This is where continuous aggregates stop being a database concept and start being visibly obviously useful. So the the design rule for the entire backend is strict. One endpoint per panel, one SQL query per endpoint and that query hits an aggregate. No RMS, no query builders, no fetching thousands of rows to join them in Python. The database does the math and the API just delivers the JSON. So we have this just to set up our API file and here is where we are starting our
[3:00:54] connection basically to get get to get all the data. Now production we we use a scopy pool not a connection per request but we’re we’re just uh we’re not going to detour into pooling right now. And then we have our API endpoints. We have our health endpoints which is this liveness probe. Here’s one of our main panel or endpoint for our main panel. Um the cost. So that’s the exact qu that’s
[3:01:25] the query we wrote in psql par parameterized cleanly using scop g’s named parameters. Every other endpoint ex is exactly the sha the same shape. So I uh and these endpoints don’t touch raw data. So before we look at the dashboard, let’s verify that claim. But uh let me just show you the other endpoints. We have the latency endpoint.
[3:01:57] And we can see a lot of these are what are commands that we’ve already ran that I showed an example, but now we’re just putting them into the API. So that’s for the errors. And then we have the top failures sessions, how many sessions per day and you can see this is all just raw SQL using everything we have been learning. Then we have histogram and remember you can see all this code yourself right in the the code link in the description. We also get the the information for one
[3:02:29] run. So, I’m back in psql and then I’m just going to run this query to see that it doesn’t touch the data. Okay, let me zoom out a little bit so it doesn’t go over so much. Basically, in this query plan, it pulls a handful of chunks from span hourly. It scans a few hundred rows. There’s no reference to agent span anywhere. The raw table has millions of rows and this dashboard query literally does not even
[3:03:00] know it exists. A dashboard whose computational cost is proportional to what it displays rather than to how much data you have ever collected. It’s better. Your dashboard is just as fast on day one as on day 1. Okay. Right now I’m in staticindex.html and we are going to basically look at the front end. So the front end is just one HTML file. If we go down, we can see
[3:03:30] that in our code, we fetch the endpoints. Um, we draw the charts and we set a loop to refresh every 5 seconds. Now, I’m not going to go through this in detail. This is not an HTML JavaScript course, but you can look through the code of in the repo if you want. Okay, it’s time to actually try this out and actually run this demo.
[3:04:00] So, this is the thing that we’ve been building for a while. So, I’ll just run uicorn and it’ll run the application. Okay. And we have it running down here the terminal. But now we can we can see this. So we have all this information about the spin the spin spans air rate worst P95 airate lag and then look at these charts can go down tool latency percentage we got the error rate latency
[3:04:32] distribution we got the top failing tools and check this out I can change the window so we went from 24 hours let’s go change it to 72 2 hours and we can see the charts change in real time. Oh, and when we change the 72 hours, we can see we got a big error rate right here. So, we can even zoom in or zoom out with our mouse wheel on here. And it’s really nice to have the this top failing tools
[3:05:03] thing here. We can also change the different projects as well. And right now we’re not getting new data, but we’re going to simulate giving getting live data. So I’m just going to open up a new terminal window and I’m going to run Python simulate live rate 60. Let me change the window just last six hours. Now let’s just watch the right edge of these charts.
[3:05:33] So now we can see data coming in here. So, we didn’t have data between this gap here, but now we have the live data coming in and we can see the charge updating in real time. So, the refresh job runs every minute. So, the last minute of data has not been material materialized. That data is coming from the live half of the real time aggregate. time cell DB is reading the
[3:06:03] newest rows straight from the raw hyper table and unioning them onto the precomputed history old data premputed and instant. So new data live. So basically it’s combining the new data with the old data um seamlessly. Okay, let me just clear or stop the simulator here. So new more no more new data is coming in. So the traffic data is now
[3:06:33] going to stop. The current bucket will finish filling and within a minute the refresh job materializes it. And then it will basically get a gap just like we have here. So that’s a production-shaped observ observability stack instrumentation batched ingest column store an aggregate ladder and a live dashboard reading only summaries. Now let’s talk more about the retention policy over here. So this makes it uh it
[3:07:07] makes it so it doesn’t fill the disk. So this helps the system not to grow forever. So we have a retention ladder. It’s a tiered retention pattern now that we have a concrete reason for every number. So let me just talk about each one because how long do I keep data is a product question disguised as an ops question. So the raw the agent span or the raw spans we keep for 14 days.
[3:07:38] Individual spans are only useful for debugging something recent. Nobody replies a specific agent run from 3 months ago. And if they need to, that’s what your log archive is for. 14 days covers what happens last sprint. Then runs we keep for 90 days, much smaller than spans. And the run list is what people browse. It’s cheaper to keep longer. Then five gra minute buckets for 90 days. Five fine grained enough to investigate an incident and a quarter is how far back anyone actually zooms at
[3:08:09] that resolution. an hourly for one year, year-over-year comparisons, capacity planning. Um, then daily we keep forever. It’s tiny. Compressed daily summaries for a whole year are a rounding error on your disk and executives ask about last year constantly. So you should ensure the retention policy doesn’t delete data that the continuous aggreate would still refresh or your aggregated data will be deleted too. Our five minute aggreate has the start offset of in interval
[3:08:41] seven days meaning it’s willing to recomputee any bucket in the last seven days raw spans are retained 14 days. Seven is less than 14. So every bucket the aggregate might refresh still has source data behind it. So let me help you understand how this can fail fail with fail with an example. So, I’m back into psql and I just created a new hyper table called foot gone raw that’s going to have 30 days of history.
[3:09:13] Now, I’m going to create a daily aggregate over it and refresh it. So, we can see that we have about we have 24 day rows per day. So, that’s perfect. 24 rows seen per day and the summary is correct. Now let’s play the part of a retention policy and delete the raw data than 25 days. So we’re seeing what happens when you accidentally destroy data that that you shouldn’t destroy. So
[3:09:44] I’m deleting from foot gun where time basically um when it’s older than 25 days. Okay. Now we can see that when we query the last 25 days um these have all now been deleted. Uh we only it the the most basically recent row is on the 20th or the oldest old rows on the 20th instead
[3:10:15] of the 19th 18th. All these other dates have just basically been deleted. And I want you to understand how this happened. Because we didn’t restrict the aggregates refresh window with a start offset. The refresh job woke up and asked the database, hey, let’s recalculate the count for 28 days ago. The rod table just answered, I have zero rows for that day. And the aggregate faithfully wrote that down. It overwrote your correct historical summary with a correct looking nothing. There’s no error. There’s no warning. There isn’t
[3:10:46] even a log line. So your yearly chart just develops a hole where the past used to be. you find out months later uh when you finally try to look up the data and note the ordering because um the gap between you made the mistake and you can see the mistake can be days which makes it hard to uh debug. So let’s just drop that table in the view right now. So basically every aggregate start offset must be shorter than its sources retention. This is the single most
[3:11:18] common way people accidentally destroy data with time scale DB and it’s entirely preventable with one simple mathematical comparison. So I just ran this command here um which is going to put every automated policy on your system in one output. The retention compression um it will even uh show column store refresh along with their intervals. And then we can
[3:11:48] eyeball the refresh out offsets against the retentions and then it just helps us check what all the data is, what all the um configuration is stored at. So now I just have run this command here to get the bytes per span. So we can kind of see how big everything’s going to be. We can see it’s it’s close to about 200. We’ll round this to 200 about 200 bytes per span. So let’s look at three scenarios based on that footprint. It says 25
[3:12:21] after column store. That means after the column store compression. So for a hobby project, let’s say you process 10,000 spans a day. That’s about 2 megabytes of of raw data. With a 14-day retention, your total raw database is under 30 megabytes. Your aggregate tables are kilobytes. You’ll never even think about storage costs. But if you’re a startup, maybe you have 10 million spans a day. It’s pretty busy. it. It’s a profitable agent project. That’s about 2 gigabytes a day uncompressed. Dropping to 250 megabytes a day compressed. Keeping 14 days of raw
[3:12:54] traces takes roughly three and a half gigabytes plus a couple of gigs for your aggregates. That fits comfortably on the smallest cheapest paid database instance available. But if you have a very large heavy project, 500 million spans a day, you’re generating a 100 gigabytes of raw te telemet telemetry daily. Compression drops that to 12 gigabytes a day. So retaining 14 days takes under 200 gigabytes. At this scale, this is where tiered storage earns its keep. You keep
[3:13:26] the most recent three days of chunks on expensive fast local SSDs for live debugging and you automatically push older chunks to just cheap external storage. So that compares the comparison that matters without retention and the aggreate ladder that startup case is 2 gigabytes a day forever 700 gigabytes in the first year and every dashboard query gets slower every single day. with the ladder storage plateaus at a a few gigabytes and query time is flat
[3:13:57] forever. So that’s just the same data, the same questions, but one of those systems can um you can basically leave alone for 30 years for three years because it’s not going to get too big. So four things I’d add if I were going to get this to production. Um first sampling for cheap spans. Not every successful 12 mil millisecond calculator called needs to be stored. You can keep all errors, all slow spans, and one in
[3:14:28] 10 of the boring ones. Your volume drops by most of itself and you lose nothing. You’d have you’d have to look at also um PII scrubbing before writes prompts contain user data. You scrub in the recorder, not in the database once it’s written and it’s in your backups. Alerting on the regression query. Uh, we already wrote it. Put it on a cron with a threshold and web hook. Add semantic search over failures with PV PG vector. Uh, embed prompts. Then ask, show me run
[3:15:00] similar to this failure. It’s pretty helpful when a customer reports something vague. So, let’s just overview what we built. So what we built a a sche a schema with the compound primary key and UU ID v7 identifiers batch copy ingest that doesn’t block the agent a column store configuration tuned to how the data is actually queried three aggregate ladder storing states rather than the results eight real operational queries one of which found a planted
[3:15:30] incident in four steps a live dashboard reading only summaries and a retention ladder that keeps the whole thing bounded forever that’s a production observ observability stack. It’s a few hundred lines of code, one PostgresQL extension, and no other infrastructure. So, I need to use Kafka, no separate analytics warehouse, no data pipeline, just a database. Okay, we’re finished with the first project and it’s time to make another project. Everything we’ve built so far has been running locally in
[3:16:01] Docker. That’s the perfect place to learn and build and is completely fine for running small workloads in production. But time scale also has a managed cloud version and it’s built by the exact same team behind the extension. It has some architectural features that only exists in the cloud. So we’re going to move over to it. We’re also going to build a completely new project so you can see these patterns applied to a differently shaped problem. So we’re over on the Tiger Data website.
[3:16:32] You can see it says from the creators of Timecale DB. And if you sign up, you get a 30-day trial of their performance plan. You don’t even need a credit card. And that’s plenty of time to just try it out and um and even try migrating a test workload. Also, they have a free shared CPU service which are great for learning inside project. So, just uh get logged in or sign signed up for the service. So after you get signed up or signed in,
[3:17:02] you can start creating your first database. So I’m going to you can choose any sort of CPU. I’m going to choose the free tier and continue. And then you can change your name. Uh I’m just going to call it fleet. You can call it anything you want. And then create service. I’m just going to create basically use the defaults for everything. My service is ready. So now that we’ve created everything, we get all the connection info. So we have a host, a port, a database name, a user, and a
[3:17:33] password. And it basically all goes into here. Our primary connection string. That’s it. It’s just like a standard Post SQL connection string. Okay. I’ll connect using psql on the command line here. And then I’ll just pass in that URL that we got from Tiger Data. And you’ll notice this SSL mode equals require in the connection string. That’s mandatory when you’re connecting over the open internet. And the console gives
[3:18:06] it to you by default. And it’s going to ask for my password. So I go back over to the dashboard over here. If I go to more details and I can say forgot your password and then just create a new password if you don’t know what it what it is and then I can just type it in here. Okay, let’s check our extensions. So I’ll say select X name X version from PG extension or by X name and we can see
[3:18:36] here timecale DB and time scale DB toolkit are already installed and active. No compile steps, no modified anything. It’s just there. So, there are other extensions installed on the machine that you just enable yourself. I’m going to turn on the extensions uh PG vector and PG vector scale which makes vector search faster at scale. We’re going to use those at the very end of this section. And I want to test something right now. Basically
[3:19:09] the hyper table, the column store, the continuous aggregate ladders, the retention policies, all the things that we’ve kind of discussed earlier in the course, they should run against this managed service without changing a single line of code. So let’s find out. So I’m back to my command line and then I’m just going to try to run all my migrations and put in our tiger data URL here. And it it’s done. It actually ran every single migration
[3:19:39] and it’s all now in the cloud. So if I go back into psql into the database on the cloud and we see this we ran this query and we see uh numchucks zero num num chunks are zero. Uh that’s exactly what we want and what we expect. I mean time scale DB creates chunks dynamically when data actually arise. We just create the schema on a fresh database but until we insert rows it doesn’t waste disk
[3:20:10] space pre-allocating empty chunks but the tables the views and the policies are all perfectly configured. And now after we’ve run all the migrations, I can go into our web dashboard and we go into explore. We can see the different objects. These hyper tables have been created, the tables that have been created. We can see how many of everything we have. We have some continuous aggregates. And if we go into the table, we can see
[3:20:42] some information. Right now, there is no data in the table, but if there was data, we would see the data here. And we can see all the fields here that we created right from our migration files. So basically we now have every table, every materialized view, every policy executed perfectly. Not one line of code changed. I want to emphasize this because it’s the strongest practical argument for using this stack. your local development
[3:21:14] database running in Docker, your CI environment, and your managed production database can all run the exact same schema. You can develop locally and deploy to the cloud or pull a snapshot from the cloud to debug on your laptop. And if you ever decide you want to leave the managed service, you are leaving with a standard Postgress dump that will restore anywhere the extension is installed. That’s a meaningfully a meaningfully different position than adopting a proprietary cloudonly database.
[3:21:44] Now clicking around a web console is fine for day one, but if you’re managing this every day, you want a CLI. So I’m just going to run brew install time scale tap tiger CLI. The arts arm 64 is just for my particular computer. Okay, it tells what to do next. And I’m going to do tiger o login. And then it’s going to open my browser for authentication. And then once it’s authenticated in the
[3:22:15] browser, it will now continue with the the login on the in the CLI. And then it gives a few different next success. We can get an MCP server for for AI coding tool. List list existing services. Create a new service. I’m going to list the existing services. So, tiger service list and here’s the service we created we just made in the browser and you can provision infrastructure directly from
[3:22:46] here too. So, I’m going to do tiger service create-c shared and then memory shared. And now it’s creating this service. So, that single command spins up a free shared service. No browser required. Okay, now it’s going to give me all the information about that service that we just created. Now I can just run tiger db connect.
[3:23:16] Now that command just drops us straight into a psql session against our database without me having ever having to copy and paste a connection string. So we can start doing stuff in our database. So, I’m just going to go out of this with slash Q. I’ll clear my screen. And before we go into go any further, I want to set up a guardrail. So, I want you to see me just do this intentionally. I can do tiger config set read only true. Now, if you
[3:23:51] remember in our after we logged in, it asked the question about this, but this is how we can manually do this from the from the the terminal. So set readonly equals all. So what this does is configure the CLI so that mutating commands are refused and any database sessions it opens are forced into readonly mode. So if I go to tiger service delete and then this is the ID of the one we just created,
[3:24:21] it’s going to fail. Error. This operation is not allowed in readonly mode. So, it doesn’t just ask, “Are you sure?” It just stops you. So, here’s what I think on connecting AI coding agents to your database. Language models are very great at exploring a schema and suggesting optimizations, and they’re also completely capable of running a drop table command because your prompt was slightly ambiguous. So, the answer isn’t never use AI with your database.
[3:24:53] The answer is to make sure the destructive path is impossible. Uh turning on readonly mode takes just one command and it turns a risky experiment into a safe one. MCP or model context protocol is a standard way to hand external tools to a language model. And as we kind of saw already, Tiger ships an MCP server directly inside the CLI. So your AI coding agent can talk to your database. So we can so we can install that which just tiger
[3:25:25] mcp install and then we can choose which one we want to configure. Uh depending on what you use I’ll just use cursor here. And now it’s already it’s basically configured successfully installed tiger mcp server configuration for cursor. So it tells our next step by just restarting the cursor to load the new configuration and then it’ll be tiger and then it gives us some things we can um try testing with one with.
[3:25:55] So let’s actually try it out. I have cursor opened and then I’m just going to go here to cursor and I’ll just say list my tiger services and tell me which region each is in. And then I just have to make tell it to always run our MCP server for Tiger Cloud. Okay, we got the information. These are the two the one we created on the website and the one we created in the CLI. And then we have the the
[3:26:26] regions. We have two region. We have two cloud services both in US East1 which does happen to be the region closest to where I live. So instead of looking up the CLI flags, it just calls the tool and gets the data. Okay, let’s try another one. Describe the agent span table including its hyper table settings and column store configuration. So this is even more interesting.
[3:26:57] It’s not just uh reading the Postgress tables. It’s reading the time scale metadata. Okay. Okay. And it looks like I need to save my password from the the fleet table locally. So I’ll just do tiger tiger db save password and I can put in the password for the table that we developed. And then I’ll just tell the AI agent that the password’s there. Okay. And now it did what we asked it for. So it
[3:27:27] described the agent span table and then including the column store the hyper table and it knows all everything including the the chucking chunk interval and everything like that and one more question given how I query agent span is my segment by configuration reasonable what would you change so now it’s going to use information from that’s getting from tagger data And it’s going to reason about my
[3:28:00] cardala cardality and query patterns which are the concepts we discussed previously. It can do this because the tiger MCP server actually has time scale skills and documentations wired into it. It isn’t just guessing based on generic Postgress training data. Okay, it’s giving me some uh suggestions. is I would segment by project only. Um and then I saying like basically what
[3:28:33] I did wrong, why the current pair is weaker than it looks, what I would set. So it’s giving very specific suggestions and uh based on this these time scale skills, but here’s my uh rule for AI, which is basically just to verify every suggestion yourself. Okay, let’s actually start this second project. I intentionally chose a problem with a completely different shape. So
[3:29:05] before the previous project was about irregular events spans arrived randomly whenever an AI agent did something. Durations varied wildly and we were aggregating over those specific events. This project is about regular regular interval sensor sensor data. We’re tracking a fleet of 2,000 EV chargers. Every single device reports exactly once every 10 seconds forever. This requires completely different tools. We have a monotonic we have monotonic counters
[3:29:35] that need to be differenced device state machines and the hardest monitoring problem of all which is depend detecting when a device stops reporting. So let’s look at this schema. So first we have the this table site and the table device. These are two standard relational tables. There are no time columns here and they are not hyper tables. These are our dimensions. They are facts about the devices that don’t
[3:30:06] change every 10 seconds. Then we create the index on device. And then we have this hyper table which is a reading. This is our see this is where we create the hyper table. Uh this is our fact table which we turn into a hyper table. So let’s look at the four different kinds of columns in here because the data tape completely dictates how you are allowed to aggregate it. First we have the power
[3:30:37] W, the voltage, the temp C. These are gauges. They go up and down. You can average them, but as we’ll see later, how you average them really matters. Second, the energy wh total. It’s a monotonic counter. It represents the lifetime energy delivered. It only ever increases unless the device reboots, at which point it resets to zero. Averaging this number is totally meaningless. You want to find the
[3:31:07] difference over a window of time. Third, we have the next one which is the state. It’s a discrete state like charging, idle, or faulted. You don’t average a word. You just ask how much time this device spent in each state. Also notice that the gauges are stored as real, which is a which are four byte floats instead of double precision eight byte floats. A charger’s temperature reading doesn’t have 15 significant digits. when you’re generating 17 million rows a day,
[3:31:39] cutting those column sizes in half makes a massive difference. So, let’s look at the primary key, which is the device ID and time. So, in the previous project, we used time and span ID, but here we put the device list first. And you’re maybe wondering why. Well, that’s because the single most common query in a fleet management app is, “Show me one device’s history over the last 24 hours.” Putting the device first means all those rows are physically adjacent
[3:32:10] on disk, making it a single incredibly fast range scan. The primary key should always follow your dominant query pattern. I’ve cleared out my fleet project or service and so I’m going to be starting this from scratch and I’m going to be using the connection info. Now I’ll put in this command to run my migration file to make sure I’m in the same directory as my m migration file and I have to put in my database URL.
[3:32:40] Okay, I’ve ran that and created on my database and we can verify that over on our web dashboard. We can now see the the objects in here, the hypert and all the things that we just created. Here’s the devices. There’s a myth that in analytics you can never use joins and therefore you must denormalize everything into a single massive fact table that leads to terrible schemas. The reality is that
[3:33:11] joining a small dimension table to a massive hyper table is completely fine and Postgress is deeply optimized for it. So let’s prove it. Before we start quering this, we need data. I have this Python script here to generate the fleet telemetry. It’s basically simulating the data that we need to do. It uses the exact same batched copy pattern we used in the first project. And it can generate 2,00
[3:33:42] devices reporting every 10 seconds. And to save us from sitting here watching a progress bar, I’ve already applied our schema and run this simulator in the background. It backfilled 24 hours worth of data, which is quite a few row rows. I’ve also planted three specific ana anomalies in that data that we’ll hunt for later. And a side note, I use a Python script here so you can see how the data is shaped. But on Tigercloud,
[3:34:13] you wouldn’t write this inest code yourself. You would use their fully managed Kafka connector to consume telemetry straight from a topic or the S3 connector to bulk load historical CSVs. So we also have this queries file here which are which are going to have some queries that we’re going to just use for for examples. So we have tons of rows of data and I want to address a massive myth in analytics. The myth is that you
[3:34:44] can never use joins and therefore you must denormalize everything into a single massive fact table that leads to terrible schemas. The reality is that joining a small dimension table to a massive hyper table is completely fine and Postgress is deeply optimized for it. So we’re going to start with this first join. So first I going to get into psql on our our hosted database.
[3:35:14] Then I’ll paste in this SQL code here. So we just joined across millions of readings and it returned in milliseconds. So it only took basically 11 seconds to run this entire query. And now we’re going to run this explain query. Again, you can find these all in the code link in the description. So if we look at the explain plan, we
[3:35:47] can see exactly why it’s so fast. Let me zoom out so we can see more on the screen at one time. It Postgress takes the device and site tables which total maybe a few thousand rows and take up a few kilobytes and builds a hash table in memory. Then it just streams the millions of readings past that hash table in the CPU cache. The join costs virtually nothing. And here’s the actual rule for data modeling.
[3:36:19] Denormalize facts about the measurement itself, but normalize attributes that describe the entity. The current state of the charger is a fact about that state about that exact second time. So it goes in the hypert. But the firmware version belongs in the device table. If a charger gets a firmware update, you update one row on the device table. You not rewrite 400 million rows in your readings table.
[3:36:50] Let’s answer the most important configuration question. What should our segment by column be when we compress this hyper table? Well, we have two obvious choices. We can segment by device ID and there are 2,000 devices or we can segment by site ID and there are 40 sites. Well, previously I told you that the rule of thumb was to aim for at least a thousand rows per compressed batch. Let’s run an experiment and actually measure both axis the storage size and the query latency.
[3:37:23] So I’ve already run all this code and we’ve run this these alter tables. These are going to create two identical copies of a day’s worth of data but compress them differently. One is by seg one is segmented by device ID. The other is segmented by site ID. Notice that for the site version I had to move device ID into the order by clause to ensure the data stays locally clustered within the segment.
[3:37:54] I ran this before we started recording because copying compressing a full day of data can take some time. So let’s compare sizes on disk first using the hypert compression stats. So basically I have this SQL code that does the comparison here. So let’s look at the percentages saved. We can see the um by device or device ID is just slightly better at just over 89%.
[3:38:25] The this result is exactly why we run this experiment instead of guessing. If you just read the documentation, you might assume that segment by segmenting by site ID would compress better because the batches are larger. With a 10-second interval, one device emits about 8,600 readings a day. Segmenting by site groups 50 devices together, creating a mass creating massive batches of over 400,000 rows. So why did device ID just
[3:38:55] went by a bit? Well, think about the physical shape of the data with segment by equals device ID. Each batch represents a single charger’s continuous timeline. The temperature curves smoothly. The power output stays steady and delta encoding can compress those patterns perfectly. When you segment by site ID, you interle 50 different chargers into the same batch. The values constantly jump up and down between different machines which disrupts the
[3:39:25] encoding and actually hurts your compression ratio. So device ID wins on disk space. But storage size is only half the benchmark. Let’s look at the second axis. query latency. So I just ran another query where we’re going to look at query speed. So here are the results. The first one for reading by device, 251 milliseconds, and then 176 milliseconds for reading by
[3:39:57] site. Okay, so I just ran this code here to check the query latency and let’s look at the query times. So when we’re reading by device, it’s 253 milliseconds and here’s 166 milliseconds. So if you’re paying close attention, you’ll notice something unexpected. By sight actually ran faster here, 166 milliseconds compared to the 253.
[3:40:27] So, uh, why did that happen? Because of how databases actually work under the hood. First, a cache warning. The first query had to fetch cold data from disk into memory. The second query ran immediately after, found what it needed in the hot cache, and finish faster. Second, we’re only quering a tiny slice of data. Right now, we only have a few megabytes of compressed data RAM. Scanning it is triv is very fast regardless of how it is segmented. But you have to protect project this architecture choice out to a production
[3:40:57] scale. So imagine you have three years of fleet data totally totaling billions of rows. If you use segment by site ID, the database is forced to decompress and scan 49 other device histories just to find the one charger you asked for. That memory overhead will crush your database. If you segment by device ID, the database read reads the batch metadata metadata and and ignores the rest of the site entirely and only decompresses the exact device you need. So despite the microbenchmark quirks of
[3:41:29] caching, device ID is definitely the right choice uh for our production workload because the single device query is what our API serves on every service detail page hundreds of times a minute. So we’re going to choose device ID. Storage is cheap. The latency for user that you your users feel when opening the device detail page 50 times an hour is not the rule of takeaway isn’t always isn’t always use device ID. The rule is segment by should be the column you filter by the most on your raw data. So
[3:42:01] now we can drop our scratch experiment tables and we can apply that measure configuration policy directly to our primary reading hyper table. Um this is because I had already this errors because I had already added that we compress after 4 hours because uncompressed chunks are the expensive ones. And we add sparse minmax indexes on temp C and power W. So queries filtering on thermos spikes or power thresholds can skip entire batches without decompressing them. Okay. Now
[3:42:32] we’re going to start working with the data more. And I have deliberately engineered three anomalies into the data set. Basically, real world problems that are incredibly hard to detect with plain SQL. A site outage. Every device at one specific site goes completely silent for some time. No air logs, no fault state, just silence. A counter reset. One device reboots and its lifetime energy WH total drops back to zero. And also a stuck state. a device gets stuck in a
[3:43:03] fault in a faulted state and faithfully report that it’s broken every 10 seconds for an entire day, but it never goes offline. So, I just ran this to see how much we have of um basically so you can kind of understand the whole database right now. These are my readings, how many devices, and the date range. So, this is just representing a day of fleet operations. We could do it bigger. We could do it more, but then it would just create an even bigger database. And I’m using a Python script here so
[3:43:34] you can see how the data is shaped. But on Tiger Cloud, you wouldn’t write this inest code yourself. You would use their fully managed Kafka connector to consume telemetry straight from a topic or the S3 connector to bulk load historical CVS. So now let’s find the anomalies. We’re going to use four specific hyperunctions designed to solve problems that are miserable to write in raw SQL. So first the counter reset. Our energy
[3:44:07] wh total column is a lifetime meter reading. It only ever increases until the device restarts at which point it drops to zero. If we write a naive SQL query to find the total energy delivered over the last 24 hours, it looks like this. For the healthy device uh 1338, it works perfectly. But you see for device 1547 which rebooted, we get a completely wrong answer. The max value
[3:44:40] was from right before the reset and the min value was zero. And what we we’re not it’s not showing us any error. Look at these numbers here for device 1338. It reports around 2 million W hours, about 2 megawatt hours, which makes complete sense for a normal operating day. But if we look at 1547, it claims to have delivered over 44 million watt hours, 20 times more than normal. So why
[3:45:10] is that? It’s because the device rebooted, dropping the meter back to zero. The min value was zero right after the restart, and the max value was 44 million right before the restart. So naive math just built a customer for the charger’s entire multi-year lifetime accumulation in a single day. And notice the worst part, there is no error. Postcrest happily executed the query and handed you a completely plausible looking invoice with a catastrophic number. So here’s the correct way to do
[3:45:41] it using time scales hyperfunctions. The this is that is the actual energy delivered. The counter egg walks the readings in chronological order. So when it sees the value decrease, it recognizes that as a reset and safely adds the pre-reset accumulation to the total. We also see the numbum resets out of that. We also pull the num resets out of that same
[3:46:12] state. We clearly see that uh device 1547 reset exactly once. That is an incredibly useful metric on its own. A charger that resets five times a day is a charger you need to replace. This uses the same two-part pattern we learned in the previous project. You build a state using an aggregate function and you read the answer out using an accessor function. And because it’s a state, we can put it in a continuous aggregate.
[3:46:45] Rate gives you the average rate of energy increase scaled to watts. Because it’s derived directly from the metered counter rather than samples from the power gauge, it represents what was actually delivered according to the hardware meter. Now let’s see how that fits into our continuous aggregate ladder. To make our dashboards lightning fast, we are going to use continuous aggregates. These are materialized views that automatically summarize your data
[3:47:16] in the background. But look closely at what we are actually storing in our hourly view. We aren’t storing finished numbers. Every single aggregate column is storing an intermediate state. We’re looking at this view right here. So, why does this matter? If you take a standard average of Monday’s power usage and a standard average of Tuesday’s power usage, you cannot safely average those two numbers together to get a weekly average. The math is wrong. By
[3:47:49] using hyper functions like states agg and the counterag, we are storing the raw mathematical state. Think of it like having the raw ingredients instead of a baked cake. Because we save the state, we can easily roll those hourly buckets up into daily or weekly buckets later with perfect accuracy. And look at the refresh policy right below it. We tell time scale to update
[3:48:22] this every 5 minutes. But notice the end offset of 2 hours. We deliberately do not close the window right up to the current minute. In the real world, IoT devices lose their cellular connection and spend their and and send their data late. So leaving a two-hour buffer ensures that late arriving data gets perfectly incorporated into the summaries before the bucket is finalized. So now let’s look at the rung
[3:48:55] two of our ladder, the daily view. Notice that the device daily queries the device hourly, not the raw reading table. Because we store the mathematical states in rung one, rung two can just call rollup on those hourly buckets, it never has to scan the 100 million rows of raw data. This is how you keep queries fast at an infinite scale. And then we have this uh refresh rate
[3:49:28] here. Okay, let’s apply this file. So I’m just going to run this command. We already I’ve now stored our URL into the environment variable. Okay. Now let’s go let me go back into our database the PSQL. And remember continuous aggregates are empty until refreshed. And because device daily depends on device hourly, we must refresh bottom first.
[3:49:58] So I’m going to do these commands to refresh them and get our data in there. Okay, now let’s query total energy delivered today from our continuous aggregate. So that’s what this line will do. So we just rolled up hourly counter states across an entire day and difference them. And this math is reset aware across bucket boundaries. If a
[3:50:30] charger reboots at like 145955 right on an hourly theme, the calculation stays completely accurate. Now let’s find our second planted anom anomaly anomaly using state a in fleet management. The morning operations meeting always starts with how much did our charger spend charging idling or broken? So I’m going to run this SQL here.
[3:51:01] Notice the syntax for duration in here. With time scale hyperunctions, you always pass the aggregate state as the first argument and the specific value you are looking at you’re looking for as the second. So if we look at this top row here, one charger has been in the faulted state for almost the entire period. That is our second planted an anomaly.
[3:51:32] The charger never went offline. It faithfully reported every 10 seconds that its internal state was broken. A simple uptime alert would never catch it, but duration in surfaces it immediately without state. A writing this query in po post P post P post P post P post P post P post P post P post P post PostgreSQL requires a massive window function with lag or lead to detect state changes time timestamp differences to compute durations and complex boundary logic for sessions active when
[3:52:03] the window opened or closed with time scale it’s one clean function call if you need to draw a timeline chart on a front end you can extract the exact intervals So here’s the SQL to extract those intervals. You can see state timeline expands the state into discrete start and end timestamps. That’s an entire NAT chart ready for a front-end UI produced directly from SQL.
[3:52:35] Now for the third anomaly, the site outage. So I have this other SQL code up here that I just ran. This represents the hardest problem in telemetry which is silence. There are no error rows there. There is no faulted status. The hardware simply stop communicating. You cannot write a wear clause to find records that do not exist. So if we look at the results here, we
[3:53:06] see multiple devices showing the exact same downtime gap. Let’s confirm the scope of this outage by joining our data back to the site dimension table. Because we need to calculate the missing heartbeats per device before we can find the maximum downtime per site, we will use a common table expression, a width clause to structure the query cleanly. And so here is the entire SQL. And let’s just
[3:53:39] look at the result. Every single device showing that downtime belongs to the exact same site. And we can see where the the worst downtime is. So this tells you immediately that you aren’t dealing with individual charger hardware failures. The physical internet connection or cellular gateway at that facility went down. This is a much better health alert than threshold gou g gouges. A device reporting weird numbers is a problem you can triage later. A
[3:54:11] device reporting complete silence is broken, disconnected, or unpowered. Let’s look at another subtle trap with continuous sensor readings which are standard averages. So uh you see we’re calculating the averages different. So first the naive average and then the time weighted average. And if we look here, we can see the numbers are just very slightly different.
[3:54:42] So standard the average assumes every row represents an equal slice of time. But real networks readings clump together. If a device sits idle at zero watts for 10 minutes, reports once, and then rapidly streams 60 readings in one minute while pulling 50 kilowatts, a standard average weights those high readings 60 times more heavily than the idle period. And so one thing is that we don’t see the time weight function in this query and that’s because um we did
[3:55:12] the heavy lifting inside our device hourly continuous aggregate view earlier when we built this view. We pass linear to the time weights function that tells time scale to linear linearly interpolate between missing readings which is the correct mathematical model for continuous values like power draw or temperature. Now here in our select we just use roll up to seamlessly merge those pre-calculated hours states together and average to extract the final correct
[3:55:45] number. You can also use integral on that same state to calculate total energy directly from your power gauge and compare it against your counter. They won’t match to the exact decimal because one is sampled and what is integrated hardware pulses, but a large discrepancy flags a faulty sensor right away. The final hyperunction addresses chart rendering. A single charger
[3:56:15] generates about 260,000 readings over a 30-day period. If you send 260,000 JSON coordinates to an 800 pixel browser chart, you will freeze the UI thread and waste bandwidth. So the LTB function um stands for largest triangle three buckets is a downsampling algorithm built directly into the database engine. it can reduce those 260,000 points down to exactly 500 points while
[3:56:47] mathematically preserving visual peaks, troughs, and silhouettes. So if you use hourly averages, a brief 3inut power spike is smoothed into nothingness. Uh LTB gives the front end a lightweight payload that retains the true shape of the data. Okay, now let’s talk about data tiering. Now, I’ve already talked about it a little bit earlier in the course, but I want to go into even more detail. When you’re dealing with time series data,
[3:57:18] you’re dealing with massive scale. A medium-siz IoT fleet can generate terabytes of data a month. If you keep all that on ultra fast, premium NVMe solidstate drives, your database bill will bankrupt your project. But time series data has a very specific life cycle. It starts out hot and quickly turns cold. The readings from the last 24 hours are are queried constantly for real-time dashboards and alerting. But
[3:57:50] the readings from six months ago, those are only queried once a month for a historical report. Data tiering solves the cost problem by splitting your database across two different types of storage. Recent hot data stays on fast, expensive SSDs. Older cold data is automatically moved into cheap Amazon S3 object storage. The magic of time scales taring is that it
[3:58:21] is completely invisible to your application. You don’t have to write custom code to query on S3 bucket to query an S3 bucket and join it with your database. It all just looks like one single PostgreSQL table. And let me show you how to set it up. So before we can write any SQL, we have to provision the cloud infrastructure. That’s why I’m on the Tiger cloud console right now. If you try to run the
[3:58:51] SQL command to tear your data right now, PostgreSQL will throw an error saying the function does not exist. That’s because the object storage manager extension isn’t actually installed on our database yet. So to install it, we are on our project um and we’re going to go I’m going to go to the explore tab. Then I’m going to click data taring and then I’m just going to click enabled tiered storage
[3:59:23] and then let’s go. Now depending on the plan that you have on Tiger Data, you may have to upgrade your account before you can enable this. Now behind the scenes, Tiger Cloud is provisioning a dedicated S3 bucket, configuring the IM security roles and installing the time scale OSM extension directly into our Postgress instance. Now, because our terminal session was
[3:59:54] already open, it might not see the new extension yet. So, I’m going to quit out of this. And then I’m going to go right back. I’m going to reconnect right into the database. Now that the cloud infrastructure is wired up, creating our policy is just one line of SQL add tiering policy and it’s added. If I would have done this before, we did that stuff on the dashboard, it would have given an error, but this time it succeeds. From now on, any chunk containing data older than 3
[4:00:25] days will automatically slide off your expensive local disc and into cheap S3 storage. Now, let’s look at the most important foot gun with tiered storage. Tiered reads are disabled by default. So, if you try to query data than 4 days, it would just return zero. Not because the data is missing. it su it it would it successfully moved to object storage but our current connection is
[4:00:55] intentionally ignoring tiered chunks. Uh why is this off by default? Well, because scanning object storage across the network has higher latency than reading a local SSD. If tiered reads were turned on globally, a poorly written dashboard query could accidentally scan three years off three years of S3 data, stalling the connection and consuming resources. So to query your cold data, you have to explicitly opt in with set times DB
[4:01:26] enable tier reads to equal true. And then I could query data older than 4 days. Right now I actually don’t have any data over older than 4 days because I just set it up with uh just 24 hours of data. But if we did, we could query all the rows. And so the production best practice is to leave tiered reads turned off globally for your main application and only enable it selectively on the analytical roles that actually need historical access.
[4:01:57] So for instance alter ro reporting set and we can enable tiered reads for reporting. Now we don’t have that role right now but that’s just an example if we had that role. Okay, this is the final section of this part. We’re going to be talking about semantic search over agent failures. So, we’re going to combine time series telemetry with vector search inside a single Postgregql database. When an AI agent fails, standard keyword
[4:02:30] queries often miss the root cause due to phrasing differences. We want to run a semantic vector search restricted to errors from the past seven days. So look at this SQL right here and actually this whole file here. So we separate the embeddings into their own table. It’s called span embeddings because a 1,536 dimension float vector is about 6 kilobytes. We only embed
[4:03:00] spans that contain errors, avoiding massive overhead on healthy spans. Because this new table is also a hyper table, time scale uses chunk exclusion to skip older time windows entirely during the vector search. Okay, so I’ve now just applied this and now let’s look what hybrid query looks like. So let’s insert a mock error log into our new table so we have something to
[4:03:31] search for and then we will run our hybrid query. So this will put in the mock error in your application code. You would use a parameter variable here to pass in the real embedding generated by open AAI. Since we are in the terminal, I’m going to use Postgress’s array fill function to generate a Mach 13,536 dimensional vector on the fly. So, we can see it execute. And if you notice this little thing
[4:04:03] here, the distance operator, it computes cosine distance, which compares vector angle rather than magnitude and the and is the standard for text embeddings. Now remember this is remember this critical indexing rule for vector databases. Your index operator class must match your match your query operator. So we created our HNSW index using vector cosine ops. So watch what happens when we write a
[4:04:34] query using this other symbol here which is which is the operator for ukidian distance. If we look here, we can in the query plan, we see that it drops back to a sequential scan. Postgress cannot use the agents index because the math operators don’t match. Always verify that your query operator matches your index definition. This pattern completely eliminates the need for a dedicated separate vector database. You avoid running sync jobs
[4:05:07] between dis desperate systems. You eliminate eventual consistency lag and you can combine relational joins, time scale partitions and vector similarity all in a single unified SQL query plan. And before we wrap up this section, let’s step out of the terminal. Writing SQL is only half the job of managing a database. The other half is keeping it healthy in production. So let’s talk just a little bit more about the cloud
[4:05:37] tiger cloud console because this dashboard provides visibility that you simply cannot get from a command line. So when you open your service you land on the overview dashboard. This gives you your core telemetry CPU load memory usage uh the storage used here and more. When you are ingesting thousands of IoT readings per a second, this is where you watch for bottlenecks. If your CPU is constantly pegged at 99% or your memory
[4:06:10] starts swapping to disk, this dashboard tells you it’s time to scale up your compute. Now, let’s look at the explore tab. This is the visual control center specifically built for time series data. If you click into our reading table, you don’t just see a standard Postgress t table schema. The explorer understands hypert. It shows you exactly how many chunks exist and how much data is compressed. Now let’s look at the
[4:06:41] explore tab. This is a visual control center specifically built for time series data. If we click on the reading table here, we don’t just see a standard Postgress table schema. The explorer understands hyper tables. It shows you exactly how many chunks exist, how much data is compressed and how much storage you are saving. You can also see the continuous aggregates we built earlier
[4:07:13] like the device daily and device hourly. It visually tracks the background jobs refreshing those views. If a refresh job fails because of a bad data type or a timeout, it gets flagged right here. You don’t have to hunt through Postgress system tables to figure out why your dashboards are suddenly outdated. Now go to the monitoring tab. This is a continuous agentless query performance
[4:07:44] tracker. It basically watches every query your application runs, normalize them and ranks them. We can we have the logs, the jobs, we have the insights. Okay, so insights is giving us a like really the most information or a lot of information. We have connectors, we have operations, and there’s a bunch of things you can do on the operations tabs, but one thing I want to point out is the backup and restore and the create recovery fork. Basically in a traditional Postgress
[4:08:15] setup you have to test a massive schema migration or a destructive query. If you have you have if you need to do that you need you have to spend hours restoring a backup straight to a staging server. But in tiger data you just can create a fork because the platform uses copy and write storage. It spins up an exact bite for bite clone of your production database in minutes running an isolated running on isolated compute. You can run your destructive experiments on the fork,
[4:08:47] verify the results and then safely delete it. So production is completely untouched. So the visibility and operational safety is exactly why we’ve moved from a local Docker container to a managed cloud environment. Okay, this wraps up this section. We moved from local docker to manage cloud. We validated complete schema portability. Configure an IoT fleet with hyperfunctions to handle real world edge cases like counter resets and network
[4:09:17] outages. Configured tier storage and uh unified vector search with part-time with time series data. Before we finish, here are 12 things to check before running this in production. You can use the summary slide at the end of this as a reference. One, enabling column store and converting chunks are separate steps. The alter table setting configures column store. It doesn’t immediately convert existing chunks. Check whether a conversion policy already exists. If it does, it can
[4:09:49] process eligible old chunks as well as chunks that become eligible later. You can also convert a specific chunk manually when you want it done immediately. Two, check how many rows each segment will contain. A segment column with too many distinct values can leave you with small batches and poor compression. Aim for enough rows per segment within each chunk to fill batches about of about a thousand rows. For example, 8,000 readings for one
[4:10:20] device would fill several batches. It doesn’t mean there’s one batch containing all 8,000. The query on screen gives you an average so you can check for devices or users with very few records. If you segment by several columns, check their combined values. Three, choose sparse index settings before converting chunks. Changing the settings doesn’t retrofit chunks already in column store. to rebuild an existing chunk with chunk with new settings.
[4:10:53] Convert it back to row store and then to column store again that rewrites data. So plan for the for the time and disk space it needs. Four, include a time range and point lookups when you have one. An IDON lookup can require searching across many chunks. A time condition lets the planner exclude chunks outside the range. It doesn’t automatically reduce the query to one chunk. That depends on how wide the range is and how your chunks are
[4:11:24] partitioned. Use the narrowest correct range and check the plan. Five. Unique constraints must include every partitioning column. For a table partitioned only by time, that can mean a primary key on time and ID. If you’ve added another partitioning dimension, include that column too. UUIDv7 can help because the ID contains a timestamp, but UU UUID V7 alone doesn’t bypass the constraint rule. You still need the
[4:11:56] appropriate unique key and a check constraint that ties the time the time column to the time stamp in the ID. Six, keep aggregate refreshes inside the data you still retain. If a refresh covers a period whose source rows have been deleted, the corresponding aggregate rows can be removed, too. They aren’t necessarily replaced with zero. A chart might show a gap or display zero if the application fills missing values that way. Keep the refresh loop back
[4:12:28] comfortably shorter than source retention. For example, refreshing 7 days back while retaining 14 days gives you a buffer. Check this whenever either policy changes and be careful with manual refreshes over older periods. Seven, the reorder policy processes each chunk once and skips the two newest. A large backfill can add rows that no longer follow the chunk’s physical order. If that affects your queries, run
[4:12:58] reorder chunk again and on the affected chunks. And if you’re testing on fresh data, remember that the policy intentionally leaves the two newest chunks alone. Eight, separate real- time aggregation from refresh policy settings. Setting materialize only to false enables real time aggregation. A query then combines materialized results with new or raw data beyond the materialization watermark. with no data
[4:13:28] avoids populating the entire view when you create it. It’s useful for controlling the initial refresh, but it isn’t required to enable real time aggre aggregation. The offset controls how close to the present the refresh runs. 30 minutes for 15minute buckets is the choice shown here, not a universal minimum. Refreshes only complete buckets within their window. Choose the offset for your workload and how late your data
[4:14:00] arrives. Also remember that corrections to older already materialized buckets still need a refresh. Real time mode doesn’t automatically recalculate all of your history. Nine watch chunk count and active index size. Around a thousand chunks is use is a useful point to review planning overhead. Not a hard limit. An hourly interval kept for a year creates 8,760
[4:14:31] chunks. So that deserves a look. Keeping the indexes on actively written chunks within roughly a quarter of available memory is another starting guideline. Consider all the active hyper tables together. Measure your workload before changing the interval and remember that a new interval normally applies only to new chunks. 10. Check whether queries include tiered data. Tiered reads are disabled by default. Without enabling them, your query can emit chunks in object storage
[4:15:02] and still return a validlooking result. Enable the setting for sessions or roles that need that history. Expect reads from object storage to have different performance from reads on local storage. 11. Monitor background job failures. A failed refresh can leave the dashboard serving old results even though the dashboard query itself succeeds. Check job errors and arrange alerts for failures. We’ll look at the error query shortly.
[4:15:34] 12. Review postgressql settings before benchmarking. Memory limits, worker capacity, and parallelism can all affect your results. They don’t replace good schema design or appropriate time scale DB settings for a self-hosted database. Time scale DB tune or PG tune can provide a starting configuration. Review the recommendations, apply them, and restart if the changed settings require it. Then measure again. Manage services provide a configured starting point, but
[4:16:06] you still need to monitor your workload. Here are the 12 checks together. Pause here if you want to save the list. It’s also in the course repository. Now, five monitoring queries you can keep. One, how much space does compression save? This query shows before and after sizes and percentage saved for each hyper table. Watch for changes over time. A lower saving could come from smaller batches. a change in
[4:16:38] segment cardality or a change in the data itself. Treat it as a reason to investigate rather than a diagnosis on its own. Two, how many chunks do we have? This shows the total chunk count. How many are compressed and the oldest time range? If the count keeps growing, check planning time and whether your interval and retention still makes sense. Changing the interval doesn’t resize the chunks already there. Three, are the jobs succeeding? This combines job definitions with their execution
[4:17:10] statistics. Check the last run status, the last successful finish, and the failure count. A recent success doesn’t erase earlier failures, and a job that hasn’t run yet may have no execution statistics. Four, what failed. This shows the recent error messages with the job ID and finish time. Use it to investigate failed policies and to build alerts. An empty result means this query found no recorded errors in the selected period.
[4:17:42] It doesn’t prove every expected job ran. So keep checking job status too. Five. How recent is the data returned by each aggregate? This compares the end of the latest bucket with the current time. It’s a useful freshness signal, but it isn’t a direct measurement of refresh lag. If traffic stops, the latest bucket gets older even when every refresh succeeds. If real time aggregation is enabled, recent raw data can make the
[4:18:12] view look current even while materialized refreshes are failing. An empty view returns no age to measure. Use this alongside the expected arrival rate, job status, and job errors. Don’t alert on the bucket age alone without considering those conditions. Two other checks are worth keeping. First, background worker capacity. Allow room for the data time scale DB schedulers and the jobs that need to run at the same time. You don’t need a dedicated
[4:18:44] worker for every policy you’ve created. Also, check PostgresQL’s overall worker limit and whether jobs are being delayed. Second, use the usual PostgreSQL health checks, connections, cash hits, bloat, and longunning transactions. Keep using those alongside the time scale DB specific queries. Tigercloud also provides monitoring in its console. And four ideas to remember. First, hyperts organize data into chunks so queries and retention policies can work on the
[4:19:15] relevant ranges. Second, choose storage around how you use the data. Recent writes often suit row store while older analytical data can benefit from column store. Third, keep mergeable states when you need to combine summaries. Averages need their weights and supported percentile and distinct count states let you combine estimates correctly. Aggregates reduce repeated work, but they don’t guarantee constant query time as every part of your workload grows.
[4:19:46] Fourth, you’re still using Postgress. You can keep using its drivers, joins, and monitoring tools while accounting for the constraints and behavior of hypert. We’ve reached the end. Thanks for watching and happy coding.