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ámetroshard_sizeamplí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 materializadaStatusy 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_histogramrecorre el campo@timestampy crea un bucket por intervalo fijo. Tiene un límitemax_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 columnaTimestamp. No existe un límite de buckets. Con una columna materializadaStatus UInt16, el filtro se convierte enStatus >= 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_phraseutiliza el índice invertido del campomessagetokenizado. Siempre se mantienen todas las listas de postings demessage: barato para las consultas, caro para las escrituras y el almacenamiento. Una consultamatch_phraserecorre 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
Bodyelimina 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 predicadopositionCaseInsensitivepara 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: prefiereTYPE text(>= 26.2, diseñado para búsqueda de texto completo y con búsqueda determinista de tokens);tokenbf_v1/ngrambf_v1están obsoletos desde ClickHouse 26.2.
hasToken / hasTokenCaseInsensitive frente a LIKE y positionCaseInsensitive:
hasTokencoincide con tokens completos en límites de puntuación o espacios (distingue mayúsculas).hasTokenCaseInsensitivehace lo mismo sin distinguirlas. Ambos se aceleran mediante un índice de saltotext(>= 26.2, preferido) otokenbf_v1(obsoleto). Usa la variante sin distinción de mayúsculas al traducir consultas de ES que emplean el analizadorstandard. Ú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 quehasToken*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
cardinalityusa HyperLogLog++ con unprecision_thresholdajustable. 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.ides un campokeyword, 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:
TraceIdno forma parte de la clave primariaORDER BY. Un simpleWHERE TraceId = ?sin aceleración exploraría toda la tabla. La solución es un índice de saltobloom_filterenTraceId. 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.