Solução da tradução de consultas
SQL de referência do ClickHouse para os cinco padrões de consulta representativos do Elasticsearch.
Consulta 1: 10 principais caminhos de requisição (status 200)
SELECT
LogAttributes['request_path'] AS request_path,
count() AS hits
FROM otel_logs
WHERE LogAttributes['log_type'] = 'web_access'
AND LogAttributes['status'] = '200'
GROUP BY request_path
ORDER BY hits DESC
LIMIT 10;Como cada mecanismo responde:
- ES: cria um bucket Terms a partir do subcampo keyword
request_path.keyword. O resultado é aproximado, pois cada shard retorna seu próprio top N e eles são mesclados — um caminho que esteja na posição 11 em todos os shards pode ser omitido. O parâmetroshard_sizeamplia a lista anterior à mesclagem para reduzir esse risco. - ClickHouse: transmite as linhas correspondentes por uma agregação hash. O resultado é exato — não há aproximação por shard. O custo do scan é proporcional às linhas em que
status = '200', beneficiadas pela coluna materializadaStatus+ a chave ORDER BY(ServiceName, Status, Timestamp).
Se esta for uma consulta frequente de dashboard, promova request_path a uma coluna materializada (ou faça a extração no collector). A versão que usa LogAttributes['request_path'] funciona, mas examina todo o Map em cada linha.
Consulta 2: contagem de erros 5xx por minuto (última hora)
SELECT
toStartOfMinute(Timestamp) AS minute,
count() AS error_count
FROM otel_logs
WHERE Timestamp >= now() - INTERVAL 1 HOUR
AND toUInt16OrZero(LogAttributes['status']) >= 500
GROUP BY minute
ORDER BY minute;Como cada mecanismo responde:
- ES:
date_histogrampercorre o campo@timestampe cria um bucket por intervalo fixo. Há uma proteçãomax_buckets(padrão 65.536); intervalos de tempo enormes ou intervalos muito pequenos geram erro. - ClickHouse:
toStartOfMinute()é uma operação aritmética barata com inteiros na colunaTimestamp. Não há limite de buckets. Com uma coluna materializadaStatus UInt16, o filtro se tornaStatus >= 500— um scan direto da coluna, sem decodificar o Map em cada linha.
Variante para um dashboard com uma coluna Status materializada:
SELECT toStartOfMinute(Timestamp) AS minute, count() AS error_count
FROM otel_logs
WHERE Timestamp >= now() - INTERVAL 1 HOUR AND Status >= 500
GROUP BY minute ORDER BY minute;Consulta 3: pesquisa de texto completo — "connection timeout" em logs de ERROR
SELECT Timestamp, ServiceName, Body
FROM otel_logs
WHERE SeverityText = 'ERROR'
AND hasTokenCaseInsensitive(Body, 'connection') -- accelerated by text skip index (>=26.2) or tokenbf_v1
AND hasTokenCaseInsensitive(Body, 'timeout') -- accelerated by text skip index (>=26.2) or tokenbf_v1
AND positionCaseInsensitive(Body, 'connection timeout') > 0 -- enforces phrase adjacency
ORDER BY Timestamp DESC
LIMIT 50;Por que três predicados, e não dois? match_phrase "connection timeout" no ES exige que os dois tokens sejam adjacentes e estejam na ordem correta. hasTokenCaseInsensitive(X, 'connection') AND hasTokenCaseInsensitive(X, 'timeout') sozinho equivale apenas a match com AND, não a match_phrase — ele também corresponderia a "timeout occurred, connection dropped". As chamadas a hasTokenCaseInsensitive fornecem a aceleração do índice de skipping; a verificação positionCaseInsensitive(...) > 0 impõe a restrição da frase. O ClickHouse avalia os predicados da esquerda para a direita, portanto as verificações baratas do índice de skipping são executadas primeiro, e o scan da substring é aplicado apenas às linhas restantes.
Por que hasTokenCaseInsensitive, e não hasToken? O analisador standard padrão do ES converte o texto em minúsculas na indexação e na consulta; portanto, match_phrase "connection timeout" corresponde a "Connection Timeout", "CONNECTION TIMEOUT" etc. hasToken diferencia maiúsculas de minúsculas e não encontraria essas variantes. hasTokenCaseInsensitive faz a correspondência nos limites dos tokens sem diferenciar maiúsculas de minúsculas e ainda é acelerado pelo índice de skipping.
Como cada mecanismo responde:
- ES:
match_phraseusa o índice invertido no campomessagetokenizado. Todas as listas de postings demessagesão mantidas o tempo todo — baratas para consultas, caras para gravações e armazenamento. Uma consultamatch_phrasepercorre os postings dos dois tokens, calcula sua interseção e verifica a adjacência usando as listas de postings posicionais. - ClickHouse: um índice de skipping em
Bodyelimina grânulos (grupos de 8.192 linhas por padrão) que certamente não contêm os tokens; então, o mecanismo examina apenas os restantes e aplica o predicadopositionCaseInsensitivepara verificar a frase. As gravações e o armazenamento são muito mais baratos do que no índice invertido do ES, pois os índices de skipping no nível de grânulo são minúsculos, mas há falsos positivos — por isso é necessário verificar a substring nos grânulos candidatos. Tipo de índice: prefiraTYPE text(>= 26.2, criado para pesquisa de texto completo e consulta determinística de tokens);tokenbf_v1/ngrambf_v1estão obsoletos desde o ClickHouse 26.2.
hasToken/hasTokenCaseInsensitive versus LIKE versus positionCaseInsensitive:
hasTokencorresponde a tokens inteiros nos limites de pontuação/espaços em branco (diferencia maiúsculas de minúsculas).hasTokenCaseInsensitivefaz o mesmo sem essa diferenciação. Ambos são acelerados por um índice de skippingtext(>= 26.2, preferido) outokenbf_v1(obsoleto). Use a variante que não diferencia maiúsculas de minúsculas ao traduzir consultas do ES que usam o analisadorstandard. Use-a como filtro na passagem barata.positionCaseInsensitive/LIKE '%substr%'fazem correspondência real de substrings — não consideram os limites dos tokens nem são acelerados pelo índice de skipping. Use-os como verificadores depois quehasToken*reduzir os grânulos candidatos. Usados sozinhos, eles examinam todas as linhas.
Consulta 4: serviços exclusivos por dia (últimos 7 dias)
-- Variant A: EXACT
SELECT
toDate(Timestamp) AS day,
uniqExact(ServiceName) AS unique_services
FROM otel_logs
WHERE Timestamp >= now() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day;
-- Variant B: APPROXIMATE (HyperLogLog-based, much faster on high-cardinality columns)
SELECT
toDate(Timestamp) AS day,
uniq(ServiceName) AS unique_services
FROM otel_logs
WHERE Timestamp >= now() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day;Qual usar em produção: uniq() (aproximado) em dashboards e alertas. O erro é ≤ 1,6%, com consumo muito menor de memória e CPU. Reserve uniqExact para consultas de auditoria/cobrança em que números exatos são importantes. Nesta carga de trabalho, ServiceName tem cerca de 5 valores distintos, então ambos são muito baratos — mas o padrão é geral: se a cardinalidade for conhecida e pequena, prefira uniqExact; para colunas ilimitadas/de alta cardinalidade (IDs de usuário ou de trace), prefira uniq ou uniqCombined.
Como cada mecanismo responde:
- ES: a agregação
cardinalityusa HyperLogLog++ comprecision_thresholdajustável. É sempre aproximada. - ClickHouse: permite escolher.
uniq→ HLL,uniqExact→ conjunto hash,uniqCombined→ híbrido HLL+hash.
Consulta 5: busca de trace por trace.id
SELECT *
FROM otel_traces
WHERE TraceId = '<TRACE_ID>'
ORDER BY Timestamp ASC
LIMIT 1000;Como cada mecanismo responde:
- ES:
trace.idé um campokeyword, indexado no índice invertido. A busca é O(log N) por meio do dicionário de termos; em seguida, cada posting é obtido — muito rápido. - ClickHouse:
TraceIdnão está na chave primáriaORDER BY. Um simplesWHERE TraceId = ?sem aceleração faria um scan completo. A solução é um índice de skippingbloom_filteremTraceId. O índice consulta o filtro Bloom de cada grânulo; a maioria dos grânulos é eliminada com baixo custo e somente os candidatos são examinados.
ALTER TABLE otel_traces
ADD INDEX trace_id_bf TraceId TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE otel_traces MATERIALIZE INDEX trace_id_bf;Por que TraceId NÃO deve ser a primeira coluna de ORDER BY:
As chaves de ordenação são um acordo com o planejador de consultas: "a maioria das consultas filtrará a partir da esquerda". Se TraceId for a primeira coluna, as linhas de um único trace ficarão agrupadas — mas todos os outros padrões de consulta (intervalo de tempo por serviço, erros mais recentes e consultas de blocos do dashboard) se tornarão scans completos, pois essas consultas não conhecem o TraceId. Buscar um trace é um padrão de agulha no palheiro: raro e pontual. Os índices de skipping (filtros Bloom) foram criados exatamente para esse caso — eles oferecem busca sublinear sem impor o custo de um layout de armazenamento inadequado.