Optimización de cadenas en ClickHouse. Informe Yandex

El motor de base de datos analíticos ClickHouse procesa muchas líneas diferentes, consumiendo recursos. Para acelerar el sistema, constantemente se agregan nuevas optimizaciones. El desarrollador de ClickHouse, Nikolay Kochetov, habla sobre el tipo de datos de cadena, incluido el nuevo tipo, LowCardinality, y explica cómo acelerar el trabajo con cadenas.


- Primero, veamos cómo puedes almacenar cadenas.



Tenemos tipos de datos de cadena. La cadena funciona bien por defecto, debe usarse casi siempre. Tiene una pequeña sobrecarga: 9 bytes por línea. Si queremos que el tamaño de la fila sea fijo y conocido de antemano, es mejor usar FixedString. En él puede establecer la cantidad de bytes que necesitamos, es conveniente para datos como direcciones IP o funciones hash.



Por supuesto, a veces algo se ralentiza. Supongamos que está haciendo una consulta en una tabla. ClickHouse lee una cantidad bastante grande de datos, por ejemplo, a una velocidad de 100 GB / s, con pocas líneas procesadas. Tenemos dos tablas que almacenan casi los mismos datos. ClickHouse lee datos de la segunda tabla a una velocidad mayor, pero lee tres veces menos filas por segundo.



Si observamos el tamaño de los datos comprimidos, será casi igual. De hecho, los mismos datos se escriben en las tablas, los primeros mil millones de números, solo en la primera columna se escriben en forma de UInt64, y en la segunda columna en String. Debido a esto, la segunda consulta lee los datos del disco por más tiempo y los descomprime.



Aquí hay otro ejemplo. Supongamos que hay un conjunto predeterminado de líneas, está limitado a una constante de 1000 o 10,000 y casi nunca cambia. Para este caso, el tipo de datos Enum es adecuado para nosotros, en ClickHouse hay dos de ellos: Enum8 y Enum16. Debido al almacenamiento en Enum, procesamos rápidamente las solicitudes.

ClickHouse tiene aceleraciones para GROUP BY, IN, DISTINCT y optimizaciones para algunas funciones, por ejemplo, para comparar con una cadena constante. Por supuesto, los números en la cadena no se convierten, pero, por el contrario, la cadena constante se convierte en el valor Enum. Después de eso, todo se compara rápidamente.

Pero también hay desventajas. Incluso si conocemos el conjunto exacto de líneas, a veces es necesario reponerlo. Ha llegado una nueva línea, tenemos que hacer ALTER.



ALTER para Enum en ClickHouse se implementa de manera óptima. No sobrescribimos los datos en el disco, pero ALTER puede reducir la velocidad debido al hecho de que las estructuras de Enum se almacenan en el esquema de la tabla misma. Por lo tanto, debemos esperar las solicitudes de lectura de la tabla, por ejemplo.

La pregunta es, ¿se puede hacer mejor? Tal vez sí. Puede guardar la estructura Enum no en el esquema de la tabla, sino en ZooKeeper. Sin embargo, pueden ocurrir problemas de sincronización. Por ejemplo, una réplica recibió datos, otra no, y si tiene una Enum antigua, entonces algo se romperá. (En ClickHouse, casi completamos las solicitudes ALTER sin bloqueo. Cuando las terminemos por completo, no tendremos que esperar las solicitudes de lectura).



Para no meterse con ALTER Enum, puede usar diccionarios externos de ClickHouse. Permítame recordarle que esta es una estructura de datos de valor clave dentro de ClickHouse, con la que puede obtener datos de fuentes externas, por ejemplo, de tablas MySQL.

En el diccionario ClickHouse, almacenamos muchas líneas diferentes y en la tabla sus identificadores en forma de números. Si necesitamos obtener una cadena, llamamos a la función dictGet y trabajamos con ella. Después de eso no debemos hacer ALTER. Para agregar algo a Enum, insertamos esto en la misma tabla MySQL.

Pero hay otros problemas. En primer lugar, la sintaxis incómoda. Si queremos obtener una cadena, debemos llamar a dictGet. En segundo lugar, la falta de algunas optimizaciones. La comparación con la cadena constante para los diccionarios tampoco es rápida.

Todavía puede haber problemas con la actualización. Supongamos que solicitamos una línea en el diccionario de caché, pero no ingresó en el caché. Luego tenemos que esperar hasta que los datos se carguen desde una fuente externa.



Un inconveniente común de ambos métodos es que almacenamos todas las claves en un solo lugar y las sincronizamos. Entonces, ¿por qué no almacenar diccionarios localmente? Sin sincronización, no hay problema. Puede almacenar el diccionario localmente en una pieza en el disco. Es decir, hicimos Insertar, grabamos un diccionario. Si trabajamos con datos en la memoria, podemos escribir un diccionario en un bloque de datos, en una columna o en algún caché para acelerar los cálculos.

Codificación de cadena de vocabulario


Entonces llegamos a la creación de un nuevo tipo de datos en ClickHouse - LowCardinality. Este es un formato para almacenar datos: cómo se escriben en el disco y cómo se leen, cómo se presentan en la memoria y el esquema de su procesamiento.



Hay dos columnas en la diapositiva. A la derecha, las cadenas se almacenan de forma estándar en el tipo de cadena. Se puede ver que estos son algunos tipos de modelos de teléfonos móviles. A la izquierda hay exactamente la misma columna, solo en el tipo LowCardinality. Consiste en un diccionario con muchas líneas diferentes (líneas de la columna de la derecha) y una lista de posiciones (números de línea).

Usando estas dos estructuras, puede restaurar la columna original. También hay un índice inverso inverso: una tabla hash que te ayuda a encontrar la posición en el diccionario por línea. Es necesario para acelerar algunas consultas. Por ejemplo, si queremos comparar, busque una línea en nuestra columna o combínelos.

LowCardinality es un tipo de datos paramétrico. Puede ser un número, o algo que se almacena como un número, o una cadena, o Nullable de ellos.



La peculiaridad de LowCardinality es que se puede guardar para algunas funciones. Se muestra un ejemplo de solicitud en la diapositiva. En la primera línea, creé una columna del tipo LowCardinality de String, la llamé S. A continuación, le pregunté su nombre: ClickHouse dijo que era LowCardinality de String. Todo bien.

La tercera línea es casi la misma, solo que llamamos la función de longitud. En ClickHouse, la función de longitud devuelve el tipo de datos UInt64. Pero obtuvimos LowCardinality de UInt64. ¿Cuál es el punto de?



Los nombres de los teléfonos móviles se almacenaron en el diccionario, aplicamos la función de longitud. Ahora tenemos un diccionario similar, que consta solo de números, estas son las longitudes de las cadenas. La columna con las posiciones no ha cambiado. Como resultado, procesamos menos datos, guardados en el tiempo de solicitud.

Puede haber otras optimizaciones, como agregar un caché simple. Al calcular el valor de una función, puede recordarla y hacerla igual, no vuelva a calcular.

La optimización también se puede hacer GROUP BY, porque nuestra columna con el diccionario ya está parcialmente agregada: puede calcular rápidamente el valor de las funciones hash y encontrar aproximadamente el depósito donde colocar la siguiente línea. También puede especializar algunas funciones agregadas, por ejemplo, uniq, porque solo puede enviarle un diccionario y dejar las posiciones intactas, de esta manera todo funcionará más rápido. Las dos primeras optimizaciones ya las hemos agregado a ClickHouse.



Pero, ¿qué sucede si creamos una columna con nuestro tipo de datos e insertamos muchas filas diferentes malas? ¿Está llena nuestra memoria? No, hay dos configuraciones especiales para esto en ClickHouse. El primero es low_cardinality_max_dictionary_size. Este es el tamaño máximo de un diccionario que se puede escribir en el disco. La inserción se produce de la siguiente manera: cuando insertamos los datos, nos llega un flujo de líneas, a partir de ellas formamos un gran diccionario general. Si el diccionario se hace más grande que el valor de configuración, escribimos el diccionario actual en el disco, y el resto de las líneas en algún lugar del lado, al lado de los índices. Como resultado, nunca volveremos a contar un diccionario grande y no tendremos problemas de memoria.

La segunda configuración se llama low_cardinality_use_single_dictionary_for_part. Imagine que en el esquema anterior, cuando insertamos los datos, nuestro diccionario estaba lleno y lo escribimos en el disco. Surge la pregunta, ¿por qué no ahora formar otro exactamente el mismo diccionario?

Cuando se desborde, lo volveremos a escribir en el disco y comenzaremos a formar un tercero. Esta configuración simplemente deshabilita esta función de manera predeterminada.

De hecho, muchos diccionarios pueden ser útiles si queremos insertar algún conjunto de líneas, pero insertamos accidentalmente "basura". Digamos que primero insertamos las líneas malas, y luego insertamos las buenas. Entonces el diccionario se dividirá en muchos diccionarios pequeños. Algunos de ellos serán con basura, pero el último será con buenas líneas. Y si leemos, digamos, solo la última pastilla, entonces todo también funcionará rápidamente.



Antes de hablar sobre las ventajas de LowCardinality, diré de inmediato que es poco probable que logremos la reducción de datos en el disco (aunque esto puede suceder), porque ClickHouse comprime los datos. Hay una opción predeterminada: LZ4. También puedes hacer compresión usando ZSTD. Pero ambos algoritmos ya implementan la compresión del diccionario, por lo que nuestro diccionario externo ClickHouse no ayudará mucho.

Para no ser infundado, tomé algunos datos de la métrica - String, LowCardinality (String) y Enum - y los guardé en diferentes tipos de datos. Resultó tres columnas, donde se escriben mil millones de filas. La primera columna, CodePage, tiene un total de 62 valores. Y puedes ver que en LowCardinality (String), los exprimieron mejor. Las cuerdas son un poco peores, pero esto probablemente se deba al hecho de que las cuerdas son cortas, almacenamos sus longitudes y ocupan mucho espacio y no se comprimen bien.

Si toma PhoneModel, hay más de 48 mil de ellos, y casi no hay diferencias entre String y LowCardinality (String). Para la URL, también guardamos solo 2 GB. Creo que no debe confiar en esto.

Estimación de la velocidad del trabajo.



Enlace de la diapositiva

Ahora evalúemos la velocidad del trabajo. Para evaluarlo, utilicé un conjunto de datos que describe los viajes en taxi en Nueva York. Estádisponibleen GitHub. Tiene un poco más de mil millones de viajes. Muestra la ubicación, las horas de inicio y finalización del viaje, el método de pago, el número de pasajeros e incluso el tipo de taxi: verde, amarillo y Uber.



Hice la primera solicitud bastante simple: pregunté dónde se solicitan los taxis con mayor frecuencia. Para hacer esto, debe tomar la ubicación desde donde ordenó, hacer GROUP BY y calcular la función de conteo. Aquí ClickHouse da algo.



Para medir la velocidad del procesamiento de consultas, creé tres tablas con los mismos datos, pero utilicé tres tipos de datos diferentes para nuestra ubicación de inicio: String, LowCardinality y Enum. LowCardinality y Enum son cinco veces más rápidos que String. Enum es más rápido porque funciona con números. Baja cardinalidad: porque se implementa la optimización GROUP BY.



Para complicar la solicitud, pregunte dónde se encuentra el parque más popular de Nueva York. Nuevamente, mediremos esto por el lugar donde se ordena el taxi con mayor frecuencia, pero al mismo tiempo filtraremos solo aquellos lugares donde la palabra "estacionamiento" esté disponible. También agregue una función similar.



Miramos la hora, vemos que Enum de repente comenzó a disminuir. Y funciona incluso más lento que el tipo de datos de cadena estándar. Esto se debe a que la función like no está optimizada para Enum. Tenemos que convertir nuestras líneas de Enum a líneas regulares; trabajamos más. LowCardinality (String) tampoco está optimizado de forma predeterminada, pero al igual que funciona allí en el diccionario, por lo que la consulta es más rápida en comparación con String.

Hay un problema más global con Enum. Si queremos optimizarlo, debemos hacerlo en todos los lugares del código. Supongamos que escribimos una nueva función: definitivamente debe obtener optimizaciones para Enum. Y en LowCardinality, todo está optimizado por defecto.



Veamos la última solicitud, más artificial. Simplemente calcularemos la función hash desde nuestra ubicación. La función hash es una solicitud bastante lenta, lleva mucho tiempo, por lo que todo se ralentizará tres veces.



LowCardinality es aún más rápido, aunque no hay filtrado. Esto se debe al hecho de que nuestras funciones solo funcionan en el diccionario. La función de cálculo hash tiene un argumento: puede procesar menos datos y también puede devolver LowCardinality.



Nuestro plan global es lograr una velocidad no inferior a la de String en cualquier caso, y ahorrar aceleración. Y tal vez algún día reemplazaremos String con LowCardinality, actualizará ClickHouse y todo funcionará un poco más rápido.

All Articles