Elasticsearch MigrationClickHouse Workshops

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âmetro shard_size amplia 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 materializada Status + 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_histogram percorre o campo @timestamp e cria um bucket por intervalo fixo. Há uma proteção max_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 coluna Timestamp. Não há limite de buckets. Com uma coluna materializada Status UInt16, o filtro se torna Status >= 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_phrase usa o índice invertido no campo message tokenizado. Todas as listas de postings de message são mantidas o tempo todo — baratas para consultas, caras para gravações e armazenamento. Uma consulta match_phrase percorre 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 Body elimina 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 predicado positionCaseInsensitive para 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: prefira TYPE text (>= 26.2, criado para pesquisa de texto completo e consulta determinística de tokens); tokenbf_v1/ngrambf_v1 estão obsoletos desde o ClickHouse 26.2.

hasToken/hasTokenCaseInsensitive versus LIKE versus positionCaseInsensitive:

  • hasToken corresponde a tokens inteiros nos limites de pontuação/espaços em branco (diferencia maiúsculas de minúsculas). hasTokenCaseInsensitive faz o mesmo sem essa diferenciação. Ambos são acelerados por um índice de skipping text (>= 26.2, preferido) ou tokenbf_v1 (obsoleto). Use a variante que não diferencia maiúsculas de minúsculas ao traduzir consultas do ES que usam o analisador standard. 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 que hasToken* 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 cardinality usa HyperLogLog++ com precision_threshold ajustá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 campo keyword, 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: TraceId não está na chave primária ORDER BY. Um simples WHERE TraceId = ? sem aceleração faria um scan completo. A solução é um índice de skipping bloom_filter em TraceId. 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.

Nesta página

PT