- Escolhendo uma chave primária - Os schemas padrão usam um
ORDER BYotimizado para padrões de acesso específicos. É improvável que os seus padrões de acesso estejam alinhados com isso. - Extraindo estrutura - Você pode querer extrair novas colunas de colunas existentes, por exemplo, da coluna
Body. Isso pode ser feito usando colunas materializadas (e visões materializadas em casos mais complexos). Isso exige alterações no schema. - Otimizando Maps - Os schemas padrão usam o tipo Map para armazenar atributos. Essas colunas permitem armazenar metadados arbitrários. Embora isso seja uma capacidade essencial, já que os metadados de eventos muitas vezes não são definidos antecipadamente e, portanto, não podem ser armazenados de outra forma em um banco de dados fortemente tipado como o ClickHouse, o acesso às chaves do map e aos seus valores não é tão eficiente quanto o acesso a uma coluna normal. Resolvemos isso modificando o schema e garantindo que as chaves de map acessadas com mais frequência sejam colunas de nível superior - veja “Extracting structure with SQL”. Isso exige uma alteração no schema.
- Simplificando o acesso a chaves de map - Acessar chaves em maps exige uma sintaxe mais verbosa. Você pode mitigar isso com aliases. Veja “Using Aliases” para simplificar consultas.
- Índices secundários - O schema padrão usa índices secundários para acelerar o acesso a Maps e consultas de texto. Normalmente, eles não são necessários e consomem espaço adicional em disco. Eles podem ser usados, mas devem ser testados para garantir que sejam realmente necessários. Veja “Secondary / Data Skipping indices”.
- Usando codecs - Você pode querer personalizar codecs de colunas se conhecer os dados esperados e tiver evidências de que isso melhora a compressão.
Extraindo a estrutura com SQL
- Extrair colunas de blobs de string. Consultá-las será mais rápido do que usar operações de string no momento da consulta.
- Extrair chaves de maps. O schema padrão coloca atributos arbitrários em colunas do tipo Map. Esse tipo oferece uma capacidade sem schema, com a vantagem de que os usuários não precisam predefinir colunas para atributos ao definir logs e traces — muitas vezes, isso é impossível ao coletar logs do Kubernetes e querer garantir que os labels do pod sejam preservados para pesquisas posteriores. Acessar chaves de map e seus valores é mais lento do que consultar colunas normais do ClickHouse. Portanto, muitas vezes é desejável extrair chaves de maps para colunas de nível raiz da tabela.
Body como uma String. Além disso, ele também pode ser armazenado na coluna LogAttributes como um Map(String, String) se o usuário tiver habilitado o json_parser no collector.
LogAttributes esteja disponível, a consulta para contar quais caminhos de URL do site recebem mais solicitações POST:
LogAttributes['request_path'], e da função path para remover os parâmetros de consulta da URL.
Se o usuário não tiver habilitado o parsing de JSON no collector, LogAttributes ficará vazio, o que nos obriga a usar funções JSON para extrair as colunas da String Body.
Prefira o ClickHouse para fazer o parsingEm geral, recomendamos que os usuários façam o parsing de JSON de logs estruturados no ClickHouse. Estamos confiantes de que o ClickHouse oferece a implementação de parsing de JSON mais rápida. No entanto, reconhecemos que talvez você queira enviar logs para outras sources e não queira que essa lógica fique no SQL.
extractAllGroupsVertical.
Considere DictionariesA consulta acima poderia ser otimizada para aproveitar Dictionaries de expressões regulares. Veja Usando Dictionaries para mais detalhes.
OTel ou ClickHouse para processamento?Você também pode realizar o processamento usando processadores e operadores do OTel collector, conforme descrito aqui. Na maioria dos casos, você verá que o ClickHouse é significativamente mais eficiente em termos de recursos e mais rápido do que os processadores do OTel collector. A principal desvantagem de realizar todo o processamento de eventos em SQL é o acoplamento da sua solução ao ClickHouse. Por exemplo, você pode querer enviar logs processados para destinos alternativos a partir do OTel collector, como o S3.
Colunas materializadas
INSERT.
SobrecargaAs colunas materializadas geram uma sobrecarga adicional de armazenamento, pois os valores são extraídos para novas colunas em disco no momento da inserção.
LogAttributes pelo collector:
Body do tipo String pode ser encontrado aqui.
Nossas três colunas materializadas extraem a página da requisição, o tipo de requisição e o domínio de referência. Elas acessam as chaves do map e aplicam funções aos seus valores. Nossa consulta seguinte é significativamente mais rápida:
Por padrão, colunas materializadas não são retornadas em um
SELECT *. Isso preserva a propriedade de que o resultado de um SELECT * sempre pode ser inserido de volta na tabela usando INSERT. Esse comportamento pode ser desativado definindo asterisk_include_materialized_columns=1 e pode ser ativado no Grafana (consulte Additional Settings -> Custom Settings na configuração da fonte de dados).Visões materializadas
Atualizações em tempo realAs visões materializadas no ClickHouse são atualizadas em tempo real à medida que os dados fluem para a tabela em que se baseiam, funcionando mais como índices atualizados continuamente. Em contraste, em outros bancos de dados, visões materializadas normalmente são snapshots estáticos de uma consulta que precisam ser atualizados (semelhante às Refreshable Materialized Views do ClickHouse).
SELECT como possível.
É importante lembrar que a consulta é apenas um gatilho executado sobre as linhas que estão sendo inseridas em uma tabela (a tabela de origem), com os resultados enviados para uma nova tabela (a tabela de destino).
Para garantir que não persistamos os dados duas vezes (nas tabelas de origem e de destino), podemos alterar a tabela de origem para usar um motor de tabela Null, preservando o schema original. Nossos OTel collectors continuarão enviando dados para essa tabela. Por exemplo, para logs, a tabela otel_logs se torna:
/dev/null. Esta tabela não armazenará nenhum dado, mas quaisquer visões materializadas anexadas ainda serão executadas sobre as linhas inseridas antes de serem descartadas.
Considere a seguinte consulta. Ela transforma nossas linhas em um formato que queremos preservar, extraindo todas as colunas de LogAttributes (pressupomos que isso tenha sido definido pelo collector usando o operador json_parser), definindo SeverityText e SeverityNumber (com base em algumas condições simples e na definição dessas colunas). Neste caso, também selecionamos apenas as colunas que sabemos que serão preenchidas, ignorando colunas como TraceId, SpanId e TraceFlags.
Body acima — caso atributos adicionais sejam incluídos posteriormente e não sejam extraídos pelo nosso SQL. Essa coluna deve comprimir bem no ClickHouse e raramente será acessada, sem impacto no desempenho das consultas. Por fim, reduzimos o Timestamp para um DateTime (para economizar espaço — consulte “Optimizing Types”) com um cast.
CondicionaisObserve o uso de condicionais acima para extrair
SeverityText e SeverityNumber. São extremamente úteis para formular condições complexas e verificar se há valores definidos em maps — partimos ingenuamente do pressuposto de que todas as chaves existem em LogAttributes. Recomendamos que os usuários se familiarizem com elas — são grandes aliadas no parsing de logs, além das funções para lidar com valores nulos!Observe como alteramos drasticamente nosso schema. Na prática, você provavelmente também terá colunas de trace que vai querer preservar, assim como a coluna
ResourceAttributes (ela geralmente contém metadados do Kubernetes). O Grafana pode usar colunas de trace para criar links entre logs e traces - veja “Usando o Grafana”.otel_logs_mv, que executa a consulta acima para a tabela otel_logs e envia os resultados para otel_logs_v2.
otel_logs_v2 no formato desejado. Observe o uso de funções tipadas de extração de JSON.
Body com funções JSON, é mostrada abaixo:
Atenção aos tipos
LogAttributes. O ClickHouse frequentemente converte de forma transparente o valor extraído para o tipo da tabela de destino, reduzindo a sintaxe necessária. No entanto, recomendamos que os usuários sempre testem suas visões usando a instrução SELECT da visão com uma instrução INSERT INTO em uma tabela de destino com o mesmo schema. Isso deve confirmar que os tipos estão sendo tratados corretamente. Dê atenção especial aos seguintes casos:
- Se uma chave não existir em um map, uma string vazia será retornada. No caso de valores numéricos, você precisará mapeá-los para um valor apropriado. Isso pode ser feito com condicionais, por exemplo,
if(LogAttributes['status'] = ", 200, LogAttributes['status']), ou com funções de conversão, se valores padrão forem aceitáveis, por exemplo,toUInt8OrDefault(LogAttributes['status'] ) - Alguns tipos nem sempre serão convertidos; por exemplo, representações em string de valores numéricos não serão convertidas em valores
enum. - As funções de extração de JSON retornam valores padrão para seu tipo quando um valor não é encontrado. Verifique se esses valores fazem sentido!
Escolhendo uma chave primária (de ordenação)
- Selecione colunas alinhadas aos filtros e padrões de acesso mais comuns. Se você normalmente inicia investigações de observabilidade filtrando por uma coluna específica, por exemplo, o nome do pod, essa coluna será usada com frequência em cláusulas
WHERE. Priorize incluí-las na chave em vez de colunas usadas com menos frequência. - Prefira colunas que ajudem a excluir uma grande porcentagem do total de linhas quando filtradas, reduzindo assim a quantidade de dados que precisa ser lida. Nomes de serviço e códigos de status costumam ser bons candidatos — neste último caso, porém, apenas se você filtrar por valores que excluam a maior parte das linhas. Por exemplo, filtrar por códigos 200 corresponderá, na maioria dos sistemas, à maior parte das linhas, enquanto erros 500 corresponderão a um pequeno subconjunto.
- Prefira colunas com alta correlação com outras colunas da tabela. Isso ajuda a garantir que esses valores também sejam armazenados de forma contígua, melhorando a compressão.
- Operações
GROUP BYeORDER BYem colunas da chave de ordenação podem se tornar mais eficientes em termos de memória.
Após identificar o subconjunto de colunas para a chave de ordenação, elas devem ser declaradas em uma ordem específica. Essa ordem pode influenciar significativamente tanto a eficiência da filtragem em colunas secundárias da chave nas consultas quanto a taxa de compressão dos arquivos de dados da tabela. Em geral, é melhor ordenar as chaves em ordem crescente de cardinalidade. Isso deve ser equilibrado com o fato de que a filtragem em colunas que aparecem mais tarde na chave de ordenação será menos eficiente do que a filtragem naquelas que aparecem antes na tupla. Equilibre esses fatores e leve em conta seus padrões de acesso. Mais importante ainda, teste variantes. Para entender melhor as chaves de ordenação e como otimizá-las, recomendamos este artigo.
Estrutura primeiroRecomendamos definir suas chaves de ordenação depois de estruturar seus logs. Não use chaves em maps de atributos na chave de ordenação nem expressões de extração de JSON. Certifique-se de que suas chaves de ordenação estejam como colunas raiz na tabela.
Usando maps
map['key'] para acessar valores nas colunas Map(String, String). Além de usar a notação de map para acessar chaves aninhadas, há funções de map especializadas do ClickHouse disponíveis para filtrar ou selecionar essas colunas.
Por exemplo, a consulta a seguir identifica todas as chaves exclusivas disponíveis na coluna LogAttributes usando a função mapKeys, seguida da função groupArrayDistinctArray (um combinador).
Evite pontosNão recomendamos usar pontos em nomes de colunas do tipo Map e poderemos descontinuar esse uso. Use um
_.Usando aliases
map é mais lento do que consultar colunas normais - veja “Acelerando consultas”. Além disso, a sintaxe é mais complexa e pode ser trabalhosa de escrever. Para contornar esse último problema, recomendamos usar colunas Alias.
As colunas ALIAS são calculadas em tempo de consulta e não são armazenadas na tabela. Portanto, é impossível fazer INSERT de um valor em uma coluna desse tipo. Com aliases, podemos referenciar chaves de map, simplificar a sintaxe e expor entradas de map de forma transparente como uma coluna normal. Considere o exemplo a seguir:
ALIAS, RemoteAddr, que acessa o map LogAttributes. Agora podemos consultar os valores de LogAttributes['remote_addr'] por essa coluna, simplificando nossa consulta, ou seja:
ALIAS é simples usando o comando ALTER TABLE. Essas colunas ficam disponíveis imediatamente, por exemplo.
Alias excluído por padrãoPor padrão,
SELECT * exclui colunas ALIAS. Esse comportamento pode ser desativado definindo asterisk_include_alias_columns=1.Otimizando tipos
Usando codecs
ZSTD costuma ser bastante adequado para conjuntos de dados de logs e traces. Aumentar o nível de compressão em relação ao valor padrão, 1, pode melhorar a compressão. No entanto, isso deve ser testado, pois valores mais altos aumentam a sobrecarga de CPU no momento da inserção. Normalmente, observamos pouco ganho ao aumentar esse valor.
Além disso, embora os timestamps se beneficiem da codificação delta em termos de compressão, eles podem piorar o desempenho das consultas se essa coluna for usada na chave primária/de ordenação. Recomendamos que os usuários avaliem os trade-offs entre compressão e desempenho de consulta.
Usando dicionários
Acelerando junçõesUsuários interessados em acelerar junções com dicionários podem encontrar mais detalhes aqui.
Tempo de inserção vs tempo de consulta
- Tempo de inserção - Em geral, isso é apropriado quando o valor de enriquecimento não muda e existe em uma fonte externa que pode ser usada para preencher o dicionário. Nesse caso, enriquecer a linha no tempo de inserção evita a consulta ao dicionário no tempo de consulta. Isso tem um custo em termos de desempenho de inserção, além de uma sobrecarga adicional de armazenamento, já que os valores enriquecidos serão armazenados como colunas.
- Tempo de consulta - Se os valores em um dicionário mudam com frequência, as consultas no tempo de consulta costumam ser mais adequadas. Isso evita a necessidade de atualizar colunas (e reescrever dados) se os valores mapeados mudarem. Essa flexibilidade vem com o custo de uma consulta no tempo de consulta. Esse custo normalmente só é perceptível quando a consulta é necessária para muitas linhas, por exemplo, ao usar uma consulta ao dicionário em uma cláusula de filtro. Para enriquecimento de resultados, ou seja, no
SELECT, essa sobrecarga normalmente não é perceptível.
Usando dicionários de IP
ip_trie.
Usamos o dataset do DB-IP em nível de cidade, disponível publicamente e fornecido pela DB-IP.com, nos termos da licença CC BY 4.0.
No readme, podemos ver que os dados estão estruturados da seguinte forma:
URL() para criar um objeto de tabela no ClickHouse com os nomes dos nossos campos e confirmar o número total de linhas:
ip_trie exige que os intervalos de endereços IP sejam expressos em notação CIDR, precisaremos transformar ip_range_start e ip_range_end.
O CIDR de cada intervalo pode ser calculado de forma concisa com a seguinte consulta:
Há muita coisa acontecendo na consulta acima. Para quem tiver interesse, leia esta excelente explicação. Caso contrário, basta entender que o trecho acima calcula um CIDR para um intervalo de IP.
ip_trie estrutura de dicionário para mapear nossos prefixos de rede (blocos CIDR) para coordenadas e códigos de país. A consulta a seguir especifica um dicionário usando esse layout e a tabela acima como origem.
Atualização periódicaOs dicionários no ClickHouse são atualizados periodicamente com base nos dados da tabela subjacente e na cláusula lifetime usada acima. Para atualizar nosso dicionário Geo IP e refletir as alterações mais recentes no dataset DB-IP, basta reinserir os dados da tabela remota geoip_url na nossa tabela
geoip, com as transformações aplicadas.ip_trie (também chamado, convenientemente, de ip_trie), podemos usá-lo para geolocalização de IP. Isso pode ser feito usando a função dictGet(), como mostrado a seguir:
RemoteAddress extraída.
Atualize periodicamenteÉ provável que os usuários queiram que o dicionário de enriquecimento de IP seja atualizado periodicamente com base em novos dados. Isso pode ser feito usando a cláusula
LIFETIME do dicionário, o que fará com que ele seja recarregado periodicamente a partir da tabela subjacente. Para atualizar a tabela subjacente, consulte “Views materializadas atualizáveis”.Usando dicionários regex (parsing de user agent)
Nos exemplos abaixo, usamos snapshots das expressões regulares mais recentes do uap-core para parsing de user-agent de junho de 2024. O arquivo mais recente, que é atualizado ocasionalmente, pode ser encontrado aqui. Você pode seguir os passos aqui para carregar os dados no arquivo CSV usado abaixo.
otel_logs_v2:
Tuplas para estruturas complexasObserve o uso de Tuplas nessas colunas de user agent. Tuplas são recomendadas para estruturas complexas em que a hierarquia é conhecida de antemão. As subcolunas oferecem o mesmo desempenho que as colunas comuns (ao contrário das chaves de map), permitindo ao mesmo tempo tipos heterogêneos.
Leitura complementar
Acelerando consultas
Usando visões materializadas (incrementais) para agregações
Esta consulta seria 10x mais rápida se usássemos a tabela
otel_logs_v2, que resulta da nossa visão materializada anterior e extrai a chave size do map LogAttributes. Usamos os dados brutos aqui apenas para fins ilustrativos e recomendamos usar a visão anterior se esta for uma consulta comum.bytes_per_hour esteja vazia e ainda não tenha recebido nenhum dado. Nossa visão materializada executa o SELECT acima sobre os dados inseridos em otel_logs (isso será feito em blocos de tamanho configurado), com os resultados enviados para bytes_per_hour. A sintaxe é mostrada abaixo:
TO aqui é fundamental, pois indica para onde os resultados serão enviados, ou seja, bytes_per_hour.
Se reiniciarmos nosso OTel collector e reenviarmos os logs, a tabela bytes_per_hour será preenchida incrementalmente com o resultado da consulta acima. Ao final, podemos confirmar o tamanho da bytes_per_hour — devemos ter 1 linha por hora:
otel_logs) para 113 ao armazenar o resultado da nossa consulta. O ponto principal é que, se novos logs forem inseridos na tabela otel_logs, novos valores serão enviados para bytes_per_hour na hora correspondente, onde serão automaticamente mesclados de forma assíncrona em segundo plano — mantendo apenas uma linha por hora, bytes_per_hour será sempre pequena e atualizada.
Como a mesclagem das linhas é assíncrona, pode haver mais de uma linha por hora quando um usuário fizer uma consulta. Para garantir que todas as linhas pendentes sejam mescladas no momento da consulta, temos duas opções:
- Usar o modificador
FINALno nome da tabela (o que fizemos na consulta de contagem acima). - Agregar pela chave de ordenação usada na nossa tabela final, ou seja, Timestamp, e somar as métricas.
Esses ganhos podem ser ainda maiores em conjuntos de dados maiores e com consultas mais complexas. Veja aqui para ver exemplos.
Um exemplo mais complexo
UniqueUsers como do tipo AggregateFunction, especificando a função de origem dos estados parciais (uniq) e o tipo da coluna de origem (IPv4). Assim como no SummingMergeTree, linhas com o mesmo valor da chave ORDER BY serão mescladas (Hour no exemplo acima).
A visão materializada associada usa a consulta anterior:
State ao final das nossas funções de agregação. Isso garante que o estado de agregação da função seja retornado em vez do resultado final. Ele conterá informações adicionais que permitem mesclar esse estado parcial com outros estados.
Depois que os dados forem recarregados, por meio de uma reinicialização do Collector, poderemos confirmar que 113 linhas estão disponíveis na tabela unique_visitors_per_hour.
GROUP BY aqui em vez de FINAL.
Usando visões materializadas (incrementais) para consultas rápidas
ServiceName, SpanName e Timestamp. Em tracing, os usuários também precisam conseguir fazer consultas por um TraceId específico e recuperar os spans associados a esse trace. Embora isso esteja presente na chave de ordenação, sua posição no final significa que a filtragem não será tão eficiente e provavelmente fará com que seja necessário varrer volumes significativos de dados ao recuperar um único trace.
O OTel collector também instala uma visão materializada e a tabela associada para resolver esse desafio. A tabela e a visão são mostradas abaixo:
otel_traces_trace_id_ts tenha o timestamp mínimo e máximo do trace. Essa tabela, ordenada por TraceId, permite recuperar esses timestamps com eficiência. Esses intervalos de timestamp podem, por sua vez, ser usados ao consultar a tabela principal otel_traces. Mais especificamente, ao recuperar um trace pelo ID, o Grafana usa a seguinte consulta:
ae9226c78d1d360601e6383928e4d22d, antes de usá-los para filtrar a tabela principal otel_traces pelos spans associados.
Essa mesma abordagem pode ser aplicada a padrões de acesso semelhantes. Exploramos um exemplo parecido em Modelagem de dados aqui.
Usando projeções
ORDER BY para uma tabela.
Nas seções anteriores, exploramos como visões materializadas podem ser usadas no ClickHouse para pré-computar agregações, transformar linhas e otimizar consultas de observabilidade para diferentes padrões de acesso.
Apresentamos um exemplo em que a visão materializada envia linhas para uma tabela de destino com uma chave de ordenação diferente da tabela original que recebe inserções, a fim de otimizar buscas por ID de trace.
As projeções podem ser usadas para resolver o mesmo problema, permitindo que o usuário otimize consultas em uma coluna que não faz parte da chave primária.
Em teoria, essa capacidade pode ser usada para fornecer múltiplas chaves de ordenação para uma tabela, com uma desvantagem clara: duplicação de dados. Especificamente, os dados precisarão ser gravados na ordem da chave primária principal, além da ordem especificada para cada projeção. Isso tornará as inserções mais lentas e consumirá mais espaço em disco.
Projeções vs. visões materializadasAs projeções oferecem muitos dos mesmos recursos que as visões materializadas, mas devem ser usadas com parcimônia, sendo as visões materializadas geralmente a opção preferida. Você deve entender as desvantagens e quando elas são apropriadas. Por exemplo, embora as projeções possam ser usadas para pré-computar agregações, recomendamos que os usuários usem visões materializadas para isso.
otel_logs_v2 por códigos de erro 500. Esse provavelmente é um padrão de acesso comum em logs, já que os usuários costumam querer filtrar por códigos de erro:
Use Null para medir o desempenhoNão exibimos os resultados aqui usando
FORMAT Null. Isso faz com que todos os resultados sejam lidos, mas não retornados, evitando assim que a consulta seja encerrada antecipadamente por causa de um LIMIT. Isso serve apenas para mostrar o tempo necessário para varrer todas as 10 milhões de linhas.(ServiceName, Timestamp). Embora pudéssemos adicionar Status ao fim da chave de ordenação, melhorando o desempenho da consulta acima, também podemos adicionar uma projeção.
ALTER, sua criação ocorrerá de forma assíncrona quando o comando MATERIALIZE PROJECTION for emitido. Você pode acompanhar o progresso dessa operação com a consulta a seguir, aguardando até que is_done=1.
SELECT * aqui, todas as colunas seriam armazenadas. Embora isso permitisse que mais consultas (usando qualquer subconjunto de colunas) se beneficiassem da projeção, isso também consumiria armazenamento adicional. Para medir o espaço em disco e a compressão, consulte “Medindo o tamanho da tabela e a compressão”.
Índices secundários/de data skipping
Índice de texto para busca de texto completo
tokenizer em sua definição. Opcionalmente, uma função de pré-processador pode ser especificada para transformar a string de entrada antes da tokenização.
As funções recomendadas para pesquisar no índice são: hasAnyTokens e hasAllTokens.
Algumas funções tradicionais de busca em strings também são otimizadas automaticamente quando há um índice de texto.
Consulte a documentação para mais detalhes e funções compatíveis aqui e aqui.
Nos exemplos abaixo, usamos um conjunto de dados estruturado de logs.
hasAnyTokens sem índice de texto, mas a consulta fará uma varredura completa e lenta da coluna Body:
Adicionando um índice de texto
ALTER TABLE:
Usando um pré-processador
msg, id, ctx, attr, etc.).
Suponha que estejamos interessados apenas em pesquisar no campo msg.
Em vez de indexar a string JSON inteira, podemos definir um pré-processador para extrair somente o valor de msg antes da tokenização.
Por exemplo:
- reduz a quantidade de texto tokenizada e indexada,
- diminui o tamanho do índice,
- reduz a probabilidade de falsos positivos e
- melhora o desempenho das consultas.