Nuestro setup de ClickHouse para reducir la latencia
ObsessionDB reduce la latencia de las queries de ClickHouse® por capas. Nuestra cache NVMe distribuida convierte el viaje a S3, cientos de milisegundos en la cola, en un salto de 0,5 ms entre nodos, y cachea los datos en el momento en que se escriben. Los projection indexes se quedan residentes en memoria y con sus gates bien puestos a escala de terabytes. Debajo quedan los settings que cualquier cluster de ClickHouse puede tocar: los gates del projection index, la query condition cache y las parallel replicas.
Casi todos los consejos para acelerar ClickHouse empiezan por la query: sorting keys, PREWHERE, skip indexes. Esa capa importa, y nuestra guía de indexes, projections y materialized views explica cuándo usar cada uno. Este artículo va de la capa de abajo, los settings de servidor y de query que deciden si la maquinaria que montaste llega a usarse. Los recorremos en el orden en que conviene revisarlos, empezando por lo único que ningún setting elimina.
En cualquier ClickHouse sobre S3, el nuestro incluido, el suelo de latencia es el viaje a object storage, y lo marca la cola de ese viaje. Las mediciones de Quickwit sitúan el primer byte típico de S3 cerca de los 30 ms; la guía de rendimiento de AWS admite de 100 a 200 ms. Las dos son medianas, y una query con fan-out no vive en la mediana: se parte en cientos de GETs y termina con el más lento. En la cola, S3 se va a 300 o 500 ms, así que una query que toca object storage unos cientos de veces se encuentra esa cola en casi todas las ejecuciones. Sumarle 30 ms a una query suele dar igual. Que cada query corra a la velocidad de la más lenta de sus cientos de peticiones es lo que te mata.
Filesystem cache, query condition cache y cache distribuida
Para evitar viajes a S3 existen varias capas, cada una resuelve un problema distinto.
ClickHouse trae dos caches locales al nodo que importan para la latencia sobre object storage. La filesystem cache guarda rangos de datos en el disco local, para no leer de S3 dos veces el mismo gránulo. La query condition cache recuerda qué gránulos no pasaron un filtro WHERE, y la siguiente ejecución de ese filtro los salta sin tocar datos.
Los clusters de ObsessionDB llevan una tercera cache por debajo de las dos: la distribuida, compartida entre todos los nodos del cluster, que suma el espacio en disco de todos y sube el hit rate de forma notable.
La filesystem cache
La filesystem cache es configuración de disco, no un setting de query. Defines un disco de tipo cache encima del disco de object storage, y una storage policy tiene que enrutar la tabla por él:
<storage_configuration>
<disks>
<s3_cached>
<type>cache</type>
<disk>s3_main</disk>
<path>/var/lib/clickhouse/cache/</path>
<max_size>100Gi</max_size>
</s3_cached>
</disks>
<policies>
<s3>
<volumes>
<main><disk>s3_cached</disk></main>
</volumes>
</s3>
</policies>
</storage_configuration>
El setting a nivel de query solo da permiso. Si ninguna policy pasa por el disco de cache, enable_filesystem_cache = 1 no permite nada, y no avisa por ningún sitio: puede haber un disco de cache perfectamente configurado en system.disks mientras cada lectura sigue yendo a object storage, la primera vez y todas las siguientes.
Una consulta contra system.query_log resuelve si estás en esa situación:
SELECT ProfileEvents['CachedReadBufferReadFromCacheBytes'] AS bytes_from_cache
FROM system.query_log
WHERE type = 'QueryFinish' AND query_id = '<your-query-id>';
Cero en una query repetida significa que la cache no está en tu ruta de lectura. Lo siguiente es mirar system.storage_policies.
En el mismo bloque de configuración viven dos trampas más. ClickHouse autogestionado solo llena la cache al leer: enable_filesystem_cache_on_write_operations viene desactivado en open source (ClickHouse Cloud lo activa), y además el disco de cache tiene que declarar cache_on_write_operations, así que un cluster autogestionado de serie sirve en frío justo sus datos más recientes.
Hemos incluido esta sección para dar el contexto completo del ecosistema, pero en ObsessionDB la llevamos desactivada por defecto. Una cache local al nodo desperdicia NVMe en un cluster: con seis nodos, una query tiene una probabilidad entre seis de caer donde los datos están calientes, y la cobertura parcial se comporta peor de lo que sugiere el porcentaje, porque unos pocos ficheros fríos por part devuelven S3 a la ruta crítica de casi cualquier lectura. Aportar ese mismo NVMe a la cache distribuida sirve a todos los nodos y deja el hit rate prácticamente en el 100%. Nuestro objetivo es cero accesos fríos, y con cache local al nodo no se llega.
La query condition cache
La query condition cache guarda un bit por filtro y gránulo: si este gránulo sobrevivió a este WHERE. Viene activada por defecto desde la 25.4, ocupa 100 MB de memoria salvo que la redimensiones, y en filtros selectivos repetidos vale un orden de magnitud; hemos visto ejecuciones repetidas bajar de varios segundos a menos de 100 ms. Déjala encendida. Los filtros sobre datos que casi solo crecen la aprovechan constantemente, y eso describe la mayoría de cargas analíticas.
Tiene un filo, y corta en los benchmarks. La segunda ejecución de una query de prueba se sirve en parte de esta cache, así que tu comparación de antes y después mide la cache en vez del cambio. Pon use_query_condition_cache = 0 dentro de cualquier harness de medición que lo necesite, y en ningún otro sitio.
Los ficheros de marks e índices tienen sus propias caches con sus propios fallos a escala; contamos cómo los dejamos residentes en una tabla de 20 TB, y el p50 de 213 ms que eso compró, en ClickHouse projections at scale.
La cache distribuida
La cache que más mueve nuestra latencia es la distribuida. Cada nodo aporta su NVMe a un mesh compartido, y rendezvous hashing decide qué nodo guarda cada fichero, así que una part cacheada en cualquier punto del cluster está a un salto de 0,5 ms de cualquier nodo, mientras que la misma lectura de S3 cuesta decenas de milisegundos en la mediana y cientos en la cola. Vive debajo de Alloy, nuestro storage engine desarrollado contra la API de SharedMergeTree, y es la razón de que podamos dejar la filesystem cache apagada.
Su comportamiento se separa de las caches nativas en tres cosas. Los datos se cachean en el momento en que un nodo los escribe, así que las particiones más recientes se sirven calientes desde su primera lectura. Los índices y marks se precalientan cuando aparece una part, la misma palanca que llevó una tabla de 20 TB a un p50 de 213 ms en projections at scale. Y el trabajo se enruta al nodo que ya tiene los bytes: alrededor del 70% de los merges leen en local en nuestros dos clusters con más carga, cuando el reparto natural en un mesh de seis nodos daría un 17%.
Las dos caches nativas le ahorran un viaje a S3 a un nodo. La distribuida se lo ahorra al cluster entero, sobrevive a los cambios de tamaño y no te pide configurar nada. El diseño completo está en el artículo de la stateless distributed cache y en building on decoupled ClickHouse.
Projections a escala de terabytes
La cache decide a qué velocidad llegan los bytes. Las projections deciden cuántos bytes hacen falta, y son donde más se separan nuestros clusters de un despliegue estándar o de ClickHouse Cloud. En los montajes tipo SharedMergeTree, las projections dejan de escalar pasados unos pocos terabytes, porque la selección de parts empieza a comerse la query. Nosotros tenemos una corriendo sobre una tabla de más de 20 TB y 200.000 millones de filas con un p50 de 213 ms; cómo hicimos las projections 10 veces más rápidas a 20 terabytes es la ingeniería que hay debajo, y mantener los ficheros de marks e índices residentes en memoria es la mayor parte.
Esa misma maquinaria trae una trampa que puedes arreglar tú. La 25.11 estrenó dos gate settings con un default de 1.000.000 de filas, y la referencia oficial de settings describe el primero como el mínimo estimado de filas a leer de la tabla. Sin embargo, la comprobación se ejecuta por part (el gate está en projectionsCommon.cpp, dentro del bucle sobre las parts), contra los rangos de filas seleccionados de cada una, así que una tabla hecha de parts por debajo del millón de filas no llega a usar nunca su projection index, mida lo que mida la tabla, y los merges de fondo no paran de producir justo ese tipo de parts cuando hay ingesta constante. En una tabla de 194.000 millones de filas, ese suelo eran 35 millones de filas y 1,6 GB leídos por query; poner los gates a cero lo dejó en 26.000 filas y 369 KB:
SET min_table_rows_to_use_projection_index = 0; -- default 1,000,000
SET max_projection_rows_to_use_projection_index = 1e9; -- default 1,000,000
Un EXPLAIN indexes = 1 normal enseña un full scan en los dos casos, porque el pruning del projection index ocurre en tiempo de lectura. Mira read_rows en system.query_log, y después EXPLAIN indexes = 1, projections = 1. Si un projection index era siquiera el mecanismo adecuado es otra decisión; indexes vs projections vs materialized views recorre la escalera.
Los settings que merece la pena tocar
Todo lo de arriba viene con la plataforma. Esto es lo que está en tu mano, en cualquier ClickHouse. El diseño de la query es lo que más latencia mueve y tiene sus propias guías: sorting keys, PREWHERE y skip indexes en ClickHouse query optimization, y la forma del lookup en dictionaries y JOINs, donde un dictGet le saca 20 veces al JOIN equivalente. Por debajo de la capa de query, dos settings merecen su propia etiqueta de aviso: uno reparte una query entre máquinas, el otro aplaza trabajo dentro de una.
Las parallel replicas (enable_parallel_replicas = 1) convierten cada réplica en un worker de la misma query en vez de una copia de respaldo. En un scan pesado o una agregación grande, el cluster se reparte la lectura y el tiempo baja con el número de nodos. En un point lookup no hay nada que repartir, y la coordinación es puro sobrecoste: las queries pequeñas pueden pagar en coordinación más de lo que ahorran, y las formas con CTEs, subqueries o JOINs pueden salir perdiendo directamente. parallel_replicas_min_number_of_rows_per_replica es la barandilla: por debajo del umbral, la query se queda en un nodo. Este setting llegó en la 24.10 y necesita el analyzer nuevo. No hay un número de réplicas correcto: una query analítica pesada puede y debe usar todos los nodos, con mucha concurrencia salen a cuenta menos, y ClickHouse elige la cifra real a partir de una docena de settings, entre ellos las filas esperadas. Trata max_parallel_replicas como un dial por workload.
Un filo más: con parallel reading activado, el planner renuncia a usar projections en esa lectura. Si tu plan de latencia depende de una projection, y después de la sección anterior es probable, limita las parallel replicas a las queries de scan con un SET por query en vez de activarlas globalmente.
La lazy materialization (query_plan_optimize_lazy_materialization) es la contraparte dentro del nodo: en queries top-N, ClickHouse lee las columnas pesadas solo para las filas que sobreviven al ORDER BY más el LIMIT. Medimos una columna ancha en un 0,45% de lectura extra con ella activada, y un 14,5% sin ella. El precipicio es query_plan_max_limit_for_lazy_materialization, con default 10.000: un LIMIT por encima apaga la optimización sin ningún mensaje, así que un endpoint paginado al que le crece el tamaño de página puede caerse por un precipicio de rendimiento.
Qué setting para qué síntoma
Recorre los síntomas en orden; cada fila es más barata de comprobar que la siguiente.
| Síntoma | Comprueba primero | El movimiento |
|---|---|---|
| Las queries repetidas van igual de lentas que las frías | CachedReadBufferReadFromCacheBytes en query_log | Enruta una storage policy por un disco de tipo cache |
| Una query selectiva hace full scan pese al projection index | read_rows, después EXPLAIN indexes = 1, projections = 1 | min_table_rows_to_use_projection_index = 0, sube el gate máximo |
| El benchmark gana a producción | Los settings del harness | use_query_condition_cache = 0 en tests, activada en producción |
| Los scans grandes ignoran tus réplicas | enable_parallel_replicas, analyzer activado | Actívalas por perfil de query, mantén la barandilla de filas mínimas |
| Los point lookups empeoraron al activar parallel replicas | El uso de projections en esas queries | Limita las parallel replicas a scans; projections y parallel reading no se mezclan |
| Las queries frías se atascan en segundos con todo lo demás bien | GETs a S3 por query | Estás en el suelo; el arreglo es arquitectura de cache, no settings |
Todo menos la última fila funciona en cualquier despliegue de ClickHouse, autogestionado o gestionado, ObsessionDB incluido. La última fila es la razón de que nuestros clusters aguanten con datasets enormes: cache al escribir, índices precalentados, enrutado por localidad y projection indexes residentes van debajo de cada tabla por defecto, y no hay setting que se los añada a un cluster estándar. El benchmark de 10.000 millones de filas contra ClickHouse Cloud enseña lo que suma esa pila, y cómo consultamos 18 terabytes en menos de un segundo recorre una carga de producción por cada una de sus capas.
Si tu p99 no cuadra con tus settings, lo miramos contigo. Nuestra performance audit coge tu query log real, le aplica esta checklist y las más profundas, y te devuelve lo que encuentre, vayas a correr en ObsessionDB o no.
FAQ
Mira primero read_rows en system.query_log. Desde la 25.11, dos gate settings (min_table_rows_to_use_projection_index, default de 1 millón, evaluado por part) pueden descartar un projection index en tablas con muchas parts pequeñas. Las projections materializadas a medias y las parallel replicas también caen a full scan sin avisar.
No. El setting solo da permiso; tiene que existir un disco de tipo cache y una storage policy que enrute tu tabla por él. Compruébalo con el profile event CachedReadBufferReadFromCacheBytes en una query repetida: si sale cero, cada lectura sigue yendo a object storage, y el arreglo está en system.storage_policies, no en tu SQL.
O no hay ninguna cache en la ruta de lectura (revisa las storage policies) o la cobertura es parcial: una query que toca 100 parts paga la latencia de S3 si cada part tiene un fichero frío. La query condition cache solo salta gránulos filtrados; no guarda los datos que sobreviven.
En scans grandes y agregaciones, sí: las réplicas se reparten la lectura. En queries pequeñas la coordinación puede costar más de lo que ahorra, y las queries que usan projections las pierden con parallel reading activado. Actívalas por query o usa parallel_replicas_min_number_of_rows_per_replica en vez de encenderlas en todo el cluster.
La filesystem cache de ClickHouse es privada de cada nodo: se calienta con sus lecturas y la limita su disco. Con seis nodos, una query tiene una probabilidad entre seis de encontrar los datos calientes. Una cache distribuida junta el NVMe de todo el cluster con rendezvous hashing: todos los nodos aciertan y las parts sobreviven a los reescalados.
Una query es tan rápida como su petición a S3 más lenta. La mediana del primer byte parece razonable, decenas de milisegundos, pero la cola está en 300 a 500 ms, y una query selectiva que toca cientos de gránulos se encuentra esa cola en casi todas las ejecuciones. Las caches, locales o distribuidas, existen para sacar a S3 de la ruta de lectura.
Seguir Leyendo
Publicado originalmente en obsessionDB. Lee el artículo original aquí.
ClickHouse is a registered trademark of ClickHouse, Inc. https://clickhouse.com