- Elegir una clave primaria - Los esquemas predeterminados usan un
ORDER BYoptimizado para patrones de acceso específicos. Es poco probable que tus patrones de acceso coincidan con ellos. - Extraer estructura - Puede que quieras extraer columnas nuevas a partir de columnas existentes, por ejemplo, la columna
Body. Esto puede hacerse con columnas materializadas (y vistas materializadas en casos más complejos). Esto requiere cambios en el esquema. - Optimizar mapas - Los esquemas predeterminados usan el tipo Map para almacenar atributos. Estas columnas permiten almacenar metadatos arbitrarios. Aunque es una capacidad esencial, ya que los metadatos de los eventos a menudo no se definen de antemano y, por lo tanto, no pueden almacenarse de otro modo en una base de datos de tipado fuerte como ClickHouse, acceder a las claves de mapa y a sus valores no es tan eficiente como acceder a una columna normal. Esto se soluciona modificando el esquema y asegurando que las claves de mapa a las que se accede con más frecuencia sean columnas de nivel superior; consulta “extracción de estructura con SQL”. Esto requiere un cambio en el esquema.
- Simplificar el acceso a las claves de mapa - Acceder a claves en mapas requiere una sintaxis más verbosa. Esto puede mitigarse con alias. Consulta “Usar alias” para simplificar las consultas.
- Índices secundarios - El esquema predeterminado usa índices secundarios para acelerar el acceso a Map y las consultas de texto. Normalmente no son necesarios y ocupan espacio adicional en disco. Pueden usarse, pero conviene probarlos para confirmar que realmente son necesarios. Consulta “Índices secundarios / de omisión de datos”.
- Usar códecs - Puede que quieras personalizar los códecs de las columnas si se ajustan a los datos previstos y tienes pruebas de que mejoran la compresión.
Extracción de estructura con SQL
- Extraer columnas de blobs de texto. Consultarlas será más rápido que usar operaciones sobre cadenas en tiempo de consulta.
- Extraer claves de mapas. El esquema predeterminado coloca atributos arbitrarios en columnas del tipo Map. Este tipo ofrece una capacidad sin esquema con la ventaja de que los usuarios no tienen que predefinir las columnas de los atributos al definir logs y traces; a menudo, esto es imposible al recopilar logs de Kubernetes y querer garantizar que se conserven las etiquetas del pod de Kubernetes para búsquedas posteriores. Acceder a las claves de un mapa y a sus valores es más lento que consultar columnas normales de ClickHouse. Por lo tanto, a menudo conviene extraer claves de mapas a columnas raíz de la tabla.
Body como un String. Además, también puede almacenarse en la columna LogAttributes como un Map(String, String) si el usuario ha habilitado el json_parser en el collector.
LogAttributes esté disponible, la consulta para contar qué rutas URL del sitio reciben más solicitudes POST:
LogAttributes['request_path'], y de la función path para quitar los parámetros de consulta de la URL.
Si el usuario no ha habilitado el análisis de JSON en el collector, LogAttributes estará vacío, lo que nos obliga a usar funciones JSON para extraer las columnas del Body de tipo String.
Use ClickHouse preferentemente para el análisisEn general, recomendamos procesar el JSON de los logs estructurados en ClickHouse. Tenemos la certeza de que ClickHouse ofrece la implementación de análisis de JSON más rápida. Sin embargo, entendemos que quizá desee enviar logs a otras sources y no quiera que esta lógica esté en SQL.
extractAllGroupsVertical.
Considere DictionariesLa consulta anterior podría optimizarse para aprovechar diccionarios de expresiones regulares. Consulte Using Dictionaries para obtener más información.
¿OTel o ClickHouse para el procesamiento?También puede realizar el procesamiento mediante procesadores y operadores del OTel collector, como se describe aquí. En la mayoría de los casos, verá que ClickHouse es significativamente más eficiente en el uso de recursos y más rápido que los procesadores del collector. La principal desventaja de realizar todo el procesamiento de eventos en SQL es el acoplamiento de su solución a ClickHouse. Por ejemplo, puede que desee enviar logs procesados a destinos alternativos desde el OTel collector, p. ej., S3.
Columnas materializadas
INSERT.
SobrecargaLas columnas materializadas añaden una sobrecarga de almacenamiento, ya que los valores se extraen y se almacenan en nuevas columnas en disco en el momento de la inserción.
LogAttributes:
Body de tipo String se puede encontrar aquí.
Nuestras tres columnas materializadas extraen la página solicitada, el tipo de solicitud y el dominio de procedencia. Estas acceden a las claves del mapa y aplican funciones a sus valores. La consulta posterior es significativamente más rápida:
De forma predeterminada, las columnas materializadas no se devuelven en un
SELECT *. Esto preserva la invariancia de que el resultado de un SELECT * siempre pueda volver a insertarse en la tabla mediante INSERT. Este comportamiento puede deshabilitarse configurando asterisk_include_materialized_columns=1 y puede habilitarse en Grafana (consulte Additional Settings -> Custom Settings en la configuración de la fuente de datos).Vistas materializadas
Actualizaciones en tiempo realLas vistas materializadas en ClickHouse se actualizan en tiempo real a medida que los datos fluyen hacia la tabla en la que se basan, funcionando más como índices que se actualizan continuamente. En cambio, en otras bases de datos las vistas materializadas suelen ser instantáneas estáticas de una consulta que deben actualizarse (de forma similar a las vistas materializadas actualizables de ClickHouse).
SELECT es posible.
Debe recordar que la consulta es solo un trigger que se ejecuta sobre las filas que se insertan en una tabla (la tabla de origen), y cuyos resultados se envían a una tabla nueva (la tabla de destino).
Para asegurarnos de no persistir los datos dos veces (en las tablas de origen y de destino), podemos cambiar el motor de la tabla de origen para que sea un Null table engine, conservando el esquema original. Nuestros OTel collectors seguirán enviando datos a esta tabla. Por ejemplo, para logs, la tabla otel_logs pasa a ser:
/dev/null. Esta tabla no almacenará ningún dato, pero cualquier vista materializada adjunta seguirá ejecutándose sobre las filas insertadas antes de que se descarten.
Considera la siguiente consulta. Esta transforma nuestras filas en un formato que queremos conservar, extrayendo todas las columnas de LogAttributes (suponemos que el collector lo ha establecido mediante el operador json_parser), estableciendo SeverityText y SeverityNumber (a partir de algunas condiciones simples y de la definición de estas columnas). En este caso, además, solo seleccionamos las columnas que sabemos que se rellenarán, ignorando columnas como TraceId, SpanId y TraceFlags.
Body indicada anteriormente, por si en el futuro se añaden atributos adicionales que nuestro SQL no extraiga. Esta columna debería comprimirse bien en ClickHouse y se accederá a ella con poca frecuencia, por lo que no afectará al rendimiento de las consultas. Por último, reducimos el Timestamp a un DateTime (para ahorrar espacio; consulte “Optimizing Types”) mediante un cast.
CondicionalesObserve el uso de condicionales más arriba para extraer
SeverityText y SeverityNumber. Son muy útiles para formular condiciones complejas y comprobar si hay valores definidos en maps; aquí asumimos ingenuamente que todas las claves existen en LogAttributes. Recomendamos a los usuarios familiarizarse con ellas: serán sus aliadas al analizar logs, además de las funciones para manejar valores NULL!Fíjese en cómo hemos cambiado drásticamente nuestro esquema. En la práctica, probablemente también tendrá columnas de trazas que querrá conservar, así como la columna
ResourceAttributes (normalmente contiene metadatos de Kubernetes). Grafana puede aprovechar las columnas de trazas para ofrecer funcionalidad de vinculación entre logs y trazas; consulte “Uso de Grafana”.otel_logs_mv que ejecuta la consulta SELECT anterior para la tabla otel_logs y envía los resultados a otel_logs_v2.
otel_logs_v2 con el formato deseado. Fíjese en el uso de funciones tipadas de extracción de JSON.
Body mediante funciones JSON:
Cuidado con los tipos
LogAttributes. ClickHouse suele convertir de forma transparente el valor extraído al tipo de la tabla de destino, lo que reduce la sintaxis necesaria. Sin embargo, recomendamos probar siempre las vistas usando la sentencia SELECT de la vista junto con una sentencia INSERT INTO sobre una tabla de destino con el mismo esquema. Esto debería confirmar que los tipos se manejan correctamente. Debe prestarse especial atención a los siguientes casos:
- Si una clave no existe en un mapa, se devolverá una cadena vacía. En el caso de los valores numéricos, será necesario asignarlos a un valor adecuado. Esto puede lograrse con condicionales, p. ej.,
if(LogAttributes['status'] = ", 200, LogAttributes['status']), o con funciones de conversión si los valores predeterminados son aceptables, p. ej.,toUInt8OrDefault(LogAttributes['status'] ) - Algunos tipos no siempre se convertirán; por ejemplo, las representaciones en cadena de valores numéricos no se convertirán en valores
enum. - Las funciones de extracción de JSON devuelven valores predeterminados para su tipo si no encuentran un valor. Asegúrate de que esos valores tengan sentido.
Evita NullableEvita usar Nullable en ClickHouse para datos de observabilidad. Rara vez es necesario en logs y trazas poder distinguir entre vacío y nulo. Esta funcionalidad añade una sobrecarga de almacenamiento adicional y afectará negativamente al rendimiento de las consultas. Consulta aquí para obtener más detalles.
Elegir una clave primaria (de ordenación)
- Selecciona columnas que se ajusten a tus filtros habituales y patrones de acceso. Si normalmente empiezas las investigaciones de observabilidad filtrando por una columna específica, por ejemplo, el nombre del pod, esa columna se usará con frecuencia en las cláusulas
WHERE. Prioriza incluir estas columnas en tu clave frente a aquellas que se usan con menos frecuencia. - Da preferencia a las columnas que, al filtrar, ayuden a excluir un gran porcentaje del total de filas, reduciendo así la cantidad de datos que hay que leer. Los nombres de servicio y los códigos de estado suelen ser buenos candidatos; en este último caso, solo si filtras por valores que excluyen la mayoría de las filas. Por ejemplo, filtrar por códigos 200 coincidirá con la mayoría de las filas en la mayoría de los sistemas, mientras que los errores 500 corresponderán a un subconjunto pequeño.
- Da preferencia a las 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, mejorando la compresión.
- Las operaciones
GROUP BYyORDER BYsobre columnas de 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 filtrar por columnas que aparecen más tarde en la clave de ordenación será menos eficiente que filtrar por las que aparecen antes en la tupla. Equilibra estos comportamientos y ten en cuenta tus patrones de acceso. Y, sobre todo, prueba variantes. Para entender mejor las claves de ordenación y cómo optimizarlas, recomendamos este artículo.
Primero, la estructuraRecomendamos decidir las claves de ordenación una vez que hayas estructurado tus logs. No uses claves en mapas de atributos para la clave de ordenación ni expresiones de extracción de JSON. Asegúrate de que las claves de ordenación estén como columnas de nivel superior en tu tabla.
Uso de mapas
map['key'] para acceder a valores en las columnas Map(String, String). Además de usar la notación de mapa para acceder a las claves anidadas, ClickHouse ofrece funciones de map especializadas para filtrar o seleccionar estas columnas.
Por ejemplo, la siguiente consulta identifica todas las claves únicas disponibles en la columna LogAttributes mediante la función mapKeys, seguida de la función groupArrayDistinctArray (un combinador).
Evita los puntosNo recomendamos usar puntos en los nombres de columnas de tipo Map y es posible que dejemos de admitir su uso. Usa un
_.Uso de alias
ALIAS, RemoteAddr, que accede al mapa LogAttributes. Ahora podemos consultar los valores de LogAttributes['remote_addr'] a través de esta columna, lo que simplifica nuestra consulta; es decir:
ALIAS es muy sencillo con el comando ALTER TABLE. Estas columnas están disponibles de inmediato; por ejemplo.
Alias excluido de forma predeterminadaDe forma predeterminada,
SELECT * excluye las columnas ALIAS. Este comportamiento se puede desactivar estableciendo asterisk_include_alias_columns=1.Optimización de tipos
Uso de codecs
ZSTD suele ser muy adecuado para conjuntos de datos de logging y trazas. Aumentar el valor de compresión respecto al valor predeterminado de 1 puede mejorar la compresión. Sin embargo, conviene probarlo, ya que los valores más altos implican una mayor sobrecarga de CPU en el momento de la inserción. Normalmente, observamos poca mejora al aumentar este valor.
Además, aunque los timestamps se benefician de la codificación delta en términos de compresión, se ha demostrado que pueden degradar el rendimiento de las consultas lentas si esta columna se utiliza en la clave primaria/de ordenación. Recomendamos a los usuarios evaluar el equilibrio entre compresión y rendimiento de las consultas en cada caso.
Uso de diccionarios
Aceleración de joinsLos usuarios interesados en acelerar joins con diccionarios pueden encontrar más detalles aquí.
Tiempo de inserción vs. tiempo de consulta
- Tiempo de inserción - Suele ser la opción adecuada si el valor de enriquecimiento no cambia y existe en una fuente externa que puede usarse para rellenar el diccionario. En este caso, enriquecer la fila en el momento de la inserción evita tener que hacer la búsqueda en el diccionario en tiempo de consulta. Esto tiene un coste en el rendimiento de inserción, además de una sobrecarga adicional de almacenamiento, ya que los valores enriquecidos se almacenarán como columnas.
- Tiempo de consulta - Si los valores de un diccionario cambian con frecuencia, las búsquedas en tiempo de consulta suelen ser más adecuadas. Esto evita tener que actualizar columnas (y reescribir datos) si cambian los valores asignados. Esta flexibilidad tiene como contrapartida el coste de hacer la búsqueda en tiempo de consulta. Este coste suele ser apreciable si la búsqueda se requiere para muchas filas; por ejemplo, al usar una búsqueda en el diccionario en una cláusula de filtro. Para el enriquecimiento de resultados, es decir, en el
SELECT, esta sobrecarga normalmente no suele ser apreciable.
Uso de diccionarios IP
ip_trie.
Usamos el dataset público de DB-IP a nivel de ciudad, proporcionado por DB-IP.com bajo los términos de la licencia CC BY 4.0.
En el archivo readme, podemos ver que los datos están estructurados de la siguiente manera:
URL() para crear una tabla de ClickHouse con nuestros nombres de campo y confirmar el número total de filas:
ip_trie requiere que los rangos de direcciones IP se expresen en notación CIDR, tendremos que transformar ip_range_start y ip_range_end.
La notación CIDR de cada rango puede calcularse de forma concisa con la siguiente consulta:
En la consulta anterior ocurren muchas cosas. Para quienes tengan interés, lean esta excelente explicación. Si no, basta con saber que lo anterior calcula un CIDR para un rango de IP.
ip_trie estructura de diccionario para asignar nuestros prefijos de red (bloques CIDR) a coordenadas y códigos de país. La siguiente consulta especifica un diccionario con este diseño y usa la tabla anterior como origen.
Actualización periódicaLos diccionarios de ClickHouse se actualizan periódicamente en función de los datos de la tabla subyacente y de la cláusula lifetime utilizada anteriormente. Para actualizar nuestro diccionario Geo IP para que refleje los cambios más recientes en el conjunto de datos DB-IP, solo tenemos que volver a insertar datos de la tabla remota geoip_url en nuestra tabla
geoip, con las transformaciones aplicadas.ip_trie (que, convenientemente, también se llama ip_trie), podemos usarlo para la geolocalización por IP. Esto puede hacerse con la función dictGet(), de la siguiente manera:
RemoteAddress extraída.
select de una vista materializada:
Actualización periódicaEs probable que los usuarios quieran que el diccionario de enriquecimiento de IP se actualice periódicamente a partir de datos nuevos. Esto puede lograrse mediante la cláusula
LIFETIME del diccionario, que hace que se recargue periódicamente desde la tabla subyacente. Para actualizar la tabla subyacente, consulta “vistas materializadas actualizables”.Uso de diccionarios de expresiones regulares (análisis de user agent)
En los ejemplos siguientes, usamos snapshots de las expresiones regulares más recientes de uap-core para el análisis de user agent de junio de 2024. El archivo más reciente, que se actualiza ocasionalmente, puede encontrarse aquí. Puede seguir los pasos aquí para cargarlo en el archivo CSV utilizado a continuación.
otel_logs_v2:
Tuples para estructuras complejasFíjate en el uso de Tuples para estas columnas de user agent. Se recomienda usar Tuples para estructuras complejas en las que la jerarquía se conoce de antemano. Las subcolumnas ofrecen el mismo rendimiento que las columnas normales (a diferencia de las claves de Map), a la vez que permiten tipos heterogéneos.
Más información
Acelerar las consultas
Uso de vistas materializadas (incrementales) para agregaciones
Esta consulta sería 10 veces más rápida si usáramos la tabla
otel_logs_v2, que es el resultado de nuestra vista materializada anterior, la cual extrae la clave size del mapa LogAttributes. Aquí usamos los datos sin procesar solo con fines ilustrativos y recomendamos usar la vista anterior si esta es una consulta habitual.bytes_per_hour está vacía y todavía no ha recibido ningún dato. Nuestra vista materializada ejecuta el SELECT anterior sobre los datos insertados en otel_logs (esto se hará sobre bloques del tamaño configurado), y los resultados se envían a bytes_per_hour. La sintaxis se muestra a continuación:
TO es clave aquí, ya que indica adónde se enviarán los resultados, es decir, a bytes_per_hour.
Si reiniciamos nuestro OTel collector y reenviamos los logs, la tabla bytes_per_hour se irá rellenando de forma incremental con el resultado de la consulta anterior. Al terminar, podremos comprobar el tamaño de la tabla bytes_per_hour: deberíamos tener 1 fila por hora:
otel_logs) a 113, al almacenar el resultado de nuestra consulta. La clave aquí es que, si se insertan nuevos logs en la tabla otel_logs, se enviarán nuevos valores a bytes_per_hour para su hora correspondiente, donde se fusionarán automáticamente de forma asíncrona en segundo plano. Al mantener solo una fila por hora, bytes_per_hour seguirá siendo siempre pequeña y estará actualizada.
Como la fusión de filas es asíncrona, puede haber más de una fila por hora cuando un usuario haga una consulta. Para asegurarnos de que las filas pendientes se fusionen en tiempo de consulta, tenemos dos opciones:
- Usar el modificador
FINALen el nombre de la tabla (que es lo que hicimos para la consulta de recuento anterior). - Agregar por la clave de ordenación utilizada en nuestra tabla final, es decir, Timestamp, y sumar las métricas.
Este ahorro de tiempo puede ser aún mayor en conjuntos de datos más grandes y con consultas más complejas. Consulta ejemplos aquí.
Un ejemplo más complejo
UniqueUsers con el tipo AggregateFunction, especificando la función de la que proceden los estados parciales (uniq) y el tipo de la columna de origen (IPv4). Al igual que en SummingMergeTree, las filas con el mismo valor de la clave ORDER BY se combinarán (Hour en el ejemplo anterior).
La vista materializada asociada utiliza la consulta anterior:
State al final de nuestras funciones de agregación. Esto garantiza que se devuelva el estado de agregación de la función en lugar del resultado final. Este incluirá información adicional que permitirá combinar este estado parcial con otros estados.
Una vez recargados los datos mediante el reinicio del collector, podemos confirmar que hay 113 filas disponibles en la tabla unique_visitors_per_hour.
GROUP BY en lugar de FINAL.
Uso de vistas materializadas (incrementales) para búsquedas rápidas
ServiceName, SpanName y Timestamp. En tracing, los usuarios también necesitan poder hacer consultas por un TraceId específico y recuperar los spans asociados a la traza correspondiente. Aunque esto está presente en la clave de ordenación, su posición al final significa que el filtrado no será tan eficiente y probablemente hará necesario escanear cantidades significativas de datos al recuperar una sola traza.
El OTel collector también instala una vista materializada y la tabla asociada para abordar este problema. La tabla y la vista se muestran a continuación:
otel_traces_trace_id_ts tenga la marca temporal mínima y máxima de la traza. Esta tabla, ordenada por TraceId, permite recuperar estas marcas temporales de manera eficiente. Estos rangos de marcas temporales pueden, a su vez, utilizarse al consultar la tabla principal otel_traces. Más concretamente, al recuperar una traza por su id, Grafana utiliza la siguiente consulta:
ae9226c78d1d360601e6383928e4d22d, antes de usarla para filtrar la tabla principal otel_traces y recuperar sus spans asociados.
Este mismo enfoque puede aplicarse a patrones de acceso similares. Analizamos un ejemplo parecido en Modelado de datos aquí.
Uso de proyecciones
ORDER BY para una tabla.
En secciones anteriores, exploramos cómo pueden usarse las vistas materializadas en ClickHouse para precalcular agregaciones, transformar filas y optimizar las consultas de observabilidad para distintos patrones de acceso.
Mostramos un ejemplo en el que la vista materializada envía filas a una tabla de destino con una clave de ordenación distinta de la tabla original que recibe las inserciones, con el fin de optimizar las búsquedas por ID de la traza.
Las proyecciones pueden usarse para resolver el mismo problema, ya que permiten al usuario optimizar consultas sobre una columna que no forma parte de la clave primaria.
En teoría, esta capacidad puede usarse para proporcionar varias claves de ordenación para una tabla, con una desventaja importante: la duplicación de datos. En concreto, los datos deberán escribirse en el orden de la clave primaria principal, además del orden especificado para cada proyección. Esto ralentizará las inserciones y consumirá más espacio en disco.
Proyecciones frente a vistas materializadasLas proyecciones ofrecen muchas de las mismas capacidades que las vistas materializadas, pero deben usarse con moderación, y a menudo se prefiere estas últimas. Debe comprender sus inconvenientes y cuándo resultan adecuadas. Por ejemplo, aunque las proyecciones pueden usarse para precalcular agregaciones, recomendamos a los usuarios utilizar vistas materializadas para ello.
otel_logs_v2 por códigos de error 500. Es probable que este sea un patrón de acceso habitual en logging, ya que los usuarios suelen querer filtrar por códigos de error:
Usa Null para medir el rendimientoAquí no mostramos los resultados con
FORMAT Null. Esto hace que se lean todos los resultados, pero no se devuelvan, evitando así que la consulta termine antes de tiempo debido a un LIMIT. Esto es solo para mostrar el tiempo que se tarda en escanear las 10m filas completas.(ServiceName, Timestamp). Aunque podríamos añadir Status al final de la clave de ordenación para mejorar el rendimiento de la consulta anterior, también podemos añadir una proyección.
ALTER, su creación es asíncrona cuando se emite el comando MATERIALIZE PROJECTION. Puede comprobar el progreso de esta operación con la siguiente consulta y esperar a que is_done=1.
SELECT * aquí, se almacenarían todas las columnas. Aunque esto permitiría que más consultas (que usen cualquier subconjunto de columnas) se beneficiaran de la proyección, también implicaría un almacenamiento adicional. Para medir el espacio en disco y la compresión, consulte “Medir el tamaño y la compresión de las tablas”.
Índices secundarios/de omisión de datos
Índice de texto para búsqueda de texto completo
tokenizer en su definición. De forma opcional, se puede especificar una función de preprocesamiento para transformar la cadena de entrada antes de la tokenización.
Las funciones recomendadas para buscar en el índice son: hasAnyTokens y hasAllTokens.
Algunas funciones tradicionales de búsqueda de cadenas también se optimizan automáticamente cuando hay un índice de texto.
Consulta la documentación para obtener más detalles y ver las funciones compatibles aquí y aquí.
En los ejemplos siguientes, usamos un conjunto de datos de logs estructurados.
hasAnyTokens sin un índice de texto, pero la consulta hará un escaneo completo lento de la columna Body:
Añadir un índice de texto
ALTER TABLE:
Uso de un preprocesador
msg, id, ctx, attr, etc.).
Supongamos que solo nos interesa buscar en el campo msg.
En lugar de indexar toda la cadena JSON, podemos definir un preprocesador para extraer únicamente el valor de msg antes de la tokenización.
Por ejemplo:
- reduce la cantidad de texto que se tokeniza e indexa,
- disminuye el tamaño del índice,
- reduce la probabilidad de falsos positivos, y
- mejora el rendimiento de las consultas.