Esta guía se centra en las optimizaciones de rendimiento más comunes y eficaces para ClickStack, suficientes para optimizar la mayoría de las cargas de trabajo reales de observabilidad, normalmente de hasta decenas de terabytes de datos al día.
Las optimizaciones se presentan en un orden intencionado, empezando por las técnicas más simples y de mayor impacto, y avanzando hacia ajustes más avanzados y especializados. Las optimizaciones iniciales deben aplicarse primero y, a menudo, por sí solas ofrecerán mejoras sustanciales. A medida que aumentan los volúmenes de datos y las cargas de trabajo se vuelven más exigentes, cada vez merece más la pena explorar las técnicas posteriores.
Antes de aplicar cualquiera de las optimizaciones descritas en esta guía, es importante estar familiarizado con algunos conceptos básicos de ClickHouse.
En ClickStack, cada fuente de datos se corresponde directamente con una o más tablas de ClickHouse. Al usar OpenTelemetry, ClickStack crea y administra un conjunto de tablas predeterminadas que almacenan datos de logs, trazas y métricas. Si utilizas esquemas personalizados o administras tus propias tablas, es posible que ya conozcas estos conceptos. Sin embargo, si simplemente envías datos mediante el OpenTelemetry Collector, estas tablas se crean automáticamente y es sobre ellas donde se aplicarán todas las optimizaciones descritas a continuación.
Las tablas se asignan a bases de datos en ClickHouse. De forma predeterminada, se usa la base de datos default; esto se puede modificar en el collector de OpenTelemetry.
Céntrate en logs y trazasEn la mayoría de los casos, la optimización del rendimiento se centra en las tablas de logs y trazas. Aunque las tablas de métricas pueden optimizarse para el filtrado, sus esquemas están intencionadamente definidos para cargas de trabajo de estilo Prometheus y, por lo general, no requieren modificaciones para la creación estándar de gráficos. En cambio, los logs y las trazas admiten una gama más amplia de patrones de acceso y, por tanto, son los que más se benefician del ajuste. Los datos de sesión tienen una experiencia de usuario fija y su esquema rara vez necesita modificarse.
Como mínimo, deberías comprender los siguientes conceptos fundamentales de ClickHouse:
| Concepto | Descripción |
|---|
| Tablas | Cómo las fuentes de datos en ClickStack se corresponden con las tablas subyacentes de ClickHouse. Las tablas en ClickHouse usan principalmente el motor MergeTree. |
| Partes | Cómo los datos se escriben en partes inmutables y se fusionan con el tiempo. |
| Particiones | Las particiones agrupan las partes de una tabla en unidades lógicas organizadas. Estas unidades son más fáciles de administrar, consultar y optimizar. |
| Merges | El proceso interno que fusiona partes para reducir la cantidad de partes que hay que consultar. Esencial para mantener el rendimiento de las consultas. |
| Gránulos | La unidad más pequeña de datos que ClickHouse lee y descarta durante la ejecución de consultas. |
| Claves primarias (de ordenación) | Cómo la clave ORDER BY determina la disposición de los datos en disco, la compresión y el descarte de datos en las consultas. |
Estos conceptos son fundamentales para el rendimiento de ClickHouse. Determinan cómo se escriben los datos, cómo se estructuran en disco y con qué eficiencia ClickHouse puede omitir la lectura de datos en tiempo de consulta. Todas las optimizaciones de esta guía, ya sean columnas materializadas, índices de omisión, claves primarias, proyecciones o vistas materializadas, se basan en estos mecanismos fundamentales.
Se recomienda revisar la siguiente documentación de ClickHouse antes de realizar cualquier ajuste:
Todas las optimizaciones descritas a continuación pueden aplicarse directamente sobre las tablas subyacentes mediante ClickHouse SQL estándar, ya sea a través de la consola SQL de ClickHouse Cloud o del cliente de ClickHouse.
Optimización 1. Materializar atributos consultados con frecuencia
La primera y más sencilla optimización para los usuarios de ClickStack es identificar los atributos que se consultan con frecuencia en LogAttributes, ScopeAttributes y ResourceAttributes, y convertirlos en columnas de nivel superior mediante columnas materializadas.
Por sí sola, esta optimización suele ser suficiente para escalar despliegues de ClickStack a decenas de terabytes al día y debe aplicarse antes de considerar técnicas de ajuste más avanzadas.
Por qué materializar atributos
ClickStack almacena metadatos, como etiquetas de Kubernetes, metadatos de servicios y atributos personalizados, en columnas Map(String, String). Aunque esto aporta flexibilidad, consultar subclaves de un mapa tiene una implicación importante en el rendimiento.
Al consultar una sola clave de una columna Map, ClickHouse debe leer del disco toda la columna del mapa. Si el mapa contiene muchas claves, esto genera E/S innecesaria y hace que las consultas sean más lentas en comparación con leer una columna dedicada.
Materializar los atributos a los que se accede con frecuencia evita esta sobrecarga, ya que extrae el valor en el momento de la inserción y lo almacena como una columna de primera clase.
Columnas materializadas:
- Se calculan automáticamente durante las inserciones
- No se pueden establecer explícitamente en sentencias INSERT
- Admiten cualquier expresión de ClickHouse
- Permiten convertir String en tipos numéricos o de fecha más eficientes
- Permiten usar skip indexes y la clave primaria
- Reducen las lecturas de disco al evitar acceder al mapa completo
ClickStack detecta automáticamente las columnas materializadas extraídas de mapas y las utiliza de forma transparente durante la ejecución de consultas, incluso cuando los usuarios siguen consultando la ruta del atributo original.
Considere el esquema predeterminado de ClickStack para las trazas, donde los metadatos de Kubernetes se almacenan en ResourceAttributes:
CREATE TABLE IF NOT EXISTS otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ParentSpanId` String CODEC(ZSTD(1)),
`TraceState` String CODEC(ZSTD(1)),
`SpanName` LowCardinality(String) CODEC(ZSTD(1)),
`SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`ScopeName` String CODEC(ZSTD(1)),
`ScopeVersion` String CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` UInt64 CODEC(ZSTD(1)),
`StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
`StatusMessage` String CODEC(ZSTD(1)),
`Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Un usuario puede filtrar trazas mediante la sintaxis de Lucene, p. ej., ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c":
Esto genera un predicado SQL similar a:
ResourceAttributes['k8s.pod.name'] = 'checkout-675775c4cc-f2p9c'
Como aquí se accede a una clave de Map, ClickHouse debe leer la columna ResourceAttributes completa para cada fila coincidente, lo que puede ser muy grande si el Map contiene muchas claves.
Si este atributo se consulta con frecuencia, debe materializarse como una columna de nivel superior.
Para extraer el nombre del pod de Kubernetes en el momento de la inserción, añada una columna materializada:
ALTER TABLE otel_v2.otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
A partir de ahora, los datos nuevos almacenarán el nombre del pod de Kubernetes como una columna específica, PodName.
Ahora los usuarios pueden consultar los nombres de los pods de Kubernetes de forma eficiente con sintaxis Lucene; por ejemplo, PodName:"checkout-675775c4cc-f2p9c"
En los datos recién insertados, esto evita por completo el acceso al mapa y reduce significativamente la E/S.
Sin embargo, aunque los usuarios sigan consultando la ruta del atributo original, por ejemplo ResourceAttributes.k8s.pod.name:"checkout-675775c4cc-f2p9c", ClickStack reescribirá automáticamente la consulta internamente para usar la columna materializada PodName, es decir, con el predicado:
PodName = 'checkout-675775c4cc-f2p9c'
Esto garantiza que los usuarios aprovechen la optimización sin cambiar paneles, alertas ni consultas guardadas.
De forma predeterminada, las columnas materializadas se excluyen de las consultas SELECT *. Así se mantiene la garantía de que los resultados de las consultas siempre puedan volver a insertarse en la tabla.
Materialización de datos históricos
Las columnas materializadas solo se aplican automáticamente a los datos insertados después de crear la columna. Para los datos existentes, las consultas sobre la columna materializada recurrirán de forma transparente a la lectura del mapa original.
Si el rendimiento con datos históricos es crítico, la columna puede rellenarse con una mutación, por ejemplo.
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
Esto reescribe las partes existentes para rellenar la columna. Las mutaciones se ejecutan en un solo hilo por parte y pueden tardar en conjuntos de datos grandes. Para limitar el impacto, las mutaciones pueden restringirse a una partición específica:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE COLUMN PodName
IN PARTITION '2026-01-02'
El progreso de las mutaciones se puede supervisar mediante la tabla system.mutations, por ejemplo.
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
Espere hasta que is_done = 1 en la mutación correspondiente.
Las mutaciones generan una sobrecarga adicional de E/S y CPU, y deben usarse con moderación. En muchos casos, basta con dejar que los datos más antiguos se eliminen de forma natural con el tiempo y confiar en las mejoras de rendimiento de los datos ingeridos recientemente.
Optimización 2. Añadir índices de omisión
Después de materializar los atributos que se consultan con frecuencia, la siguiente optimización es añadir índices de omisión de datos para reducir aún más la cantidad de datos que ClickHouse necesita leer durante la ejecución de la consulta.
Los índices de omisión permiten a ClickHouse evitar escanear bloques completos de datos cuando puede determinar que no existen valores coincidentes. A diferencia de los índices secundarios tradicionales, los índices de omisión operan a nivel de gránulo y son más eficaces cuando los filtros de las consultas excluyen grandes porciones del conjunto de datos. Si se usan correctamente, pueden acelerar de forma significativa el filtrado de atributos de alta cardinalidad sin cambiar la semántica de la consulta.
Considere el esquema predeterminado de trazas de ClickStack, que incluye índices de omisión:
CREATE TABLE IF NOT EXISTS otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ParentSpanId` String CODEC(ZSTD(1)),
`TraceState` String CODEC(ZSTD(1)),
`SpanName` LowCardinality(String) CODEC(ZSTD(1)),
`SpanKind` LowCardinality(String) CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`ScopeName` String CODEC(ZSTD(1)),
`ScopeVersion` String CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` UInt64 CODEC(ZSTD(1)),
`StatusCode` LowCardinality(String) CODEC(ZSTD(1)),
`StatusMessage` String CODEC(ZSTD(1)),
`Events.Timestamp` Array(DateTime64(9)) CODEC(ZSTD(1)),
`Events.Name` Array(LowCardinality(String)) CODEC(ZSTD(1)),
`Events.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`Links.TraceId` Array(String) CODEC(ZSTD(1)),
`Links.SpanId` Array(String) CODEC(ZSTD(1)),
`Links.TraceState` Array(String) CODEC(ZSTD(1)),
`Links.Attributes` Array(Map(LowCardinality(String), String)) CODEC(ZSTD(1)),
`__hdx_materialized_rum.sessionId` String MATERIALIZED ResourceAttributes['rum.sessionId'] CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_rum_session_id __hdx_materialized_rum.sessionId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_span_attr_value mapValues(SpanAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1,
INDEX idx_lower_span_name lower(SpanName) TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 8
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
TTL toDate(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
Estos índices se centran en dos patrones comunes:
- Filtrado de cadenas con alta cardinalidad, como TraceId, identificadores de sesión, claves de atributo o valores
- Filtrado por rangos numéricos, como la duración del span
Los índices de filtro de Bloom son el tipo de índice de omisión más utilizado en ClickStack. Son especialmente adecuados para columnas de texto con alta cardinalidad, normalmente con al menos decenas de miles de valores distintos. Una tasa de falsos positivos de 0.01 con granularidad 1 es un buen valor predeterminado para empezar, ya que equilibra la sobrecarga de almacenamiento con una depuración eficaz.
Siguiendo con el ejemplo de la Optimización 1, supongamos que el nombre del pod de Kubernetes se ha materializado a partir de ResourceAttributes:
ALTER TABLE otel_traces
ADD COLUMN PodName String
MATERIALIZED ResourceAttributes['k8s.pod.name']
Luego, se puede agregar un índice de omisión de filtro de Bloom para acelerar los filtros sobre esta columna:
ALTER TABLE otel_traces
ADD INDEX idx_pod_name PodName
TYPE bloom_filter(0.01)
GRANULARITY 1
Una vez añadido, el índice de omisión debe materializarse; consulte “Materializar el índice de omisión.”
Una vez creado y materializado, ClickHouse puede omitir granulos completos que con certeza no contienen el nombre del pod de Kubernetes solicitado, lo que puede reducir la cantidad de datos leídos durante consultas como PodName:"checkout-675775c4cc-f2p9c".
Los filtros de Bloom son más eficaces cuando la distribución de los valores hace que un valor dado aparezca en un número relativamente pequeño de partes. Esto suele ocurrir de forma natural en cargas de trabajo de observabilidad, donde metadatos como los nombres de pods de Kubernetes, los ID de trace o los identificadores de sesión se correlacionan con el tiempo y, por tanto, se agrupan según la clave de ordenación de la tabla.
Como ocurre con todos los índices de omisión, los filtros de Bloom deben añadirse de forma selectiva y validarse con patrones de consulta reales para garantizar que aportan un beneficio medible; consulte “Evaluar la eficacia del índice de omisión.”
Los índices MinMax almacenan los valores mínimo y máximo por gránulo y son extremadamente ligeros. Son especialmente eficaces para columnas numéricas y consultas por rango. Aunque puede que no aceleren todas las consultas, tienen un coste bajo y casi siempre merece la pena añadirlos a campos numéricos.
Los índices MinMax funcionan mejor cuando los valores numéricos están ordenados de forma natural o se mantienen dentro de rangos estrechos en cada parte.
Supongamos que se consulta con frecuencia un offset de Kafka desde SpanAttributes:
SpanAttributes['messaging.kafka.offset']
Este valor se puede materializar y convertir a un tipo numérico:
ALTER TABLE otel_traces
ADD COLUMN KafkaOffset UInt64
MATERIALIZED toUInt64(SpanAttributes['messaging.kafka.offset'])
A continuación, puede añadirse un índice minmax:
ALTER TABLE otel_traces
ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1
Esto permite que ClickHouse omita partes de forma eficiente al filtrar por rangos de offset de Kafka, por ejemplo, al depurar el consumer lag o el comportamiento de reprocesamiento.
De nuevo, el índice debe materializarse antes de estar disponible.
Materializar el índice de omisión
Después de añadir un índice de omisión, este solo se aplica a los datos ingeridos recientemente. Los datos históricos no se beneficiarán del índice hasta que se materialice explícitamente.
Si ya ha añadido un índice de omisión, por ejemplo:
ALTER TABLE otel_traces ADD INDEX idx_kafka_offset KafkaOffset TYPE minmax GRANULARITY 1;
Debe materializar explícitamente el índice para los datos existentes:
ALTER TABLE otel_traces MATERIALIZE INDEX idx_kafka_offset;
Materialización de índices de omisiónMaterializar un índice de omisión suele ser una operación ligera y segura, especialmente en el caso de los índices MinMax. En el caso de índices de filtro de Bloom sobre conjuntos de datos grandes, puede ser preferible materializarlos por partición para controlar mejor el uso de recursos; por ejemplo:ALTER TABLE otel_v2.otel_traces
MATERIALIZE INDEX idx_kafka_offset
IN PARTITION '2026-01-02';
La materialización de un índice de omisión se ejecuta como una mutación. Su progreso puede supervisarse mediante tablas del sistema.
SELECT *
FROM system.mutations
WHERE database = 'otel'
AND table = 'otel_traces'
ORDER BY create_time DESC;
Espere hasta que is_done = 1 para la mutación correspondiente.
Una vez completada, confirme que se han creado los datos del índice:
SELECT database, table, name,
data_compressed_bytes,
data_uncompressed_bytes,
marks_bytes
FROM system.data_skipping_indices
WHERE database = 'otel'
AND table = 'otel_traces'
AND name = 'idx_kafka_offset';
Los valores distintos de cero indican que el índice se ha materializado correctamente.
Es importante señalar que el tamaño del índice de omisión afecta directamente al rendimiento de la consulta. Los índices de omisión muy grandes, del orden de decenas o cientos de gigabytes, pueden tardar un tiempo apreciable en evaluarse durante la ejecución de la consulta, lo que puede reducir, o incluso anular, su beneficio.
En la práctica, los índices minmax suelen ser muy pequeños y poco costosos de evaluar, por lo que casi siempre es seguro materializarlos. Los índices de filtro de Bloom, por otro lado, pueden crecer de forma significativa en función de la cardinalidad, la granularidad y la probabilidad de falsos positivos.
El tamaño del filtro de Bloom puede reducirse aumentando la tasa permitida de falsos positivos. Por ejemplo, aumentar el parámetro de probabilidad de 0.01 a 0.05 produce un índice más pequeño que se evalúa más rápido, a costa de una poda menos agresiva. Aunque pueden omitirse menos gránulos, la latencia global de la consulta puede mejorar gracias a una evaluación más rápida del índice.
Por lo tanto, ajustar los parámetros del filtro de Bloom es una optimización que depende de la carga de trabajo y debe validarse con patrones de consulta reales y volúmenes de datos similares a los de producción.
Para obtener más información sobre los índices de omisión, consulta la guía “Comprender los índices de omisión de datos de ClickHouse.”
Evaluación de la eficacia de los skip indexes
La forma más fiable de evaluar la poda de los skip indexes es usar EXPLAIN indexes = 1, que muestra cuántas partes y gránulos se descartan en cada etapa de la planificación de la consulta. En la mayoría de los casos, conviene ver una reducción significativa del número de gránulos en la etapa Skip, idealmente después de que la clave primaria ya haya reducido el espacio de búsqueda. Los skip indexes se evalúan después de la poda de particiones y de la poda por clave primaria, por lo que su impacto se mide mejor en relación con las partes y los gránulos que quedan.
EXPLAIN confirma si se está aplicando la poda, pero no garantiza una mejora neta del rendimiento. Los skip indexes tienen un coste de evaluación, especialmente si el índice es grande. Haga siempre benchmark de las consultas antes y después de añadir y materializar un índice para confirmar mejoras reales de rendimiento.
Por ejemplo, considere el skip index predeterminado de filtro Bloom para TraceId incluido en el esquema Traces predeterminado:
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1
Puedes usar EXPLAIN indexes = 1 para ver lo eficaz que es en una consulta selectiva:
EXPLAIN indexes = 1
SELECT *
FROM otel_v2.otel_traces
WHERE (ServiceName = 'accounting')
AND (TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974');
ReadFromMergeTree (otel_v2.otel_traces)
Indexes:
PrimaryKey
Keys:
ServiceName
Parts: 6/18
Granules: 255/35898
Skip
Name: idx_trace_id
Description: bloom_filter GRANULARITY 1
Parts: 1/6
Granules: 1/255
En este caso, el filtro de la clave primaria reduce primero de forma considerable el conjunto de datos (de 35898 gránulos a 255), y luego el filtro Bloom lo reduce aún más hasta un único gránulo (1/255). Este es el patrón ideal para los skip indexes: el filtrado por clave primaria acota la búsqueda y, después, el skip index descarta la mayor parte de lo que queda.
Para validar el impacto real, haz un benchmark de la consulta con ajustes estables y compara el tiempo de ejecución. Usa FORMAT Null para evitar la sobrecarga de la serialización de resultados y desactiva la caché de condiciones de consulta para que las ejecuciones sean repetibles:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
SETTINGS use_query_condition_cache = 0
2 rows in set. Elapsed: 0.025 sec. Processed 8.52 thousand rows, 299.78 KB (341.22 thousand rows/s., 12.00 MB/s.)
Peak memory usage: 41.97 MiB.
Ahora ejecuta la misma consulta con los skip indexes deshabilitados:
SELECT *
FROM otel_traces
WHERE (ServiceName = 'accountingservice') AND (TraceId = '4512e822ca3c0c68bbf5d4a263f9943d')
FORMAT Null
SETTINGS use_query_condition_cache = 0, use_skip_indexes = 0;
0 rows in set. Elapsed: 0.702 sec. Processed 1.62 million rows, 56.62 MB (2.31 million rows/s., 80.71 MB/s.)
Peak memory usage: 198.39 MiB.
Desactivar use_query_condition_cache garantiza que los resultados no se vean afectados por decisiones de filtrado almacenadas en caché, y establecer use_skip_indexes = 0 proporciona una base de referencia limpia para la comparación. Si la poda es efectiva y el coste de evaluar el índice es bajo, la consulta indexada debería ser notablemente más rápida, como en el ejemplo anterior.
Si EXPLAIN muestra una poda de gránulos mínima, o el skip index es muy grande, el coste de evaluar el índice puede anular cualquier beneficio. Usa EXPLAIN indexes = 1 para confirmar la poda y luego ejecuta un benchmark para confirmar mejoras de rendimiento de extremo a extremo.
Cuándo añadir skip indexes
Los skip indexes deben añadirse de forma selectiva, en función de los tipos de filtros que los usuarios aplican con más frecuencia y de la distribución de los datos en las partes y los gránulos. El objetivo es descartar suficientes gránulos como para compensar el coste de evaluar el propio índice, por lo que es esencial hacer benchmarks con datos parecidos a los de producción.
En las columnas numéricas que se usan en filtros, un skip index minmax casi siempre es una buena opción. Es ligero, barato de evaluar y puede ser eficaz para predicados de rango, especialmente cuando los valores están poco ordenados o quedan confinados a rangos estrechos dentro de las partes. Incluso cuando minmax no ayuda con un patrón de consulta concreto, su sobrecarga suele ser lo bastante baja como para que siga siendo razonable mantenerlo.
Columnas de texto. Use filtros Bloom cuando la cardinalidad sea alta y los valores sean dispersos.
Los filtros Bloom son más eficaces en columnas de texto con alta cardinalidad en las que cada valor tiene una frecuencia relativamente baja, lo que significa que la mayoría de las partes y los gránulos no contienen el valor buscado. Como regla general, los filtros Bloom son más prometedores cuando la columna tiene al menos 10.000 valores distintos, y a menudo ofrecen el mejor rendimiento con más de 100.000 valores distintos. También son más eficaces cuando los valores coincidentes se agrupan en un número reducido de partes secuenciales, lo que normalmente ocurre cuando la columna está correlacionada con la clave de ordenación. De nuevo, esto puede variar según el caso; nada sustituye a las pruebas en el mundo real.
Optimización 3. Modificar la clave primaria
La clave primaria es uno de los componentes más importantes para optimizar el rendimiento de ClickHouse en la mayoría de las cargas de trabajo. Para ajustarla eficazmente, es necesario comprender cómo funciona y cómo interactúa con los patrones de consulta. En última instancia, la clave primaria debe ajustarse a la forma en que los usuarios acceden a los datos, en particular a las columnas por las que se filtra con más frecuencia.
Aunque la clave primaria también influye en la compresión y en la estructura de almacenamiento, su propósito principal es el rendimiento de las consultas. En ClickStack, las claves primarias predeterminadas ya vienen optimizadas para los patrones de acceso de observabilidad más comunes y para lograr una buena compresión. Las claves predeterminadas de las tablas de logs, trazas y métricas están diseñadas para ofrecer un buen rendimiento en flujos de trabajo habituales.
Filtrar por columnas que aparecen antes en la clave primaria es más eficiente que filtrar por columnas que aparecen más tarde. Aunque la configuración predeterminada es suficiente para la mayoría de los usuarios, en algunos casos modificar la clave primaria puede mejorar el rendimiento para cargas de trabajo concretas.
Nota sobre la terminologíaA lo largo de este documento, el término “ordering key” se usa indistintamente con “primary key”. En sentido estricto, estos conceptos difieren en ClickHouse, pero en ClickStack normalmente se refieren a las mismas columnas especificadas en la cláusula ORDER BY de la tabla. Para más información, consulta la documentación de ClickHouse sobre cómo elegir una clave primaria distinta de la clave de ordenación.
Antes de modificar cualquier clave primaria, se recomienda encarecidamente leer nuestra guía para entender cómo funcionan los índices primarios en ClickHouse:
El ajuste de la clave primaria depende de la tabla y del tipo de datos. Un cambio que beneficia a una tabla y a un tipo de datos puede no aplicarse a otros. El objetivo siempre es optimizar para un tipo de datos concreto, por ejemplo, logs.
Normalmente optimizarás las tablas de logs y trazas. Rara vez es necesario cambiar la clave primaria de otros tipos de datos.
A continuación se muestran las claves primarias predeterminadas para las tablas de ClickStack de logs y métricas.
- Logs (
otel_logs) - (ServiceName, TimestampTime, Timestamp)
- Traces (‘otel_traces) -
(ServiceName, SpanName, toDateTime(Timestamp))
Consulta “Tablas y esquemas usados por ClickStack” para ver las claves primarias usadas por las tablas de otros tipos de datos. Por ejemplo, las tablas de trazas están optimizadas para filtrar por nombre de servicio y nombre de span, seguidos del timestamp y el trace ID. Las tablas de logs, en cambio, están optimizadas para filtrar por nombre de servicio, luego por fecha y después por timestamp. Aunque lo ideal es que el usuario aplique los filtros en el orden de la clave primaria, las consultas seguirán beneficiándose enormemente al filtrar por cualquiera de estas columnas en cualquier orden, ya que ClickHouse descarta datos antes de leerlos.
Al elegir una clave primaria, también hay otros factores que conviene tener en cuenta para determinar el orden óptimo de las columnas. Consulta “Elegir una clave primaria.”
Las claves primarias deben cambiarse de forma aislada en cada tabla. Lo que tiene sentido para logs puede no tenerlo para trazas o métricas.
Cómo elegir una clave primaria
Primero, determine si sus patrones de acceso difieren sustancialmente de los valores predeterminados de una tabla concreta. Por ejemplo, si normalmente filtra los logs por nodo de Kubernetes antes que por nombre del servicio, y esto representa un flujo de trabajo predominante, puede justificar un cambio en la clave primaria.
Modificar la clave primaria predeterminadaLas claves primarias predeterminadas son suficientes en la mayoría de los casos. Los cambios deben hacerse con cautela y solo con una comprensión clara de los patrones de consulta. Modificar una clave primaria puede degradar el rendimiento de otros flujos de trabajo, por lo que es fundamental realizar pruebas.
Una vez que haya identificado las columnas deseadas, puede empezar a optimizar la clave de ordenación o clave primaria.
Pueden aplicarse algunas reglas sencillas para ayudar a elegir una clave de ordenación. A veces pueden entrar en conflicto entre sí, así que considérelas en este orden. Intente seleccionar un máximo de 4-5 claves mediante este proceso:
- Seleccione columnas que se ajusten a sus filtros habituales y patrones de acceso. Si normalmente inicia investigaciones de observabilidad filtrando por una columna específica, p. ej., el nombre del pod de Kubernetes, esta columna se usará con frecuencia en las cláusulas
WHERE. Priorice incluirlas en la clave frente a otras que se utilicen con menos frecuencia.
- Prefiera columnas que ayuden a excluir un gran porcentaje del total de filas al filtrar, reduciendo así la cantidad de datos que es necesario leer. Los nombres de servicio y los códigos de estado suelen ser buenos candidatos; en este último caso, solo si filtra por valores que excluyen la mayoría de las filas. Por ejemplo, filtrar por códigos 200 coincidirá, en la mayoría de los sistemas, con la mayor parte de las filas, mientras que los errores 500 corresponderán a un subconjunto pequeño.
- Prefiera columnas que probablemente estén muy correlacionadas con otras columnas de la tabla. Esto ayudará a garantizar que esos valores también se almacenen de forma contigua, lo que mejora la compresión.
- Las operaciones
GROUP BY (agregaciones para gráficos) y ORDER BY (ordenación) sobre columnas incluidas en la clave de ordenación pueden ser más eficientes en el uso de memoria.
Una vez identificado el subconjunto de columnas para la clave de ordenación, estas deben declararse en un orden específico. Este orden puede influir significativamente tanto en la eficiencia del filtrado sobre columnas secundarias de la clave en las consultas como en la relación de compresión de los archivos de datos de la tabla. En general, lo mejor es ordenar las claves en orden ascendente de cardinalidad. Esto debe equilibrarse con el hecho de que el filtrado sobre columnas que aparecen más adelante en la clave de ordenación será menos eficiente que el filtrado sobre las que aparecen antes en la tupla. Equilibre estos comportamientos y tenga en cuenta sus patrones de acceso. Lo más importante es probar distintas variantes. Para comprender mejor las claves de ordenación y cómo optimizarlas, se recomienda leer “Cómo elegir una clave primaria.”. Para profundizar aún más en el ajuste de la clave primaria y en las estructuras de datos internas, consulte “Una introducción práctica a los índices primarios dispersos en ClickHouse.”
Cambio de la clave primaria
Si tienes claros los patrones de acceso antes de la ingestión de datos, simplemente elimina y vuelve a crear la tabla para el tipo de datos correspondiente.
El siguiente ejemplo muestra una forma sencilla de crear una nueva tabla de logs con el esquema existente, pero con una nueva clave primaria que incluye la columna SeverityText antes de ServiceName.
Crear una nueva tabla
CREATE TABLE otel_logs_temp AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
Clave de ordenación frente a clave primariaTen en cuenta que, en el ejemplo anterior, es necesario especificar PRIMARY KEY y ORDER BY.
En ClickStack, casi siempre son iguales.
ORDER BY controla la disposición física de los datos, mientras que PRIMARY KEY define el índice disperso.
En casos poco frecuentes, con cargas de trabajo muy grandes, pueden diferir, pero la mayoría de los usuarios debería mantenerlos alineados.
Intercambiar y eliminar la tabla
La instrucción EXCHANGE se usa para intercambiar los nombres de las tablas de forma atómica. La tabla temporal (ahora la antigua tabla predeterminada) se puede eliminar.EXCHANGE TABLES otel_logs_temp AND otel_logs
DROP TABLE otel_logs_temp
Sin embargo, la clave primaria no puede modificarse en una tabla existente. Para cambiarla, es necesario crear una nueva tabla.
El siguiente proceso puede utilizarse para garantizar que los datos antiguos se conserven y sigan pudiéndose consultar de forma transparente (usando su clave existente en HyperDX, si es necesario), mientras que los datos nuevos se exponen a través de una nueva tabla optimizada para los patrones de acceso de los usuarios. Este enfoque garantiza que las canalizaciones de ingestión no tengan que modificarse: los datos siguen enviándose a los nombres de tabla predeterminados y todos los cambios son transparentes para los usuarios.
Rara vez compensa hacer backfill de los datos existentes en una nueva tabla a gran escala. El coste de cómputo y E/S suele ser alto y no justifica las ventajas de rendimiento. En su lugar, deja que los datos más antiguos expiren mediante TTL, mientras que los datos más recientes se benefician de la clave mejorada.
A continuación se usa el mismo ejemplo de introducir SeverityText como primera columna de la clave primaria. En este caso, se crea una tabla para los datos nuevos y se conserva la tabla anterior para el análisis histórico.
Crear una nueva tabla
Crea la nueva tabla con la clave primaria deseada. Observa el sufijo _23_01_2025: adáptalo para que sea la fecha actual. Por ejemplo:CREATE TABLE otel_logs_23_01_2025 AS otel_logs
PRIMARY KEY (SeverityText, ServiceName, TimestampTime)
ORDER BY (SeverityText, ServiceName, TimestampTime)
Crear una tabla Merge
El motor Merge (no debe confundirse con MergeTree) no almacena datos por sí mismo, pero permite leer al mismo tiempo de cualquier número de tablas.CREATE TABLE otel_logs_merge
AS otel_logs
ENGINE = Merge(currentDatabase(), 'otel_logs*')
currentDatabase() asume que el comando se ejecuta en la base de datos correcta. De lo contrario, especifica explícitamente el nombre de la base de datos.
Ahora puedes consultar esta tabla para confirmar que devuelve datos de otel_logs.Actualizar HyperDX para leer desde la tabla Merge
Configura HyperDX para usar otel_logs_merge como tabla de la fuente de datos de logs.En este punto, las escrituras continúan en otel_logs con la clave primaria original, mientras que las lecturas usan la tabla Merge. No hay ningún cambio visible para los usuarios ni impacto en la ingestión.Intercambiar las tablas
Ahora se usa una instrucción EXCHANGE para intercambiar atómicamente los nombres de las tablas otel_logs y otel_logs_23_01_2025.EXCHANGE TABLES otel_logs AND otel_logs_23_01_2025
Las escrituras ahora van a la nueva tabla otel_logs con la clave primaria actualizada. Los datos existentes permanecen en otel_logs_23_01_2025 y siguen siendo accesibles a través de la tabla Merge. El sufijo indica la fecha en que se aplicó el cambio y representa el timestamp más reciente contenido en esa tabla.Este proceso permite cambiar la clave primaria sin interrumpir la ingestión y sin impacto visible para el usuario.
Este proceso puede adaptarse si se requieren más cambios en la clave primaria. Por ejemplo, si una semana después decides que, en realidad, SeverityNumber debería formar parte de la clave primaria en lugar de SeverityText. El siguiente proceso puede adaptarse tantas veces como sea necesario para aplicar cambios en la clave primaria.
Crear una nueva tabla
Crea la nueva tabla con la clave primaria deseada.
En el ejemplo siguiente, 30_01_2025 se usa como sufijo para indicar la fecha de la tabla. Por ejemplo:CREATE TABLE otel_logs_30_01_2025 AS otel_logs
PRIMARY KEY (SeverityNumber, ServiceName, TimestampTime)
ORDER BY (SeverityNumber, ServiceName, TimestampTime)
Intercambiar las tablas
Ahora se utiliza una sentencia EXCHANGE para intercambiar atómicamente los nombres de las tablas otel_logs y otel_logs_30_01_2025.EXCHANGE TABLES otel_logs AND otel_logs_30_01_2025
Las escrituras ahora se dirigen a la nueva tabla otel_logs con la clave primaria actualizada. Los datos antiguos permanecen en otel_logs_30_01_2025, accesibles a través de la tabla Merge.
Tablas redundantesSi hay políticas TTL configuradas, lo cual se recomienda, las tablas con claves primarias antiguas que ya no reciben escrituras se irán vaciando gradualmente a medida que caduquen los datos. Deben supervisarse y limpiarse periódicamente cuando ya no contengan datos. Por ahora, este proceso de limpieza es manual.
Optimización 4. Uso de vistas materializadas
ClickStack puede aprovechar las vistas materializadas incrementales para acelerar las visualizaciones que dependen de consultas con agregaciones intensivas, como calcular la duración media de las solicitudes por minuto a lo largo del tiempo. Esta funcionalidad puede mejorar drásticamente el rendimiento de las consultas y suele ser especialmente beneficiosa en implementaciones de mayor tamaño, de unos 10 TB al día en adelante, además de permitir escalar hasta el rango de petabytes al día. Las vistas materializadas incrementales están en Beta y deben usarse con precaución.
Para obtener más información sobre cómo usar esta funcionalidad en ClickStack, consulta nuestra guía específica “ClickStack - Vistas materializadas.”
Optimización 5. Aprovechar las proyecciones
Las proyecciones representan una optimización avanzada final que puede considerarse una vez evaluadas las columnas materializadas, los skip indexes, las claves primarias y las vistas materializadas. Aunque las proyecciones y las vistas materializadas pueden parecer similares, en ClickStack cumplen funciones distintas y conviene usarlas en escenarios diferentes.
En la práctica, una proyección puede entenderse como una copia adicional y oculta de la tabla que almacena las mismas filas en un orden físico diferente. Esto le da a la proyección su propio índice primario, distinto de la clave ORDER BY de la tabla base, lo que permite a ClickHouse descartar datos de forma más eficaz para patrones de acceso que no se ajustan al orden original.
Las vistas materializadas pueden lograr un efecto similar al escribir explícitamente filas en una tabla de destino independiente con una clave de ordenación distinta. La diferencia principal es que ClickHouse mantiene las proyecciones de forma automática y transparente, mientras que las vistas materializadas son tablas explícitas que ClickStack debe registrar y seleccionar de forma intencionada.
Cuando una consulta se ejecuta sobre la tabla base, ClickHouse evalúa la disposición base y las proyecciones disponibles, examina sus índices primarios y selecciona la disposición que puede producir el resultado correcto leyendo la menor cantidad de gránulos. Esta decisión la toma automáticamente el analizador de consultas.
Por lo tanto, en ClickStack, las proyecciones son más adecuadas para la reordenación pura de datos, donde:
- Los patrones de acceso son sustancialmente distintos de la clave primaria predeterminada
- No resulta práctico cubrir todos los flujos de trabajo con una sola clave de ordenación
- Quiere que ClickHouse elija de forma transparente la disposición física óptima
Para la preagregación y la aceleración de métricas, ClickStack prefiere claramente las vistas materializadas explícitas, que dan a la capa de aplicación un control total sobre la selección y el uso de las vistas.
Para obtener más contexto, consulte:
Supongamos que su tabla de trazas está optimizada para el patrón de acceso predeterminado de ClickStack:
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
Si también tiene un flujo de trabajo principal que filtra por TraceId (o que agrupa y filtra con frecuencia en función de este), puede agregar una proyección que almacene las filas ordenadas por TraceId y tiempo:
ALTER TABLE otel_v2.otel_traces
ADD PROJECTION prj_traceid_time
(
SELECT *
ORDER BY (TraceId, toDateTime(Timestamp))
);
Usa comodinesEn la proyección de ejemplo anterior, se usa un comodín (SELECT *). Aunque seleccionar un subconjunto de columnas puede reducir la sobrecarga de escritura, también limita cuándo puede usarse la proyección, ya que solo pueden aprovecharla las consultas que puedan resolverse por completo con esas columnas. En ClickStack, esto suele restringir el uso de la proyección a casos muy específicos. Por este motivo, en general se recomienda usar un comodín para maximizar su aplicabilidad.
Al igual que ocurre con otros cambios en la organización de los datos, la proyección solo afecta a las partes nuevas. Para crearla para los datos ya existentes, materialízala:
ALTER TABLE otel_v2.otel_traces
MATERIALIZE PROJECTION prj_traceid_time;
Materializar una proyección puede llevar mucho tiempo y consumir una cantidad considerable de recursos. Como los datos de observabilidad normalmente expiran por TTL, esto solo debe hacerse cuando sea absolutamente necesario. En la mayoría de los casos, basta con dejar que la proyección se aplique solo a los datos recién ingeridos, para que optimice los intervalos de tiempo consultados con más frecuencia, como las últimas 24 horas.
ClickHouse puede elegir la proyección automáticamente cuando estima que tendrá que leer menos gránulos que con la estructura base. Las proyecciones son más fiables cuando representan una simple reordenación del conjunto completo de filas (SELECT *) y los filtros de la consulta se ajustan claramente al ORDER BY de la proyección.
Las consultas que filtran por TraceId (especialmente con igualdad) e incluyen un intervalo de tiempo se beneficiarían de la proyección anterior. Por ejemplo:
-- Obtener un trace específico rápidamente
SELECT *
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
ORDER BY Timestamp;
-- Agregación acotada por trace
SELECT
toStartOfMinute(Timestamp) AS t,
count() AS spans
FROM otel_traces
WHERE TraceId = 'aeea7f401feb75fc5af8eb25ebc8e974'
AND Timestamp >= now() - INTERVAL 1 DAY
GROUP BY t
ORDER BY t;
Las consultas que no restringen TraceId, o que filtran principalmente por otras dimensiones que no ocupan las primeras posiciones en la clave de ordenación de la proyección, normalmente no se beneficiarán de ello (y en su lugar pueden leer desde la estructura base).
Las proyecciones también pueden almacenar agregaciones (de forma similar a las vistas materializadas). En ClickStack, por lo general no se recomiendan las agregaciones basadas en proyecciones, porque su selección depende del analizador de ClickHouse y su uso puede ser más difícil de controlar y comprender. En su lugar, prefiera vistas materializadas explícitas que ClickStack pueda registrar y seleccionar intencionadamente en la capa de aplicación.
En la práctica, las proyecciones son más adecuadas para flujos de trabajo en los que se pasa con frecuencia de una búsqueda amplia a un análisis detallado centrado en una traza (por ejemplo, recuperar todos los spans de un TraceId específico).
- Sobrecarga de inserción: Una proyección
SELECT * con una clave de ordenación distinta equivale, en la práctica, a escribir los datos dos veces, lo que incrementa la E/S de escritura y puede requerir CPU adicional y mayor rendimiento de disco para sostener la ingestión.
- Úselas con moderación: Lo mejor es reservar las proyecciones para patrones de acceso realmente distintos, en los que una segunda ordenación física permita una poda significativa en una gran parte de las consultas; por ejemplo, cuando dos equipos consultan el mismo conjunto de datos de maneras fundamentalmente diferentes.
- Valídelo con benchmarks: Como con cualquier ajuste, compare la latencia real de las consultas y el uso de recursos antes y después de añadir y materializar una proyección.
Para obtener información de fondo más detallada, consulte:
Proyecciones ligeras con _part_offset
Las proyecciones ligeras están en Beta para ClickStackNo se recomiendan las proyecciones ligeras basadas en _part_offset para las cargas de trabajo de ClickStack. Aunque reducen el almacenamiento y la E/S de escritura, pueden introducir más accesos aleatorios en tiempo de consulta, y su comportamiento en producción a escala de observabilidad aún se está evaluando. Esta recomendación puede cambiar a medida que esta funcionalidad madure y recopilemos más datos operativos.
Las versiones más recientes de ClickHouse también admiten proyecciones aún más ligeras que almacenan solo la clave de ordenación de la proyección junto con un puntero _part_offset a la tabla base, en lugar de duplicar filas completas. Esto puede reducir considerablemente la sobrecarga de almacenamiento, y las mejoras recientes permiten la poda a nivel de gránulo, por lo que se comportan más como verdaderos índices secundarios. Consulte:
Si necesita varias claves de ordenación, las proyecciones no son la única opción. En función de las restricciones operativas y de cómo quiera que ClickStack dirija las consultas, considere lo siguiente:
- Configurar su OpenTelemetry Collector para que escriba en dos tablas con claves
ORDER BY diferentes y crear fuentes de ClickStack independientes para cada tabla.
- Crear una vista materializada como canalización de copia; es decir, adjuntar una vista materializada a la tabla principal que seleccione filas sin procesar en una tabla secundaria con una clave de ordenación distinta (un patrón de desnormalización o enrutamiento). Cree una fuente para esta tabla de destino. Puede encontrar ejemplos aquí.