Elasticsearch MigrationClickHouse Workshops

Solución de la traducción de consultas

SQL de ClickHouse modelo para los cinco patrones representativos de consultas de Elasticsearch.


Consulta 1: 10 principales rutas de solicitud (estado 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;

Cómo responde cada motor:

  • ES: crea un bucket Terms a partir del subcampo keyword request_path.keyword. El resultado es aproximado porque cada shard devuelve sus propios N elementos principales y después se combinan; una ruta que ocupe el puesto 11 en todos los shards puede quedar fuera. El parámetro shard_size amplía la lista previa a la combinación para mitigar este efecto.
  • ClickHouse: transmite las filas coincidentes a través de una agregación hash. El resultado es exacto: no hay aproximación por shard. El coste de la exploración es proporcional a las filas donde status = '200', que se benefician de la columna materializada Status y de la clave ORDER BY (ServiceName, Status, Timestamp).

Si esta consulta aparece en un dashboard muy utilizado, promueve request_path a una columna materializada (o extráela en el collector). La versión que usa LogAttributes['request_path'] funciona, pero explora el Map completo en cada fila.


Consulta 2: recuento de errores 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;

Cómo responde cada motor:

  • ES: date_histogram recorre el campo @timestamp y crea un bucket por intervalo fijo. Tiene un límite max_buckets (65 536 de forma predeterminada); los intervalos temporales enormes o los intervalos diminutos producen un error.
  • ClickHouse: toStartOfMinute() es una operación aritmética entera barata sobre la columna Timestamp. No existe un límite de buckets. Con una columna materializada Status UInt16, el filtro se convierte en Status >= 500: una exploración directa de la columna, sin decodificar el Map por fila.

Variante para un dashboard con una columna materializada Status:

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: búsqueda de texto completo — "connection timeout" en logs 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 qué tres predicados y no dos? match_phrase "connection timeout" en ES exige que los dos tokens sean adyacentes y estén en orden. Solo hasTokenCaseInsensitive(X, 'connection') AND hasTokenCaseInsensitive(X, 'timeout') equivale a match con AND, no a match_phrase: coincidiría con "timeout occurred, connection dropped". Las llamadas a hasTokenCaseInsensitive aportan la aceleración del índice de salto; la comprobación positionCaseInsensitive(...) > 0 impone la restricción de frase. ClickHouse evalúa los predicados de izquierda a derecha, por lo que primero se ejecutan las comprobaciones baratas del índice de salto y la exploración de subcadenas solo se aplica a las filas supervivientes.

¿Por qué hasTokenCaseInsensitive y no hasToken? El analizador standard predeterminado de ES convierte el texto a minúsculas al indexar y consultar, por lo que match_phrase "connection timeout" coincide con "Connection Timeout", "CONNECTION TIMEOUT", etc. hasToken distingue mayúsculas y omitiría esas variantes. hasTokenCaseInsensitive coincide en los límites de tokens sin distinguir mayúsculas y sigue acelerado por el índice de salto.

Cómo responde cada motor:

  • ES: match_phrase utiliza el índice invertido del campo message tokenizado. Siempre se mantienen todas las listas de postings de message: barato para las consultas, caro para las escrituras y el almacenamiento. Una consulta match_phrase recorre los postings de ambos tokens, los interseca y también verifica la adyacencia posicional mediante las listas de postings posicionales.
  • ClickHouse: un índice de salto en Body elimina gránulos (grupos de 8 192 filas por defecto) que con seguridad no contienen los tokens; después el motor solo explora los supervivientes y aplica el predicado positionCaseInsensitive para verificar la frase. Las escrituras y el almacenamiento son mucho más baratos que con el índice invertido de ES porque los índices de salto por gránulo son diminutos, pero existen falsos positivos; de ahí la comprobación de subcadena en los gránulos candidatos. Tipo de índice: prefiere TYPE text (>= 26.2, diseñado para búsqueda de texto completo y con búsqueda determinista de tokens); tokenbf_v1 / ngrambf_v1 están obsoletos desde ClickHouse 26.2.

hasToken / hasTokenCaseInsensitive frente a LIKE y positionCaseInsensitive:

  • hasToken coincide con tokens completos en límites de puntuación o espacios (distingue mayúsculas). hasTokenCaseInsensitive hace lo mismo sin distinguirlas. Ambos se aceleran mediante un índice de salto text (>= 26.2, preferido) o tokenbf_v1 (obsoleto). Usa la variante sin distinción de mayúsculas al traducir consultas de ES que emplean el analizador standard. Úsala como filtro para la pasada barata.
  • positionCaseInsensitive / LIKE '%substr%' realizan una verdadera búsqueda de subcadenas: no reconocen límites de tokens ni se aceleran mediante índices de salto. Úsalos como verificación después de que hasToken* reduzca los gránulos candidatos. Utilizados solos, exploran todas las filas.

Consulta 4: servicios únicos por día (últimos 7 días)

-- 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;

Cuál usar en producción: uniq() (aproximada) en dashboards y alertas. El error es ≤ 1,6 % con un consumo de memoria y CPU mucho menor. Reserva uniqExact para consultas de auditoría o facturación donde importen las cifras exactas. En esta carga de trabajo, ServiceName tiene unos 5 valores distintos, por lo que ambas son trivialmente baratas; pero el patrón se generaliza: si se sabe que la cardinalidad es pequeña, prefiere uniqExact; para columnas de cardinalidad alta o ilimitada (ID de usuario o de traza), prefiere uniq o uniqCombined.

Cómo responde cada motor:

  • ES: la agregación cardinality usa HyperLogLog++ con un precision_threshold ajustable. Siempre es aproximada.
  • ClickHouse: te permite elegir. uniq → HLL, uniqExact → conjunto hash, uniqCombined → híbrido HLL+hash.

Consulta 5: búsqueda de traza por trace.id

SELECT *
FROM otel_traces
WHERE TraceId = '<TRACE_ID>'
ORDER BY Timestamp ASC
LIMIT 1000;

Cómo responde cada motor:

  • ES: trace.id es un campo keyword, indexado en el índice invertido. La búsqueda es O(log N) mediante el diccionario de términos y después se obtiene cada posting: muy rápida.
  • ClickHouse: TraceId no forma parte de la clave primaria ORDER BY. Un simple WHERE TraceId = ? sin aceleración exploraría toda la tabla. La solución es un índice de salto bloom_filter en TraceId. El índice consulta el filtro de Bloom de cada gránulo; la mayoría se elimina con poco coste y solo se exploran los candidatos.
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 qué TraceId NO debe ser la primera columna de ORDER BY:

Las claves de ordenación son un acuerdo con el planificador de consultas: "la mayoría de las consultas filtrarán desde la izquierda". Si conviertes TraceId en la primera columna, las filas de una traza quedan juntas, pero todos los demás patrones de consulta (intervalo de tiempo por servicio, errores más recientes, consultas de bloques del dashboard) pasan a explorar toda la tabla porque no conocen el TraceId. La búsqueda de trazas es un patrón de aguja en un pajar: una búsqueda puntual infrecuente. Los índices de salto (filtros de Bloom) están diseñados precisamente para este caso: ofrecen una búsqueda sublineal sin pagar el coste de alterar la disposición del almacenamiento.

En esta página

ES