Introdução
Conceitos do ClickHouse
As tabelas são atribuídas a bancos de dados no ClickHouse. Por padrão, o banco de dados
default é usado — isso pode ser alterado no OpenTelemetry Collector.
No mínimo, você deve entender os seguintes fundamentos do ClickHouse:
Esses conceitos são centrais para o desempenho do ClickHouse. Eles determinam como os dados são gravados, como são estruturados em disco e com que eficiência o ClickHouse pode evitar a leitura de dados no momento da consulta. Cada otimização neste guia, seja com colunas materializadas, skip indexes, chaves primárias, projeções ou visões materializadas, baseia-se nesses mecanismos fundamentais.
Recomenda-se que você revise a seguinte documentação do ClickHouse antes de realizar qualquer otimização de desempenho:
- Criando tabelas no ClickHouse - Uma introdução simples às tabelas.
- partes
- partições
- mesclagens
- Primary keys/indexes
- Como o ClickHouse armazena dados: parts e grânulos - Guia mais avançado sobre como os dados são estruturados e consultados no ClickHouse, cobrindo grânulos e chaves primárias em detalhes.
- MergeTree- Guia avançado de referência do MergeTree útil para comandos e detalhes internos.
Otimização 1. Materialize os atributos consultados com frequência
LogAttributes, ScopeAttributes e ResourceAttributes e promovê-los a colunas de nível superior por meio de colunas materializadas.
Só essa otimização muitas vezes já é suficiente para escalar implantações do ClickStack para dezenas de terabytes por dia e deve ser aplicada antes de considerar técnicas de ajuste mais avançadas.
Por que materializar atributos
Map(String, String). Embora isso ofereça flexibilidade, consultar subchaves de um Map tem uma implicação importante de desempenho.
Ao consultar uma única chave de uma coluna Map, o ClickHouse precisa ler a coluna Map inteira do disco. Se o Map contiver muitas chaves, isso resulta em I/O desnecessária e consultas mais lentas em comparação com a leitura de uma coluna dedicada.
Materializar atributos acessados com frequência evita essa sobrecarga ao extrair o valor no momento da inserção e armazená-lo como uma coluna propriamente dita.
Colunas materializadas:
- São calculadas automaticamente durante as inserções
- Não podem ser definidas explicitamente em instruções INSERT
- Oferecem suporte a qualquer expressão do ClickHouse
- Permitem conversão de tipo de String para tipos numéricos ou de data mais eficientes
- Permitem o uso de skip indexes e chave primária
- Reduzem leituras do disco ao evitar o acesso completo ao Map
O ClickStack detecta automaticamente colunas materializadas extraídas de Maps e as usa de forma transparente durante a execução da consulta, mesmo quando os usuários continuam consultando o caminho original do atributo.
Exemplo
ResourceAttributes:
ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
Isso gera um predicado SQL semelhante a:
Map, o ClickHouse precisa ler a coluna ResourceAttributes inteira para cada linha correspondente — o que pode ser muito grande se o Map contiver muitas chaves.
Se esse atributo for consultado com frequência, ele deverá ser materializado como uma coluna de nível superior.
Para extrair o nome do pod do Kubernetes no momento da inserção, adicione uma coluna materializada:
PodName.
Agora, os usuários podem consultar nomes de pod com eficiência usando a sintaxe Lucene, por exemplo: PodName:"checkout-675775c4cc-f2p9c"
Para dados inseridos recentemente, isso elimina totalmente o acesso ao map e reduz significativamente a E/S.
No entanto, mesmo que os usuários continuem consultando o caminho do atributo original, por exemplo, ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c", o ClickStack reescreverá automaticamente a consulta internamente para usar a coluna PodName materializada, ou seja, usando o predicado:
Por padrão, as colunas materializadas são excluídas de
SELECT * queries. Isso preserva a invariante de que os resultados da consulta sempre podem ser reinseridos na tabela.Materialização de dados históricos
system.mutations, por exemplo.
is_done = 1 para a mutação correspondente.
Otimização 2. Adicionando skip indexes
- Filtragem de strings com alta cardinalidade, como TraceId, identificadores de sessão, chaves de atributos ou valores
- Filtragem por faixa numérica, como a duração de um span
Filtros de Bloom
PodName:"checkout-675775c4cc-f2p9c".
Os filtros de Bloom são mais eficazes quando a distribuição dos valores faz com que um determinado valor apareça em um número relativamente pequeno de partes. Isso costuma ocorrer naturalmente em cargas de trabalho de observabilidade, em que metadados como nomes de pods do Kubernetes, IDs de trace ou identificadores de sessão estão correlacionados com o tempo e, portanto, agrupados pela chave de ordenação da tabela.
Como acontece com todos os skip indexes, os filtros de Bloom devem ser adicionados de forma seletiva e validados com base em padrões reais de consulta para garantir que ofereçam um benefício mensurável - consulte “Avaliando a eficácia do skip index.”
Índices min-max
SpanAttributes:
Materializar skip index
Materialização de skip indexesMaterializar um skip index geralmente é uma operação leve e segura, especialmente no caso de índices minmax. Para índices de filtro de Bloom em datasets grandes, os usuários podem preferir materializar por partição para ter mais controle sobre o uso de recursos, por exemplo:
is_done = 1 para a mutação correspondente.
Quando estiver concluída, confirme que os dados do índice foram criados:
0.01 para 0.05 produz um índice menor, que é avaliado mais rapidamente, ao custo de uma poda menos agressiva. Embora menos grânulos possam ser ignorados, a latência geral da consulta pode melhorar devido à avaliação mais rápida do índice.
Ajustar os parâmetros do filtro de Bloom é, portanto, uma otimização que depende da carga de trabalho e deve ser validada com padrões reais de consulta e volumes de dados semelhantes aos de produção.
Para mais detalhes sobre skip indices, consulte o guia “Entendendo os data skipping indexes do ClickHouse.”
Avaliando a eficácia dos skip indexes
EXPLAIN indexes = 1, que mostra quantas partes e grânulos são eliminados em cada etapa do planejamento da consulta. Na maioria dos casos, o ideal é ver uma grande redução no número de grânulos na etapa Skip, de preferência depois que a chave primária já tiver reduzido o espaço de busca. Os skip indexes são avaliados após o pruning de partições e o pruning pela chave primária, portanto seu impacto é medido melhor em relação às partes e aos grânulos restantes.
EXPLAIN confirma se o pruning ocorre, mas não garante, por si só, um ganho líquido de desempenho. Os skip indexes têm um custo de avaliação, especialmente se o índice for grande. Sempre faça benchmark das consultas antes e depois de adicionar e materializar um índice para confirmar melhorias reais de desempenho.
Por exemplo, considere o skip index padrão com filtro de Bloom para TraceId incluído no esquema padrão de Traces:
EXPLAIN indexes = 1 para ver a eficácia dele em uma consulta seletiva:
FORMAT Null para evitar a sobrecarga da serialização dos resultados e desative o cache de condições de consulta para manter as execuções repetíveis:
use_query_condition_cache garante que os resultados não sejam afetados por decisões de filtragem armazenadas em cache, e definir use_skip_indexes = 0 fornece uma referência limpa para comparação. Se a poda for eficaz e o custo de avaliação do índice for baixo, a consulta indexada deverá ser significativamente mais rápida, como no exemplo acima.
Quando adicionar skip indexes
Otimização 3. Modificando a chave primária
Uma observação sobre a terminologiaAo longo deste documento, o termo “chave de ordenação” é usado de forma intercambiável com “chave primária”. Em termos estritos, eles diferem no ClickHouse, mas, no ClickStack, normalmente se referem às mesmas colunas especificadas na cláusula
ORDER BY da tabela. Para mais detalhes, consulte a documentação do ClickHouse sobre como escolher uma chave primária diferente da chave de ordenação.- Logs (
otel_logs) -(ServiceName, TimestampTime, Timestamp) - Traces (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
Escolhendo uma chave primária
Modificando a chave primária padrãoAs chaves primárias padrão são suficientes na maioria dos casos. As alterações devem ser feitas com cautela e somente com uma compreensão clara dos padrões de consulta. Modificar uma chave primária pode degradar o desempenho de outros fluxos de trabalho, portanto, é essencial testar.
- Selecione colunas que estejam alinhadas com seus 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 sua chave em vez daquelas 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ços e códigos de status costumam ser bons candidatos — neste último caso, apenas se você filtrar por valores que excluam a maioria das linhas; por exemplo, filtrar por códigos 200, na maioria dos sistemas, corresponderá à maior parte das linhas, em comparação com erros 500, que 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 BY(agregações para gráficos) eORDER BY(ordenação) em colunas da chave de ordenação podem ser mais eficientes em termos de memória.
Alterando a chave primária
SeverityText antes de ServiceName.
1
Criar nova tabela
Chave de ordenação vs chave primáriaObserve que, no exemplo acima, é necessário especificar
PRIMARY KEY e ORDER BY.
No ClickStack, elas quase sempre são iguais.
O ORDER BY controla o layout físico dos dados, enquanto a PRIMARY KEY define o índice esparso.
Em casos raros de workloads muito grandes, elas podem ser diferentes, mas a maioria dos usuários deve mantê-las alinhadas.2
Fazer EXCHANGE e excluir a tabela
A instruçãoEXCHANGE é usada para trocar os nomes das tabelas de forma atômica. A tabela temporária (agora a antiga tabela padrão) pode então ser excluída.Fazer backfill dos dados existentes em uma nova tabela raramente vale a pena em escala. O custo de compute e de E/S geralmente é alto e não justifica os ganhos de desempenho. Em vez disso, deixe os dados mais antigos expirarem via TTL, enquanto os dados mais novos se beneficiam da chave aprimorada.
SeverityText como a primeira coluna da chave primária é usado abaixo. Nesse caso, uma tabela é criada para novos dados, mantendo a tabela antiga para análise histórica.
1
Criar nova tabela
Crie a nova tabela com a chave primária desejada. Observe o sufixo_23_01_2025 — adapte-o para a data atual. Por exemplo:2
Criar uma tabela Merge
O motor Merge (não confundir com MergeTree) não armazena dados por si só, mas permite ler de várias outras tabelas simultaneamente.currentDatabase() pressupõe que o comando seja executado no banco de dados correto. Caso contrário, especifique o nome do banco de dados explicitamente.otel_logs.3
Atualizar o HyperDX para ler da tabela Merge
Configure o HyperDX para usarotel_logs_merge como tabela da fonte de dados de logs.Neste ponto, as escritas continuam em otel_logs com a chave primária original, enquanto as leituras usam a tabela Merge. Não há mudança visível para os usuários nem impacto na ingestão.4
Fazer EXCHANGE das tabelas
Agora, uma instruçãoEXCHANGE é usada para trocar, de forma atômica, os nomes das tabelas otel_logs e otel_logs_23_01_2025.otel_logs com a chave primária atualizada. Os dados existentes permanecem em otel_logs_23_01_2025 e continuam acessíveis pela tabela Merge. O sufixo indica a data em que a mudança foi aplicada e representa o timestamp mais recente contido nessa tabela.Esse processo permite alterar a chave primária sem interromper a ingestão e sem impacto visível para o usuário.SeverityNumber deve fazer parte da chave primária, em vez de SeverityText. O processo a seguir pode ser adaptado quantas vezes forem necessárias alterações na chave primária.
1
Criar nova tabela
Crie a nova tabela com a chave primária desejada. No exemplo abaixo,30_01_2025 é usado como sufixo para indicar a data da tabela. Por exemplo:2
Trocar as tabelas
Agora, uma instruçãoEXCHANGE é usada para trocar atomicamente os nomes das tabelas otel_logs e otel_logs_30_01_2025.otel_logs, com a chave primária atualizada. Os dados antigos permanecem em otel_logs_30_01_2025, acessíveis por meio da tabela merge.Tabelas redundantesSe houver políticas de TTL em vigor, o que é recomendado, as tabelas com chaves primárias antigas que não estiverem mais recebendo gravações serão esvaziadas gradualmente à medida que os dados expirarem. Elas devem ser monitoradas e limpas periodicamente quando não contiverem mais dados. No momento, esse processo de limpeza é manual.
Otimização 4. Aproveitando visões materializadas
Otimização 5. Explorando projeções
ORDER BY da tabela base, permitindo que o ClickHouse descarte dados com mais eficiência para padrões de acesso que não se alinham com a ordenação original.
As visões materializadas podem alcançar um efeito semelhante ao gravar explicitamente linhas em uma tabela de destino separada com uma chave de ordenação diferente. A principal diferença é que as projeções são mantidas automática e transparentemente pelo ClickHouse, enquanto as visões materializadas são tabelas explícitas que precisam ser registradas e selecionadas intencionalmente pelo ClickStack.
Quando uma consulta tem como destino a tabela base, o ClickHouse avalia o layout base e todas as projeções disponíveis, inspeciona seus índices primários e seleciona o layout capaz de produzir o resultado correto lendo o menor número de grânulos. Essa decisão é tomada automaticamente pelo analisador de consultas.
No ClickStack, portanto, as projeções são mais adequadas para reordenação pura de dados, em que:
- Os padrões de acesso são fundamentalmente diferentes da chave primária padrão
- É impraticável cobrir todos os fluxos de trabalho com uma única chave de ordenação
- Você quer que o ClickHouse escolha de forma transparente o layout físico ideal
Exemplo de projeções
Use caracteres curingaNo exemplo de projeção acima, é usado um caractere curinga (
SELECT *). Embora selecionar um subconjunto de colunas possa reduzir a sobrecarga de gravação, isso também limita quando a projeção pode ser usada, já que apenas consultas que possam ser totalmente atendidas por essas colunas são elegíveis. No ClickStack, isso costuma restringir o uso de projeções a casos bem específicos. Por esse motivo, em geral, recomenda-se usar um caractere curinga para maximizar a aplicabilidade.Materializar uma projeção pode levar bastante tempo e consumir muitos recursos. Como os dados de observabilidade normalmente expiram por meio de TTL, isso só deve ser feito quando for absolutamente necessário. Na maioria dos casos, basta deixar que a projeção se aplique apenas aos dados recém-ingestados, permitindo otimizar os intervalos de tempo consultados com mais frequência, como as últimas 24 horas.
SELECT *) e os filtros da consulta estão bem alinhados com o ORDER BY da projeção.
Consultas que filtram por TraceId (especialmente com igualdade) e incluem um intervalo de tempo se beneficiariam da projeção acima. Por exemplo:
TraceId, ou que filtram principalmente por outras dimensões que não vêm primeiro na chave de ordenação da projeção, normalmente não se beneficiam disso (e podem acabar lendo pelo layout base).
As projeções também podem armazenar agregações (de forma semelhante às visões materializadas). No ClickStack, agregações baseadas em projeções geralmente não são recomendadas, porque a seleção depende do analisador do ClickHouse, e o uso pode ser mais difícil de controlar e compreender. Em vez disso, prefira visões materializadas explícitas, que o ClickStack possa registrar e selecionar intencionalmente na camada de aplicação.
TraceId específico).
Custos e orientações
- Sobrecarga de inserção: Uma projeção
SELECT *com uma chave de ordenação diferente efetivamente grava os dados duas vezes, o que aumenta a E/S de gravação e pode exigir CPU adicional e maior taxa de transferência em disco para sustentar a ingestão. - Use com parcimônia: As projeções são mais indicadas para padrões de acesso realmente diversos, em que uma segunda ordenação física proporciona uma poda significativa para uma grande parcela das consultas, por exemplo, quando duas equipes consultam o mesmo conjunto de dados de maneiras fundamentalmente diferentes.
- Valide com benchmarks: Como em qualquer ajuste, compare a latência real das consultas e o uso de recursos antes e depois de adicionar e materializar uma projeção.
Projeções leves com _part_offset
As projeções leves estão em Beta para o ClickStackProjeções leves baseadas em
_part_offset não são recomendadas para workloads do ClickStack. Embora reduzam o armazenamento e a E/S de gravação, elas podem introduzir mais acessos aleatórios no momento da consulta, e seu comportamento em produção na escala da observabilidade ainda está sendo avaliado. Essa recomendação pode mudar à medida que o recurso amadurece e coletamos mais dados operacionais._part_offset para a tabela base, em vez de duplicar linhas completas. Isso pode reduzir bastante a sobrecarga de armazenamento, e melhorias recentes permitem poda no nível de grânulo, fazendo com que elas se comportem mais como índices secundários de fato. Veja:
Alternativas
- Configurar o OpenTelemetry Collector para gravar em duas tabelas com chaves
ORDER BYdiferentes e criar fontes separadas no ClickStack para cada tabela. - Criar uma visão materializada como um pipeline de cópia, ou seja, anexar uma visão materializada à tabela principal que selecione linhas brutas para uma tabela secundária com uma chave de ordenação diferente (um padrão de desnormalização ou roteamento). Crie uma fonte para essa tabela de destino. Exemplos podem ser encontrados aqui.